Sections

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§

terminal
$ 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 in op assertions.
  • 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'.

Watch out: 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:

ProbeMeasuresExampleWhere 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(...).

MethodWhat it measuresExample
toBeWithin(min, max)DC value within the closed intervaltb.expect.op('D1.i').toBeWithin('8m', '12m')
toBeCloseTo(v, tol)DC value equal to v within ± toltb.expect.op('out').toBeCloseTo(5, 0.01)
toBeAbove(v)DC value above vtb.expect.op('R1.v').toBeAbove(1.2)
toBeBelow(v)DC value below vtb.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.

MethodWhat it measuresExample
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§

RangePhaseMeaning
F1xxlint (pre-solver)structural topology
S1xxlinear solverunsolvable system
S2xxnonlinear solverNewton-Raphson
A3xxassertionsspec not met
T0xxtoolingbench authoring error

Active codes§

CodeSlugSeverityWhen it fires
F101floating-neterrornet with no DC path to ground (isolated by a capacitor/current source)
F103unreached-componenterrorcomponent (or island) with no connection to the grounded circuit — would be silently ignored by the solver
F104voltage-source-short / voltage-source-looperrorideal source shorted, or a loop of ideal sources (an inductor counts as a 0 V source in DC)
F105source-clamped-diodeerrorforward-biased diode/LED clamped directly across an ideal source with no series resistance
F106dangling-pinwarningpin with no connection at all
S101unsolvable-systemerrorsingular matrix that lint did not model (rare; please report)
S201nr-nonconvergenceerrorNR did not converge at the DC point — carries the offending component, ΔV, and oscillation history
S202nr-nonconvergenceerrorNR did not converge at a transient step
A301assertion-failedfailassertion failed — carries measured, expected, margin, and a verified hint when tb.param is present
T001bench-errorerrorthe bench itself threw an exception during elaboration
T002stage-crasherrora 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§

CodeMeaningAgent action
0all requested stages passeddone
1assertion failed (valid circuit)adjust values (use the hint!) or topology
2invalid/unsolvable circuit (F/S with error severity)fix the structure first
3tooling 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 settleWithin that never settles reports measured = end of the window (finite) — never Infinity, which would become null in JSON.
  • hint.passingRange covers only the longest contiguous run of passing samples (each sample passes lint AND all op assertions); the exact simulated values are in passingSamples.

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.

forge — commands
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):

forge session
$ 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).

TypeExamplePinsValueDC 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:

PhaseDeliverableWhy
v0.2L2 stage (sweep/transfer curves), forge sweep, forge probe over the ledgercompletes the staged verification ladder
v0.3Token-budget report (failures-only + drill-down), --watch with a hard solve timeoutsub-second iteration; never block the agent's tool call
v0.4MCP server (forge mcp) with the same schema as the CLIthe agent uses it as a native tool
v0.5forge optimize (Nelder-Mead over the ladder), gmin/source stepping in NRthe agent never picks a resistor by hand again
v0.6UI = RunRecord viewer: assertion chips on the schematic, bands on the oscilloscope, ledger timelinehuman and agent see the same artifact
v0.7Strong determinism, SPICE export + ngspice cross-check in CIpublic numerical credibility
v0.8Level-1 MOSFET, Ebers-Moll BJT, behavioral sources, op-amp macro-modelunlocks real specs
v1.0CircuitBench: public eval of "iterations-to-green" per model; freeze of the forge-run/1.0 schemathe standard wins because agents provably converge on it