intent.py — explained

circuitgenome/sizer/gmid/intent.py — making analog design intent explicit as a three-level hierarchy, so every gm/Id choice is named, reasoned, and overridable.

Part 1 · Why this file even exists

The one job: take every fuzzy human sentence like "the current-source load should be strong-ish for high output resistance" and turn it into a small, named, overridable data object the sizer can read top-down — and that it can later explain back to you.

Here is the whole story before a single line of code.

A gm/Id sizer has to answer a hundred tiny "how strong should this transistor be?" questions. Each answer is really a design decision: which inversion region to put the device in, how long to make its channel, and why. The naive way is to sprinkle magic numbers through the sizing code — gm_id = 10 here, L = 4 * Lmin there — with the reasoning living only in the author's head.

Think of it like a recipe. You could write "add some salt" in ten different places and hope every cook guesses the same amount. Or you could write one ingredients table at the top — salt: 1 tsp, "brings out the sweetness" — that every step reads from, that a guest can inspect, and that you can tweak in exactly one place. intent.py is that ingredients table for transistor sizing.

The three levels of intent

The file's whole shape is a three-level hierarchy. The spec says what the circuit must do; the middle layer says how each building block should behave and why; the bottom layer is that intent stamped onto each physical device.

Level 1 · Circuit intent — SizingSpec gain · GBW · PM · swing · power (the what) Level 2 · Functional-block intent — BlockIntent per block: role · gm/Id region · L multiple · rationale tune / override Level 3 · Transistor intent — TransistorIntent the block intent resolved onto each device
Top-down intent. The middle layer (highlighted) is the tunable one — the single place a caller or optimizer reaches in to retune a building block.

Good case vs. bad case

Why bother with a whole file for this? Because the alternative rots. Compare the two worlds:

implicit — magic numbers gm_id = 10 # ??? ✗ reasoning lives in someone's head ✗ can't retune one block in isolation ✗ the result can't explain itself ✗ same 10 copied to five places explicit — BlockIntent registry BlockIntent(CS, 10, 4, "high rout…") ✓ rationale travels with the number ✓ override one block, rest fall back ✓ surfaced in the sizing result ✓ one source of truth
Same numbers, opposite maintainability. The file's real product isn't the values — it's making the values named, reasoned, and reachable.

The cast of characters

The file is small — two dataclasses, one lookup function, one resolver, and a top-level config object. Hover any box to trace who it leans on.

resolve_transistor_intents() stamp intent onto every device functional_block() (slot, role) → block name DEFAULT_BLOCK_INTENTS the 10 default block intents _SIGNAL_BLOCK / _CS_BLOCK slot → block lookup tables BlockIntent role · gm/Id · L · rationale TransistorIntent the per-device result
The resolver on top; a lookup function and a registry feed it; the output is one TransistorIntent per device. We'll learn it bottom-up so the top reads easily.

Looking ahead: before any code, Part 2 builds the one idea the whole file rests on — that gm/Id is not a measurement but a choice of inversion region.

Part 2 · gm/Id is a design choice, not a reading

You need exactly one concept before the code makes sense. It's less scary than it looks.

First — what is gm/Id?

gm is a transistor's transconductance: how much output current wiggle you get per volt of input wiggle. It's the raw material of gain. Id is the DC current the device burns. So gm/Id is transconductance efficiencyhow much gain you buy per microamp of current. Units: 1/V.

Analogy: think of gm/Id as miles per gallon for a transistor. A high value means lots of "go" (gm) for little "fuel" (Id). A low value means you're burning current hard for the same gm.

The key move: choosing gm/Id chooses the inversion region

Here's the pivot that makes the whole methodology work. The value you pick for gm/Id maps one-to-one onto how strongly the channel is turned on — its inversion region:

STRONG MODERATE WEAK ~5–8 ~10–16 ~18–25 gm/Id (1/V) 8 · cascode 10 · current src 14 · signal* ◀ faster · larger Vdsat more gm/µA · smaller Vgs ▶
The single number gm/Id is the inversion knob. The three constants this file defines (8, 10, 14) live exactly where the domain says they should. *signal 14 is only a nominal estimate — see below.

