#!/usr/bin/env python3
"""ngspice-hier-op -- incremental ("hierarchical") operating-point solver.

Produce AN operating point for a netlist that a plain `.op` cannot solve, at any
cost, WITHOUT analysing what the circuit does.  Not the perfect OP -- a starting
point the designer takes over from.  The baseline alternative is no OP at all.

Method: never hand the solver the whole problem at once.  Take a subset that
converges, save its state, add ONE block back, re-solve seeded from that state.
Every solve is then a small perturbation of an already-solved circuit.

Per block, stop at the first rung that yields a state:

  a  .op            seeded with the incoming state as .nodeset
  b  .tran ... uic  seeded with the incoming state as .ic, t = 50n
       -> save endpoint -> .op from that endpoint
       -> OP fails: double t and repeat, up to 4 doublings (50n 100n 200n 400n)
  c  ladder exhausted -> hand over the best transient endpoint anyway, labelled
       'transient' rather than 'op'.  Never return nothing.

`tran uic` is the escalation because it is ROBUST -- it always produces a state.
It is NOT a functional check; nothing here inspects or interprets behaviour.
Non-clocked blocks settle in ~50n or never; for clocked blocks the time is
unknowable without the designer, so the doubling ladder is a bounded search.

Backtracking: if a block fails BOTH rungs, the previous step's transient may have
been too short.  Re-run the IMMEDIATELY preceding step at the longer durations and
retry the failing block once; still failing -> stop.  One backtrack, not a search
(going further back would invalidate every step folded after it).

YOUR FILES ARE NEVER MODIFIED.  Blocks are masked with the `disable` command, in
the netlist ngspice holds in memory; the seed and the analysis are appended in a
generated file passed alongside your deck.

Usage:
  ngspice-hier-op DECK [options]
  hier_op DECK              (alias inside ngspice: `shell ngspice-hier-op`)
"""
import argparse, json, os, re, subprocess, sys, time

DOUBLINGS = 4
T0 = 50e-9
RSHUNT = 1e9
MIN_DEVICES = 300
PRIM = set("mrcdqivejfhglkbstw")

FAIL_MARKS = (
    "simulation(s) aborted", "could not be simulated successfully",
    "doAnalyses:", "Timestep too small", "no writable vector found",
    "incomplete or empty netlist", "circuit not parsed",
    "Unable to find definition of model",
)


def say(*a):
    print(*a, flush=True)


# ---------- netlist parsing (read-only; the deck is never written) ----------

def logical_lines(path):
    buf = None
    for raw in open(path, errors="replace"):
        ln = raw.rstrip("\n")
        if ln.lstrip().startswith("+") and buf is not None:
            buf += " " + ln.lstrip()[1:].strip()
            continue
        if buf is not None:
            yield buf
        buf = ln
    if buf is not None:
        yield buf


def top_instances(cir):
    out, depth = [], 0
    for ln in logical_lines(cir):
        st = ln.lstrip(); low = st.lower()
        if low.startswith(".subckt"): depth += 1; continue
        if low.startswith(".ends"):   depth -= 1; continue
        if depth == 0 and st[:1] in ("x", "X"):
            toks = [t for t in st.split() if "=" not in t]
            if len(toks) >= 3:
                out.append((toks[0], toks[-1], toks[1:-1]))
    return out


def device_counts(cir):
    defs, cur, depth, top = {}, None, 0, []
    for ln in logical_lines(cir):
        st = ln.lstrip(); low = st.lower()
        if low.startswith(".subckt"):
            depth += 1
            if depth == 1:
                cur = {"kids": [], "prims": 0}
                defs[st.split()[1]] = cur
            continue
        if low.startswith(".ends"):
            depth -= 1
            if depth == 0: cur = None
            continue
        c = st[:1].lower()
        if not c or c in ("*", "."):
            continue
        if c == "x":
            toks = [t for t in st.split() if "=" not in t]
            if len(toks) >= 3:
                (cur["kids"] if cur else top).append((toks[0], toks[-1]))
        elif c in PRIM and cur:
            cur["prims"] += 1
    memo = {}
    def total(name):
        if name in memo: return memo[name]
        d = defs.get(name)
        if not d: return 0
        memo[name] = 0
        n = d["prims"] + sum(total(t) for _i, t in d["kids"])
        memo[name] = n
        return n
    return {i: total(t) for i, t in top}


