functional_block_recognizer.py — Layer 2

The judge. Layer 1 handed over a pile of overlapping candidates; this file decides which one truly fills each functional role — with a topology if it has one, by ranking heuristics if it doesn't.

Part 1 · Why does Layer 2 exist?

The one job: "Layer 1 gave me many overlapping candidates for the same devices. Which one is the real input pair, the real load, the real tail?"

Remember Layer 1's deliberate cowardice: it reports every pattern that fits and never picks a winner.

So a single differential pair might show up as an input_pair candidate and get re-matched by a bias-mirror pattern, and a cascode pattern, all overlapping.

Someone has to be the judge. That's this file.

The analogy: casting a play from a huge audition pile

Layer 1 is an open audition — everyone who vaguely fits a role gets a callback slip. Layer 2 is the casting director: exactly one actor per role, chosen by how well they actually fit the part. And the "part" is defined by wiring — which nets a real input pair must connect to.

Part 2 · Two modes, because sometimes you know the script

The casting director works in two very different situations:

SR candidates arrive (overlapping, unjudged) know topology don't assign_slots() match each slot's expected wiring; precise, exact assignment group_by_category() filter noise, rank by heuristics; best-guess grouping
With a topology template it's exact matching. Without one, it's a funnel of filters and rankings that make a best guess.

Topology mode is the easy, precise case — you have the blueprint. Topology-free mode is the hard, heuristic case — most of this file's complexity lives there.

Part 3 · The two scorers — tiny, and everything hinges on them

_connectivity_score — used by topology mode

"How many of this candidate's pins land on the exact nets the slot expects?"

return sum(1 for pin, net in structure.pins.items()
           if slot_connections.get(pin) == net)

A candidate whose in1/in2/tail pins all match the slot's expected nets scores 3; a spurious overlap scores 0. Highest score wins the slot.

_external_port_score — used by topology-free mode

"How many of this candidate's pins touch a subcircuit external port?"

return sum(1 for net in structure.pins.values() if net in external_ports)

This is the port-vs-internal distinction from Layer 0, finally cashing in. A real input pair's in1/in2 hit the subcircuit's actual input ports; an internal bias mirror touches no ports. Port-adjacency becomes the proxy for "is this on the real signal path?"

Two five-line functions carry the entire judgement. Everything else in the file is orchestration around these two scores.

Part 4 · assign_slots() — topology mode, traced

The one job: "For each named slot in the topology, pick the candidate whose wiring fits best, and don't reuse a candidate twice."

for slot in topology.slots:
    candidates = [s for s in sr_result.structures
                  if s.category == slot.category and id(s) not in assigned_ids]
    if not candidates: continue
    slot_connections = topology.slot_connections(slot.name)
    best = max(candidates, key=lambda s: _connectivity_score(s, slot_connections))
    slot_assignments[slot.name] = SlotAssignment(...)
    assigned_ids.add(id(best))

Two guards do the real work:

  • category filter — only candidates of the slot's category are even considered;
  • assigned_ids — once a structure fills a slot, its id() is banked so it can't fill a second slot of the same category (crucial when a fully-differential topology has two compensation slots).

Why score even when a category has only one slot? Because SR overlaps mean a category can have several candidates regardless of slot count. Connectivity scoring picks the one actually wired into that slot's nets. A slot with no candidates is silently omitted — an honest "I didn't find this."

Part 5 · group_by_category() — the topology-free funnel

The one job: "With no blueprint, throw out the obvious noise and rank what's left into a best-guess block → category → candidate structure."

This is the file's centerpiece: a multi-pass funnel that narrows a noisy candidate pile into clean groups.

initial grouping: block → category Filter pass drop spurious gain_stage candidates (classes A/B/C) Multi-category pass (gain_stage_1) re-rank input_pair by distinct external ports; then follow the signal chain to load & tail_current Split pass split single-category blocks into gain_stage_N by port score CategoryGroupResult
A four-stage funnel: group, prune noise, re-rank the multi-category stage by signal chain, then split remaining stages.

