The introspectable, fail-fast construction service (#157): a declarative, replayable by-identifier op-script that builds a runnable blueprint through validated ops and emits the #155 value, reusing the engine's existing gates (no second validator, C24). Surface-agnostic core = an eager/holistic gate split: the per-op-decidable checks (name resolution, edge kind-match, the >1-cover DoubleWiredPort arm, param bind) fire eagerly at the offending op; the only-at-end-decidable checks (0-cover UnconnectedPort totality, DuplicateParamPath injectivity, root-role boundness) stay holistic at finalize. Both cadences call the SAME extracted predicates. Plus build-free introspection over the closed std_vocabulary (enumerable companion) and a partial document's unwired slots. The JSON op-list + `aura graph build`/`introspect` CLI is a thin shell (iteration 2). Spec auto-signed under /boss on a grounding-check PASS (11/11 load-bearing current-behaviour assumptions ratified by named green tests). Derived sub-forks logged on the issue. refs #157
21 KiB
Introspectable, fail-fast construction service — Design Spec
Date: 2026-06-29
Status: Draft — awaiting user spec review
Authors: orchestrator + Claude
Issue: #157 (depends on #155, landed d5602ec)
Goal
Deliver an introspectable, fail-fast construction service that lets a data-level author (Claude Code, or a small LLM) build a runnable blueprint through validated ops — without hand-writing the raw-index serialized format (#155) and without authoring a many-invariant artifact blind. The service emits a blueprint (the #155 value), reusing the engine's existing construction gates rather than introducing a second validator (C24).
The surface, settled in #157's discussion, is a declarative, replayable, by-identifier op-script: a document of construction ops, replayed op-by-op from scratch, stopping at the first op that does not resolve and naming it. The document is the artifact (a Strudel-style document-re-eval model — the script replayed from scratch is deterministic (C1) and a World-owned topology value (C24)), with no held process state that can drift from the history that produced it. MCP is out of scope (deferred, consumer-strength-dependent).
The work has a surface-agnostic core (the value of #157) and a thin CLI shell over it:
- Core (engine): split the construction gates into eager per-op checks
and holistic end-of-document checks, both calling the same extracted
predicates; a per-op-fallible construction surface that drives them; and a
build-free introspection API over the closed
aura-stdvocabulary and over a partial script. - Shell (CLI): a JSON op-list encoding and the
aura graph build/aura graph introspectsubcommands.
This retires, in concert with #159, the hard-wired aura-cli harness
(sample_blueprint, crates/aura-cli/src/main.rs:3313) — topology as Rust
source is exactly what C24 forbids; this issue supplies the data-authoring
surface that replaces it.
Feature-acceptance criterion (applied, aura CLAUDE.md)
aura's deliverable is the engine, and its north star (C24) is topology as data for every author. The criterion for this feature:
- A data-level author naturally reaches for it. The author writes a document of ops (shown below) instead of Rust builder source or raw-index JSON — and is never authoring blind, because introspection answers "what nodes exist, what are this node's ports/kinds, what is still unwired" without a build.
- It measurably improves correctness / removes redundancy. A constructed graph runs identically to its Rust-built twin (C1); the hard-wired CLI harness it replaces is removed source. No second validator is introduced — the per-op and holistic checks are the same engine predicates at two cadences.
- It reintroduces no class of failure the core constraint exists to eliminate. The failure mode that sank the RustAst DSL was unaided authoring of a many-invariant artifact. The op-script does not reintroduce it: every op is checked fail-fast against the engine's own gates, and introspection means the author never authors blind. The document carries no logic — only constructive ops with literal params — so it is data, not an applied/Turing-complete DSL (C24, invariant 10).
The worked author program under Concrete code shapes is this criterion's empirical evidence.
Architecture
Three layers, named §A/§B/§C; §A+§B are the surface-agnostic engine core (iteration 1), §C the CLI shell (iteration 2).
§A — Eager / holistic gate split (the shared predicates)
Today, all structural validation runs at finalize, in two stages:
GraphBuilder::build()(crates/aura-engine/src/builder.rs:131) resolves port/field names to indices, yieldingBuildErrorfor unknown/ambiguous ports and bad handles. It does not check edge kinds — the module doc (builder.rs:50-52) states a name-resolved edge with mismatched kinds surfaces only downstream.Composite::compile_with_params→compile_with_cells(blueprint.rs:262,:207) runs the three structural gates:check_param_namespace_injective(:566),validate_wiring(:628, which includes the edge kind-match, raised asBootstrap(KindMismatch)at:644), andcheck_ports_connected(:688).
The op-script needs some of these checks to fire per op (acceptance 2: "rejected at the op"), while others are only decidable once the document ends. The split is forced by what is decidable when:
| Check | Today (finalize) | Op-script cadence | Why |
|---|---|---|---|
| Name resolution (port/field → index) | build() |
eager (per op) | the referenced node is already added |
| Edge kind-match (producer field kind == consumer slot kind) | validate_wiring :638-649 |
eager (per op) | both schemas known at the connect op |
Slot covered >1 (DoubleWiredPort) |
check_ports_connected :708 |
eager (per op) | a second cover is visible the moment it happens |
| Param bind name/kind | bind panics (node.rs) |
eager (per op) | the node's schema is known at add |
Slot covered 0 (UnconnectedPort) |
check_ports_connected :707 |
holistic (finalize) | a mid-document graph is legitimately incomplete |
Param-namespace injective (DuplicateParamPath) |
check_param_namespace_injective |
holistic (finalize) | only decidable once all nodes + names exist |
| Role-kind consistency, root-role boundness, output range | compile_with_* |
holistic (finalize) | whole-graph properties |
The principle is sharpest at check_ports_connected, whose match has exactly
two failure arms: the >1 arm (DoubleWiredPort) is eagerly decidable; the
0 arm (UnconnectedPort) is not. The op-script front-runs the >1 arm and
defers the 0 arm.
No second validator. The eager path does not re-implement these checks: the
edge-kind check is extracted from validate_wiring's inline loop into a
shared predicate called by both the eager op and the unchanged holistic
validate_wiring; the eager coverage tracker shares the "exactly-once"
invariant with the unchanged check_ports_connected (which remains the holistic
backstop, still catching both arms). The name-resolution helpers
(builder.rs:160-198, resolve_slot/resolve_field) become shared
(pub(crate)) and are called eagerly per op.
§B — Per-op-fallible construction surface + introspection (engine)
A new engine surface (planner names the type; provisionally GraphSession)
drives the eager checks. It is not an extension of GraphBuilder:
GraphBuilder's documented contract is infallible accumulate, one fallible
build() (builder.rs:1-9, :67-68); the op-script's stop-at-first-failing-op
is a different, per-op-fallible discipline. The new surface reuses
GraphBuilder's resolution helpers and the §A predicates over shared internals,
rather than overloading one type with two contracts.
It owns: the in-progress nodes/schemas/edges/roles/outputs (as GraphBuilder
does), an incremental coverage map (node, slot) → count (the eager
double-wire tracker), and a name→handle map for the document's as NAME
identifiers. Each op is applied fallibly; a terminal finish runs the holistic
gates and returns the Composite (→ blueprint_to_json for the emitted
blueprint; → compile_with_params for the runnable FlatGraph).
Introspection is read-only and build-free, in two modes:
- Static (type-level): enumerate the vocabulary; for a type, its ports +
kinds (
NodeSchema.inputs/.output,PortSpec/FieldSpeccarryname+kind) and its param paths (PrimitiveBuilder::params). All declared pre-build (C8). This requires an enumerable companion to the closedstd_vocabularymatch (see §Components) — afn(&str)->Option<…>resolver alone cannot be listed. - Partial-script (instance-level): the still-unwired slots of an in-progress session = every added node's declared input slots minus the covered ones, read straight off the coverage map.
§C — JSON op-list encoding + CLI shell
The document's canonical encoding is a JSON op-list (serde-native, mirroring #155's blueprint JSON), so the core carries no hand-written parser/lexer (the smallest "no new language" surface). A line-oriented human text front-end is a deferred ergonomic sugar (not this cycle).
aura graph build < doc.json deserializes the op-list, replays it through a
GraphSession, and on success emits the blueprint JSON (and/or compiles to a
FlatGraph); on the first failing op it prints the op index + cause and exits
non-zero. aura graph introspect answers the static + partial-script queries.
Both extend the existing flat argv match (main.rs:3292); the bare
["graph"] HTML-render arm is unaffected for now (its sample_blueprint
removal is #159's concern, with which this pairs).
Concrete code shapes
Worked author program (the acceptance evidence)
The author builds the sma_cross signal blueprint — price → fast/slow SMA → Sub → Bias, exposed as bias. Every node is in the zero-arg vocabulary
(SMA, Sub, Bias), so it round-trips today. (A full harness adds
SimBroker/Recorder, whose construction-arg / mpsc::Sender builders are
deliberately outside the #155 vocabulary — #156; so the op-script's near-term
reach is signal blueprints, not full harnesses, exactly as #157's sequencing
note states.)
Canonical document (sma_cross.json):
[
{"op": "source", "role": "price", "kind": "f64"},
{"op": "add", "type": "SMA", "as": "fast", "bind": {"length": 2}},
{"op": "add", "type": "SMA", "as": "slow", "bind": {"length": 4}},
{"op": "add", "type": "Sub"},
{"op": "add", "type": "Bias"},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
$ aura graph build < sma_cross.json
# stdout: the #155 blueprint JSON (format_version + blueprint{nodes,edges,…})
Deferred text-sugar equivalent (illustrative only, not in this cycle):
source price: f64
add SMA(length=2) as fast
add SMA(length=4) as slow
add Sub
add Bias
feed price -> fast.series, slow.series
connect fast.value -> sub.lhs
connect slow.value -> sub.rhs
connect sub.value -> bias.signal
expose bias.bias as bias
Fail-fast rejection (acceptance 2) — each names the offending op + cause, and nothing after it runs:
$ echo '[{"op":"add","type":"Nope"}]' | aura graph build
error: op 0 (add): unknown node type "Nope" # not in std_vocabulary
# kind mismatch — a bool field into an f64 slot, rejected at the connect op
$ aura graph build < kindbad.json
error: op 4 (connect gt.value -> bias.signal): kind mismatch (producer bool, consumer f64)
# double-wire — a second producer into an already-wired slot
$ aura graph build < doublewire.json
error: op 5 (connect slow.value -> sub.lhs): slot sub.lhs already wired
Introspection (acceptance 3) — build-free:
$ aura graph introspect --vocabulary
Add And Bias CarryCost ConstantCost Delay EMA EqConst FixedStop Gt Latch
LongOnly Mul PositionManagement Resample RollingMax RollingMin Sizer SMA
Sqrt Sub VolSlippageCost
$ aura graph introspect --node SMA
SMA inputs: series:f64 output: value:f64 params: length:i64
# the still-unwired slots of a partial document
$ aura graph introspect --unwired < partial.json
sub.rhs:f64 bias.signal:f64
Engine shapes (secondary, before → after)
(1) Extract the edge-kind predicate — shared by the eager op and the
unchanged holistic validate_wiring.
// NEW shared predicate (pub(crate)); the body is lifted verbatim from
// validate_wiring's edge loop (blueprint.rs:638-649), so the variant is
// unchanged (Bootstrap(KindMismatch)) and existing compiled-graph tests stay green.
pub(crate) fn edge_kind_check(
from: &NodeSchema, from_field: usize,
to: &NodeSchema, slot: usize,
) -> Result<(), CompileError> {
let f = from.output.get(from_field).ok_or(CompileError::BadInteriorIndex)?;
let s = to.inputs.get(slot).ok_or(CompileError::BadInteriorIndex)?;
if f.kind != s.kind {
return Err(CompileError::Bootstrap(BootstrapError::KindMismatch {
producer: f.kind, consumer: s.kind,
}));
}
Ok(())
}
// validate_wiring's edge loop now calls edge_kind_check (after its index lookups);
// the eager connect op calls the same predicate. ONE check, two cadences.
(2) check_ports_connected is unchanged (blueprint.rs:688) — it stays the
holistic backstop catching the 0 arm (UnconnectedPort) and, defensively,
the >1 arm. The eager surface front-runs the >1 arm via its incremental
coverage map:
// inside the per-op connect/feed handler (eager half of the exactly-once invariant)
let n = *self.coverage.entry((to_node, slot)).or_insert(0);
if n >= 1 { return Err(OpError::SlotAlreadyWired { node: to_node, slot }); } // DoubleWiredPort, raised early
self.coverage.insert((to_node, slot), n + 1);
(3) A non-panicking bind — today PrimitiveBuilder::bind panics on an
unknown/ambiguous/kind-wrong param (node.rs, the debug_assert/expect
path). The add … bind{…} op needs a fallible variant so a bad param is
rejected at the op, not a panic:
// NEW: the fallible twin of bind() (same exactly-one-match + kind rule, Result not panic)
pub fn try_bind(self, slot: &str, value: Scalar) -> Result<Self, BindOpError> { … }
(4) The per-op surface (engine) — op enum + drive/finish:
pub enum Op { // serde-deserialized from the JSON op-list (§C)
Source { role: String, kind: ScalarKind },
Input { role: String },
Add { type_id: String, as_name: Option<String>, bind: Vec<(String, Scalar)> },
Feed { role: String, into: Vec<String> }, // "node.slot" identifiers
Connect { from: String, to: String }, // "node.field" -> "node.slot"
Expose { from: String, as_name: String },
}
pub struct GraphSession { /* nodes, schemas, edges, roles, out, coverage, name->handle */ }
impl GraphSession {
pub fn new(name: &str, vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>) -> Self;
pub fn apply(&mut self, op: Op) -> Result<(), OpError>; // eager checks (§A)
pub fn finish(self) -> Result<Composite, OpError>; // holistic gates → Composite
// build-free introspection over the partial session:
pub fn unwired(&self) -> Vec<(String /*node.slot*/, ScalarKind)>;
}
// replay driver: stop at first failing op, naming it (acceptance 2)
pub fn replay(name: &str, ops: Vec<Op>, vocab: …) -> Result<Composite, (usize, OpError)>;
OpError carries by-identifier causes (UnknownNodeType(String),
UnknownPort{node,name}, KindMismatch{…}, SlotAlreadyWired{…},
DuplicateIdentifier(String), BadParam(BindOpError), and a holistic
Incomplete(CompileError) wrapping the finalize gate). The eager variants reuse
§A's predicates and builder.rs's resolution; Incomplete wraps the unchanged
compile_with_params/finalize path.
§C — enumerable vocabulary companion
// aura-std/src/vocabulary.rs — companion to the existing std_vocabulary match (:30)
pub fn std_vocabulary_types() -> &'static [&'static str] {
&["Add","And","Bias","CarryCost","ConstantCost","Delay","EMA","EqConst",
"FixedStop","Gt","Latch","LongOnly","Mul","PositionManagement","Resample",
"RollingMax","RollingMin","Sizer","SMA","Sqrt","Sub","VolSlippageCost"]
}
// introspection maps each type → std_vocabulary(type).schema() for ports/kinds/params.
// (The same iterable surface would let #160's guard test iterate; #160 stays separate.)
Components
aura-engine(§A): extractedge_kind_check(and the index-range lookups it needs) fromvalidate_wiring; makeresolve_slot/resolve_fieldshared (pub(crate)); addPrimitiveBuilder::try_bind(fallible twin ofbind).check_ports_connectedandcompile_with_paramsare unchanged.aura-engine(§B): theGraphSessionper-op surface +Op/OpErrorenums + thereplaydriver +unwiredintrospection. New module (e.g.construction.rs).aura-std(§C):std_vocabulary_types()— the enumerable companion.aura-cli(§C): serdeDeserializeforOp(the JSON op-list); the["graph","build", …]and["graph","introspect", …]argv arms overmain.rs:3292; rendering ofOpError/ introspection results to stdout/stderr with the op index.
Data flow
document (JSON op-list) [§C]
└─ serde → Vec<Op>
└─ replay(name, ops, std_vocabulary) ──── [§B]
├─ per op: apply(op) → eager checks [§A predicates] ──┐ stop at first Err,
│ │ naming op index + cause
└─ finish() → holistic gates → Composite ┘
├─ blueprint_to_json(&composite) → emitted blueprint (#155) [title: "emits blueprints"]
└─ composite.compile_with_params(params) → FlatGraph
└─ identical to the Rust-built twin's FlatGraph [acceptance 1, C1]
introspection (no build):
--vocabulary → std_vocabulary_types()
--node T → std_vocabulary(T).schema() (ports/kinds) + .params()
--unwired → replay partial → GraphSession::unwired()
Error handling
- Eager (per op):
applyreturnsOpErrorwith a by-identifier cause; thereplaydriver returns(op_index, OpError); the CLI printsop N (kind): causeand exits non-zero. Stop-at-first — no op after the failing one runs. - Holistic (finish):
Incomplete(CompileError)wraps the unchanged finalize gates (UnconnectedPort,DuplicateParamPath,UnboundRootRole, …). AnUnconnectedPortis reported against the document as a whole, not an op. - No second validator: every eager rejection traces to a §A predicate that the holistic gate also uses; the holistic gates remain the backstop. A bug in a predicate fails both cadences identically (a single fix site).
Testing strategy
Iteration 1 (engine, §A+§B) — driven directly through GraphSession/replay,
no CLI:
- Acceptance 1 (identical run): author
sma_cross(the worked example) viareplay; author the same viaGraphBuilder; compile both with the same params; assertflat.edgesandflat.sourcesequal — modeled on the greenbuilder_harness_compiles_identically_to_hand_wired(builder.rs:234). - Acceptance 2 (per-op rejection): one test per cause — unknown node
(
add Nope→ op 0), edge kind-mismatch (bool→f64 → at the connect op), double-wire (second connect into a slot → at that op), bad bind param. Each asserts the failing op index + theOpErrorvariant. - Eager/holistic boundary: a document missing a connection passes every
per-op
applybutfinishreturnsIncomplete(UnconnectedPort{…})— proving totality is not front-run (the legitimately-incomplete mid-document state). - No-second-validator: a kind-mismatch caught eagerly and the same wiring
caught by
validate_wiringboth resolve toedge_kind_check(the eagerOpError::KindMismatchand the holisticBootstrap(KindMismatch)agree). - Introspection:
std_vocabulary_types()lists exactly the resolvable keys (cross-check againststd_vocabulary— a key present in one but not the other fails);--node SMAshape;unwired()on a partial session.
Iteration 2 (CLI, §C) — E2E: aura graph build < doc.json emits the blueprint;
an invalid op prints op N + cause and exits non-zero; aura graph introspect
answers each query. cargo test --workspace green; clippy --all-targets -D warnings clean.
Acceptance criteria
- (1) A
sma_crosssignal blueprint built purely through the ops compiles to aFlatGraphidentical (edges + sources) to itsGraphBuilder-built twin (C1). - (2) An invalid op — unknown node, edge kind-mismatch, double-wire, bad bind param — is rejected at that op, naming the op index + cause; no later op runs.
- (3) Introspection answers the node vocabulary, a node's ports/kinds + param paths, and a partial document's unwired slots — all without a build.
- No second validator: the eager and holistic checks call the same extracted
predicates (
edge_kind_check, the exactly-once coverage invariant); the finalize gates (compile_with_params) are unchanged. aura graph buildemits the #155 blueprint JSON for a valid document.cargo test --workspacegreen;cargo clippy --workspace --all-targets -D warningsclean.
Iteration scope (for the planner)
- Iteration 1 — engine core (§A + §B): the gate split + shared predicates,
GraphSession/Op/replay,try_bind, the introspection API + enumerable vocabulary companion. Fully testable through the engine API; no CLI. - Iteration 2 — CLI shell (§C): the JSON op-list serde encoding and the
aura graph build/aura graph introspectsubcommands; E2E tests.