def port_nets(cir, inst):
    for name, _t, nets in top_instances(cir):
        if name.lower() == inst.lower():
            return {n.lower() for n in nets}
    return set()


# ---------- state files ----------

_ST = re.compile(r"^\s*\.(ic|nodeset)\s+v\((.+?)\)\s*=\s*(\S+)", re.I)


def read_state(path):
    out = []
    for ln in open(path, errors="replace"):
        m = _ST.match(ln)
        if m:
            out.append((m.group(2).strip(), m.group(3)))
    return out


def write_state(path, entries, form):
    with open(path, "w") as f:
        for net, val in entries:
            f.write(f".{form} v({net}) = {val}\n")
    return len(entries)


# .include accepts EITHER quote style, and unquoted.  Matching only one of them
# is silent: the include is skipped, its sources are never seen, and --force
# happily puts a second source on a node one of them already drives -> a source
# loop and a singular <name>#branch row.  Cost me a run to find.
_INCLUDE = re.compile(r"""^\s*\.(?:include|inc)\s+['"]?([^'"\s]+)['"]?""", re.I)


def driven_nodes(cir, _seen=None):
    """Nodes already held by a top-level ideal-voltage element (V, E, H).

    --force must never put a second source on such a node: two ideal voltage
    sources in parallel is a source LOOP, the matrix is structurally singular,
    and the run dies with `singular matrix: check node <something>#branch`.

    Follows .include, because the stimulus normally lives in an included file
    and that is exactly where the supplies and the power-down controls are.
    Unreadable includes (a PDK path that is not mounted) are skipped, not fatal:
    a missing library cannot contribute top-level sources anyway.
    """
    if _seen is None:
        _seen = set()
    cir = os.path.abspath(cir)
    if cir in _seen:
        return set()
    _seen.add(cir)

    out, depth = set(), 0
    try:
        lines = list(logical_lines(cir))
    except OSError:
        return out
    for ln in lines:
        st = ln.lstrip()
        low = st.lower()
        if low.startswith(".subckt"):
            depth += 1
            continue
        if low.startswith(".ends"):
            depth -= 1
            continue
        m = _INCLUDE.match(st)
        if m:
            inc = m.group(1)
            if not os.path.isabs(inc):
                inc = os.path.join(os.path.dirname(cir), inc)
            out |= driven_nodes(inc, _seen)
            continue
        if depth == 0 and low[:1] in ("v", "e", "h"):
            toks = [t for t in st.split() if "=" not in t]
            if len(toks) >= 3:
                out.update(t.lower() for t in toks[1:3])
    return out


_FORCE_BAD = re.compile(r"[^0-9a-z_]+")

# Internal node of a --force clamp.  Deliberately unlikely to collide with a
# design net.  These are OUR nodes, not the circuit's: they must never be saved
# into a state, or the next step clamps them too and creates hopclamp_hopclamp_x
# -- the matrix then grows by a factor per block.  Filtered in emit_both().
CLAMP = "hopclamp_"


