From 38e459aeb63bac1d3c817457e84d3036537c4041 Mon Sep 17 00:00:00 2001 From: Jefferson Oliveira Date: Sun, 24 May 2026 21:55:13 -0300 Subject: [PATCH] docs: bring DOCS into line with multi-bit wires and topology v02 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-bit support is now a real part of the surface. Update the documentation set so the language reference, decisions log, runtime contract, preview reference, and getting-started walkthrough all describe the world as compiled today. - DOCS/language.md: declarations and signals carry `[N]` width annotations; new §6 covers literal widths, slice / bit-index / concat (low operand on the left), parametric sub-circuits via ``/`[W]`, and parametric built-in macros. - DOCS/decisions/language.md: append a Multi-bit wires section with the locked decisions (LSB=0 bit numbering, half-open slice, concat ordering, parameter scope, default width 1, E014/E015/E016 codes, BigInt WASM ABI, topology v02). - DOCS/circuit-format.md: width annotation in the syntax overview, per-kind width rules in the primitives table, slice / concat / bit-index in the signal grammar, topology v02, diagnostics table. - DOCS/simulation-engine.md: pool storage described as lazily allocated multi-tier; ComponentType / Component.Kind extended with slice and concat; per-kind port-name table extended; remove the stale width=1-only caveats. - DOCS/architecture.md: rewrite the width=1 caveat to reflect the lazily-allocated multi-tier layout. - DOCS/preview.md: document --expand-display, multi-bit pin labels, and the three LED rendering modes; bump section magic to v02. - DOCS/getting-started.md: migrate the Node walkthrough to the BigInt paired ABI (setPin(id,value,defined), getOutputValue/getOutputDefined) and add a multi-bit driving example. - CLAUDE.md: rewrite fact #7 in the engine summary; re-aim the plan pointer at the archived path. - DOCS/archive/{index.md,plan-multi-bit-language.md}: archive the multi-bit plan and add its index row. --- CLAUDE.md | 2 +- DOCS/architecture.md | 2 +- DOCS/archive/index.md | 1 + DOCS/{ => archive}/plan-multi-bit-language.md | 0 DOCS/circuit-format.md | 54 +++- DOCS/decisions/language.md | 113 ++++++++ DOCS/getting-started.md | 36 ++- DOCS/language.md | 262 +++++++++++++++--- DOCS/preview.md | 29 +- DOCS/simulation-engine.md | 47 ++-- 10 files changed, 466 insertions(+), 80 deletions(-) rename DOCS/{ => archive}/plan-multi-bit-language.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 696026b..b288acd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Facts that materially shape edits: 4. **Propagation is per-timestamp batched.** `propagate()` drains every event at the current timestamp `T` in Phase 1 (commit state, collect changed), then in Phase 2 walks the outputs of changed components, recalculating and rescheduling. Without that batching, a downstream gate with multiple upstream events at the same `T` can read partial state, dedup the corrective re-enqueue, and stick on the wrong final value. See `DOCS/simulation-engine.md` for the full rationale. 5. **Delays are compile-time constants:** `PROPAGATION_DELAY = 5`, `WIRE_PROPAGATION_DELAY = 1`. `wire`, `output_pin`, and `led` use the wire delay; everything else uses the gate delay. 6. **The allocator is global, not parameterised.** Allocations route through `memory.allocator` from `lib/memory.zig`. On WASM that is `std.heap.wasm_allocator`; on native (test builds) it is a `GeneralPurposeAllocator`. Do not add an allocator parameter to engine functions. -7. **Today only width=1 (tier 0) is wired; wider widths trap.** The `BitVecState` / `PoolHandle` split exists so wider widths can be added by introducing a new pool tier without re-touching the propagator. This is the active multi-tier work tracked in `DOCS/plan-multi-bit-language.md`. +7. **Widths 1 through 64 are wired; pool tiers are lazily allocated per width.** Each tier indexes a separate SoA pool (`tier == width`; tier 0 is unused). `PoolHandle.tier`/`.slot` make every read/write a single dispatch followed by a direct bitmap op against the tier's `(values, defined)` u64 buffers. The width=1 tier packs 64 slots per word; wider tiers store one u64 per slot. Widths > 64 trap at allocation time. The historical roll-out lives in `DOCS/archive/plan-multi-bit-language.md`. `COLLECT_METRICS` is a compile-time switch wired by `build.zig` per consumer: `false` for native and WASM, `true` only for `zig build bench`. When false, `Circuit.metrics` is `void` and every counter bump is dead-code stripped, so production and test builds are byte-identical to a metrics-free engine. diff --git a/DOCS/architecture.md b/DOCS/architecture.md index ab8eaff..a1775d5 100644 --- a/DOCS/architecture.md +++ b/DOCS/architecture.md @@ -107,7 +107,7 @@ Allocations route through `memory.allocator` from `lib/memory.zig`. In the WASM Wire state does **not** live inline on `Component`. Each component carries an opaque `PoolHandle` into a width-tiered Structure-of-Arrays pool owned by `Circuit`. Reads and writes flow through `Circuit.readState` / `Circuit.writeState`, which dispatch on `PoolHandle.tier` exactly once and then perform a direct bitmap operation against the pool's `(values, defined)` u64 buffers. The width=1 tier packs 64 slots per word. -The value currency above the pool is `BitVecState` (`value`, `defined`, `width`). It is what events carry, what gates evaluate, and what listeners receive. The split exists so wider wires (4/8/32-bit busses) can be added by introducing a new pool tier without re-touching the propagator. Today only width=1 (tier 0) is wired; wider widths trap. See `DOCS/simulation-engine.md` for the full surface. +The value currency above the pool is `BitVecState` (`value`, `defined`, `width`). It is what events carry, what gates evaluate, and what listeners receive. The split lets every legal width (1 through 64) live in its own lazily-allocated pool tier, with the propagator untouched — each tier indexes by `tier == width` (tier 0 is unused). The width=1 tier packs 64 slots per word; wider tiers store one u64 per slot. Widths > 64 trap at allocation time. See `DOCS/simulation-engine.md` for the full surface. ## Layer 2 — Prebuilt runtime template (`templates/`) diff --git a/DOCS/archive/index.md b/DOCS/archive/index.md index 0e7cb94..e734be5 100644 --- a/DOCS/archive/index.md +++ b/DOCS/archive/index.md @@ -9,3 +9,4 @@ Each file is a highlight view. The full plan prompt, every phase plan, and every | [plan-test-speed.md](plan-test-speed.md) | Test-suite speed-up plan (highlights; full bundle in git history) | | [plan-zig-free-cli.md](plan-zig-free-cli.md) | Self-contained `circ-compile`: no Zig at user runtime (highlights; full bundle in git history) | | [plan-cli-preview.md](plan-cli-preview.md) | `circ-compile --preview`: ASCII circuit schematic rendering with opaque/expanded macro modes and ANSI color (highlights; full bundle in git history) | +| [plan-multi-bit-language.md](plan-multi-bit-language.md) | Multi-bit wires language extension (literal widths, slice / bit-index / concat, parametric sub-circuits, topology format v02). Landed across stages S1–S12. | diff --git a/DOCS/plan-multi-bit-language.md b/DOCS/archive/plan-multi-bit-language.md similarity index 100% rename from DOCS/plan-multi-bit-language.md rename to DOCS/archive/plan-multi-bit-language.md diff --git a/DOCS/circuit-format.md b/DOCS/circuit-format.md index 6c4c3cc..1cf4479 100644 --- a/DOCS/circuit-format.md +++ b/DOCS/circuit-format.md @@ -6,17 +6,22 @@ ## Syntax Overview -A `.circ` file contains a sequence of top-level declarations. Each declaration either names input pins or instantiates a component with named port connections. +A `.circ` file contains a sequence of top-level declarations. Each declaration either names input pins or instantiates a component with named port connections. Every signal-carrying declaration may carry an optional width annotation `[N]` where `N` is in `[1, 64]`; a missing `[N]` means width 1. ``` -input [, ...] +input [] [, ...] +output [] [, ...] - ( + [] [] ( = [, = ...] ) ``` +`` is a literal `[N]` (or a parameter inside a parametric sub-circuit; see [`language.md`](language.md) §6.3). `` is a comma-separated list `[w0, w1, ...]` that pins the width parameters of an imported sub-circuit at the call site. + +For the full language reference (parametric sub-circuits, slice/index/concat signals, the `` introduction form, built-in parametric macros, and diagnostic semantics) see [`language.md`](language.md). + ## Comments Line comments start with `//` and run to end of line (see `Annotation` in `lib/grammar/proto-circ.peg`). Hash (`#`) is not a comment starter in `.circ`. @@ -27,9 +32,11 @@ Line comments start with `//` and run to end of line (see `Annotation` in `lib/g ``` input pin1, pin2 +input[4] addr +input[W] data ``` -Declares one or more named input pins for the circuit. Input pins are driven externally (by the JavaScript host) and have no input ports of their own. +Declares one or more named input pins for the circuit. Input pins are driven externally (by the host) and have no input ports of their own. An optional `[N]` after `input` makes the pin `N` bits wide; the parametric form `input[W]` declares both a width parameter and an input of that width (see [`language.md`](language.md) §6.3). ### Component Instances @@ -42,17 +49,17 @@ Declares one or more named input pins for the circuit. Input pins are driven ext `` is the gate kind. Recognised types: -| Type | Ports | Description | -|----------|----------|---------------------------------------------------------------------| -| `and` | `a`, `b` | AND gate | -| `not` | `in` | NOT gate (inverter) | -| `led` | `in` | Output indicator | -| `wire` | `in` | Pass-through | -| `output` | `in` | Externally observable output pin (special-cased declaration kind) | +| Type | Ports | Width rule | Description | +|----------|----------|-------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `and` | `a`, `b` | bit-parallel; both inputs must match the instance width | AND gate | +| `not` | `in` | bit-parallel; input must match the instance width | NOT gate (inverter) | +| `led` | `in` | input must match the instance width; widths `> 1` render as a numeric display | Output indicator | +| `wire` | `in` | input must match the instance width | Pass-through | +| `output` | `in` | input must match the instance width | Externally observable output pin (special-cased declaration kind) | -`input` is a sibling pin declaration with its own syntax (no port list), described above. +`input` is a sibling pin declaration with its own syntax (no port list), described above. All primitives accept an optional `[N]` width annotation; absent it, width is 1. -**Built-in macro gates** expand at compile time to nested `and` / `not` (and optionally other macros). They use **`a`** and **`b`** as input ports and expose **`out`**. Unlike `and`/`not`, **no `import`** is required — the compiler behaves as if `import … from "/.circ"` were present. +**Built-in macro gates** expand at compile time to nested `and` / `not` (and optionally other macros). They use **`a`** and **`b`** as input ports and expose **`out`**. Unlike `and`/`not`, **no `import`** is required — the compiler behaves as if `import … from "/.circ"` were present. Each macro is parametric: `or[8] g(a=x, b=y)` produces a bit-parallel 8-bit OR. | Type | Ports | Expansion | |--------|----------|-----------| @@ -67,7 +74,12 @@ Declares one or more named input pins for the circuit. Input pins are driven ext `` is one of: - `.out` — the output of a named component or primitive with a single implicit output `.out`. - `.` — the output pin of an imported subcircuit that exposes `` (`sum`, `carry`, etc.). -- An inline component expression (see nested components below) +- `[i]` — single-bit index into a multi-bit signal (yields width 1). +- `[lo..hi]` — half-open slice `[lo, hi)` of a multi-bit signal (yields width `hi - lo`). +- `{, , ...}` — concatenation, **low operand on the left**, so `{a, b}` puts `a` in the low bits and `b` in the high bits. +- An inline component expression (see nested components below). + +See [`language.md`](language.md) §4 and §6 for the full signal grammar and width-checking rules. ## Signal References @@ -129,3 +141,17 @@ Parse tree node types used by `lib/syntax/translate.zig`: | `NodeType_Error` | Parse failure at this position | `lib/syntax/nodes/declaration.zig` handles `Declaration` rule nodes and recognises the sub-rules `input`, `output`, and `component` to determine declaration kind. + +## Topology Format Version + +Compiled `.wasm` artifacts embed the resolved circuit as a custom section. The current format identifier is `CIRC` (v02), bumped from the v01 format that preceded multi-bit support. Each serialised component now carries a `width: u8` byte; the runtime materialises the corresponding pool tier on `init()`. See [`wasm-api.md`](wasm-api.md) for the host-facing ABI and `lib/topology/serializer.zig` for the wire layout. + +## Diagnostic Codes + +The stable validator surface (E001–E016, W001–W003) is enumerated in [`language.md`](language.md) §4.2 and `lib/validator/codes.zig`. The multi-bit codes added with v02 are: + +| Code | Meaning | +| --- | --- | +| E014 | width mismatch between a driver and the port it feeds | +| E015 | width annotation `[N]` on a declaration whose declared type is scalar | +| E016 | sub-circuit call-site arity mismatch (wrong number of `<...>` parameters) | diff --git a/DOCS/decisions/language.md b/DOCS/decisions/language.md index c6b790b..cb104c2 100644 --- a/DOCS/decisions/language.md +++ b/DOCS/decisions/language.md @@ -49,3 +49,116 @@ The surface syntax of `.circ` is documented in [../circuit-format.md](../circuit **Rationale.** Conflating the two would mean every sub-circuit author has to choose between "this is for display" and "this is for the parent", or worse, every LED becomes implicitly part of the public interface. Keeping them separate lets a sub-circuit have internal LEDs (debug indicators) without polluting its external contract, and lets the top-level circuit have outputs without forcing a visual element. **Alternatives.** A single "sink" concept covering both. Simpler vocabulary, but loses the distinction between rendering and interface that consumers actually care about. + +--- + +## Multi-bit wires + +The 17 decisions below were locked during the multi-bit wires initiative (stages S1–S12). The full historical plan with rationale and stage ordering moved to `DOCS/archive/plan-multi-bit-language.md` after S12 closed; that file is the place to read for *why* each decision shaped the way it did. + +### 1. Width annotation syntax + +**Decision.** A width is declared with `[N]` after the type keyword: `input[4] a, b`, `and[4] g(...)`, `output[4] r(in=...)`. A missing `[N]` means width 1. + +**Rationale.** Postfix annotation keeps existing scalar `.circ` files legal as-is, which is the load-bearing property — the validator's job becomes a single equality check per connection and no caller has to be touched when a sub-circuit goes multi-bit. + +### 2. Bit numbering + +**Decision.** `a[0]` is the LSB; bit `i` carries weight `2^i`. + +**Rationale.** Matches `BitVecState.value`'s bit layout exactly, so the topology format, the engine, and the language all agree byte-for-byte. + +### 3. Slice notation + +**Decision.** `a[lo..hi]` is half-open. `a[0..4]` covers bits 0, 1, 2, 3 — four bits. + +**Alternatives.** Closed-range (`a[0..3]` covers four bits) is mathematically equivalent but every empty/identity case ends in an off-by-one trap. Half-open keeps `hi - lo == width`. + +### 4. Concatenation + +**Decision.** `{low, high}` with braces, low-on-left ordering. `{a, b}` produces a width `a.width + b.width` value where bits `[0, a.width)` come from `a` and bits `[a.width, ...)` come from `b`. + +**Rationale.** Low-on-left lines up with how `BitVecState.value` is stored (LSB at bit 0) and how slice notation places the lower bound on the left. A Verilog-flavoured high-on-left ordering was rejected because it forces every shape transformation to mentally reverse the operand list. + +### 5. Sub-circuit parametricity + +**Decision.** A sub-circuit becomes parametric by introducing parameters with `` on its `input` declarations. Use sites inside the file write `[Identifier]`. Callers bind via `name[N, M, ...]` positionally at the instance name. + +### 6. `` versus `[W]` + +**Decision.** Angle brackets are the *introduction* form (declares a parameter). Square brackets are the *reference* form (an integer literal or a previously-introduced parameter name). + +**Rationale.** Visually different brackets make it immediately obvious whether you're looking at a declaration or a use. The compiler can also produce better diagnostics: `[W]` without a corresponding `` triggers `E015`, and the suggestion can point at the exact spelling change. + +### 7. Where parameters can appear + +**Decision.** Width annotations only. Parameters never appear inside slice bounds, indices, arithmetic, or anywhere else. + +**Rationale.** Keeping parameters to declaration positions only means the resolver can do all specialization with a tree rewrite at one fixed place (`Identifier.width` and `ComponentInstance.width_args`). Allowing parameters inside `a[W..W+4]` would require an arithmetic-evaluation pass and pull resolver complexity sideways for marginal gain. + +### 8. `[N]` on a scalar sub-circuit + +**Decision.** Compile error `E015`. The diagnostic suggests adding `` to the sub-circuit. + +### 9. Multiple parameters + +**Decision.** Allowed. Positional at call sites; ordered by the position of first introduction in the source file. + +**Rationale.** Positional binding matches every other language's convention for parameterised types and avoids the readability cost of keyword arguments at every call site. Source-order-of-introduction is the only ordering that's both well-defined and visible to the reader; declaration order in `` may differ from reference order in the body, and the introducer wins. + +### 10. Missing `[N]` at call site + +**Decision.** Defaults all parameters to 1. + +**Rationale.** This is the entire reason every existing scalar caller of the built-in macros kept working after S8 rewrote them as parametric. Without this rule, S8 would have been a breaking change for every `.circ` file in the world. + +### 11. `` discipline + +**Decision.** A parametric sub-circuit always declares its parameter in angle brackets on the introducing `input` line, never elided or implicit. + +**Rationale.** Two reasons to read a `.circ` file: understand its logic and understand its interface. Hiding parametricity would force the reader to infer it from internal usages. The `` mark is small, visible, and unambiguous. + +### 12. LED at width > 1 + +**Decision.** Default `--preview` rendering is a single hex numeric display (`0x?` for fully undefined). With `--expand-display` and `N < 8`, render `N` indicator glyphs (LSB on the left). At `N >= 8` with `--expand-display`, fall back to numeric and emit one stderr warning per offending LED. + +**Rationale.** Indicator rows wider than 7 don't fit comfortably in a typical terminal; the cap is a UX call, not a technical one. The warning is non-fatal so the user still gets a render and can re-run without the flag. + +### 13. Truth table + +**Decision.** One column per pin; multi-bit values rendered per format flag. `--truth-table-format binary|hex|decimal` (default binary). Hard cap at 16 total input bits across all inputs; `--truth-table-cap` overrides up to 24. + +**Rationale.** `2^16 = 65,536` rows is the practical ceiling for a glance-readable truth table; pushing past 24 (16 M rows) is almost never what the user wanted, so the override is opt-in. + +### 14. WASM host API + +**Decision.** Three exports per pin: + +- `setPin(id: i32, value: i64, defined: i64) -> void` +- `getOutputValue(id: i32) -> i64` +- `getOutputDefined(id: i32) -> i64` + +The two i64 fields are read by JS as `BigInt`. The full rationale (paired exports vs. a single out-pointer call) lives in `DOCS/wasm-api.md`. + +### 15. Macro migration + +**Decision.** The five built-in macros (`or`, `xor`, `nand`, `nor`, `xnor`) are written with `` annotations. `nor` and `xnor` propagate width through their internal `or` / `xor` calls (`or inner[W]`, `xor inner[W]`). + +**Rationale.** Decision #10 is what makes this safe: scalar callers default `W` to 1 and get byte-identical IR and topology. Bench's `golden matches` invariant tracks this empirically. + +### 16. Signal-expression composition + +**Decision.** A signal source on the right-hand side of a port binding is one of: + +- a bare name (`a`) +- `name.port` +- `name[i]` or `name[lo..hi]` +- `name.port[i]` or `name.port[lo..hi]` +- an anonymous component instance (`not(in = a).out`) +- a brace concat (`{ ... }`) containing any of the above + +### 17. Topology format version + +**Decision.** Bumped from `0x01` (pre-multibit) to `0x02`. Both `ComponentRecord` (min) and `FullComponentRecord` (full) carry a `width: u8` byte per component. Slice records carry auxiliary `(lo, hi)` bytes in the min section. Concat records carry their operand list in the full section's auxiliary slot. + +See `DOCS/circuit-format.md` for the exact byte layout. diff --git a/DOCS/getting-started.md b/DOCS/getting-started.md index acc1e43..ff9f3bf 100644 --- a/DOCS/getting-started.md +++ b/DOCS/getting-started.md @@ -102,7 +102,7 @@ Outputs (1) id=0 name=out driver=1.out ``` -`setPin` takes the **input pin's component id** (`0` for `a`). `getOutputState` takes the **driver component id** of the output (`1` here — the `not` gate that drives `out`), not the output_pin's own id. +`setPin(id, value, defined)` takes the **input pin's component id** (`0` for `a`) along with a `(value, defined)` `BitVecState` pair. The paired output reads `getOutputValue(id)` and `getOutputDefined(id)` take the **driver component id** of the output (`1` here — the `not` gate that drives `out`), not the output_pin's own id. ## 4. Drive the compiled `.wasm` from Node @@ -127,8 +127,14 @@ const ptr = w.topology_alloc(topoBytes.length); new Uint8Array(w.memory.buffer).set(topoBytes, ptr); w.init(); -w.setPin(0, 0); w.run(); console.log("a=0 -> NOT a =", w.getOutputState(1)); -w.setPin(0, 1); w.run(); console.log("a=1 -> NOT a =", w.getOutputState(1)); + +const read = (id) => + w.getOutputDefined(id) === 0n + ? "undefined" + : w.getOutputValue(id) === 0n ? "low" : "high"; + +w.setPin(0, 0n, 1n); w.run(); console.log("a=0 -> NOT a =", read(1)); +w.setPin(0, 1n, 1n); w.run(); console.log("a=1 -> NOT a =", read(1)); ``` Run it: @@ -140,11 +146,24 @@ node examples/run.mjs examples/inverter.wasm Expected: ``` -a=0 -> NOT a = 1 -a=1 -> NOT a = 0 +a=0 -> NOT a = high +a=1 -> NOT a = low +``` + +The two i64 parameters cross the boundary as JavaScript `BigInt` values; `value` and `defined` each pack one bit per signal bit. A scalar pin uses `(0n, 1n)` for low, `(1n, 1n)` for high, and `(_, 0n)` for undefined. The full export list emitted by `circ-compile … -o out.wasm` today is exactly `topology_alloc`, `init`, `run`, `setPin`, `getOutputValue`, `getOutputDefined` (plus `memory`); see [`DOCS/wasm-api.md`](wasm-api.md) for the full contract. The two `env` callbacks (`debugEnabled` and `onDebugLog`) are required imports — supply the no-op stubs above unless you want debug logging. + +### Driving a multi-bit input + +For a wider input declared as `input[4] a`, both `value` and `defined` use one bit per signal bit. To drive a 4-bit bus to the value `0b1010` with every bit defined: + +```js +w.setPin(input_id, 0b1010n, 0b1111n); +w.run(); +const v = w.getOutputValue(output_id); // BigInt, e.g. 0b1010n for a passthrough +const d = w.getOutputDefined(output_id); // BigInt, 0b1111n ``` -`0` means low, `1` means high, `2` means undefined. The full export list emitted by `circ-compile … -o out.wasm` today is exactly `topology_alloc`, `init`, `run`, `setPin`, `getOutputState` (plus `memory`); see [`DOCS/wasm-api.md`](wasm-api.md) for the full contract. The two `env` callbacks (`debugEnabled` and `onDebugLog`) are required imports — supply the no-op stubs above unless you want debug logging. +Bits set beyond the declared width are silently masked. See [`DOCS/wasm-api.md`](wasm-api.md) for the full `BitVecState` semantics. ## 5. Use a built-in macro (`xor`) @@ -171,8 +190,9 @@ Once a file participates in the project pipeline, root-pin component IDs are ass ```js for (let i = 0; i < 64; i++) { - const v = w.getOutputState(i); - if (v !== 2) console.log(`id=${i} -> ${v}`); + if (w.getOutputDefined(i) !== 0n) { + console.log(`id=${i} -> value=${w.getOutputValue(i)} defined=${w.getOutputDefined(i)}`); + } } ``` diff --git a/DOCS/language.md b/DOCS/language.md index a66be8c..a71047f 100644 --- a/DOCS/language.md +++ b/DOCS/language.md @@ -11,11 +11,14 @@ This document is the language reference. For an end-to-end tutorial see [`getting-started.md`](getting-started.md); for the runtime API exposed by the compiled artifact see [`wasm-api.md`](wasm-api.md). -> **Status.** This reference covers the v0 surface: imports, input/output pins, -> primitive components (`and`, `not`, `led`, `wire`), the auto-imported macro -> family (`or`, `nand`, `nor`, `xor`, `xnor`), anonymous nested components, and -> sub-circuit instantiation. Anything not mentioned here is not part of the -> language yet. +> **Status.** This reference covers the surface as of the multi-bit-wires +> release: imports, input/output pins, primitive components (`and`, `not`, +> `led`, `wire`), the auto-imported macro family (`or`, `nand`, `nor`, `xor`, +> `xnor`), anonymous nested components, sub-circuit instantiation, and width +> annotations on every signal-carrying declaration (`[N]` for literal widths, +> `` for parametric ones). Slice (`a[lo..hi]`), bit-index (`a[i]`), and +> concatenation (`{a, b}`) signal expressions are also part of the language. +> Anything not mentioned here is not part of the language yet. --- @@ -119,65 +122,91 @@ Paths are resolved relative to the directory of the file containing the import. ## 3. Declarations A `circ` program is a sequence of declarations. The four kinds are described -below. +below. Every declaration that carries a signal may optionally name its width +in bits with a `[N]` annotation; a missing `[N]` means width 1, which is what +makes pre-multi-bit `.circ` files legal as-is. ### 3.1 Input Pins ``` input a input clk, reset +input[4] addr, data // 4-bit buses ``` `input` declares one or more externally driven pins. An input pin has no input ports of its own; its single output is referenced as **``** or equivalently **`.out`** elsewhere in the program. Input pins are driven -from the host via `setPin(component_id, value)` after compilation. +from the host via `setPin(component_id, value, defined)` after compilation +(see [`wasm-api.md`](wasm-api.md) for the BigInt-pair convention). + +The `[N]` annotation between the keyword and the names sets the width for +every name in that `input` line. To declare pins at different widths, write +separate `input` lines. + +A sub-circuit becomes parametric by introducing a parameter with `` on its +`input` lines (covered fully in §6): + +``` +input[W] a, b // a, b inherit the parameter W as their width +``` ### 3.2 Output Pins ``` output sum(in = adder.out) +output[4] result(in = alu.out) ``` `output` declares a named externally observable pin and binds its single port -`in` to a signal. Output pins are read from the host via -`getOutputState(driver_id)` (note: the driver's id, not the output pin's own -id; see [`wasm-api.md`](wasm-api.md)). +`in` to a signal. Multi-bit outputs use the same `[N]` annotation. Output pins +are read from the host via two paired exports: `getOutputValue(driver_id)` +returns the `BitVecState.value` field as a BigInt, `getOutputDefined(driver_id)` +returns `BitVecState.defined`. Both take the **driver** component id, not the +output pin's own id; see [`wasm-api.md`](wasm-api.md). ### 3.3 Component Instances -A component is instantiated by writing its **type**, an instance **name**, and -a parenthesised list of **port bindings**: +A component is instantiated by writing its **type**, an optional `[N]` width, +an instance **name**, and a parenthesised list of **port bindings**: ``` and gate1(a = pin1, b = pin2) -not inv (in = clk.out) +not inv (in = clk.out) +and[4] adder(a = x, b = y) // 4-bit AND ``` The available primitive types are: -| Type | Input ports | Output | Notes | -| ------ | ----------- | ------ | -------------------------------------- | -| `and` | `a`, `b` | `out` | 2-input AND gate. | -| `not` | `in` | `out` | Inverter. | -| `led` | `in` | — | Visualisation sink. No `.out`. | -| `wire` | `in` | `out` | Pass-through. See §5. | +| Type | Input ports | Output | Notes | +| ------ | ----------- | ------ | ---------------------------------------------------------------------- | +| `and` | `a`, `b` | `out` | Bitwise AND. Width controlled by `[N]`; defaults to 1. | +| `not` | `in` | `out` | Bitwise inverter. Width controlled by `[N]`; defaults to 1. | +| `led` | `in` | — | Visualisation sink. Multi-bit form renders per `--preview` flags. | +| `wire` | `in` | `out` | Pass-through. See §5. | -The auto-imported macro family expands at compile time to nested `and`/`not` -gates. They share a common port shape: +The auto-imported macro family is parametric in width; the default-missing-`[N]` +rule keeps every existing scalar caller working unchanged. -| Type | Input ports | Output | Expansion | +| Type | Input ports | Output | Expansion (per width slot) | | ------ | ----------- | ------ | ---------------------------------- | | `or` | `a`, `b` | `out` | `not(and(not a, not b))` | | `nand` | `a`, `b` | `out` | `not(and a b)` | -| `nor` | `a`, `b` | `out` | `not(or a b)` | -| `xor` | `a`, `b` | `out` | `or(and a !b, and !a b)` | -| `xnor` | `a`, `b` | `out` | `not(xor a b)` | +| `nor` | `a`, `b` | `out` | `not(or[W] a b)` — `or` propagates width | +| `xor` | `a`, `b` | `out` | `and(or a b, nand a b)` | +| `xnor` | `a`, `b` | `out` | `not(xor[W] a b)` — `xor` propagates width | A user-defined sub-circuit is referenced by the alias bound in its `import` declaration. Its ports are exactly the names declared as `input`/`output` in -the imported file (e.g. the `half_adder` sub-circuit exposes input ports `a`, -`b` and output ports `sum`, `carry`). +the imported file. If the imported sub-circuit is parametric (declares one or +more `` parameters), the caller binds widths positionally with the +`name[N, M, ...]` form at the instance name: + +``` +mux inst[4, 2](data = x, select = sel) // mux instantiated at W=4, S=2 +``` + +Omitting the `[N, ...]` defaults all parameters to 1. ### 3.4 Imports @@ -201,8 +230,8 @@ import xor "/xor.circ" ## 4. Signals and Wiring A *signal* is whatever you place on the right-hand side of a port binding. It -identifies the source of the bit that drives the port. Signals come in three -forms. +identifies the source of the bit(s) that drive the port. Signals come in six +forms: **Reference to an input pin:** @@ -221,6 +250,38 @@ The `.out` suffix is the implicit output port of any single-output primitive. For sub-circuit instances, use the explicit output port name from the imported file: `ha.sum`, `ha.carry`, etc. +**Bit index (`name[i]` or `name.port[i]`).** Picks a single bit out of a +multi-bit signal: + +``` +input[4] bus +and g(a = bus[0], b = bus[3]) // bit 0 AND bit 3 +``` + +Bit 0 is the LSB. The result is a width-1 signal. + +**Slice (`name[lo..hi]` or `name.port[lo..hi]`).** Picks a contiguous range of +bits, half-open: + +``` +input[8] bus +and[4] low_half(a = bus[0..4], b = some_other_4bit_signal) +``` + +`bus[0..4]` covers bits 0, 1, 2, 3 — a 4-bit signal. The width of a slice is +`hi - lo`. An out-of-range or inverted slice is `E002`. + +**Concatenation (`{low, high, ...}`).** Joins two or more signals into a wider +one, low-on-left: + +``` +input a, b, c, d +and[4] combine(a = {a, b, c, d}, b = ...) +// bits: [0]=a, [1]=b, [2]=c, [3]=d +``` + +The output width is the sum of operand widths. + **Anonymous nested components.** A component may be instantiated inline as the value of a port. The nested instance has no name; its `.out` is wired immediately into the enclosing port: @@ -239,7 +300,7 @@ rules as a named one. ### 4.1 Validation Rules The compiler enforces a small set of rules on the resulting graph; violations -produce diagnostics with stable codes (`E001`–`E013`, `W001`–`W003`, see +produce diagnostics with stable codes (`E001`–`E016`, `W001`–`W003`, see `circuit-format.md` for the full catalogue): * Every signal reference must resolve to a declared name (`E001`). @@ -252,6 +313,12 @@ produce diagnostics with stable codes (`E001`–`E013`, `W001`–`W003`, see not shadow primitive type names (`E006`). * The induced signal graph must be acyclic (`E008`). See §5 for the role `wire` plays in cycle detection. +* Connection widths must agree on both ends. A connection from a width-4 + source to a width-8 destination is `E014` (width mismatch). +* Passing `[N]` widths to a scalar (non-parametric) sub-circuit is `E015`; the + diagnostic suggests adding `` to the callee. +* Parametric arity mismatch (caller writes `[N, M]` but the callee declares + one parameter, or vice versa) is `E016`. --- @@ -319,15 +386,138 @@ wire w2(in = n2.out) // closes the chain — but the chain has length 4 and (In this snippet `w2.out` is forward-referenced; the validator resolves names globally, so order in source is irrelevant for binding.) -### 5.3 What wires are *not* +### 5.3 Multi-bit wires + +A `wire` also takes the `[N]` annotation when it carries a multi-bit signal: + +``` +input[4] bus +wire[4] buffered_bus(in = bus) +and[4] g(a = buffered_bus.out, b = some_4bit_signal) +``` + +The wire's input width must match the source's output width, and its output +width is the same `N`. Mismatches surface as `E014`. + +### 5.4 What wires are *not* + +A `wire` is not a tri-state line and not a clocked register. It is a +value-preserving pass-through over a fixed-width signal. If you find yourself +wanting either of those things, the language does not yet model them. + +--- + +## 6. Multi-bit Wires + +`circ` programs may carry signals wider than one bit. Every signal-carrying +declaration accepts an optional `[N]` annotation; a missing `[N]` means width +1. Sub-circuits may take their widths as parameters with the `` form. The +authoritative decision record lives in +[`decisions/language.md`](decisions/language.md); this section is the user- +facing reference. + +### 6.1 Literal widths -A `wire` is not a multi-bit bus, not a tri-state line, and not a clocked -register. It is a value-preserving pass-through over a single bit. If you find -yourself wanting any of those things, the language does not yet model them. +The simplest form annotates a fixed width on a declaration: + +``` +input[4] a, b +and[4] g(a = a, b = b) +output[4] r(in = g.out) +``` + +`a`, `b`, the `and` gate `g`, and the output `r` are all width-4. The +validator checks that every connection's source width matches its destination +width. + +`a[0]` is the LSB. Bit `i` has weight `2^i`. This matches the +`BitVecState.value` bit layout the engine uses internally; there is no +conversion between the user-visible numbering and the runtime numbering. + +### 6.2 Slice, bit-index, and concatenation + +See §4 for the signal-expression syntax. A slice or bit-index produces a +narrower signal; a brace concat produces a wider one. The resolver lowers +each to an engine-level component, so users never write `slice(...)` or +`concat(...)` directly — the syntactic forms are the only way to invoke +them. + +``` +input[8] bus +and[4] g(a = bus[0..4], b = bus[4..8]) // AND the two halves +and bit_eq(a = bus[0], b = bus[7]) // compare LSB to MSB + +input a, b +input[2] tail +output[4] out(in = {a, b, tail}) // out = a | (b << 1) | (tail << 2) +``` + +### 6.3 Parametric sub-circuits + +A sub-circuit becomes parametric by introducing parameters with `` on its +`input` declarations. Use sites inside the file reference the parameter as +`[W]`: + +``` +// wide_not.circ +input[W] a +not[W] inv(in = a) +output[W] o(in = inv.out) +``` + +Callers bind widths positionally with `name[N, ...]` at the instance name: + +``` +import wide_not "wide_not.circ" + +input[4] x +wide_not inst[4](a = x) +output[4] r(in = inst.o) +``` + +Multiple parameters are allowed and ordered by source-position of first +introduction: + +``` +// mux_lib.circ +input[W] data +input[S] select +// ... body uses [W] and [S] independently +``` + +The caller binds positionally: `mux inst[4, 2](data = ..., select = ...)` +maps `[W]` to 4 and `[S]` to 2. + +Two rules to remember: + +* **Missing `[N]` at the call site defaults all parameters to 1.** This is + what keeps every existing scalar caller of the built-in macros working + unchanged. +* **Angle brackets only on the introducing line.** A parametric sub-circuit + marks its parameter once, on `input`. Internal references use `[W]`, + not ``. + +Width-mismatch on connections involving a sub-circuit boundary, missing +`` introductions, and arity mismatches at call sites surface as `E014`, +`E015`, and `E016` respectively. + +### 6.4 The built-in macros are parametric + +`or`, `xor`, `nand`, `nor`, `xnor` ship with `` declarations. Scalar +callers (no `[N]`) default `W` to 1, which produces byte-identical IR and +topology to the pre-multibit form. Wider callers get the natural multi-bit +gate. `nor` and `xnor` propagate width through their internal `or` / `xor` +calls. + +``` +input[8] x, y +nor[8] n(a = x, b = y) // bitwise NOR across all 8 bits +output[8] z(in = n.out) +``` --- -## 6. A Worked Example +## 7. A Worked Example The program below builds a half-adder out of primitives and exposes its sum and carry as outputs. It exercises every construct in the language: imports, @@ -379,7 +569,7 @@ The `--preview` flag prints an ASCII schematic of the resolved circuit; see --- -## 7. Where to Go Next +## 8. Where to Go Next * [`getting-started.md`](getting-started.md) — install the compiler, write your first circuit, drive it from Node. diff --git a/DOCS/preview.md b/DOCS/preview.md index 572b102..cd1c2b2 100644 --- a/DOCS/preview.md +++ b/DOCS/preview.md @@ -44,6 +44,7 @@ The `[xor:g]` box represents the entire `xor g(...)` instance as a single labele |------|---------| | `--preview` | Selects preview mode. Mutually exclusive with `--emit-zig` and `--inspect`. `-o` is rejected at parse time. | | `--expand-macros` | Renders subcircuits as their full primitive expansion instead of as a single labeled box. Only valid with `--preview`. | +| `--expand-display` | Renders an `led[N]` (width > 1) as a row of `N` single-bit LED cells with explicit `b0..bN-1` slice connections, instead of the default opaque multi-bit numeric display box. Only valid with `--preview`. | | `--color=auto\|always\|never` | Enables ANSI color (per-kind: input pins green, gates cyan, LEDs yellow, macros magenta, wires dim). Defaults to `auto` (color when stdout is a TTY *and* `NO_COLOR` is unset). `always` overrides `NO_COLOR` per the convention used by `git`/`ls`/`grep`. | The render path is fully in-memory: parse → resolve → translate → topology build → layout → render → stdout. No `.wasm` is written, no temp directory, no subprocess. @@ -76,6 +77,30 @@ Names longer than the cell width are truncated; shorter names pad with spaces. T **Layout determinism.** Rendering uses a five-stage pipeline (collapse → columns → rows → place → route), followed by a junction-picker pass that resolves crossings into the right corner/T-glyph. Every decision uses ascending node id as the universal tie-breaker; hash-map iteration is forbidden as an ordering source. The same `.circ` source produces byte-identical output across runs and platforms. +## Multi-bit pins + +A component declared with a width annotation (`input[4] a`, `led[4] disp`, `wire[8] bus`) renders with the width appended to its label as `[N]`: + +``` +╭──────╮ ╭────────────╮ +│ a[4] ├○───▶┤ [led:disp] │ +╰──────╯ ╰────────────╯ +``` + +A scalar pin omits the suffix, so the label width-marker is the visual cue that distinguishes a 1-bit and an N-bit wire. The wire glyph itself is the same — there is no "bus" glyph. + +### LED rendering modes + +A multi-bit `led[N]` (width > 1) has three rendering modes: + +| Mode | Trigger | Cell content | +|------|---------|--------------| +| **Numeric** | width > 1, all bits `defined` | The unsigned integer value (`0`–`2^N-1`) inside the box. | +| **Numeric + warning** | width > 1, some bits `defined`, others `undefined` | The integer value formed from the defined bits, with a warning marker (`?`) showing partial state. | +| **Indicator** | width = 1 | The single-bit LED glyph: lit on `high`, dim on `low`, `?` on `undefined`. | + +With `--expand-display`, the multi-bit form decomposes into `N` scalar LEDs wired to explicit bit-index slices of the input signal, which is the right view when you need to debug per-bit drive state. + ## Macro modes Built-in macros (`or`, `nand`, `nor`, `xor`, `xnor`) and user-imported subcircuits expand into primitive gates during compilation. The renderer can display them two ways: @@ -89,8 +114,8 @@ The renderer reads from a versioned topology payload embedded in compiled `.wasm | Section | Contains | |---------|----------| -| `circ.topology.v0.min` | Flat primitive components (id, kind) + connections. Magic `CIRC`, version `0x01`. The "lightweight" payload — what the runtime needs. | -| `circ.topology.v0.full` | Adds per-component instance names + subcircuit-origin chains. Magic `CIRF`, version `0x01`. The "rich" payload — what the renderer (and any future inspection tooling) needs. | +| `circ.topology.v0.min` | Flat primitive components (id, kind, `width: u8`) + connections. Magic `CIRC`, version `0x02`. The "lightweight" payload — what the runtime needs. | +| `circ.topology.v0.full` | Adds per-component instance names + subcircuit-origin chains. Magic `CIRF`, version `0x02`. The "rich" payload — what the renderer (and any future inspection tooling) needs. | `--preview` builds the `full` payload in memory (skipping the `.wasm` write) and feeds it directly into the renderer. Tools that consume a `.wasm` artifact from disk can parse the same payload via `lib/topology/full_decoder.zig:decode`. diff --git a/DOCS/simulation-engine.md b/DOCS/simulation-engine.md index 2c690b8..0ae7492 100644 --- a/DOCS/simulation-engine.md +++ b/DOCS/simulation-engine.md @@ -13,7 +13,7 @@ Wire state used to live inline on every `Component` as a single-bit `State` enum - A **value type**, `BitVecState`, that carries `(value, defined, width)` and is the currency of every event, dedup check, gate evaluation, and listener callback. - A **width-tiered Structure-of-Arrays pool** owned by `Circuit`, addressed by an opaque `PoolHandle`. Components carry a `state_handle` instead of an inline state field; reads and writes go through `Circuit.readState` / `Circuit.writeState`. -Today only width=1 is wired (tier 0); the rest of the value/handle machinery is in place so wider widths can land without re-touching propagation. Most callers only need `readState` / `writeState` plus the `BitVecState` constructors and predicates; `Pool` is exposed but rarely used directly. +Widths 1 through 64 are all wired today; the engine allocates a pool tier on demand the first time a component of that width is created. Lazy tier init keeps single-bit circuits at one allocated pool instead of 64. Most callers only need `readState` / `writeState` plus the `BitVecState` constructors and predicates; `Pool` is exposed but rarely used directly. ## Types @@ -59,7 +59,7 @@ Note that `toInt` and `toTransportByte` use **different** encodings. `toInt` is ```zig pub const ComponentType = enum { - input_pin_gate, not_gate, led, and_gate, wire, output_pin, + input_pin_gate, not_gate, led, and_gate, wire, output_pin, slice, concat, }; const Kind = union(ComponentType) { @@ -72,10 +72,17 @@ const Kind = union(ComponentType) { }, wire: struct { inputs: std.ArrayList(*Component) = .{} }, output_pin: struct { inputs: std.ArrayList(*Component) = .{} }, + slice: struct { from: *Component, lo: u8, hi: u8 }, + concat: struct { operands: []const *Component }, }; ``` -Backward edges are flat `std.ArrayList(*Component)` lists. `and_gate` is the only kind with two distinct input ports; it splits into `inputs_a` and `inputs_b`, selected positionally by the `"a"` / `"b"` port name in `connect`. Every other kind has a single `inputs` list keyed by `"in"`. `output_pin` is the sub-circuit/root output primitive: it appears in the IR for every `output …` declaration and acts as a wire-with-a-name. +Backward edges are flat `std.ArrayList(*Component)` lists for the eight-input gates (`and_gate` uses `inputs_a` / `inputs_b`; every other "input-based" kind uses a single `inputs` list keyed by `"in"`). `output_pin` is the sub-circuit/root output primitive: it appears in the IR for every `output …` declaration and acts as a wire-with-a-name. + +`slice` and `concat` are bit-shape kinds, not user-written primitives. The resolver lowers the language-level `a[lo..hi]`, `a[i]`, and `{a, b, ...}` signal sources into these kinds; users never write them directly. + +- A `slice` reads `from`'s current state, masks to bits `[lo, hi)`, and shifts right by `lo`. The output's width is `hi - lo`. Bit-index `a[i]` lowers to a slice with `hi = lo + 1`. +- A `concat` ORs each operand into a running bit-position. Operands listed low-on-left: bits `[0, op0.width)` come from `op0`, bits `[op0.width, op0.width + op1.width)` from `op1`, and so on. The output's width is the sum of operand widths. The integer encoding used by the topology format (`lib/topology/`) and `Component.Kind` constructor is: @@ -88,6 +95,8 @@ pub fn toKind(kind: u8) !Component.Kind { 3 => .and_gate, 4 => .wire, 5 => .output_pin, + 6 => .slice, + 7 => .concat, else => return error.InvalidComponentKind, }; } @@ -95,14 +104,16 @@ pub fn toKind(kind: u8) !Component.Kind { Per-kind port names: -| Kind | Input ports | Output port | -|------------------|---------------------------|-------------| -| `input_pin_gate` | `"in"` (sub-circuit only) | `"out"` | -| `not_gate` | `"in"` | `"out"` | -| `and_gate` | `"a"`, `"b"` | `"out"` | -| `wire` | `"in"` | `"out"` | -| `output_pin` | `"in"` | `"out"` | -| `led` | `"in"` | `"out"` | +| Kind | Input ports | Output port | +|------------------|--------------------------------------------|-------------| +| `input_pin_gate` | `"in"` (sub-circuit only) | `"out"` | +| `not_gate` | `"in"` | `"out"` | +| `and_gate` | `"a"`, `"b"` | `"out"` | +| `wire` | `"in"` | `"out"` | +| `output_pin` | `"in"` | `"out"` | +| `led` | `"in"` | `"out"` | +| `slice` | `"in"` | `"out"` | +| `concat` | `"operand_0"`, `"operand_1"`, … one per op | `"out"` | ### `Component` @@ -168,7 +179,7 @@ pub const Pool = struct { The pool packs 64 slots per `u64` word across two parallel buffers (one for value bits, one for defined bits). The two buffers grow together; `allocateSlot` is the only growth site and always appends to both, so length-mismatch is structurally impossible. -`PoolHandle.tier` selects which pool to dispatch to. Today only width=1 (tier 0) is exercised; wider widths trap with a pointer back to the follow-up issue inside `tierIndexForWidth`. Width is recovered from the pool, not stored on the handle, so handles stay 8 bytes. +`PoolHandle.tier` selects which pool to dispatch to. The convention is `tier == width`; tier 0 is unused and tiers 1..64 each carry their own pool, lazily allocated the first time a component of that width is created. Width is recovered from the handle's tier, not stored on the handle's body, so handles stay 8 bytes. You rarely construct `Pool` or `PoolHandle` directly; `Circuit.createComponent` allocates a slot and stamps the handle onto the new component. @@ -184,10 +195,10 @@ pub const Circuit = struct { /// Scratch buffer reused across propagate() calls so the first append in /// each propagation does not reallocate from zero capacity. changed_at_step: std.ArrayList(*Component) = .{}, - /// Width-tiered SoA pool for wire state. Single field today because only - /// tier 0 (width=1) has storage; widens to an array indexed by tier - /// once multi-bit widths land. - tier1: Pool = Pool.init(1), + /// Width-tiered SoA pool for wire state. Each entry is `?Pool`, lazily + /// allocated the first time a component of that width is created. + /// Indexed by tier where `tier == width`; tier 0 is unused. + tiers: [MAX_WIDTH + 1]?Pool, /// Benchmark counters. Zero-sized (`void`) outside the bench build, so /// shipping and test builds carry zero bytes and zero instructions on /// the metrics path. @@ -234,7 +245,7 @@ pub fn readState(self: *const Circuit, handle: PoolHandle) BitVecState; pub fn writeState(self: *Circuit, handle: PoolHandle, state: BitVecState) void; ``` -Tier dispatch happens exactly once per read/write, so every layer above these three functions sees only the value type. Today only width=1 is legal; wider widths trap via the dispatcher. +Tier dispatch happens exactly once per read/write, so every layer above these three functions sees only the value type. Any width in `[1, 64]` is legal; out-of-range widths trap via the dispatcher. You normally do not call `allocateStateSlot` yourself: `Circuit.createComponent` does it as part of publishing a new component. @@ -359,4 +370,4 @@ Used by every multi-driver port read: - Otherwise returns the last seen state during the scan (the loop unconditionally overwrites `dominant_state` on each iteration, so the final iteration wins among the non-`high` drivers). - Returns `undefined` if every driver is `undefined`. -The width=1 helpers (`isHigh`, `isLow`) are the comparison currency here; the dominant rule is width-1 today and will widen alongside the pool tiers. +The width=1 helpers (`isHigh`, `isLow`) are the comparison currency on scalar buses (the most common case). Multi-bit fan-in works the same way per bit: any `defined` bit set to high across the drivers wins; the dominant state is computed bit-parallel against the BitVecState `value` and `defined` fields.