Skip to main content

Base SHDL: Specification

The Intermediate Representation of the SHDL Compilation Pipeline github.com/rafa-rrayes/SHDL


1. Introduction​

1.1 What Is Base SHDL?​

Base SHDL is the canonical intermediate representation (IR) at the heart of the SHDL compilation pipeline. It is the bridge between Expanded SHDL — the high-level language developers write — and the generated C code that performs the simulation.

Base SHDL is built on one fundamental design principle: the structural core describes only single-bit logic. All higher-level information — multi-bit ports, hierarchy traceability, source locations, debugger support — lives in a dedicated metadata section that travels with the structural description but is strictly separated from it.

This separation yields two benefits: the structural core becomes trivially simple to parse and compile, and the metadata becomes a well-defined, extensible contract between the frontend (flattener) and all downstream consumers (compiler, debugger, driver, future backends).

1.2 Role in the Pipeline​

Expanded SHDL (what you write)
│
▼
┌──────────────┐
│ Flattener │ fixed sequence of lowering stages
└──────────────┘
│
▼
Base SHDL (this specification)
┌──────────────────────────────────┐
│ Structural Core │ Pure single-bit logic
│ + Metadata Section │ Everything the tooling needs
└──────────────────────────────────┘
│
├──▶ SHDLC Compiler ──▶ C ──▶ Shared Library
├──▶ Debugger (SHDB) (planned)
├──▶ Python Driver (Circuit)
└──▶ Future backends (Verilog, WASM, ...)

1.3 What Changed from v1​

In v1, multi-bit port declarations like A[16] appeared in the structural core, so the compiler grammar, parser, and code generator all had to handle indexing and width rules. Debug metadata was a separate .shdb JSON file with no formal relationship to the IR.

In v2:

  • The structural core contains only single-bit port names. A 16-bit port A[16] becomes 16 individual ports: A_1_, A_2_, ..., A_16_.
  • A metadata section (meta { … }) follows the structural component and carries everything that isn't gate-level logic. The metadata is a single JSON object (Section 4) — there is one serialization, not two.
  • The metadata is the debug info. The .shdb file planned for the compiler's debug build is to be that metadata section verbatim, plus the compiler-added State Region layout. Neither the debug build nor .shdb exists in 1.1.0 (shdlc_goals.md §3.3–§4).

2. Document Structure​

A Base SHDL file has two parts:

┌─────────────────────────────────┐
│ STRUCTURAL CORE │ component ... { ... }
│ Pure single-bit gate-level │
│ logic description │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ METADATA SECTION │ meta { ... }
│ Port groups, hierarchy, source │
│ maps, timing, debugger config, │
│ statistics, documentation │
└─────────────────────────────────┘

The structural core is complete and self-contained — a minimal consumer (e.g., a future Verilog backend) can parse only the structural core and ignore the metadata entirely. The metadata is optional for parsing the circuit structure but required for full tooling support (multi-bit poke/peek, hierarchy navigation, source-level debugging, settle()).


3. Structural Core​

3.1 Design Principle​

The structural core describes a circuit using only single-bit wires and primitive gates. No multi-bit ports, no indexing, no widths. Every port is a named single-bit wire. Every gate has single-bit inputs and a single-bit output. This is the most reduced possible representation of a digital circuit: a flat netlist of primitive Boolean gates connected by named wires.

3.2 Grammar​

Component = "component" IDENTIFIER
"(" IdentifierList ")"
"->"
"(" IdentifierList ")"
"{"
{ InstanceDecl }
ConnectBlock
"}" ;

IdentifierList = [ IDENTIFIER { "," IDENTIFIER } ] ;

InstanceDecl = IDENTIFIER ":" PrimitiveType ";" ;
PrimitiveType = "AND" | "OR" | "NOT" | "XOR" | "__VCC__" | "__GND__" ;

ConnectBlock = "connect" "{" { Connection } "}" ;
Connection = Signal "->" Signal ";" ;

Signal = IDENTIFIER
| IDENTIFIER "." IDENTIFIER ;