def write_force(path, entries, rseries=1.0, norton=False):
    """--force: pin the already-solved part of the circuit with ideal sources.

    Emits `Vname <node> 0 <value>` for every node of the incoming state, so the
    previously folded blocks cannot move and only the newly added block has
    anything to solve -- a Dirichlet boundary around the new block.

    This is NOT the same as seeding with .ic, despite both fixing a value:
      * `.ic` stamps a 1e10 conductance on the node's existing row, so the node
        is held but the circuit can still push against it, and it applies only
        in a tran-op (MODETRANOP && !MODEUIC) -- a plain .op parses it and never
        uses it.
      * a voltage source is ideal: it adds a BRANCH row, supplies unlimited
        current, and applies to every analysis.  The pinned nodes therefore stop
        obeying KCL -- the sources silently absorb whatever mismatch exists, so
        the result is an OP of the new block against a perfect boundary, not an
        OP of the combined circuit.  Releasing the sources and re-solving from
        the result is what turns it into one.

    The instance name is derived from the node name (non-alphanumerics -> '_')
    so the generated deck stays readable; a counter is appended only where
    sanitising two different node names collides.
    """
    seen, n = {}, 0
    with open(path, "w") as f:
        f.write("* generated by ngspice-hier-op --force\n")
        f.write("* every node of the incoming state, clamped towards its solved\n")
        f.write("* value: the already-solved circuit is held, only the new block\n")
        f.write(f"* moves.  Series R = {rseries:g} ohm.\n")
        f.write("*\n")
        f.write("* SOFT clamp, not an ideal source.  An ideal source per node made\n")
        f.write("* the matrix structurally singular on a large deck (immediate\n")
        f.write("* `singular matrix: check node <src>#branch`, before any method\n")
        f.write("* ran) even with source loops ruled out.  With a series resistor\n")
        f.write("* each pinned node KEEPS ITS OWN KCL ROW and the circuit can push\n")
        f.write("* against the clamp, so no rank problem is possible -- and the\n")
        f.write("* clamp can be RELEASED gradually by raising R, which an ideal\n")
        f.write("* source cannot do.\n")
        for net, val in entries:
            # Uniquing must register the GENERATED name too.  Sanitising is
            # many-to-one ('a.b' and 'a_b' both -> a_b), and if the fallback
            # 'a_b_1' is not recorded, a real net literally named 'a_b_1'
            # emits a SECOND element with that name -- one silently wins and a
            # node goes unclamped with no diagnostic.
            root = _FORCE_BAD.sub("_", net.lower()).strip("_") or "n"
            base, k = root, 0
            while base in seen:
                k += 1
                base = f"{root}_{k}"
            seen[base] = True
            if norton:
                # Norton equivalent of the same clamp: a conductance straight on
                # the node's diagonal plus an injected current.  No extra node
                # and no extra branch row -- the matrix stays the size of the
                # plain circuit, where the Thevenin form roughly TRIPLES the
                # unknowns (one node + one V-source branch row per clamp).
                # Price: the injected current is value/R, so at R=1 ohm a 1.8 V
                # node injects 1.8 A and the individual stamps are large even
                # though the NET current is identical to the Thevenin form.
                try:
                    inj = float(val) / rseries
                except ValueError:
                    inj = 0.0
                f.write(f"r{CLAMP}{base} {net} 0 {rseries:g}\n")
                f.write(f"i{CLAMP}{base} 0 {net} {inj:.6g}\n")
            else:
                f.write(f"v{CLAMP}{base} {CLAMP}{base} 0 {val}\n")
                f.write(f"r{CLAMP}{base} {net} {CLAMP}{base} {rseries:g}\n")
            n += 1
    return n


def finite(v):
    try:
        x = float(v)
    except ValueError:
        return False
    return x == x and x not in (float("inf"), float("-inf"))


def emit_both(src, base, drop=()):
    """Every step leaves BOTH forms -- .nodeset seeds a .op, .ic seeds a tran uic.
    Feeding the wrong one is a SILENT no-op, so never make that a judgement call.

    Non-finite entries are dropped.  A node the solver could not resolve comes
    back as nan/inf, and seeding the next step with `.ic v(x) = -nan` is strictly
    worse than saying nothing about that node: the value propagates into the
    matrix instead of leaving the solver free to work it out.
    """
    d = {n.lower() for n in drop}
    kept, bad = [], []
    for n, v in read_state(src):
        if n.lower() in d:
            continue
        if n.lower().startswith(CLAMP):
            continue        # our own --force clamp node; see CLAMP above
        (kept if finite(v) else bad).append((n, v))
    write_state(base + ".ic", kept, "ic")
    write_state(base + ".nodeset", kept, "nodeset")
    return len(kept), len(bad)


# ---------- running ----------

