Documentation
Code becomes a circuit — and verification closes the loop.
Quick start§
Circuit Forge is a code-based analog circuit simulator that runs 100% in the browser, with no backend. You describe the circuit in TypeScript — with named pins like anode/cathode and +/- — and the numerical engine (Modified Nodal Analysis + Newton-Raphson, DC and transient analyses) solves everything locally. The visual canvas editor, with probes and an oscilloscope, is a projection of the code, never the other way around.
Beyond the simulator there's forge: a verification loop designed for LLM agents (and humans). A .bench.ts bench describes a circuit + spec; the CLI runs the lint → op → tran ladder and returns diagnostics with a stable code (like F105), a source-code location, simulation-verified sizing hints, and exit codes with semantics — so that an agent converges on a working circuit in a few iterations, without parsing loose text.
Running it§
$ npm install $ npm run dev # app at http://localhost:5173 $ npm test # Vitest suite $ npm run forge -- verify bench/led-driver.bench.ts # verification ladder
First circuit in the simulator§
In the simulator's code editor (the Simulator tab), this voltage divider is the "hello, world": build the circuit, connect the pins, analyze, and read the results in the console.
const circuit = new Circuit();
const gnd = new Ground();
const v1 = new VoltageSource(10);
const r1 = new Resistor(1000);
const r2 = new Resistor(2000);
v1.pin('+').connect(r1.pin('1'));
r1.pin('2').connect(r2.pin('1'));
r2.pin('2').connect(gnd.pin('1'));
v1.pin('-').connect(gnd.pin('1'));
circuit.analyze('dc');
console.log('V_out (R2):', r2.voltage.toFixed(3), 'V');
console.log('Corrente:', (r1.current * 1000).toFixed(3), 'mA');
You should see V_out (R2): 6.667 V — the 1k/2k divider across 10 V. From there, open the ready-made examples or go straight to the benches.
Benches§
A bench (a .bench.ts file) is the complete verification artifact: circuit + free parameters + assertions. The bench(name, fn) function receives a testbench builder tb with this API:
tb.add(id, component)— registers a component with a stable id ('R1','D1'…). This id is the identity in diagnostics, in probes, and in--set.tb.gnd— the reference node (ground). Every circuit needs a DC path to it.tb.name(name, pin)— gives a net a stable name (e.g.'out') so it can be probed inopassertions.tb.param(id, {min, max, scale})— sizing delegation: declares the free parameter. LLMs are good at topology and bad at continuous sizing; when an assertion fails, the platform sweeps the range, simulates each point, and returns the passing range (verified hint) — the agent never guesses numbers.tb.expect.op(...)/tb.expect.tran(...)— the assertions: the spec as a test.
Units§
Values accept a number or a string with an engineering suffix: '4.7k', '10m', '2u', '1M', '1e-6'.
m ≠ M — m is milli (10⁻³) and M is mega (10⁶). '10m' is 10 mΩ/mA/ms; '1M' is 1 MΩ.bench/led-driver.bench.ts annotated§
import { bench, VoltageSource, Resistor, LED } from 'circuit-forge';
/**
* LED driver: 5 V source, series resistor, LED to ground.
* The canonical "hello, world" of the verification ladder — passes as-is.
*/
export default bench('led-driver', (tb) => {
// stable ids: they are the identity in diagnostics, probes, and --set
const v1 = tb.add('V1', new VoltageSource(5));
const r1 = tb.add('R1', new Resistor('330'));
const d1 = tb.add('D1', new LED());
// named pins eliminate the most common class of LLM error
v1.pin('+').connect(r1.pin('1'));
r1.pin('2').connect(d1.pin('anode'));
d1.pin('cathode').connect(tb.gnd);
v1.pin('-').connect(tb.gnd);
// named net: now 'led' is addressable in op assertions
tb.name('led', d1.pin('anode'));
// sizing is the platform's job: if an op assertion fails,
// it sweeps this range and returns a simulation-VERIFIED hint
tb.param('R1', { min: '100', max: '10k', scale: 'log' });
// the spec as a test
tb.expect.op('D1.i').toBeWithin('5m', '15m'); // LED lit, but safe
tb.expect.op('led').toBeWithin(1.8, 2.6); // plausible forward drop
});
Run it with npm run forge -- verify bench/led-driver.bench.ts. There are intentionally broken examples in bench/broken/ to see each diagnostic in action.
Probes§
Every assertion measures a probe. The grammar is short:
| Probe | Measures | Example | Where valid |
|---|---|---|---|
'<id>.i' | current through the component | 'D1.i' | op and tran |
'<id>.v' | voltage across the component | 'R1.v' | op and tran |
'<net>' | voltage of a net named via tb.name(...) | 'out' | op only |
Automatic nets (n1, n2, …) are deliberately not addressable: the numbering changes when you add or remove components, so a 'n3' probe would silently break across edits. Name the net with tb.name('out', pin) to guarantee stability — and that's why the n<digits> pattern is reserved and cannot be used as a net name.
tb.name('out', r1.pin('2')); // names the pin's net
tb.expect.op('out').toBeCloseTo(2.5, 0.1); // now it's probeable
Assertions§
op stage — DC operating point§
Format: tb.expect.op(probe).method(...).
| Method | What it measures | Example |
|---|---|---|
toBeWithin(min, max) | DC value within the closed interval | tb.expect.op('D1.i').toBeWithin('8m', '12m') |
toBeCloseTo(v, tol) | DC value equal to v within ± tol | tb.expect.op('out').toBeCloseTo(5, 0.01) |
toBeAbove(v) | DC value above v | tb.expect.op('R1.v').toBeAbove(1.2) |
toBeBelow(v) | DC value below v | tb.expect.op('D1.i').toBeBelow('20m') |
tran stage — transient§
Format: tb.expect.tran({tstop, dt}).probe(probe).method(...) — chainable, with multiple probes and checks in the same window.
| Method | What it measures | Example |
|---|---|---|
toSettleWithin({target, tol, by}) | the signal settles at target ± tol (fraction) by the instant by; measures the settling time | .toSettleWithin({target: 5, tol: 0.05, by: '4m'}) |
toNeverExceed(v) | the signal never exceeds v across the whole window (overshoot/peak) | .toNeverExceed('25m') |
toStayAbove(v, {from}) | the signal stays above v (optionally starting from from) | .toStayAbove(4.5, {from: '2m'}) |
toStayBelow(v, {from}) | the signal stays below v (optionally starting from from) | .toStayBelow('6m') |
toRippleBelow(amp, {from}) | the peak-to-peak ripple amplitude stays below amp (starting from from) | .toRippleBelow('50m', {from: '3m'}) |
toEndCloseTo(v, tol) | the window's final value is v ± tol | .toEndCloseTo(5, 0.1) |
A real example, from bench/rc-lowpass.bench.ts (R = 1k, C = 1µ, τ = 1 ms):
tb.expect
.tran({ tstop: '6m', dt: '10u' })
.probe('C1.v')
.toSettleWithin({ target: 5, tol: 0.05, by: '4m' })
.toNeverExceed(5.5)
.toEndCloseTo(5, 0.1)
.probe('R1.i')
.toStayBelow('6m'); // inrush current limited by V/R
Diagnostics§
Every error has a stable code (never renumbered — parsers and agents can depend on it), a source-code location, and a fix. forge explain <code> gives the long explanation with the physics behind each one.
Ranges§
| Range | Phase | Meaning |
|---|---|---|
| F1xx | lint (pre-solver) | structural topology |
| S1xx | linear solver | unsolvable system |
| S2xx | nonlinear solver | Newton-Raphson |
| A3xx | assertions | spec not met |
| T0xx | tooling | bench authoring error |
Active codes§
| Code | Slug | Severity | When it fires |
|---|---|---|---|
| F101 | floating-net | error | net with no DC path to ground (isolated by a capacitor/current source) |
| F103 | unreached-component | error | component (or island) with no connection to the grounded circuit — would be silently ignored by the solver |
| F104 | voltage-source-short / voltage-source-loop | error | ideal source shorted, or a loop of ideal sources (an inductor counts as a 0 V source in DC) |
| F105 | source-clamped-diode | error | forward-biased diode/LED clamped directly across an ideal source with no series resistance |
| F106 | dangling-pin | warning | pin with no connection at all |
| S101 | unsolvable-system | error | singular matrix that lint did not model (rare; please report) |
| S201 | nr-nonconvergence | error | NR did not converge at the DC point — carries the offending component, ΔV, and oscillation history |
| S202 | nr-nonconvergence | error | NR did not converge at a transient step |
| A301 | assertion-failed | fail | assertion failed — carries measured, expected, margin, and a verified hint when tb.param is present |
| T001 | bench-error | error | the bench itself threw an exception during elaboration |
| T002 | stage-crash | error | a stage (op/tran) crashed after elaboration — platform bug; the RunRecord is still emitted (exit 3) |
Fix confidence contract§
Every fix carries a confidence:
verified
The platform simulated the fix and it passes all of the stage's assertions. The agent should apply it directly.
mechanical
Deterministic transformation (not simulated, but no judgment required).
suggested
Heuristic; the agent should reason before applying.
Invariants (enforced in code review): never emit a fix that references a flag or capability that does not exist; never emit an unsimulated number without a suggested label.
Exit codes§
| Code | Meaning | Agent action |
|---|---|---|
0 | all requested stages passed | done |
1 | assertion failed (valid circuit) | adjust values (use the hint!) or topology |
2 | invalid/unsolvable circuit (F/S with error severity) | fix the structure first |
3 | tooling error (misuse, bench threw) | fix the bench/invocation |
Semantic notes§
- Lint always runs (even with
--stage op): solving a circuit that lint would reject would mean silently discarding components and reporting fabricated numbers. - A
settleWithinthat never settles reportsmeasured= end of the window (finite) — neverInfinity, which would becomenullin JSON. hint.passingRangecovers only the longest contiguous run of passing samples (each sample passes lint AND all op assertions); the exact simulated values are inpassingSamples.
CLI§
forge runs via npm run forge -- <command>. The output is deterministic — no timestamps and no colors in the text — so two identical runs diff cleanly.
USAGE forge verify <bench.ts> [options] # runs the ladder: lint → op → tran forge log list [--json] # lists ledger runs (.forge/runs.jsonl) forge log show <runId> [--json] # full RunRecord of a past run forge explain <code> # docs for a diagnostic, e.g.: forge explain F101 forge catalog [type] [--json] # component datasheet + grammar VERIFY OPTIONS --stage op,tran # runs only these stages (lint ALWAYS runs) --set R1=330,V1=5 # overrides values without editing the bench --json # emits the full RunRecord as JSON on stdout --no-hints # skips verified sizing hints (faster) --no-ledger # does not record this run in .forge/runs.jsonl
Full session: fail with hint → --set → pass§
With bench/broken/led-overcurrent.bench.ts — series resistor too small (LED at ~28 mA against a spec of 8–12 mA):
$ npm run forge -- verify bench/broken/led-overcurrent.bench.ts bench led-overcurrent netlist cb4f1e998fc8 ([email protected]) ✓ lint pass ✗ op fail A301 a1: D1.i within [8m, 12m] — measured 28.04m (outside by 16.04m, 2.34x) at bench/broken/led-overcurrent.bench.ts:22 hint (verified): R1 in [261, 316.2] passes all op assertions — try R1 = 261 – tran skipped (op assertions failed) FAIL · 1 problem · first failure: a1 · exit 1 run cb4f1e998fc8-001 → .forge/runs.jsonl (forge log show cb4f1e998fc8-001) $ npm run forge -- verify bench/broken/led-overcurrent.bench.ts --set R1=280 bench led-overcurrent netlist cb4f1e998fc8 set R1=280 ([email protected]) ✓ lint pass ✓ op pass 1 assertion passed PASS · exit 0
Exit code 1 said "valid circuit, spec not met"; the verified hint gave the passing range; --set R1=280 applied the fix without editing code. For agents, --json returns the same content as a structured RunRecord, and forge log list keeps the history of what has already been tried outside the context (anti context attrition).
Components§
The catalog below is the same one that forge catalog returns — the "datasheet" the agent consults. Values accept a number or a string with units ('4.7k', '10m', '2u', '1M'; remember: m = milli, M = mega).
| Type | Example | Pins | Value | DC behavior |
|---|---|---|---|---|
| Resistor | tb.add('R1', new Resistor('4.7k')) |
'1', '2' |
resistance in ohms (finite, > 0); accepts a string with units | conducts; sweepable via tb.param |
| Capacitor | tb.add('C1', new Capacitor('10u')) |
'1', '2' |
capacitance in farads (finite, > 0) | OPEN in DC — nets isolated behind it need a DC path (see F101); sweepable |
| Inductor | tb.add('L1', new Inductor('10m')) |
'1', '2' |
inductance in henries (finite, > 0) | 0 V short in DC — in parallel with an ideal source it fires F104; sweepable |
| VoltageSource | tb.add('V1', new VoltageSource(5)) |
'+', '-' |
volts DC (finite); set .acAmplitude/.frequency for a sine wave in the transient |
ideal source — sources in parallel fire F104; sweepable |
| CurrentSource | tb.add('I1', new CurrentSource('1m')) |
'+', '-' |
amperes (finite); the current flows into the + pin externally |
does not fix node voltages — a node fed only by it floats (F101); sweepable |
| Diode | tb.add('D1', new Diode({ Is: 1e-14, n: 1 })) |
'anode', 'cathode' |
Shockley parameters {Is, n, Vt}; nonlinear (Newton-Raphson) |
needs series resistance or NR may fail to converge (S201) |
| LED | tb.add('D1', new LED()) |
'anode', 'cathode' |
diode with LED defaults (~2 V forward drop at mA currents) | ALWAYS use a series resistor; probe D1.i for the operating current |
| Switch | tb.add('SW1', new Switch(false)) |
'com', 'a', 'b' |
SPDT: closed=false routes com→a, closed=true routes com→b; .toggle() |
conducts (channel 1 mΩ closed / 1 TΩ open) |
| Button | tb.add('BT1', new Button(false)) |
'1', '2' |
momentary: conducts while .pressed === true |
conducts (1 mΩ pressed / 1 TΩ released) |
Beyond the built-ins, custom components can be defined via defineComponent({ pins, params, stamp, onStep, ... }) — with internal state and a nonlinear flag — as in the MCU + LEDs example.
Roadmap§
v0.1 already delivers the complete loop: validated MNA engine (DC + transient, Newton-Raphson), the bench() DSL, pre-solver structural lint, simulation-verified hints, and the CLI with a ledger. From here on:
| Phase | Deliverable | Why |
|---|---|---|
v0.2 | L2 stage (sweep/transfer curves), forge sweep, forge probe over the ledger | completes the staged verification ladder |
v0.3 | Token-budget report (failures-only + drill-down), --watch with a hard solve timeout | sub-second iteration; never block the agent's tool call |
v0.4 | MCP server (forge mcp) with the same schema as the CLI | the agent uses it as a native tool |
v0.5 | forge optimize (Nelder-Mead over the ladder), gmin/source stepping in NR | the agent never picks a resistor by hand again |
v0.6 | UI = RunRecord viewer: assertion chips on the schematic, bands on the oscilloscope, ledger timeline | human and agent see the same artifact |
v0.7 | Strong determinism, SPICE export + ngspice cross-check in CI | public numerical credibility |
v0.8 | Level-1 MOSFET, Ebers-Moll BJT, behavioral sources, op-amp macro-model | unlocks real specs |
v1.0 | CircuitBench: public eval of "iterations-to-green" per model; freeze of the forge-run/1.0 schema | the standard wins because agents provably converge on it |