Part 1 · Why this file even exists
The one job: be the single dictionary of naming conventions — which slot names exist, which bias-current group each belongs to, and which net names are rails — so the sizer can recognize any topology that speaks this vocabulary without a single line of new code.
Here is the whole story without reading the function at the bottom.
Two sizers — the Level-1 analytical sizer and the gm/Id pipeline — both need to look at a circuit template and answer questions like "does this device carry the full tail current or half of it?" and "is this gate tied to a supply rail?" Every one of those answers depends on a naming convention: a differential input transistor is always in a slot called input_pair, a supply is always a net called vdd!, and so on.
Think of it like a library catalog — or the standardized part numbers in an electronics warehouse. The books (transistors) live all over the building, but the catalog says exactly which shelf (bias group) each call-number prefix belongs to. Nobody re-invents the shelving scheme per book; they look it up in one place.
Without such a catalog, every function that reasons about currents or rails would hard-code its own list of magic strings. Add a new topology with a new second-stage naming scheme, and you'd be hunting those magic strings across the whole sizer. This file is that catalog — the frozensets below are the shelving scheme, gathered so a new topology is supported by extending groups here, and only here.
The file has three moving parts, and we'll take them in learning order: the slot groups (Part 2) that classify transistors by how much current they carry and what stage they belong to, the net names (Part 3) that mark supply and bias rails, and the single tiny function (Part 4), is_signal_device(), that leans on those net names to tell a signal transistor from a current-source load.
Looking ahead: Part 2 opens the two current-group buckets and shows why a differential pair carries half the tail current while the tail carries all of it.
Part 2 · Slots → bias & stage groups
The one job: sort every FBR slot name into the group that tells the sizer how much current the device carries and whether it counts as a gain stage.
The two bias-current groups
# Slots that carry iBias/2 per transistor (both sides of the differential pair).
HALF_BIAS_SLOTS = frozenset({"input_pair", "load"})
# Slots whose transistors each carry the full iBias.
FULL_BIAS_SLOTS = frozenset({"tail_current", "bias_gen"})
Why two groups? Because Kirchhoff's current law splits the tail. A differential pair is a fork in a river: one tail source pushes a current iBias up into the pair, and it divides evenly down the two branches. Each branch device — and the load stacked above it — therefore sees only iBias/2. The tail transistor and the bias generator that mirrors it, sitting on the un-forked trunk, each carry the whole iBias.
A concrete lookup. The sizer holds a device in slot "input_pair" and asks "what current?" It checks:
"input_pair" in HALF_BIAS_SLOTS # True → iBias / 2
"tail_current" in FULL_BIAS_SLOTS # True → iBias
No arithmetic lives here — just membership. The meaning ("halve it" / "don't") lives in the sizer; this file only supplies the sorted set.
The stage groups — and one deliberately-excluded set
SECOND_STAGE_SLOTS = frozenset({"second_stage", "second_stage_p", "second_stage_n"})
THIRD_STAGE_SLOTS = frozenset({"third_stage", "third_stage_p", "third_stage_n"})
OUTPUT_STAGE_SLOTS = frozenset({"output_stage", "output_stage_p", "output_stage_n"})
# All gain-stage slot names (used by the topology-mismatch guard).
STAGE_SLOTS = SECOND_STAGE_SLOTS | THIRD_STAGE_SLOTS
Each stage has one single-ended name (second_stage) plus two fully-differential names (second_stage_p / _n). Grouping all three under one frozenset means the sizer says slot in SECOND_STAGE_SLOTS once and works for both SE and FD topologies — a tidy example of the catalog absorbing a naming variation so callers don't have to.
Now the hidden idea, drawn. STAGE_SLOTS is the union of the second- and third-stage sets — and OUTPUT_STAGE_SLOTS is pointedly left out:
Why is OUTPUT_STAGE_SLOTS kept out of STAGE_SLOTS? A source-follower output buffer has gain ≈ 1. If it were counted as a gain stage, the gain / phase-margin / topology-mismatch machinery would multiply an extra ≈1 factor into the gain product and mis-classify the topology. Leaving it out keeps the follower invisible to that machinery — its devices are still sized (via their per-transistor intent) but never counted as a stage. I like this: the exclusion is a one-line design decision made loud by its own comment.
Part 3 · Nets → bias rails
The one job: name the special nets — the ones that mean "this gate is tied to a fixed supply/bias" or "this node is an AC ground" — so downstream code recognizes them without hard-coding strings.
# External supply / bias net names — gate connected to these → current-source load.
BIAS_NETS = frozenset({"vdd!", "vss!", "gnd!", "ibias"})
# Supply-rail net names (AC grounds for output-resistance walks).
RAILS = frozenset({"vdd!", "vss!", "gnd!", "0"})
These two sets look almost the same but answer different questions, and the difference is the whole point. They share the three physical supplies vdd!, vss!, gnd! — but each keeps one member the other doesn't:
Why the split? The two sets are consulted in different walks:
BIAS_NETSanswers "is this transistor's gate held at a fixed potential?" A gate onibiasmeans a mirror reference — a current-source load, not a signal device. Butibiasis not a supply rail you'd treat as an AC ground.RAILSanswers "is this node an AC ground?" for output-resistance walks. SPICE's universal ground"0"counts as a rail there, but you'd never say a gate on"0"makes a bias mirror.
| Question being asked | Set to check | The odd member |
|---|---|---|
| Gate on a fixed bias → current-source load? | BIAS_NETS | ibias |
| Node is an AC ground for rout walks? | RAILS | 0 |
Part 4 · is_signal_device() & the one lever
The one job: of the two transistors in a gain stage, decide which one carries the signal (its gate is driven by the previous stage's output) versus the one that's just a current-source load (its gate sits on a bias net).
def is_signal_device(device: Device) -> bool:
gate = device.terminals.get("g", "")
return (bool(gate) and gate not in BIAS_NETS
and not gate.startswith("net_bias") and not gate.endswith("_pref")
and not gate.endswith("_ncasc"))
Analogy: you're sorting mail. A letter is "real correspondence" (signal) unless it matches one of a few junk-mail patterns (bias). The function is a chain of ands — the device is a signal device only if the gate net passes every filter. Fail any one and it's a bias device. The neat part is it works regardless of NMOS/PMOS polarity: it never looks at the transistor type, only at where the gate is wired.
Traced on four real gate nets
| Gate net | Which filter fires | Verdict |
|---|---|---|
net_out1 | passes all five | signal device ✓ |
vdd! | in BIAS_NETS | bias (current-source load) |
net_bias3 | starts with net_bias | bias (internal ref) |
bias_gen_pref | ends with _pref | bias (mirror reference) |
The last two patterns catch the constructed bias generator's internal reference gates — *_pref (the PMOS-side mirror reference) and *_ncasc (the wide-swing cascode level). Those come from synthesizer/config/bias_legs.yaml, slot-prefixed on assembly to names like bias_gen_pref. Devices gated by them are bias plumbing, not signal — hence the two endswith guards.
The hidden idea: extend in exactly one place
Step back and the whole file has a single organizing principle:
Every sizer function reasons through these sets, never through inline string literals. So a new topology that introduces, say, a fourth-stage slot or a new bias-net convention is supported by adding one member to a frozenset in this module — and every downstream walk picks it up for free. That's the payoff the docstring promises: a conforming topology needs no sizer changes, and a non-conforming one is onboarded by editing this catalog alone.
What it honestly does not do
- It stores no behavior — no currents are computed, no rails resolved to volts. It only classifies names; the meaning lives in the callers.
- The bias-reference detection is string-pattern based (
net_bias*,*_pref,*_ncasc). A topology that names an internal reference differently would slip pastis_signal_device()until its convention is added here. - The groups are curated by hand. Nothing enforces that a template actually uses these slot names — a typo'd slot simply falls into none of the sets and is silently un-classified.
In one sentence: taxonomy.py is the sizer's single source of truth for naming — four slot groups that say how much current a device carries and whether it's a gain stage (with source-follower buffers deliberately excluded), two net sets that mark bias rails versus AC grounds, and one small polarity-agnostic function that reads those net sets to separate signal transistors from current-source loads — so a new topology is supported by extending a set here, and only here.