def run_attempt(a, tag, drop, seed, analysis, pstran_only=False):
    """One ngspice run: generated file carries the seed + .control; deck untouched.

    pstran_only selects the configuration for the `.op` taken from a transient
    endpoint: gmin and source stepping OFF, pseudo-transient ON.  Measured on a
    large mixed-signal deck: with them on the OP fails, with them off it lands a
    faithful operating point at the true gmin.  Two reasons, both real:
      * the gmin continuation can FOLD -- the branch of solutions turns back at
        some conductance and no step size crosses it, so gmin cannot get there;
      * on terminal failure the gmin routine does not restore the state it
        started from, so whatever runs after it inherits the wandered point
        rather than your seed.
    pstran continues along a different parameter, so the fold does not apply --
    but only if it is given the seed rather than the wreckage.

    Everything the generated file names is a BARE lowercase filename, and ngspice
    is run with its working directory set to outdir.  ngspice lowercases netlist
    content, so an absolute path written into a .control is silently mangled the
    moment any component contains an upper-case letter (mktemp -d names do) and
    wrnodev then writes nowhere.
    """
    tag = tag.lower()
    inc_name, state_name = tag + ".inc", tag + ".state"
    inc = os.path.join(a.outdir, inc_name)
    state = os.path.join(a.outdir, state_name)
    log = os.path.join(a.outdir, tag + ".log")
    if os.path.exists(state):
        os.remove(state)
    with open(inc, "w") as f:
        f.write("* generated by ngspice-hier-op -- your deck is NOT modified\n")
        if a.rshunt:
            # Removing a block leaves the nets it drove with nothing holding
            # them, so they go singular and come back as nan.  rshunt ties every
            # node to ground through a large resistance; it is structural to this
            # method rather than a tuning knob.  --rshunt 0 turns it off.
            f.write(f".option rshunt={a.rshunt:g}\n")
        if pstran_only:
            # the post-transient .op: hand pstran the seed, not the wreckage of
            # methods that cannot get there anyway (see the docstring above).
            f.write(".option gminsteps=0 srcsteps=0\n")
            f.write(".option pstran\n")
        # NOTE: rung (a) deliberately does NOT enable pstran/dptran.  It is the
        # LAST method inside a single .op (Newton -> gmin -> source stepping ->
        # optran -> pstran), so there it can only ever start from a state that
        # gmin and source stepping have already failed on -- and the gmin routine
        # does not restore the state it began with when it gives up.  Measured on
        # a large mixed-signal deck: in that position it costs many minutes and
        # never converges; given the same seed with gmin/src OFF it converges.
        # That is what pstran_only above is for.  If you want to re-test it here,
        # test it WITH gminsteps=0 srcsteps=0, not as a trailing method.
        if seed:
            f.write(f".include {os.path.basename(seed)}\n")
        f.write(".control\n")
        if drop:
            f.write("disable " + " ".join(drop) + "\nreset\n")
        for c in analysis:
            f.write(c + "\n")
        # `quit` so the deck's OWN analysis cards (a .op/.tran the user already
        # has) do not run after our block: they would double every attempt's
        # cost, and a failure of theirs lands in the same log we judge ours by,
        # marking a perfectly good state as FAILED.
        f.write(f"wrnodev {state_name}\nquit\n.endc\n")
    t0 = time.time()
    with open(log, "w") as fh:
        subprocess.run([a.ngspice, "-b", a.deck, inc_name], cwd=a.outdir,
                       stdout=fh, stderr=subprocess.STDOUT)
    secs = time.time() - t0

    # A seeded node with no matrix diagonal (e.g. its only element was stripped
    # by topology reduction) cannot be seeded under KLU: unlike SPARSE, KLU
    # cannot create the element, and ngspice aborts with a misleading
    # "doAnalyses: out of memory".  Seeding is the whole method here, so fall
    # back to SPARSE for this run only -- the deck's own solver choice is left
    # alone everywhere it works.
    txt = open(log, errors="replace").read()
    if seed and "cannot create a new element" in txt:
        say("    (KLU cannot seed an element-less node; retrying with .option sparse)")
        body = open(inc).read().split("\n")
        body.insert(1, ".option sparse")
        open(inc, "w").write("\n".join(body))
        with open(log, "w") as fh:
            subprocess.run([a.ngspice, "-b", a.deck, inc_name], cwd=a.outdir,
                           stdout=fh, stderr=subprocess.STDOUT)
        secs = time.time() - t0
        txt = open(log, errors="replace").read()

    ok, why = True, "converged"
    if not (os.path.exists(state) and os.path.getsize(state) > 0 and read_state(state)):
        ok, why = False, "no state written"
    else:
        for m in FAIL_MARKS:
            if m in txt:
                ok, why = False, m
                break
    say(f"    [{tag.split('__')[-1]}] {'OK ' if ok else 'FAIL'} {secs:7.1f}s  {why}")
    return ok, state, secs, why