Key properties:

  • No [N] anywhere. Ports are bare identifiers. Signals are bare identifiers or instance.port references.
  • Exactly one component per file (the flattened top-level component).
  • The grammar is parseable by a simple recursive-descent parser with one token of lookahead and no backtracking.

3.3 Primitive Types​

Base SHDL has exactly six primitive types.

Logic Gates​

GateInputsOutputC OperatorOperation
ANDA, BO&O = A ∧ B
ORA, BO|O = A ∨ B
NOTAO^ 1O = ¬A
XORA, BO^O = A ⊕ B

Power Pins​

PinInputsOutputBehavior
__VCC__(none)OAlways HIGH (1)
__GND__(none)OAlways LOW (0)

All primitives use a consistent port convention: inputs are A (and B for two-input gates); the output is always O.

The C operator column is what shdlc emits for each gate. Every wire is held as a 0/1 byte, so NOT is compiled as XOR with 1 rather than ~, which would set the upper bits.

AND, OR, and NOT form a functionally complete set; XOR is included because it dominates arithmetic circuits and maps directly to a single C operator. Derived gates (NAND, NOR, XNOR) are compositions in Expanded SHDL and arrive in Base SHDL already decomposed.

3.4 Port Naming Convention for Expanded Ports​

When the flattener decomposes a multi-bit port into single-bit wires:

{PortName}_{BitIndex}_

The trailing underscore is the bus-index terminator, distinguishing bus-expanded ports from hierarchy-flattened names (which use a single underscore separator, e.g., fa1_x1).

Expanded SHDL DeclarationBase SHDL Port Names
A[4]A_1_, A_2_, A_3_, A_4_
Sum[16]Sum_1_, ..., Sum_16_
Cin (single-bit)Cin

Bit indexing remains 1-based (LSB = bit 1), consistent with Expanded SHDL.

The …_<digits>_ shape is reserved: Expanded SHDL forbids user identifiers matching it (shdl.md §2.2), so a bus-expanded wire such as Sum_2_ can never collide with a user-named port. This is what lets the single trailing underscore unambiguously mark a bus index.

3.5 Instance Naming​

PatternExampleOrigin
Simple namex1, a1Direct gate in top-level
Hierarchy-prefixedfa1_x1, fa2_a1Flattened from subcomponents
Deep hierarchyalu1_add1_fa1_x1Multi-level flattening
Constant bitFIVE_bit3Materialized from constants

Names starting with __ are reserved for system use.

The naming reservations of §3.4 (the …_<digits>_ bus-index shape) and the __… prefix are producer-side obligations: the flattener guarantees the names it emits respect them. They are not re-validated by consumers. A downstream tool (e.g. SHDLC) may accept a structural core whose gate or port names violate these reservations, treating such a name as an ordinary opaque identifier with no semantic interpretation — it does not attempt to read a …_<digits>_ name as a bus bit. A Base SHDL file produced by something other than the SHDL flattener is therefore free to use any identifiers it likes for gates, provided the connection rules (§3.6) hold; the reservations exist to keep the flattener's own generated names collision-free (§3.4, shdl.md §2.2), not as a parse-time gate on hand-written Base SHDL.

3.6 Connection Rules​

  1. Single driver. Each gate input and each component output port has exactly one source.
  2. Fan-out allowed. A single source may drive multiple destinations.
  3. No floating inputs. Every gate input must be connected.
  4. No floating outputs. Every component output must be driven.
  5. All connections are single-bit — inherent, since every signal is a single-bit wire.
  6. Input wires are sources only. A component input port may not be a connection destination — it is driven from outside (by poke), never from within the netlist.
  7. Only O is readable on an instance. A gate's only output is its pin O; instance.O is the sole readable instance signal. An instance's input pins (A, B) are connection destinations, not sources, and no other pin name exists. Reading any pin other than O from an instance is invalid.

Rules 6 and 7 are enforced and re-validated by consumers (unlike the §3.4/§3.5 naming reservations, which are producer-side): they are structural invariants every Base SHDL file must satisfy, hand-written or flattener-emitted.

