Agent Guide¶
Documentation Home | CLI Reference | JSON Circuit Format | MCP Server
This page is written for an autonomous agent driving this codebase, not for a person reading over its shoulder. It covers what the tools cost, which result means what, and the failure modes that produce a confident wrong answer rather than an error.
What this codebase is¶
A local analog-circuit simulator and optimizer. A circuit is a JSON file. The
solvers run in a compiled Rust core (circuitopt_core); Python describes the
problem, Rust computes it. There is no cloud service and no licensed simulator
in the loop — foundry model files are read from the local machine and never
written into the repository.
Three things follow from that, and they shape every task you will be given:
- Runs are cheap enough to iterate on. A full 45-point PVT signoff of a 40-transistor amplifier takes about 11 seconds. You are expected to measure rather than reason about what the circuit will do.
- Results are unit-bearing and gated. An analysis either produces a valid
measurement or raises
SimulationInvalid. There is no third state. - Nothing is inferred. Phase margin, settling time, integrated noise and
saturation checks come only from an explicit
signoffblock. The tools will not guess them from an AC response or the last transient sample.
The rule that matters most¶
Never convert a failure into a number.
Non-convergence, a NaN, a missing measurement, an unsupported topology — each
of these is a result. It is invalid, and invalid is more severe than
fail. If you catch SimulationInvalid and substitute a default, a fallback,
or a previous run's value, you have produced a report that says the design
works when nobody knows whether it does. The campaign layer is built to make
this hard: an invalid case poisons its PVT point, and an invalid point poisons
the campaign status.
The same applies to specifications. If a design misses a spec, report the miss. Do not widen the limit in the JSON to make the run go green unless the user asked you to change the specification, and say so explicitly when you do.
Two ways in¶
| Surface | Use it when | Entry point |
|---|---|---|
| CLI | You have a shell and a checkout. This is the fuller surface. | circuit-opt <subcommand> |
| MCP | You are a tool-calling agent with a workspace directory. | circuit-opt mcp |
The CLI has 13 subcommands; the MCP server exposes 12 tools over a
path-confined workspace. MCP covers the common design loop and refuses
absolute paths and ..; the CLI covers everything, including the parts that
need arbitrary file access.
CLI subcommands by cost¶
| Cost | Subcommands | Notes |
|---|---|---|
| Milliseconds, no solver | passive-bom, plot |
Read JSON or an existing result. |
| Seconds | run, adc, chopper |
One circuit, one analysis suite. |
| Seconds to minutes | signoff, corners, mc, explore, dataset |
Scales with grid size × workers. |
| Hours | verify-engine on a large case |
Spawns ngspice; use for final cross-checks only. |
MCP tools¶
| Tool | Cost | Purpose |
|---|---|---|
get_capabilities |
free | Installed models, PDK corners, analyses, job kinds. |
validate_circuit |
free | Schema and structural check. Call before simulating. |
passive_inventory |
free | Resistor/capacitor inventory and area estimate from deck JSON. |
run_analysis |
seconds | Bounded DC/AC/noise/transient summary for one circuit. |
submit_exploration |
job | Candidate sweep with Pareto selection. |
submit_mismatch_mc |
job | Per-device mismatch Monte Carlo. |
submit_pvt |
job | Corner sweep of one circuit; temperature and supply axes on silicon. |
submit_signoff |
job | Multi-testbench PVT campaign from a manifest. |
list_jobs / get_job / cancel_job |
free | Poll and cooperative cancellation. |
signoff_margins |
free | Per-constraint margin table from a saved result. |
inspect_signoff_result |
free | Selected failing points from a saved result. |
submit_pvt and submit_signoff also answer different questions. A PVT sweep
shows how one testbench moves across corners — gain, bandwidth, input-referred
noise per grid point. A signoff campaign runs a whole manifest against declared
constraints and returns a verdict. Sweep to understand, sign off to decide.
signoff_margins and inspect_signoff_result answer different questions.
Margins rank every spec by how much room is left and tell you what constrains
the design. Inspection tells you where a specific spec failed. When deciding
what to change next, read margins first.
The design loop¶
validate_circuit structural errors are free to find
-> run_analysis one circuit, is it even alive
-> submit_signoff the real grid
-> signoff_margins which spec is tightest, and where
-> change ONE thing
-> repeat
Change one thing per iteration. The measured couplings in this codebase are not intuitive — lengthening one current-source device collapsed an entire amplifier because it pushed a control node below a bias floor 400 mV away — and a two-variable change leaves you unable to attribute the result.
Reading a campaign result¶
statusispass,fail, orinvalid.invaliddominatesfail, which dominatespass.worst_casenames one measurement. It ranks runs; it does not describe a design, because it hides every other spec that is also close to its limit.- The margin table is the design view.
normalized_marginis a fraction of the limit, negative when violated, so rows with different units compare directly. Rows come back tightest first.
Do not stop at nominal¶
A signoff that passes only at nominal component values is not a signoff. Poly resistors carry roughly 20 % absolute tolerance and MOM capacitors roughly 10 %:
The result is robust only when every perturbed run also passes. A
compensation network tuned on a knife edge will pass nominal and fail silicon.
Changing a design¶
Device sizes live either in the circuit JSON or in a generator script that emits several testbenches from one source of truth. Prefer the generator when one DUT appears in several decks, so the decks cannot drift apart.
For a generator, drive it with tools/design_iterate.py rather than editing
and re-running by hand:
python tools/design_iterate.py run --generator my_gen \
--manifest examples/campaign.json --set "SZ:M9=175/0.25" --set CC=850e-15
Every override asserts that its target exists, so a typo aborts instead of silently evaluating the unmodified design.
If you do edit source text programmatically, assert that the target string
is present before replacing it. str.replace does not raise when it matches
nothing; it returns the string unchanged. Three full measurement rounds were
once spent on a design that had never been modified.
Never run git checkout <file> to undo an edit. It discards uncommitted work
without warning. Snapshot the file first and copy it back.
Traps that produce a confident wrong answer¶
These are the failure modes that do not raise. Each cost real debugging time.
Unknown configuration keys are rejected on purpose. A misspelled option that was silently ignored would run with the default and produce a plausible result for a configuration nobody asked for. If you get an unknown-key error, fix the key; do not route around the check.
Geometry has bin limits. TSMC28 core devices accept a per-finger width of
0.1–2.5 µm and a length of 0.03–1.0 µm. W is the total width and NF splits
it into fingers, so a wide device needs a proportional NF. Out-of-range
geometry raises 0 bins; it does not silently clamp.
NF and M are not the same knob. NF splits one device's width into
fingers: same total current, different gate capacitance. M is parallel
instances: current, capacitance and noise power all scale. Confusing them
changes the answer without changing the netlist's plausibility.
Two outputs mean a difference, not a sum. outputs: ["OUTP", "OUTN"]
senses OUTP - OUTN. A single output senses to ground.
An AC run with no ac_drives has no stimulus. It returns the -180 dB gain
floor rather than an error. If a gain looks impossibly bad, check that a
stimulus is configured before you touch the circuit.
A measured bandwidth can be the top of the sweep. bw_Hz is found by
scanning the swept response, so a circuit whose corner lies above the grid
reports the last grid point — and it reports it as a number, indistinguishable
from a measurement. corner_table (behind circuit-opt corners) defaults to
the AFE range of 0.01 Hz – 10 kHz, which makes a 79 kHz silicon OTA read as
"BW = 10 kHz" and its 84 µV input noise read as 2.1 V. Pass --freqs-* and
--noise-band to match the circuit, and treat a bandwidth sitting exactly on
the grid ceiling as unmeasured. The service's pvt and mc jobs inherit the
circuit's own analyses.ac.freqs and analyses.noise.band for this reason and
echo back what they used.
In an MC summary this same trap reads as robustness. Every sample hits the identical ceiling, so the spread collapses to sigma zero — a design that looks remarkably tight is often one whose metric was never measured. Check the reported sweep range before believing a small sigma.
A sweep of a chopper measures nothing unless the chopper has an AC path.
corners and mc both measure gain, bandwidth and noise from an AC run. A
testbench driven only through its periodic block has no ac_drives, so the
sweep returns the -180 dB floor for every corner and every sample, and the
input-referred noise — output noise divided by that floor — comes back around
1e134 V. Nothing raises. Sweep such a circuit with PSS/PAC/PNoise, or give it an
ac_drives entry first. A gain near the floor invalidates every other number in
the same result.
Each analysis reads a different stimulus block, and transient reads none by
default. ac/noise are excited by input_drives (a device gate) and
ac_drives (a node); transient is excited only by a periodic block, and
_run_transient passes no inputs at all when there isn't one. So a deck with
input_drives and no periodic — sky130_5t_ota, and most single-testbench
decks — has a properly excited AC run and a transient in which every source sits
at DC. It converges, every node is a flat line, and the result is reported as a
successful solve: measured vout peak-to-peak is exactly 0.0. Before believing
a transient, check that the circuit has a periodic block or that you passed
inputs= yourself; a zero-swing output is the signature. The GUI derives one
from the AC ports on request and writes it to analyses.transient.periodic,
which merges over the top-level block so PSS/PAC keep their own excitation.
A background job silently drops a field its request model does not declare.
The service uses pydantic only as a thin request wrapper, so an undeclared key
is discarded rather than rejected — a caller passing freqs to an endpoint that
never declared it gets a successful job measured over the wrong range. When
adding a parameter, assert it reaches the result, not merely that the request
returned 202.
Device state is path-dependent. BSIM handles keep their warm start and limiting state between calls. Concurrent campaign points must lease into isolated cache scopes or results depend on worker count. This is already handled inside the campaign layer; it matters if you write a new parallel driver.
A transient's start point must match its first sample. The DC operating
point has to be solved with every waveform-driven source pinned at u(t0).
Getting this wrong once drove a common mode 0.42 V into the rail and failed
every PVT point of two unrelated topologies, while the frozen golden corpus
stayed bit-exact. When a whole trajectory diverges, suspect initial conditions
before device models.
The reported version can be stale. get_capabilities reads the version
from the installed distribution metadata. An editable install made before the
last version bump reports the old number. Check python tools/version.py
check against the repository rather than trusting the capabilities payload.
Verification discipline¶
A new test must be shown to fail. Write the test, then deliberately break the invariant it claims to protect, confirm the test goes red, and restore. A test that passes against the broken code asserts nothing. This is not optional here: one existing test compared object identities across a free, and passed for the wrong reason roughly four runs in five.
Restore from a file snapshot, not from git.
Clear __pycache__ between mutation runs or you will measure the old
bytecode.
The golden corpus is the reference oracle. tests/golden/engine_parity
is frozen and reproduced bit-exactly. If a change moves those numbers, that is
the finding — investigate before re-freezing.
ngspice is the independent second engine. circuit-opt verify-engine runs
one signoff case through both the native BSIM4 path and the foundry model-card
path and diffs node trajectories. It keys its verdict on the settled deviation,
because a large deviation at a fast transition is usually step placement rather
than disagreement. Use it to confirm a result you intend to rely on, not as a
routine step — it is hours, not seconds.
Gates before you report work as done¶
pytest tests -q # 935 passed, ~110 s
ruff check circuitopt tests examples
python -m mkdocs build --strict
python tools/version.py check
mkdocs build --strict aborts on warnings, including broken documentation
links. It cannot catch a syntax error inside a ```mermaid fence — those render
blank in a browser and pass the build.
Where to look next¶
| Question | Document |
|---|---|
| What does each module do? | docs/module_overview.md |
| What happens between the CLI and the Rust core? | docs/solver_call_flow_zh.html |
| What fields can a circuit JSON contain? | docs/json_circuit_format.md |
| How is a PVT campaign configured? | docs/signoff_campaign.md |
| Which PDKs and corners exist? | docs/pdk_support.md |
| How do I set up a development environment? | docs/development.md |
| What changed recently, and why? | CHANGELOG.md |
Worked designs live under "Design Cases" in the navigation. They record the dead ends as well as the result — a topology that was measured and abandoned is documented as abandoned, so you do not spend a day rediscovering it.