def fmt(t):
    return f"{t*1e9:g}n"


def fold(a, blk, drop, state_in, tag):
    """One block: walk rung a -> b(xN doublings) -> c.  Returns (label, base, rec)."""
    rec = {"block": blk, "drop": list(drop), "attempts": []}
    seed_ns = seed_ic = None
    if state_in:
        # the added block's port nets sat at whatever its ABSENCE imposed -- drop
        # them so the incoming state does not fight the block being added
        nets = port_nets(a.deck, blk) if blk else set()
        base = os.path.join(a.outdir, tag + "__seed")
        n, bad = emit_both(state_in + ".ic", base, drop=nets)
        seed_ns, seed_ic = base + ".nodeset", base + ".ic"
        say(f"   seed: {n} nodes ({len(nets)} of {blk}'s port nets dropped)"
            if blk else f"   seed: {n} nodes")
        if bad:
            say(f"   !! {bad} node(s) came back non-finite (nan/inf) and were NOT seeded.")
            say( "      Removing a block leaves the nets it drove floating; with no")
            say( "      `.option rshunt` nothing ties them down, so they go singular.")

    # --force pins the incoming state with ideal sources instead of guiding the
    # solve with a .nodeset.  Rung (a) only: rung (b) is a `tran uic`, and a
    # circuit whose every node is held by a source cannot evolve, so forcing
    # there would freeze the transient it depends on.
    seed_a = seed_ns
    if state_in and a.force:
        ff = os.path.join(a.outdir, tag + "__force.cir")
        if not hasattr(a, '_drv_cache'):
            a._drv_cache = driven_nodes(a.deck)   # 9 blocks -> parse once
        drv = a._drv_cache
        ent, skipped = [], 0
        for n_, v in read_state(state_in + ".ic"):
            if not finite(v):
                continue
            if n_.lower() in nets:
                # A PORT NET of the block being added.  Its value in the incoming
                # state was produced with this block ABSENT, so clamping it there
                # holds the net at whatever its absence imposed and the new block
                # can never drive it.  Concretely: before xref is folded, vref
                # sits at 0; clamp vref to 0 through 1 ohm and the bandgap has to
                # fight the clamp to come up, and loses.  The interface must stay
                # free -- only the INTERIOR of the already-solved circuit is held.
                skipped += 1
                continue
            if n_.lower().startswith(CLAMP):
                skipped += 1        # never clamp a clamp
                continue
            if "#" in n_:
                # NEVER put a source on a '#' row.  `<name>#branch` is a BRANCH
                # CURRENT unknown, not a voltage node, and device-internal rows
                # (m...#source) are the solver's, not the netlist's.  Naming one
                # in an element line does not pin that row -- it invents a new
                # node with that name and leaves the real row unconnected.
                skipped += 1
                continue
            if n_.lower() in drv or n_.strip() == "0":
                skipped += 1        # already held by a source -> would be a loop
                continue
            ent.append((n_, v))
        nf = write_force(ff, ent, a.force_r, a.force_norton)
        say(f"   force: {nf} nodes soft-clamped at {a.force_r:g} ohm (--force);"
            f" the already-solved circuit is held")
        if skipped:
            say(f"          {skipped} skipped: port nets of {blk} (must stay free),"
                f" nodes already driven by a top-level V/E/H source (a second"
                f" source there is a loop), '#' branch/internal rows, and ground")
        seed_a = ff

    ok, st, secs, why = run_attempt(a, tag + "__a", drop, seed_a, ["op"])
    rec["attempts"].append({"rung": "a", "t": None, "ok": ok, "secs": secs, "why": why})
    if ok:
        out = os.path.join(a.outdir, tag)
        emit_both(st, out)
        rec.update(result="op", rung="a")
        return "op", out, rec

    best, t = None, a.t0
    for i in range(a.doublings):
        ok, st, secs, why = run_attempt(
            a, f"{tag}__b{i}_tran{fmt(t)}", drop, seed_ic,
            [f"tran {t/100:.4g} {t:.4g} uic"])
        rec["attempts"].append({"rung": "b.tran", "t": t, "ok": ok, "secs": secs, "why": why})
        if not ok:
            say(f"    tran produced no state at {fmt(t)}; not doubling further")
            break
        best = st
        mid = os.path.join(a.outdir, f"{tag}__b{i}_end")
        emit_both(st, mid)
        ok2, st2, secs2, why2 = run_attempt(
            a, f"{tag}__b{i}_op", drop, mid + ".nodeset", ["op"], pstran_only=True)
        rec["attempts"].append({"rung": "b.op", "t": t, "ok": ok2, "secs": secs2, "why": why2})
        if ok2:
            out = os.path.join(a.outdir, tag)
            emit_both(st2, out)
            rec.update(result="op", rung=f"b@{fmt(t)}")
            return "op", out, rec
        t *= 2

    if best:
        say("    ladder exhausted; delivering the transient endpoint as the state")
        out = os.path.join(a.outdir, tag)
        emit_both(best, out)
        rec.update(result="transient", rung="c")
        return "transient", out, rec

    rec.update(result="both-failed", rung=None)
    return None, None, rec