Reading it across: turn gm/Id down (strong inversion) and the device is fast, tolerant, with a big saturation voltage. Turn it up (weak inversion) and you get maximum gm per microamp with a tiny Vgs, at the cost of speed and area. The whole art of gm/Id sizing is picking the right spot for each device's job.

Three regions, three jobs — a comparison

Regiongm/Id (1/V)Great forCosts you
Strong inversion5–8headroom, speed (ft), matching, high rout with long Lgm efficiency
Moderate inversion10–16the balanced default for signal devices
Weak inversion18–25max gain per µA, low-power, tiny Vgsspeed, area

The one twist: for signal devices, gm/Id is solved, not chosen

This is the subtlety that shapes the data model. For a current source or load, gm/Id is a free knob you dial to a region. But for a signal device (the input pair, a gain-stage driver), gm/Id is not free — it is pinned by the spec:

gm/Idsignal = gmrequired / ID

The spec demands a certain gm to hit its gain-bandwidth; once the current is fixed by Kirchhoff, gm/Id falls out of arithmetic. So in the registry, signal blocks carry gm_id = None — a deliberate "this is solved later, not set here." Hold onto that None; it's the reason the field is float | None.

Part 3 · The vocabulary constants — the warm-up

The one job: give the three inversion regions and three channel-length choices short, self-documenting names, so the registry below reads like prose instead of a wall of numbers.

# --- gm/Id inversion regions used as the block defaults (1/V) ---
_MODERATE = 14.0        # signal nominal (pre-geometry estimate); real gm/Id is solved
_STRONG = 10.0          # current source: headroom + output resistance
_STRONG_CASCODE = 8.0   # cascode: small Vdsat to preserve stacked headroom

# --- channel length as a multiple of L_min ---
_L_SIGNAL = 2.0         # signal: balance gain and ft
_L_CS = 4.0             # current source: longer L → higher output resistance
_L_CASCODE = 3.0        # cascode

Why is it called _STRONG?

I really like these names. _STRONG = 10.0 doesn't say "ten," it says "put this device in strong inversion." The number is an implementation detail; the name is the intent. When you later read BlockIntent(CURRENT_SOURCE, _STRONG, _L_CS, …) you read a sentence: "a current source, in strong inversion, with a long channel." That's the whole reason to hoist constants to the top.

The channel-length lever, drawn to scale

The _L_* constants are multiples of the minimum drawn length. Analogy: a longer garden hose resists flow more. A longer transistor channel resists current changes more — it has higher output resistance — which is exactly what a current source wants and a fast signal device does not.

signal 2× Lmin — balance gain & ft cascode 3× Lmin — moderate current source 4× Lmin — highest rout longer channel → higher output resistance
Current sources get the longest channel because their whole job is to look like a stiff, high-resistance current; signal devices stay short to stay fast.

Notice the constants aren't used here — they're just definitions. Every place downstream that needs "strong inversion, long channel" will reach for _STRONG and _L_CS by name. Recap:

ConstantValueMeaning
_MODERATE14.0nominal signal gm/Id (only an estimate — real value is solved)
_STRONG10.0current-source gm/Id: headroom + output resistance
_STRONG_CASCODE8.0cascode gm/Id: small Vdsat to preserve the stack
_L_SIGNAL2.0short channel, fast signal device
_L_CS4.0long channel, stiff current source
_L_CASCODE3.0medium channel for the cascode

Part 4 · BlockIntent and the registry

The one job: bundle the four decisions about one functional building block — its role, its gm/Id region, its channel length, and the human reason — into a single frozen record; then list one such record for every kind of block the amplifier can have.

The record itself

@dataclass(frozen=True)
class BlockIntent:
    role: str            # SIGNAL / CURRENT_SOURCE / CASCODE
    gm_id: float | None  # target gm/Id, or None when it is *solved*
    l_mult: float        # channel length as a multiple of length.min
    rationale: str       # why this region/length — surfaced in the result

