sizer.py — explained

circuitgenome/sizer/sizer.py — the front door of Initial Sizing: one entry point that routes each circuit to the right sizer for its technology.

Part 1 · Why this file even exists

The one job: given a circuit and the technology it will be built in, decide which sizer should compute its transistor W/L values — and hand the work off to that one.

Here is the whole story without a line of code.

CircuitGenome does not have one way to size a circuit — it has several, and which one is correct depends entirely on the fabrication technology. A modern node characterized with a gm/Id lookup table deserves the accurate, block-based gm/Id pipeline. An idealized generic technology, which has no such table, can only use the fast analytical Level-1 sizer built on square-law equations. And a real PTM/SPICE node that has not yet been characterized deserves neither — running square-law math on it would produce numbers that look plausible and are quietly wrong.

Imagine walking into a hospital. You do not pick your own specialist — you meet a triage nurse at the front desk. She reads your chart, and in one glance sends you to cardiology, or to the general ward, or tells you honestly "we cannot treat this here yet." That is exactly what size_circuit is: a triage desk for sizing. It reads one field on the technology chart and points you at the right specialist — no more, no less.

size_circuit(…, tech, spec) the dispatcher — reads the tech chart has a gm/Id LUT? yes size_gmid block-based gm/Id pipeline no a PTM/SPICE node? yes UnsupportedTechError refuse — no valid sizer no size_level1 Level-1 CP-SAT sizer reached only by the card-less “generic” tech
One question at a time, top to bottom. The first “yes” wins; falling all the way through means the plain analytical sizer.

The cast of characters

The file itself is tiny — one function. But it stands in front of three destinations. Hover any box to trace its link.

size_circuit() the dispatcher size_gmid() gm/Id block pipeline size_level1() Level-1 CP-SAT sizer UnsupportedTechError raised, not called
One caller, three exits. Two are function calls (solid); the third is a raised exception (dashed) — a refusal, not a hand-off.

Looking ahead: Part 2 opens the door and looks at what flows through it — the six inputs the dispatcher receives and the single SizingResult it promises back.

Part 2 · What comes in, what goes out

The one job of the signature: gather everything the pipeline has learned about a circuit so far, so the dispatcher has enough on the chart to pick a sizer.

Think of a maître d' at a restaurant. Before seating you, he reads a few things off your reservation: the party size, any allergies, the time. He does not cook — he just has enough information to route you to the right table and the right waiter. size_circuit reads six such fields.

def size_circuit(
    parsed: ParsedNetlist,                 # Layer-0 — the raw circuit
    sr_result: SubcircuitRecognitionResult,# Layer-1 — kept for API symmetry
    fbr_result: FunctionalBlockRecognitionResult,  # Layer-2 — the slot map
    topology: TopologyTemplate,            # which template matched
    tech: TechParams,                      # THE routing field lives here
    spec: SizingSpec,                      # performance targets
    *,
    time_limit_s: float = 30.0,             # Level-1 solver budget only
) -> SizingResult:

Most of these are passed straight through to whichever sizer runs. The one that decides the route is tech. The dispatcher only ever inspects two attributes on it — tech.gmid_lut and tech.spice_model — and everything else rides along untouched.

parsed netlist FBR result (L2) topology + spec tech the routing field size_circuit route + hand off SizingResult W/L, cap, metrics
Six things in, one SizingResult out. Only tech changes who does the work — the rest just rides along.

Whatever route is chosen, the promise on the way out is identical: a single SizingResult holding per-transistor sizing, the compensation cap, computed metrics, and safety margins. The caller never has to know which sizer produced it — which is the whole point, and the idea we return to in Part 5.

Part 3 · The three routes

The one job: ask two yes/no questions of tech, in order, and take the first branch that fires.

This is the entire body of the function, and it reads like a railway switch. A train arrives on one track; the points are thrown based on a single reading, sending it down exactly one of three lines. The order of the questions is the logic — the second question is only ever asked if the first was “no.”

# Route 1 — gm/Id technologies (LUT present) use the block pipeline.
if getattr(tech, "gmid_lut", None):
    from .gmid import size_gmid
    return size_gmid(parsed, sr_result, fbr_result, topology, tech, spec)

# Route 2 — a PTM/SPICE node WITHOUT a LUT must not fall through.
if getattr(tech, "spice_model", None):
    raise UnsupportedTechError(...)

# Route 3 — only the card-less 'generic' tech reaches here.
from .analytical.level1 import size_level1
return size_level1(parsed, sr_result, fbr_result, topology, tech, spec,
                   time_limit_s=time_limit_s)

Notice there is no explicit elif or else. Each branch returns (or raises), so control never reaches the next question unless the current one failed. The bare code at the bottom is the default — the track the train coasts onto when both switches were left open.

Why getattr(tech, "gmid_lut", None) and not tech.gmid_lut?

The getattr(..., None) form asks “does this tech carry a LUT, and is it non-empty?” in one safe move. If the attribute is missing entirely, or is None, or is an empty container, the whole expression is falsy and the switch stays open. It is a truthiness test, not just an existence test — a deliberately forgiving reading of the chart.