3.7 Complete Structural Example​

A 2-bit ripple-carry adder:

component Add2(A_1_, A_2_, B_1_, B_2_, Cin) -> (Sum_1_, Sum_2_, Cout) {
fa1_x1: XOR;
fa1_x2: XOR;
fa1_a1: AND;
fa1_a2: AND;
fa1_o1: OR;

fa2_x1: XOR;
fa2_x2: XOR;
fa2_a1: AND;
fa2_a2: AND;
fa2_o1: OR;

connect {
A_1_ -> fa1_x1.A;
B_1_ -> fa1_x1.B;
A_1_ -> fa1_a1.A;
B_1_ -> fa1_a1.B;
fa1_x1.O -> fa1_x2.A;
Cin -> fa1_x2.B;
fa1_x1.O -> fa1_a2.A;
Cin -> fa1_a2.B;
fa1_a1.O -> fa1_o1.A;
fa1_a2.O -> fa1_o1.B;
fa1_x2.O -> Sum_1_;

A_2_ -> fa2_x1.A;
B_2_ -> fa2_x1.B;
A_2_ -> fa2_a1.A;
B_2_ -> fa2_a1.B;
fa2_x1.O -> fa2_x2.A;
fa1_o1.O -> fa2_x2.B;
fa2_x1.O -> fa2_a2.A;
fa1_o1.O -> fa2_a2.B;
fa2_a1.O -> fa2_o1.A;
fa2_a2.O -> fa2_o1.B;
fa2_x2.O -> Sum_2_;
fa2_o1.O -> Cout;
}
}

No [N] indexing appears anywhere. Every port and signal is a bare identifier.


4. Metadata Section​

4.1 Purpose and Placement​

The metadata section carries everything downstream tools need that is not gate-level structure. It follows the structural core, after the component's closing brace, and its body is a single JSON object:

component Name(...) -> (...) {
...
}

meta {
"version": "2.0",
...
}

There is exactly one serialization. The bytes between the meta braces are the same JSON that the planned .shdb file is to contain (Section 7) — the .shdb is to be produced by copying the embedded object out, not by translating a second text format. This makes it impossible for the structure and its metadata to fall out of sync, and gives every consumer one grammar (JSON) to parse instead of two.

A minimal consumer can stop reading at the component's closing brace. The full toolchain reads both.

4.2 Grammar Overview​

The metadata section is the keyword meta followed by a JSON object whose top-level keys are the metadata blocks:

MetaSection = "meta" JsonObject ; (* JsonObject is standard RFC 8259 JSON *)
KeyBlockStatus
versionmetadata format versionrecommended
portsport grouping (§4.3)required for multi-bit tooling
hierarchycomponent hierarchy map (§4.4)optional
source_mapsource location mapping (§4.5)optional
constantsconstant origin tracking (§4.6)optional
timingdepth & propagation (§4.7)required for settle()
monitorsdebugger watch groups (§4.8)optional
statscircuit statistics (§4.9)optional
docdocumentation/provenance (§4.10)optional
initinitial-state seeds (§4.11)optional

Every block is optional for parsing the circuit structure (a minimal consumer reads only the structural core), and the keys may appear in any order. Unknown keys are ignored, giving forward compatibility: a future block can be added without breaking existing tools. The version string lets a consumer detect the format revision it is reading. All examples below show the JSON exactly as it appears embedded in meta { … }.

What the SHDL flattener emits. The reference flattener always emits all ten keys, in the order of the table above, pretty-printed with two-space indentation; constants, monitors and init are empty objects when there is nothing to record, and doc.description is omitted when the module has no doc comment. The golden output of the conformance case conformance/cases/add2/expected.base.shdl is a complete, byte-exact example.

4.3 ports — Port Grouping​

Optional for structural parsing; required for multi-bit tooling. Maps user-facing multi-bit signal names to their constituent single-bit wires. This is what lets the driver and compiled library accept poke("A", 42).