The initial grouping sorts every category's candidates by _external_port_score descending. Then the passes correct that ranking where it's misleading — which is the interesting part.

Part 6 · The three classes of spurious matches

The filter pass exists because Layer 1's over-reporting produces predictable false positives. The code names three and kills each:

ClassWhat re-matchedTell-tale sign
AAn input-pair / bias-ref nmos re-matched as a gain stage'in' pin on an external port
BA bias mirror's pmos leg re-matched as a gain stage'bias' pin on an external port
CCascode load devices re-matched as a gain stagean nmos whose source ≠ gnd!
# Class A & B: pin lands on an external port → not a real stage input
if s.pins.get("in") in ext or s.pins.get("bias") in ext:
    continue
# Class C: (single-category blocks only) a floating-source nmos = cascode
if single_cat and any(
        d.type == "nmos" and d.terminals.get("s") != _GND
        for d in s.devices):
    continue

Why is Class C gated on single_cat?

Because a genuine input pair's nmos sources connect to net_tail, not gnd! — applying Class C to a multi-category gain_stage_1 block would wrongly delete the real input pair. Restricting it to single-category (second-stage) blocks avoids that. This is exactly the kind of subtle context-dependence that makes topology-free mode hard.

The signal-chain following

The multi-category pass is the cleverest bit. After re-ranking input_pair by how many distinct external ports its {in1, in2} hit (a real diff pair hits two; a bias mirror hits one; a spurious pair hits zero), it uses the winning input pair to chain-follow:

load.in1/in2 ≟ input_pair.out1/out2  ·  tail_current.out ≟ input_pair.tail

In plain English: the real load is the one whose inputs are wired to the input pair's outputs; the real tail is the one whose output is wired to the input pair's tail node. The circuit's own connectivity resolves the ambiguity — no topology needed.

Part 7 · The split pass — separating stacked stages

The one job: "A three-stage opamp has several gain stages of the same category. Order them from first to last."

If a gain_stage_* block has exactly one category but more than one candidate, those candidates are almost certainly consecutive stages. Sort them by ascending _external_port_score and fan them out into numbered blocks:

structs.sort(key=lambda s: _external_port_score(s, ext))
for i, s in enumerate(structs):
    to_add.append((f"gain_stage_{base + i}", cat, [s]))
gain_stage_2 3 candidates gain_stage_2 (score 0) gain_stage_3 (score 1) gain_stage_4 (score 2) highest port-score = closest to output = last stage
The candidate whose 'out' pin reaches the external output port scores highest and becomes the final stage.

The intuition: the later a stage sits in the chain, the closer it is to the external output port, so the more external ports it touches. Ascending port-score = first-to-last stage order.

Part 8 · The hidden idea

Underneath all three passes is one principle, applied over and over:

Connectivity is identity. A structure's role in the circuit is determined entirely by which nets its pins touch — external ports vs. internal nets, and whether those internal nets chain to a neighbor's pins.

Topology mode states this exactly: score against known expected nets. Topology-free mode approximates it with proxies: external-port adjacency stands in for "on the signal path," and pin-net equality between neighbors stands in for "wired together." Every filter, every re-sort, every split is just a different way of asking where does this thing connect?

And notice the payoff of Layer 1's earlier restraint: because SR over-reported and never disambiguated, Layer 2 has all the candidates it needs to make these connectivity comparisons. Had SR committed early, the true match might already have been thrown away. The two layers' contracts fit together like a lock and key.


In one sentence: functional_block_recognizer.py is the casting director that turns Layer 1's overlapping audition pile into one actor per role — by exact wiring when a topology blueprint exists, and by a funnel of connectivity heuristics (port-adjacency, signal-chain following, stage-splitting) when it doesn't.

Looking ahead → Part 6 · __init__.py is the last stop: the tiny public façade that re-exports everything you've now met into one clean, importable API.