Two techs, traced by hand

Let us walk three concrete technologies through the switch and watch where each lands.

technology questions asked lands at ptm130_lut PTM + a LUT gm/Id LUT? ✓ yes stops at question 1 size_gmid generic card-less LUT? ✗ · SPICE? ✗ falls through both size_level1 ptm130 PTM, no LUT LUT? ✗ · SPICE? ✓ caught by question 2 Unsupported TechError
Same two questions, three destinations. A LUT wins immediately; no LUT plus a SPICE model is refused; neither means the plain generic path.
Technologygmid_lutspice_modelRoute taken
ptm130_lutpresentsize_gmid
genericabsentabsentsize_level1
ptm130absentpresentUnsupportedTechError

Looking ahead: that third row — the deliberate refusal — is the most interesting line in the file. Part 4 asks why the dispatcher chooses to fail there instead of quietly sizing.

Part 4 · The honest refusal

The one job: when a PTM/SPICE node has no gm/Id LUT, refuse loudly instead of running the analytical sizer — because its numbers would be silently wrong there.

Here is the whole limitation of the file, stated on purpose. The Level-1 analytical sizer is built on square-law equations — the textbook ID ∝ (VGS − Vth model. That model is a fine idealization, which is exactly why the generic technology is defined to obey it. But a real 130 nm PTM node does not obey square-law; short-channel effects bend those curves badly. Feed a real node's parameters into square-law math and you get transistor sizes that look reasonable and are quantitatively wrong.

if getattr(tech, "spice_model", None):
    raise UnsupportedTechError(
        f"Technology '{tech.name}' is a PTM/SPICE-model node without a gm/Id "
        f"LUT. The analytical (Level-1) sizer is not valid for PTM nodes — "
        f"characterize a gm/Id LUT first (see issue #73). Only the 'generic' "
        f"technology uses the analytical sizer.")

Analogy: this is the triage nurse saying “we cannot treat this here yet” instead of guessing. A wrong-but-confident answer is worse than an honest stop, because a downstream user would trust the sizes and build on sand. The exception message even points the way forward — characterize a LUT first — so the failure is a signpost, not a dead end.

PTM node, no gm/Id LUT real curves, not square-law if it fell through to Level-1… plausible but wrong sizes ✗ the silent bug gm/Id exists to avoid raise UnsupportedTechError honest stop ✓ “characterize a LUT first”
The dashed path is the tempting mistake — falling through to a sizer whose math does not apply. The file deliberately takes the solid path instead.

One subtlety in the ordering: this refusal sits after the LUT check. So a PTM node that does have a LUT (like ptm130_lut in our trace) never reaches this branch — it was already routed to size_gmid. The spice_model question only fires for a PTM node that is missing its LUT. That is the whole reason Route 1 must come first.

Part 5 · The hidden idea

Although the file never names it, this dispatcher is a textbook Strategy pattern — a single boundary that chooses an interchangeable algorithm at runtime and hides that choice from everyone above it.

The payoff is caller ignorance, in the good sense. Every layer above sizing calls one function, size_circuit, and receives one SizingResult. It never imports size_gmid or size_level1, never branches on the technology, never learns that three different sizers even exist. Add a fourth sizer tomorrow and not a single caller changes.

caller (Layer-3 API) knows only one function size_circuit() — the only door below the line: callers never see any of these size_gmid size_level1 UnsupportedTechError swap or add a sizer here — no caller changes
One door, three rooms behind it. The dispatch boundary is the whole value: it lets the “how” change without disturbing the “what.”

And there is a pleasing rhyme here. One layer down, CircuitGenome already uses a DeviceModel strategy — a gm/Id model versus a square-law model, chosen per technology, behind one interface. size_circuit mirrors that same shape one level up: the sizer is chosen the same way the device model is chosen, on the same signal (does this tech have a LUT?). The dispatch at the top and the strategy underneath are the same idea wearing two hats.

What it honestly does not do

  • It does no sizing itself — every route either delegates or refuses. If a bug appears in the numbers, it lives in size_gmid or size_level1, not here.
  • It reads exactly two fields of tech. A technology with, say, both a LUT and a SPICE model is routed to gm/Id and never reaches the refusal — the order encodes the priority.
  • It trusts that fbr_result is in topology mode; it does not re-validate that here.
  • time_limit_s matters only on the Level-1 path — it is quietly ignored by the gm/Id route.

Why it's elegant

The cleverness is restraint. A less disciplined dispatcher might try to be helpful and size the PTM node anyway. This one draws a hard line: route when a valid sizer exists, refuse honestly when none does, and never let a caller learn which sizer ran. Seventy lines, one job, done cleanly.


In one sentence: sizer.py is the single front door of Initial Sizing — it reads one technology field, routes a circuit to the gm/Id pipeline (if a LUT exists) or the Level-1 analytical sizer (for the card-less generic tech), refuses PTM nodes that lack a LUT rather than fabricate wrong numbers, and hands every caller back one identical SizingResult without ever revealing which sizer produced it.