"ports": {
"inputs": {
"A": ["A_1_", "A_2_"],
"B": ["B_1_", "B_2_"],
"Cin": ["Cin"]
},
"outputs": {
"Sum": ["Sum_1_", "Sum_2_"],
"Cout": ["Cout"]
}
}
  • List order encodes bit position: index 0 = bit 1 (LSB).
  • Single-bit ports map to a one-element list, keeping the representation uniform.

Coverage and well-formedness. When the ports block is present, both the inputs and outputs keys must appear (each possibly an empty object); a consumer rejects a ports block missing a direction key, naming the missing key. Within the structural core's header, ports need not cover every single-bit wire: an input wire absent from ports is simply not poke-addressable (it stays at its power-on value, 0), and an output wire absent from ports is not peek-observable — both legal-but-defined, consistent with the referenced-bits philosophy that governs constants (§3.4) and init (§4.11). A single structural wire, however, may not be claimed twice on the input side: listing one input wire under two port entries (or twice within one) would make poke aliasing silent and ambiguous, so it is rejected. Output wires may be reused freely (the same wire fanning into two named output groups is harmless observation). When ports is wholly absent, the consumer synthesizes identity single-bit ports from the structural header (one port per header wire); that fallback fires only on a completely missing block, not on a present-but-partial one.

4.4 hierarchy — Component Hierarchy Map​