It's frozen=True — once built, it can't be mutated. That's on purpose: a default intent should be a constant you copy-and-modify, never something a caller accidentally edits in place. Think of each BlockIntent as a printed spec card.

BlockIntent — one building block's spec card role what sizing rule applies — SIGNAL / CURRENT_SOURCE / CASCODE gm_id the free knob — a region, or None = "solved from spec" l_mult channel length × Lmin rationale the sentence that explains the choice, kept for reporting
Four fields. The middle one — gm_id — is the tunable knob; None means the region is deferred and solved from the gm requirement.

The default registry — ten blocks by role

DEFAULT_BLOCK_INTENTS is a dictionary from block name to its BlockIntent. It's long, but it's really just three families. Grouped by role, the ten entries fall into a tidy picture:

SIGNAL gm/Id = None · L 2× input_stage gain_stage output_stage CURRENT_SOURCE gm/Id = 10 · L 4× active_load stage_load tail_current bias_generator cmfb current_source CASCODE gm/Id = 8 · L 3× cascode just one — the stack booster
Ten blocks, three families. All signal blocks defer gm/Id (None); every current source shares the strong-inversion / long-L profile; the lone cascode gets its own tighter setting.

Here are two representative entries in full — one from each of the interesting families:

"input_stage": BlockIntent(
    SIGNAL, None, _L_SIGNAL,
    "Convert the differential input voltage to current with high gm, low
     noise and good matching. gm/Id is solved to meet GBW/gain …"),

"tail_current": BlockIntent(
    CURRENT_SOURCE, _STRONG, _L_CS,
    "Tail current source: set the input-pair bias current with adequate
     saturation headroom and high rout. A low gm/Id (strong inversion)…"),

Read them as sentences. The input_stage says "I'm a signal device, my gm/Id is None because it's solved, keep me short." The tail_current says "I'm a current source, put me in strong inversion at gm/Id = 10, make me long." The rationale string isn't a comment that rots — it rides along into the sizing result so the finished design can explain why each device is the way it is.

Part 5 · functional_block() — naming a device's block

The one job: given where a device sits (its slot) and what kind of device it is (signal? cascode?), return the name of the functional building block it belongs to — so we can look up its intent.

def functional_block(slot: str, is_signal: bool, is_cascode: bool) -> str:
    if is_signal:
        return _SIGNAL_BLOCK.get(slot, "gain_stage")
    if is_cascode:
        return "cascode"
    return _CS_BLOCK.get(slot, "current_source")

It's a three-step waterfall with a priority order that matters. Signal wins first — a cascode is never treated as a signal device — then cascode, and everything else falls through to "some kind of current source."

is_signal? yes _SIGNAL_BLOCK[slot] default → "gain_stage" no is_cascode? yes "cascode" no _CS_BLOCK[slot] · default → "current_source"
Signal precedence first, cascode second, current-source fallback last. Each branch also has a safe default for an unknown slot.

The .get(slot, default) is a lovely defensive touch: if a device lands in a slot the tables never anticipated, it doesn't crash — it falls back to the most sensible generic block. A stray signal device becomes a gain_stage; a stray current source becomes a generic current_source.

The subtlety worth its own figure: a slot is not a block

This is the single most important idea in the file's data model. The docstring flags it explicitly: a functional building block is not the same as an FBR slot. One slot can hold devices of different roles.

Take the second_stage slot. It houses two very different transistors: the signal driver that provides the gain, and its current-source load. Same slot — but they must get opposite intents. That's exactly why the block is keyed by (slot, role), split across the two lookup tables:

slot: second_stage one FBR slot, two devices role = SIGNAL gain_stage the signal driver — via _SIGNAL_BLOCK role = CURRENT_SOURCE stage_load the current-source load — via _CS_BLOCK
Same slot, two roles, two blocks. This is why _SIGNAL_BLOCK and _CS_BLOCK both list second_stage — pointing it at gain_stage and stage_load respectively.

Look back at the two tables and you'll see this everywhere: second_stage, third_stage, and output_stage all appear in both maps, sending the signal driver to a stage block and the load to stage_load. The role, decided upstream by is_signal_device(), picks the lane.

Part 6 · resolve_transistor_intents() — Level 2 → Level 3

The one job: walk over every real transistor in the circuit and stamp the right block's intent onto it, producing one TransistorIntent per device — the record the geometry solver actually consumes.

First, the Level-3 record. It's a BlockIntent plus two extra facts: which device (ref) and which block it came from.

@dataclass(frozen=True)
class TransistorIntent:
    ref: str          # which device, e.g. "M1"
    block: str        # which functional block it resolved to
    role: str
    gm_id: float | None
    l_mult: float
    rationale: str

And the resolver itself — the orchestrator from our call graph:

def resolve_transistor_intents(
    all_transistors,          # ref → (Device, slot_name)
    cascode_refs,             # set of refs that are cascodes
    block_intents=DEFAULT_BLOCK_INTENTS,
):
    out = {}
    for ref, (device, slot) in all_transistors.items():
        block = functional_block(slot, is_signal_device(device), ref in cascode_refs)
        bi = block_intents[block]
        out[ref] = TransistorIntent(ref=ref, block=block, role=bi.role,
                                    gm_id=bi.gm_id, l_mult=bi.l_mult,
                                    rationale=bi.rationale)
    return out

The single most important line is the one that computes block: it turns the two things we know about a device — its slot, and its role flags — into a block name. Everything after is a copy: look the name up in the registry, and spread that BlockIntent's fields into a per-device TransistorIntent. It's a pure function of its inputs: same circuit in, same intents out.

device (ref, slot) functional_block() → block name registry → BlockIntent Transistor- Intent
Four hops per device: know where it sits → name its block → fetch that block's intent → stamp it onto the device.

Traced by hand with real devices

Suppose the circuit hands us two transistors:

all_transistors = {
    "M1": (input_pair_device, "input_pair"),   # is_signal_device → True
    "M5": (load_device,       "load"),         # is_signal_device → False
}
cascode_refs = set()   # nothing cascoded here

Walk it device by device:

M1 — signal, so functional_block("input_pair", True, False) takes the first branch: _SIGNAL_BLOCK["input_pair"] = "input_stage". Look up the registry → BlockIntent(SIGNAL, None, 2.0, "…"). Stamp:

TransistorIntent("M1", "input_stage", SIGNAL, None, 2.0, "Convert the diff …")

Note the gm_id = None — M1's inversion region will be solved from the gm requirement later, exactly as Part 2 promised.

M5 — not signal, not cascode, so we fall to the last branch: _CS_BLOCK["load"] = "active_load". Registry → BlockIntent(CURRENT_SOURCE, 10.0, 4.0, "…"). Stamp:

TransistorIntent("M5", "active_load", CURRENT_SOURCE, 10.0, 4.0, "First-stage active …")

Here gm/Id is a concrete 10.0 — a real chosen region, strong inversion, ready for the solver. Two devices in, two fully-explained intents out.

refslotblockrolegm/Id
M1input_pairinput_stageSIGNALNone2.0
M5loadactive_loadCURRENT_SOURCE10.04.0

Part 7 · GmIdIntent — the circuit-wide knobs

The one job: hold the whole registry plus the few knobs that aren't per-block, and make retuning a single building block a one-line copy-and-replace.

@dataclass(frozen=True)
class GmIdIntent:
    block_intents: dict[str, BlockIntent] = field(
        default_factory=lambda: dict(DEFAULT_BLOCK_INTENTS))
    signal_gm_id: float = _MODERATE              # fallback, pre-geometry gds estimate
    current_source_gm_id: float = _STRONG
    cascode_gm_id: float = _STRONG_CASCODE
    signal_l_mult: float = _L_SIGNAL
    current_source_l_mult: float = _L_CS
    cascode_l_mult: float = _L_CASCODE
    degeneration_factor: float = 0.5          # gm1·R for a degenerated input pair
    cmfb_sense_r: float = 1.0e6              # CMFB averager R (Ω, large)

Two things earn their keep here. First, block_intents defaults via a lambda that builds a fresh copy of the registry — so two GmIdIntents never share (and accidentally corrupt) the same dictionary. Second, the role-level scalars (signal_gm_id, …) are the fallback the device model uses for a rough gds estimate before geometry is solved, and the two resistor knobs cover the parts of the circuit the block registry doesn't — source degeneration and the CMFB sense resistors.

The whole point: override one block, keep the rest

Because the registry is a plain dict, retuning one building block is a copy with one key replaced. Everything you don't touch falls back to the default automatically.

DEFAULT_BLOCK_INTENTS your copy (one key swapped) input_stage → None active_load → gm/Id 10 tail_current → gm/Id 10 cascode → gm/Id 8 … 6 more, untouched … replace one entry input_stage → None active_load → gm/Id 10 tail_current → gm/Id 18 ✎ cascode → gm/Id 8 … 6 more, still default …
An optimizer that wants a weaker (higher gm/Id) tail changes exactly one entry; the other nine building blocks keep their defaults for free.

That is the extension point the docstring promises: "To retune a single building block, pass a copy of block_intents with that entry replaced." A future optimizer can sweep one block's gm/Id without disturbing anything else — precisely because intent is data, not scattered code.

The module ends with a single ready-made default:

DEFAULT_INTENT = GmIdIntent()

— the batteries-included configuration every caller starts from.

Part 8 · The hidden idea, the limits, and the one line

The hidden idea

The file never writes an equation, but two hidden relationships give it all its meaning. The first is what makes a gm/Id number worth choosing at all — it sets a saturation voltage:

Vdsat 2gm/ID

Plug the registry's numbers in: a current source at gm/Id = 10 wants about 2/10 = 0.20 V of headroom; a cascode at gm/Id = 8 about 0.25 V; a weak-inversion device at gm/Id = 20 only 0.10 V. The region name and the voltage budget are two views of the same choice — which is why picking a region is a real electrical decision, not a label.

The second hidden idea is the one that splits the whole data model in two — the signal loophole from Part 2:

signal: gm/Id = gmreq / ID → store None

Signal devices don't get to choose a region; the spec chooses it for them. That single fact is why gm_id is float | None, why every signal block carries None, and why _MODERATE = 14 is only a "nominal" estimate for a pre-geometry calculation rather than a real setting.

Step back and the file is really implementing one clean mapping — a pure lookup from a device's identity to its design intent:

device (slot, role)  →  functional block  →  (region, L, rationale)

What it honestly does not do

  • It sizes nothing. It produces intent — the region, length, and reason — that a separate geometry solver (GmIdModel.geometry_for) consumes. It's the recipe, not the cooking.
  • For signal devices it deliberately refuses to set gm/Id (None); that number is solved elsewhere from the gm requirement.
  • The taxonomy is fixed — ten blocks, three roles. A device in an unrecognized slot falls back to gain_stage or current_source; a device that is somehow both signal and cascode is treated as signal (signal precedence).
  • Two circuit features — source degeneration and CMFB sense resistors — live as scalar knobs on GmIdIntent, outside the block registry, because they aren't per-transistor regions.

Why it's elegant

The whole design rides one move: turn a fuzzy design decision into an immutable, named, copyable data record, and attach its reason to it. That single move buys explainability (the rationale rides into the result), tunability (copy-one-key overrides), and safety (frozen defaults, fresh-copy factories) — all without a line of control flow. The three-level hierarchy is just this record applied at three altitudes.


In one sentence: intent.py makes analog design intent explicit as a three-level hierarchy — a spec at the top, a registry of per-building-block BlockIntent records in the tunable middle (each naming a gm/Id inversion region, a channel length, and a human rationale, with None for signal devices whose region is solved from the spec), resolved by functional_block and resolve_transistor_intents onto one explainable, overridable TransistorIntent per device.