hooks.py — Layer 1 escape hatches

When a fixed-size template can't describe a variable-size structure, a hook runs at match time to accept-and-extend (or reject) it.

Part 1 · Why do hooks exist at all?

The one job: "A pattern's template has a fixed number of devices. But some real structures have a variable number. How do we match those?"

A same_net template is rigid: it says "exactly two nmos, sources tied." Perfect for a differential pair.

But a bias generator is not fixed-size. It has one master reference and then however many output "legs" the circuit happens to need — anywhere from 1 to 7. You can't write a template for "1 to 7 of something."

So the recognizer offers an escape hatch. A pattern can name a hook — a "module:function" path. After the base template matches, recognize() calls the hook, handing it the match, and lets the hook discover the rest by walking the netlist directly.

The analogy: a building inspector's clipboard

The template finds the front door (the master reference). The hook is the inspector who then walks the whole house counting rooms, and either signs off with the full room list attached — or condemns the building (returns None).

Part 2 · The hook contract

Every hook has the identical signature and one of two possible outcomes:

def hook(assignment, pins, netlist) -> HookMatch | None
base template matched (assignment, pins, netlist) hook decides HookMatch → accept + extend None → drop the match
A hook is a veto with a bonus: it can kill a match, or accept it and staple extra devices/pins on.

A returned HookMatch carries two lists that get merged into the final structure:

  • extra_devices — appended to RecognizedStructure.devices.
  • extra_pins — merged into RecognizedStructure.pins (e.g. leg1_out, leg2_out, vdd).

Recall from the recognizer exactly where this fires:

if pattern.hook:
    result = _resolve_hook(pattern.hook)(assignment, pins, netlist)
    if result is None:
        continue                       # hook rejected → skip this match
    devices_list = devices_list + result.extra_devices
    pins = {**pins, **result.extra_pins}

Part 3 · The "legs" idea — the motivating case

The one job: "Starting from one diode-connected master reference, find every output leg that mirrors it."

A current-mirror bias generator looks like a comb: one master reference tooth, then many parallel legs, each mirroring the reference current out to a different rail.

master (mref) diode nmos g=d=ibias ibias net (shared gate line) leg 1 nmos + pmos → leg1_out leg 2 → leg2_out … up to 7
The template matches only the master. The hook walks the ibias rail and discovers each leg gated off it.

A "leg" is a self-contained little group: an nmos whose gate ties to the master's ibias node and source to gnd (so it mirrors the reference current), paired with whatever delivers its output — a diode-connected pmos, or a resistor, depending on the flavor.

Part 4 · One leg, traced by hand

Take diode_connected_mosfet_bias_legs. The master reference is mref with:

mref.terminals = {d:"ibias", g:"ibias", s:"gnd!", b:"gnd!"}
ibias_net = "ibias"   gnd_net = "gnd!"

The hook scans every device looking for an nmos leg. Consider candidate mL:

CheckmLPass?
type == nmosnmos
gate == ibias_netg = "ibias"
source == gnd_nets = "gnd!"

It mirrors the master! Its drain net (say "n_leg1") becomes the leg's output. Now find the pmos sitting on that drain, diode-connected:

pmos_leg: p.type=="pmos" and p.d==out_net and p.g==out_net and p.s==p.b

Found pL with d=g="n_leg1", s=b="vdd!". The leg is complete:

leg_count = 1
extra_devices += [mL, pL]
extra_pins["leg1_out"] = "n_leg1"
extra_pins.setdefault("vdd", "vdd!")   # first leg records the supply
claimed.add(mL.ref); claimed.add(pL.ref)

The claimed set is the quiet hero here.

It stops a device being counted into two legs. Every candidate loop begins with if …ref in claimed: continue, so the scan naturally partitions the netlist into disjoint legs.

Part 5 · The three leg-hunters — same skeleton, different legs

Three hooks share the exact same shape (find mirrors of a reference, pair each with its output device) and differ only in what a "leg" is made of:

HookReference (mref)A leg is…Records
diode_connected_mosfet_bias_legsdiode nmos on gnd(nmos mirror, diode pmos)vdd
magic_battery_bias_legsdiode pmos on vdd(pmos mirror, diode nmos)gnd
resistor_bias_legsdiode pmos on vdd(pmos mirror, resistor)gnd

They're mirror images with the polarity flipped — the "why" behind writing three near-identical functions instead of one generic one is readability: each reads top-to-bottom as the physical story of that specific bias style.

The fourth, richer one: constructed_bias_legs

This is the heavyweight. It handles a constructed multi-reference generator with a two-pass sweep:

Pass 1 — NMOS-referenced nmos mirrors of master; each drain hosts • a plain pmos diode (a pnode), or • a riding diode + floor resistor (cascode_vdd), or • an nmos cascode + top diode (pref branch), or • nothing (bare mirror) Pass 2 — PMOS-referenced for each pnode found in pass 1, find pmos legs gated on it whose drain hosts a diode nmos or a gnd resistor pnodes
Pass 1 discovers PMOS reference gates (pnodes); Pass 2 follows each to its PMOS-referenced legs.

The drain condition in Pass 2 ("its drain must host a diode or a resistor to gnd") is what keeps consumer devices out — a second-stage pmos gated by a bias rail has neither on its drain, so it's correctly ignored.

Part 6 · Guard hooks — rejection as the whole point

Not every hook extends a match. Some exist purely to reject false matches. These are the simplest hooks in the file:

def resistor_tail_vdd_check(assignment, pins, netlist):
    if assignment["r1"].terminals["t1"] != "vdd!":
        return None                      # not a tail resistor → reject
    return HookMatch(extra_devices=[], extra_pins={})

Why needed? The resistor_tail_vdd pattern's template is just "one resistor" — with no constraints it would match every resistor in the netlist (load resistors, degeneration resistors, bias resistors). The guard says "only if one end is the global vdd! rail," pinning it to the actual tail resistor.

Four such guards exist (tail_vdd, tail_gnd, load_vdd, load_gnd) — each a one-line rail check that a template alone can't express.

The tricky rejection contract between hooks

Notice the interplay: constructed_bias_legs returns None when it finds only the plain NMOS-referenced shape, deliberately ceding that match to diode_connected_mosfet_bias_legs (listed after it). This keeps legacy single-flavor netlists recognized under their historical names. The rejection isn't a failure — it's a handoff.

Part 7 · The hidden idea

Hooks turn the recognizer from a fixed-template matcher into something strictly more powerful:

fixed template + runtime graph-walk = variable-arity pattern

The template supplies the anchor — the one device the search can hang onto. The hook supplies the unbounded exploration — following nets outward from that anchor to discover however many devices are actually there. It's the same trick a regex engine uses when it hands off to a callback for a match that plain regex syntax can't express.

And the design keeps hooks pure and total in the same spirit as the rest of Layer 1: a hook only ever reads the netlist and returns a value. It never mutates anything, never picks between competing structures — it just says "yes, and here's the rest" or "no." All the disambiguation still waits for Layer 2.


In one sentence: hooks.py is a set of building inspectors that start from one anchor device a template found, walk the netlist to discover a variable number of extra devices (a bias generator's legs), and either sign off with the full list attached or reject the match outright.

Looking ahead → Part 5 · functional_block_recognizer.py is Layer 2 — where all those overlapping SR candidates finally get resolved into named functional slots.