Records the original hierarchy before flattening, for tools that need to map a flat gate back to the instance tree (the planned debugger's tree view, scope navigation, hierarchical paths such as fa1.x1.O, and instance inspection). This is the block the flattener emits for the conformance case conformance/cases/add2 (circuit.shdl instantiates FullAdder from fullAdder.shdl twice; fa2 is elided here and has the same shape):

"hierarchy": {
"Add2": {
"source_file": "circuit.shdl",
"source_line": 4,
"instances": {
"fa1": {
"type": "FullAdder",
"source_file": "circuit.shdl",
"source_line": 5,
"prefix": "fa1_",
"ports": {
"A": "A_1_", "B": "B_1_", "Cin": "Cin",
"Sum": "Sum_1_", "Cout": "fa1_o1.O"
},
"instances": {
"x1": { "type": "XOR", "gate": "fa1_x1" },
"a1": { "type": "AND", "gate": "fa1_a1" },
"x2": { "type": "XOR", "gate": "fa1_x2" },
"a2": { "type": "AND", "gate": "fa1_a2" },
"o1": { "type": "OR", "gate": "fa1_o1" }
}
},
"fa2": { … }
}
}
}
  • The single top-level key is the top component's name. Its source_file/source_line give where that component is defined.
  • For a user-component instance, source_file/source_line give where the instance is declared in its parent (here fa1: FullAdder; on line 5 of circuit.shdl), not where FullAdder is defined. prefix is the name prefix its gates receive, and instances recurses into its body.
  • A primitive instance (including a power pin materialized from a constant) is a leaf: { "type": …, "gate": <flat gate name> }.
  • ports maps each of the instance's interface ports to the structural signal that carries it: a top-level wire (A_1_), or a gate output (fa1_o1.O) when the net reaches no top-level port. A multi-bit port maps to a list of such signals, LSB first.
  • An instance of a parameterized component (shdl.md §4.4) also carries "params" with its bound values, placed after type — for example "type": "AdderN", "params": { "N": 8 } in examples/add100.shdl.

4.5 source_map — Source Location Mapping​

Maps gates back to their origin in the Expanded SHDL sources, in both directions (gate → location, and location → gates). The add2 case again, with gates cut to three entries:

"source_map": {
"gates": {
"fa1_x1": { "file": "fullAdder.shdl", "line": 2, "column": 5 },
"fa1_a1": { "file": "fullAdder.shdl", "line": 2, "column": 15 },
"fa2_x1": { "file": "fullAdder.shdl", "line": 2, "column": 5 }
},
"lines": {
"circuit.shdl": {
"5": ["fa1_x1", "fa1_a1", "fa1_x2", "fa1_a2", "fa1_o1"],
"6": ["fa2_x1", "fa2_a1", "fa2_x2", "fa2_a2", "fa2_o1"]
},
"fullAdder.shdl": {
"2": ["fa1_x1", "fa1_a1", "fa2_x1", "fa2_a1"],
"3": ["fa1_x2", "fa1_a2", "fa2_x2", "fa2_a2"],
"4": ["fa1_o1", "fa2_o1"]
}
}
}
  • gates has one entry per gate: the position (1-based line and column) of the primitive-instance declaration that produced it, in the file that defines the enclosing component. Both copies of x1 point at fullAdder.shdl line 2.
  • lines is keyed by file name, then by line number (as a string). A gate is listed under its own declaration line and under the declaration line of every user-component instance that encloses it. So circuit.shdl line 5, fa1: FullAdder;, lists all five fa1_ gates, although no gate's gates entry points there.
  • Files are sorted by name, lines are in ascending numeric order, and gates keep their netlist order.

lines is therefore a superset of the inverse of gates, not its exact inverse. Both are derived from the same instantiation records during flattening; a consumer that edits one must regenerate the other rather than hand-maintain it.

4.6 constants — Constant Origin Tracking​

Records which __VCC__/__GND__ instances came from named constants, so the debugger can display FIVE = 5 instead of raw power-pin states.

"constants": {
"FIVE": {
"value": 5,
"width": 3,
"bits": {
"1": "FIVE_bit1",
"2": "FIVE_bit2",
"3": "FIVE_bit3"
}
}
}

Keys are the constant names prefixed like gate names: a constant declared inside a sub-instance appears as <prefix><NAME> (for example add_ZERO). bits lists only the bits that were actually referenced and therefore materialized (shdl.md §8): an unreferenced high bit of a constant has no power-pin instance, since a constant is conceptually an unbounded unsigned value whose leading bits are all 0.

4.7 timing — Circuit Depth and Propagation​

The combinational depth information computed during flattening/analysis. This is what makes settle() possible and propagation delay visible.

"timing": {
"max_depth": 5,
"output_depths": { "Sum_1_": 2, "Sum_2_": 4, "Cout": 5 },
"is_combinational": true,
"has_feedback": false,
"critical_path": ["A_1_", "fa1_x1", "fa1_a2", "fa1_o1", "fa2_a2", "fa2_o1", "Cout"]
}

(These are the real values for the §3.7 netlist: Sum_1_ is driven by fa1_x2 at depth 2; the carry fa1_o1 arrives at depth 3, so fa2_x2/fa2_a2 sit at depth 4 and Cout at 5.)

  • output_depths has exactly one entry per single-bit output wire: the number of gate levels between the inputs and that output. A top-level input has depth 0 and every gate adds 1. Power pins (__VCC__/__GND__) count as gates of depth 1: their output holds the power-on 0 at cycle 0 and only reflects the pin from cycle 1. An output wired straight from an input has depth 0.

  • critical_path is one longest path, and each adjacent pair in it is a real connection. It usually starts at an input wire, then lists gate names, and ends at the deepest output wire. It can instead start at a gate: at a power pin, or, under feedback, at a gate whose only inputs come from inside its own loop. For examples/srLatch.shdl it is ["i1", "Q"].

  • is_combinational is the negation of has_feedback.

  • max_depth is the minimum number of simulation cycles for all outputs to reflect current inputs under SHDL's one-gate-level-per-cycle semantics.

  • has_feedback tells the driver whether settle() can safely converge.

  • Depths are computed on the feedback-free subgraph. Gates are grouped into strongly connected components, and an edge between two gates of the same feedback component (a loop of two or more gates, or a self-loop) is excluded from the longest-path analysis.

  • Under feedback (has_feedback = true) max_depth is the depth of the feedback-free shell only; it is not a settle count, since a circuit with feedback has no guaranteed fixed point. In that case settle() is disabled and the circuit must be advanced explicitly with step(n) (shdl.md §11.3).

4.8 monitors — Debugger Watch Configuration​

Named groups of signals worth observing together. The schema is an object mapping a group name to a list of gate or wire names:

"monitors": {
"carry_chain": ["fa1_o1", "fa2_o1"]
}

It is intended to enable watch :carry_chain, record signals :carry_chain, and grouped display panels in the planned SHDB debugger.

V1 status. In V1 the flattener always emits monitors as an empty object {}. There is no annotation syntax in Expanded SHDL and no automatic population path, so no group is ever produced; the block is emitted (always present, always empty) purely so that consumers have a stable, well-typed key. The {group: [gate_or_wire, …]} schema above is reserved for a future revision that adds population (flattener heuristics or source annotations); consumers should tolerate a populated monitors but must not depend on one in V1.

4.9 stats — Circuit Statistics​

"stats": {
"total_gates": 10,
"total_connections": 23,
"total_ports": 8,
"by_type": { "XOR": 4, "AND": 4, "OR": 2 }
}
  • total_gates counts primitive instances, including materialized power pins.
  • total_connections counts lines in the connect block (the §3.7 example has 23).
  • total_ports counts single-bit input wires plus single-bit output wires in the header.
  • by_type counts gates per primitive type, only for types that occur.

4.10 doc — Documentation and Provenance​

"doc": {
"description": "2-bit ripple-carry adder",
"source": "add2.shdl",
"flattened_at": "2026-06-10T14:30:00Z"
}
  • description is the main module's doc comment (the first """…""" comment before any token) with surrounding whitespace stripped; the key is omitted when the module has none.
  • source is the main source file's name (no directory).
  • flattened_at is the --timestamp value when one is given (used verbatim), else the SOURCE_DATE_EPOCH environment variable rendered as UTC YYYY-MM-DDTHH:MM:SSZ, else the current UTC time. Pinning either makes the output byte-reproducible.

4.11 init — Initial State​

Records the power-on value of any net seeded by an Expanded SHDL init block (shdl.md §11.4). Every wire defaults to 0 at cycle 0; this block lists only the nets given a different seed, so the compiler can seed the power-on state (and a debugger could display it).

This is the block for examples/srLatch.shdl, whose init seeds the cross-coupled latch at a fixed point (Q = 0):

"init": {
"o1.O": 1,
"Q": 0,
"o2.O": 0,
"Qn": 1
}
  • Keys are structural signals, named the same way as the connection sources in the structural core. A seeded net that reaches a top-level output port is keyed by that output wire: Q above is the output of gate i1, which drives Q. Any other net is keyed by its driving gate's output, gate.O. Multi-bit targets are recorded already expanded to single-bit wires (Q_1_, Q_2_, …), and a seed written inside a sub-component appears once per instance under the prefixed name (bit1_latch_o1.O, bit2_latch_o1.O, … in examples/registerN.shdl).
  • Values are 0 or 1. A net absent from this block holds the default of 0.
  • init carries no logic — it is purely the value present before the first step; subsequent cycles evolve from the gate netlist alone.

5. How Each Consumer Uses the Metadata​

ConsumerBlocks UsedPurpose
SHDLC (release)ports, initGenerate poke()/peek() with user-facing names; seed power-on state
Python Driverports, timing, init, stats, doc.descriptionMulti-bit poke/peek; settle(); read-only introspection via Circuit.info
SHDLC (debug) — plannedallCode generation + write .shdb
SHDB Debugger — plannedallHierarchy, source display, watch groups, constants, power-on state
External observers — plannedports, stats + State Region layout (.shdb)Shared-memory interpretation
Future backendsportsReconstruct multi-bit declarations

6. Constraints Summary​

Structural Core​

ConstraintRationale
No multi-bit ports or [N]Single-bit wires are the true atomic unit
No component parametersBound and specialized during monomorphization
No user-defined component typesAll hierarchy is flattened
No generators, conditionals, slices, or concatenationAll repetition, selection, slicing, and grouping is expanded
No named constantsReplaced by __VCC__/__GND__ instances
No init blocksPower-on seeds live in metadata (init, §4.11)
No imports; no comments emittedPure structural description (shdlc skips # line comments, but the flattener never writes one)
Exactly one componentThe flattened top-level
Only six primitive typesAND, OR, NOT, XOR, __VCC__, __GND__
No …_<digits>_ user namesReserved for bus expansion (§3.4)

Metadata​

ConstraintRationale
All blocks optionalGraceful degradation for minimal consumers
Unknown blocks ignoredForward compatibility
All names referenced must exist in the coreConsistency between the two halves

Documented Limits​

The representation imposes two scale limits, fixed by the downstream code generator:

LimitValueRationale
Maximum port width64 bitsA port's value crosses the ABI in a uint64_t (shdlc_goals.md §3.1)
Maximum input-wire count65535Single-bit input wires are indexed by a 16-bit table in generated C (§3.4)

No maximum gate count is defined: the total number of gates is bounded only by available memory, not by a fixed cap.


7. Serialization​

There is a single serialization for metadata: JSON, embedded between the meta braces of the Base SHDL file. There is no second, text-only encoding to keep in sync.

Planned — not implemented in 1.1.0: the compiler's debug build is to write a standalone .shdb file holding the same bytes, produced by lifting the embedded object out and appending the compiler-added State Region layout (state_region), which is the only field the flattener does not itself emit. The example below shows the top-level shape of the metadata object, with that planned key last.

{
"version": "2.0",
"ports": {
"inputs": { "A": ["A_1_", "A_2_"], "B": ["B_1_", "B_2_"], "Cin": ["Cin"] },
"outputs": { "Sum": ["Sum_1_", "Sum_2_"], "Cout": ["Cout"] }
},
"hierarchy": { },
"source_map": { },
"constants": { },
"timing": { "max_depth": 5, "is_combinational": true },
"monitors": { },
"stats": { },
"doc": { },
"init": { },
"state_region": { }
}

The driver, the planned debugger, and any external tool load one format. Because the embedded metadata and the .shdb are to be the same object, the structure and its metadata cannot fall out of sync.


8. Design Rationale​

Why remove multi-bit ports from the structural core? The core's job is to describe logic, and logic operates on single bits. A multi-bit port is a convenience grouping, not a structural primitive. Moving it to metadata makes the structural grammar simpler (no indexing rules, no bounds checking) and more honest about what it is: a flat netlist of Boolean gates.

Why embed the metadata rather than a separate file? Embedding makes it impossible for the structure and metadata to fall out of sync. The planned JSON .shdb is to be the embedded object lifted out verbatim (plus the State Region layout), never generated independently.

Why a single JSON serialization rather than a human-readable text form and JSON? Two encodings of the same information mean two grammars, two parsers, and a translation step that can drift — and the text form's grammar was never fully specified, only shown by example. Collapsing to one canonical format (JSON, which every consumer already reads) removes the drift risk entirely and leaves nothing to translate. JSON is readable enough embedded in the file; the structural core above it remains the human-facing part.

Why PortName_N_? The trailing underscore disambiguates bus-expanded ports from hierarchy-flattened gate names without introducing new syntax characters. Alternatives like PortName.N (conflicts with instance.port) and PortName#N (conflicts with comments) were considered and rejected. The scheme is safe because the …_<digits>_ pattern is reserved in Expanded SHDL (§3.4; shdl.md §2.2), so no user identifier can collide with a bus-expanded wire.

Why include timing? The single most common usability problem in SHDL v1 was users guessing how many step() calls a circuit needs. With max_depth in the metadata, the driver implements settle() definitively — without compromising SHDL's gate-level, one-level-per-cycle simulation model.

Why keep init in metadata rather than the structural core? Initial state is a power-on value, not a gate. Putting it in the core would mean either inventing a stateful primitive or letting wires carry annotations — both of which would compromise the core's one job: describing single-bit Boolean logic. As metadata, the seed reaches exactly the consumers that need it (the compiled library applies it in reset(), which also runs at load; the driver's reset() returns to it; the planned debugger is to display it) while the netlist stays a pure netlist. Every wire still defaults to 0, so a circuit with no init block needs no init metadata at all.