def last_tran_t(rec):
    ts = [x["t"] for x in rec.get("attempts", []) if x["rung"] == "b.tran" and x["ok"] and x["t"]]
    return max(ts) if ts else None


def main():
    ap = argparse.ArgumentParser(
        prog="ngspice-hier-op",
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""examples:
  ngspice-hier-op chip.cir --list         show the fold order, run nothing
  ngspice-hier-op chip.cir                fold every block, chain the states
  ngspice-hier-op chip.cir --t0 5e-6      start clocked blocks at 5us, not 50n

inside ngspice (or a .control block):
  hier_op chip.cir

output (in --outdir, default ./hier_op):
  <step>.ic        state as .ic       -- seeds a `.tran ... uic`
  <step>.nodeset   same state         -- seeds a `.op`
  <step>.json      which rung produced it, and what each attempt cost
  <step>__*.log    the ngspice run behind every attempt

Each state is labelled `op` (a converged operating point) or `transient` (an
endpoint delivered because the ladder was exhausted).  A `transient` state is a
usable starting point, not an operating point -- the label is the difference.""")
    ap.add_argument("deck", help="your netlist; it is NEVER modified")
    ap.add_argument("--ngspice", default="ngspice", help="ngspice binary")
    ap.add_argument("--outdir", default="hier_op", help="where states/logs are written")
    ap.add_argument("--min-devices", type=int, default=MIN_DEVICES,
                    help="ignore top-level instances smaller than this; below it the "
                         "segmentation is too fine to be worth a step (default %(default)s)")
    ap.add_argument("--rshunt", type=float, default=RSHUNT,
                    help="resistance from every node to ground, added to each run; "
                         "removing blocks leaves floating nodes, so this is "
                         "structural here (0 disables, default %(default)g)")
    ap.add_argument("--t0", type=float, default=T0, help="first transient length")
    ap.add_argument("--doublings", type=int, default=DOUBLINGS)
    ap.add_argument("--force-r", type=float, default=1.0, metavar="OHMS",
                    help="series resistance of the --force clamp (default 1). "
                         "Smaller holds harder; larger releases.  Raising it in "
                         "steps and re-solving is a continuation that releases the "
                         "clamp gradually.")
    ap.add_argument("--force-norton", action="store_true",
                    help="emit the --force clamp as its Norton equivalent (R to "
                         "ground + injected current) instead of a source behind a "
                         "resistor.  Electrically identical; keeps the matrix the "
                         "size of the plain circuit instead of ~3x, at the cost of "
                         "large individual stamps (value/R amps per node).")
    ap.add_argument("--force", action="store_true",
                    help="pass the previous state to rung (a) as ideal voltage "
                         "sources (Vname node 0 value) instead of a .nodeset, so "
                         "the already-solved circuit is LOCKED and only the newly "
                         "added block has to solve.  The pinned nodes stop obeying "
                         "KCL -- the sources absorb any mismatch -- so the result "
                         "is an OP of the new block against a fixed boundary, not "
                         "of the whole circuit; release and re-solve to get that. "
                         "Rung (b) is unaffected: a fully pinned circuit cannot "
                         "run a transient.")
    ap.add_argument("--list", action="store_true", help="show fold order and exit")
    a = ap.parse_args()

    a.deck = os.path.abspath(a.deck)
    if not os.path.exists(a.deck):
        sys.exit(f"no such deck: {a.deck}")
    # A relative --ngspice would be resolved against whatever directory each run
    # happens to start in, not the one the user typed it from.  A bare name is
    # left alone so PATH lookup still works.
    if os.sep in a.ngspice:
        a.ngspice = os.path.abspath(a.ngspice)
    os.makedirs(a.outdir, exist_ok=True)
    a.outdir = os.path.abspath(a.outdir)

    dc = device_counts(a.deck)
    cand = sorted(((n, c) for n, c in dc.items() if c >= a.min_devices),
                  key=lambda kv: kv[1])
    if not cand:
        sys.exit(f"no top-level instances with >= {a.min_devices} devices in {a.deck}")
    if a.list:
        say(f"fold candidates (>= {a.min_devices} devices), smallest-first:")
        for n, c in cand:
            say(f"  {c:8d}  {n}")
        return 0

    order = [n for n, _c in cand]
    say(f"deck   : {a.deck}  (never modified)")
    say(f"blocks : {len(order)} to fold, smallest-first: {', '.join(order)}")

    out = list(order)
    say("\n--- base: all fold candidates disabled, no seed")
    label, state, rec = fold(a, None, out, None, "base")
    json.dump(rec, open(os.path.join(a.outdir, "base.json"), "w"), indent=2)
    if not label:
        say("!! the base subset itself produced no state -- nothing to build on")
        return 2
    say(f"   -> base state [{label}]")

    folded, prev, backtracked, stopped = [], None, False, None
    i = 0
    while i < len(order):
        blk = order[i]
        rest = [b for b in out if b != blk]
        tag = f"s{i+1}_{blk}"
        say(f"\n--- [{i+1}/{len(order)}] fold {blk}")
        step = {"blk": blk, "tag": tag, "state_in": state, "drop": rest}
        label, newstate, rec = fold(a, blk, rest, state, tag)
        json.dump(rec, open(os.path.join(a.outdir, tag + ".json"), "w"), indent=2)

        if label:
            step["rec"] = rec
            state, out = newstate, rest
            folded.append((blk, label, rec.get("rung")))
            say(f"   -> state [{label}] via rung {rec.get('rung')}")
            prev = step
            i += 1
            continue

        say(f"    {blk} failed BOTH .op and tran uic")
        if backtracked:
            stopped = f"{blk} still fails after one backtrack"; break
        if prev is None:
            stopped = f"{blk} fails and there is no preceding step to lengthen"; break
        t_used = last_tran_t(prev["rec"])
        used = sum(1 for x in prev["rec"]["attempts"] if x["rung"] == "b.tran")
        if not t_used:
            stopped = (f"{blk} fails and {prev['blk']} came from .op -- "
                       f"no transient to lengthen"); break
        if used >= a.doublings:
            stopped = f"{blk} fails and {prev['blk']} already used all its doublings"; break
        say(f"    backtracking to {prev['blk']}: re-running from {fmt(t_used*2)}")
        a2 = argparse.Namespace(**vars(a)); a2.t0 = t_used * 2
        a2.doublings = a.doublings - used
        l2, s2, r2 = fold(a2, prev["blk"], prev["drop"], prev["state_in"],
                          prev["tag"] + "_bt")
        backtracked = True
        if not l2:
            stopped = f"backtrack of {prev['blk']} produced no state"; break
        state = s2; prev["rec"] = r2
        say(f"    {prev['blk']} re-solved longer; retrying {blk}")

    say("\n=== summary ===")
    for blk, res, rung in folded:
        say(f"  folded {blk:14s} [{res} via {rung}]")
    say(f"  final state : {state}.ic  /  {state}.nodeset")
    say(f"  still out   : {', '.join(out) or 'none'}")
    if stopped:
        say(f"  STOPPED     : {stopped}")
        say("  (every state produced up to here is still on disk and usable)")
    return 1 if stopped else 0


if __name__ == "__main__":
    sys.exit(main())
