From a5b887a95515ad8a851f412598a720ad4820dc32 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 29 Jun 2026 18:08:27 +0200 Subject: [PATCH] =?UTF-8?q?plan:=200088=20construction=20op-script=20?= =?UTF-8?q?=E2=80=94=20iteration=201=20(engine=20core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration-1 plan for #157 §A+§B: the eager/holistic gate split (extract edge_kind_check + shared name-resolution helpers + try_bind, all reusing the engine's gates — no second validator), the GraphSession/Op/replay per-op surface, build-free unwired/vocabulary introspection, and the acceptance-1 replay-equals-GraphBuilder compile-identity test. The JSON op-list + CLI are iteration 2. Eight RED-first tasks; struct literals + filter strings verified against the current tree. refs #157 --- docs/plans/0088-construction-op-script.md | 1194 +++++++++++++++++++++ 1 file changed, 1194 insertions(+) create mode 100644 docs/plans/0088-construction-op-script.md diff --git a/docs/plans/0088-construction-op-script.md b/docs/plans/0088-construction-op-script.md new file mode 100644 index 0000000..99408ef --- /dev/null +++ b/docs/plans/0088-construction-op-script.md @@ -0,0 +1,1194 @@ +# Construction op-script — Iteration 1 (engine core) — Implementation Plan + +> **Parent spec:** `docs/specs/0088-construction-op-script.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Build the surface-agnostic engine core of the introspectable, +fail-fast construction service (#157, §A + §B): split the construction gates +into shared per-op (eager) + holistic predicates, add a per-op-fallible +`GraphSession`/`replay` surface that emits a `Composite`, and the build-free +introspection it needs — all driven through the engine API, no CLI. + +**Architecture:** §A extracts the edge-kind predicate and the name-resolution +helpers into `pub(crate)` shared functions (called by both the eager op and the +unchanged holistic gates — no second validator) and adds a fallible +`PrimitiveBuilder::try_bind`. §B adds a new `aura-engine::construction` module: +`Op`/`OpError`, a `GraphSession` that applies ops fallibly with eager kind + +double-wire checks, a `finish` that runs the unchanged holistic gates, a +`replay` driver that stops at the first failing op, and `unwired` introspection. +§C's engine-facing piece is the enumerable `std_vocabulary_types()` companion in +aura-std. `compile_with_params` / `check_ports_connected` stay behaviourally +unchanged. + +**Tech Stack:** `aura-engine` (`blueprint.rs`, `builder.rs`, new +`construction.rs`, `lib.rs`), `aura-core` (`node.rs`), `aura-std` +(`vocabulary.rs`, `lib.rs`). + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-engine/src/blueprint.rs` — extract `pub(crate) fn + edge_kind_check`; widen `validate_wiring` + `check_param_namespace_injective` + to `pub(crate)`. +- Modify: `crates/aura-engine/src/builder.rs` — extract `pub(crate)` + name-resolution helpers `resolve_input_slot` / `resolve_output_field`; rewrite + `GraphBuilder::resolve_slot` / `resolve_field` to delegate. +- Modify: `crates/aura-core/src/node.rs` — add `pub fn try_bind` + `pub enum + BindOpError` (keep `bind` verbatim). +- Modify: `crates/aura-core/src/lib.rs` — re-export `BindOpError`. +- Modify: `crates/aura-std/src/vocabulary.rs` — add `pub fn + std_vocabulary_types()`. +- Modify: `crates/aura-std/src/lib.rs:86` — re-export `std_vocabulary_types`. +- Create: `crates/aura-engine/src/construction.rs` — `Op` / `OpError` / + `GraphSession` / `replay` / `unwired` (§B). +- Modify: `crates/aura-engine/src/lib.rs` — `mod construction;` + `pub use`. +- Test: all tests live in the `#[cfg(test)] mod tests` of the file they cover. + +--- + +### Task 1: §A — extract the shared `edge_kind_check` predicate + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs:628-680` (`validate_wiring`), + `:566` (`check_param_namespace_injective`) + +- [ ] **Step 1: Write the failing test** + +Add to `blueprint.rs`'s `#[cfg(test)] mod tests` (find it with `grep -n "mod +tests" crates/aura-engine/src/blueprint.rs`): + +```rust +#[test] +fn edge_kind_check_accepts_match_and_rejects_mismatch() { + use super::edge_kind_check; + use aura_core::{FieldSpec, NodeSchema, PortSpec, ScalarKind}; + // producer: one f64 output field; consumer: slot 0 is f64, slot 1 is bool. + let from = NodeSchema { + inputs: vec![], + output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }], + params: vec![], + }; + let to = NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::F64, firing: aura_core::Firing::Any, name: "a".into() }, + PortSpec { kind: ScalarKind::Bool, firing: aura_core::Firing::Any, name: "b".into() }, + ], + output: vec![], + params: vec![], + }; + assert!(edge_kind_check(&from, 0, &to, 0).is_ok()); + assert_eq!( + edge_kind_check(&from, 0, &to, 1), + Err(CompileError::Bootstrap(BootstrapError::KindMismatch { + producer: ScalarKind::F64, + consumer: ScalarKind::Bool, + })) + ); +} +``` + +> Note: confirm `NodeSchema` field names with `grep -n "pub struct NodeSchema" +> -A4 crates/aura-core/src/node.rs` and `PortSpec` with `grep -n "pub struct +> PortSpec" -A5 crates/aura-core/src/node.rs`. The struct literal above must +> match exactly (`PortSpec { kind, firing, name }`, `FieldSpec { name, kind }`, +> `NodeSchema { inputs, output, params }`). If any field name differs, fix the +> literal here before running. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-engine edge_kind_check_accepts_match_and_rejects_mismatch` +Expected: FAIL — compile error `cannot find function `edge_kind_check` in this scope`. + +- [ ] **Step 3: Extract the predicate and rewire `validate_wiring`** + +In `blueprint.rs`, add this free function next to `validate_wiring` (its body is +lifted verbatim from the current edge loop at `:638-649`): + +```rust +/// The per-edge kind predicate, shared by `validate_wiring` (holistic) and the +/// eager `connect` op (`construction.rs`) — one check, two cadences (no second +/// validator). Looks the producer field + consumer slot up by index and rejects +/// a kind mismatch with the SAME variant bootstrap uses, so 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(()) +} +``` + +Then replace the edge loop body inside `validate_wiring` (`:638-649`) — the +current block: + +```rust + for e in edges { + let from = nodes.get(e.from).ok_or(CompileError::BadInteriorIndex)?.signature(); + let to = nodes.get(e.to).ok_or(CompileError::BadInteriorIndex)?.signature(); + let f = from.output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?; + let s = to.inputs.get(e.slot).ok_or(CompileError::BadInteriorIndex)?; + if f.kind != s.kind { + return Err(CompileError::Bootstrap(BootstrapError::KindMismatch { + producer: f.kind, + consumer: s.kind, + })); + } + } +``` + +with: + +```rust + for e in edges { + let from = nodes.get(e.from).ok_or(CompileError::BadInteriorIndex)?.signature(); + let to = nodes.get(e.to).ok_or(CompileError::BadInteriorIndex)?.signature(); + edge_kind_check(&from, e.from_field, &to, e.slot)?; + } +``` + +Also widen the two holistic gates `finish` will reuse (Task 7): change `fn +validate_wiring` (`:628`) to `pub(crate) fn validate_wiring` and `fn +check_param_namespace_injective` (`:566`) to `pub(crate) fn +check_param_namespace_injective`. + +- [ ] **Step 4: Run the new test + the existing kind-mismatch gate** + +Run: `cargo test -p aura-engine edge_kind_check_accepts_match_and_rejects_mismatch` +Expected: PASS (1 passed). + +Run: `cargo test -p aura-engine compile_rejects_kind_mismatch_without_building` +Expected: PASS (the holistic edge-kind gate is behaviourally unchanged). + +--- + +### Task 2: §A — shared name-resolution helpers + +**Files:** +- Modify: `crates/aura-engine/src/builder.rs:160-198` + +- [ ] **Step 1: Write the failing test** + +Add to `builder.rs`'s `#[cfg(test)] mod tests` (`:201`): + +```rust +#[test] +fn shared_port_resolution_matches_exactly_one() { + use super::{resolve_input_slot, resolve_output_field, PortResolveError}; + use aura_std::Sub; + use crate::BlueprintNode; + let schema = BlueprintNode::from(Sub::builder()).signature(); // lhs, rhs -> value + assert_eq!(resolve_input_slot(&schema, "lhs"), Ok(0)); + assert_eq!(resolve_input_slot(&schema, "rhs"), Ok(1)); + assert_eq!(resolve_input_slot(&schema, "nope"), Err(PortResolveError::Unknown)); + assert_eq!(resolve_output_field(&schema, "value"), Ok(0)); + assert_eq!(resolve_output_field(&schema, "nope"), Err(PortResolveError::Unknown)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-engine shared_port_resolution_matches_exactly_one` +Expected: FAIL — compile error `cannot find function `resolve_input_slot``. + +- [ ] **Step 3: Extract the helpers and delegate** + +In `builder.rs`, add (above `impl GraphBuilder`'s `resolve_slot`, or at module +scope after the `GraphBuilder` impl): + +```rust +/// A name→index resolution outcome, shared by `GraphBuilder` (mapped to +/// `BuildError`) and `GraphSession` (mapped to `OpError`). Over `&str`, so it +/// serves both `&'static str` literals and runtime document names. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PortResolveError { + Unknown, + Ambiguous, +} + +/// Resolve an input-port name to its slot index by exactly-one-match. +pub(crate) fn resolve_input_slot(schema: &NodeSchema, name: &str) -> Result { + let m: Vec = schema + .inputs + .iter() + .enumerate() + .filter(|(_, port)| port.name == name) + .map(|(i, _)| i) + .collect(); + match m.as_slice() { + [i] => Ok(*i), + [] => Err(PortResolveError::Unknown), + _ => Err(PortResolveError::Ambiguous), + } +} + +/// Resolve an output-field name to its field index by exactly-one-match. +pub(crate) fn resolve_output_field(schema: &NodeSchema, name: &str) -> Result { + let m: Vec = schema + .output + .iter() + .enumerate() + .filter(|(_, f)| f.name == name) + .map(|(i, _)| i) + .collect(); + match m.as_slice() { + [i] => Ok(*i), + [] => Err(PortResolveError::Unknown), + _ => Err(PortResolveError::Ambiguous), + } +} +``` + +Then rewrite `GraphBuilder::resolve_slot` (`:160-177`) and `resolve_field` +(`:181-198`) to delegate, preserving the exact `BuildError` variants: + +```rust + fn resolve_slot(&self, p: &InPort) -> Result<(usize, usize), BuildError> { + let schema = self.schemas.get(p.node).ok_or(BuildError::BadHandle { node: p.node })?; + match resolve_input_slot(schema, p.name) { + Ok(i) => Ok((p.node, i)), + Err(PortResolveError::Unknown) => { + Err(BuildError::UnknownInPort { node: p.node, name: p.name.to_string() }) + } + Err(PortResolveError::Ambiguous) => { + Err(BuildError::AmbiguousInPort { node: p.node, name: p.name.to_string() }) + } + } + } + + fn resolve_field(&self, p: &OutPort) -> Result { + let schema = self.schemas.get(p.node).ok_or(BuildError::BadHandle { node: p.node })?; + match resolve_output_field(schema, p.name) { + Ok(i) => Ok(i), + Err(PortResolveError::Unknown) => { + Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() }) + } + Err(PortResolveError::Ambiguous) => { + Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() }) + } + } + } +``` + +- [ ] **Step 4: Run the new test + the existing builder resolution tests** + +Run: `cargo test -p aura-engine shared_port_resolution_matches_exactly_one` +Expected: PASS (1 passed). + +Run: `cargo test -p aura-engine unknown_in_port_is_reported_at_build` +Expected: PASS (BuildError variants unchanged). + +Run: `cargo test -p aura-engine ambiguous_in_port_is_reported` +Expected: PASS. + +--- + +### Task 3: §A — fallible `try_bind` + `BindOpError` + +**Files:** +- Modify: `crates/aura-core/src/node.rs:189-237` (add `try_bind` after `bind`), + `crates/aura-core/src/lib.rs` (re-export) + +- [ ] **Step 1: Write the failing test** + +Add to `node.rs`'s `#[cfg(test)] mod tests` (`grep -n "mod tests" +crates/aura-core/src/node.rs`): + +```rust +#[test] +fn try_bind_ok_and_typed_errors() { + use super::BindOpError; + use aura_std::Sma; // SMA has one i64 param `length` + // ok: exact name, matching kind + assert!(Sma::builder().try_bind("length", Scalar::i64(3)).is_ok()); + // unknown param name + assert_eq!( + Sma::builder().try_bind("nope", Scalar::i64(3)).err(), + Some(BindOpError::UnknownParam("nope".into())) + ); + // kind mismatch (f64 into an i64 param) + assert_eq!( + Sma::builder().try_bind("length", Scalar::f64(3.0)).err(), + Some(BindOpError::KindMismatch { + param: "length".into(), + expected: ScalarKind::I64, + got: ScalarKind::F64, + }) + ); +} +``` + +> `.err()` not `.unwrap_err()`: the `Ok` type `PrimitiveBuilder` is not `Debug` +> (it holds a build closure), mirroring `builder.rs`'s tests. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-core try_bind_ok_and_typed_errors` +Expected: FAIL — compile error `cannot find type `BindOpError`` / `no method +`try_bind``. + +- [ ] **Step 3: Add `BindOpError` and `try_bind`** + +In `node.rs`, add the error type near the other error/struct definitions (module +scope): + +```rust +/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the +/// data-level construction surface (`GraphSession`, #157). `bind` keeps its +/// panic contract (and its pinned messages); `try_bind` reports the same three +/// conditions as values. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BindOpError { + /// No still-open param has this name. + UnknownParam(String), + /// More than one still-open param has this name. + AmbiguousParam(String), + /// The value's kind does not equal the param's declared kind. + KindMismatch { param: String, expected: ScalarKind, got: ScalarKind }, +} +``` + +Then add `try_bind` immediately after `bind` (inside `impl PrimitiveBuilder`). +Its body mirrors `bind` (`:189-237`) but returns `Result` instead of panicking — +deliberately a separate method, not a refactor of `bind`, so `bind`'s pinned +panic messages (`bind_unknown_slot_panics` etc.) are untouched: + +```rust +/// The fallible twin of [`bind`](Self::bind): same exactly-one-match + kind +/// rule, returning [`BindOpError`] instead of panicking. Used by the op-script +/// construction surface, where a bad param is rejected at the op, not a panic. +pub fn try_bind(mut self, slot: &str, value: Scalar) -> Result { + let matches: Vec = self + .schema + .params + .iter() + .enumerate() + .filter(|(_, p)| p.name == slot) + .map(|(i, _)| i) + .collect(); + let pos = match matches.as_slice() { + [pos] => *pos, + [] => return Err(BindOpError::UnknownParam(slot.to_string())), + _ => return Err(BindOpError::AmbiguousParam(slot.to_string())), + }; + if value.kind() != self.schema.params[pos].kind { + return Err(BindOpError::KindMismatch { + param: slot.to_string(), + expected: self.schema.params[pos].kind, + got: value.kind(), + }); + } + let name = self.schema.params[pos].name.clone(); + let kind = self.schema.params[pos].kind; + let mut orig = pos; + let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect(); + prior.sort_unstable(); + for p in prior { + if p <= orig { + orig += 1; + } + } + self.bound.push(BoundParam { pos: orig, name, kind, value }); + self.schema.params.remove(pos); + let inner = self.build; + self.build = Box::new(move |open: &[Cell]| { + let mut full = open.to_vec(); + full.insert(pos, value.cell()); + inner(&full) + }); + Ok(self) +} +``` + +Re-export from `aura-core/src/lib.rs`: find the `pub use node::{...}` line +(`grep -n "pub use node" crates/aura-core/src/lib.rs`) and add `BindOpError` to +its brace list. + +- [ ] **Step 4: Run the new test + existing bind-panic tests** + +Run: `cargo test -p aura-core try_bind_ok_and_typed_errors` +Expected: PASS (1 passed). + +Run: `cargo test -p aura-core bind_unknown_slot_panics` +Expected: PASS (bind's panic contract unchanged). + +Run: `cargo test -p aura-core bind_kind_mismatch_panics` +Expected: PASS. + +--- + +### Task 4: §C companion — enumerable `std_vocabulary_types()` + +**Files:** +- Modify: `crates/aura-std/src/vocabulary.rs:30-56`, + `crates/aura-std/src/lib.rs:86` + +- [ ] **Step 1: Write the failing test** + +Add to `vocabulary.rs`'s `#[cfg(test)] mod tests` (`:58`): + +```rust +#[test] +fn std_vocabulary_types_lists_exactly_the_resolvable_keys() { + use super::{std_vocabulary, std_vocabulary_types}; + // every listed type resolves to a builder carrying that exact label + for t in std_vocabulary_types() { + let b = std_vocabulary(t).unwrap_or_else(|| panic!("{t} listed but unresolvable")); + assert_eq!(&b.label(), t, "listed type must round-trip to its label"); + } + // the list is the WHOLE resolvable set: a couple of known non-members stay out + assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node + assert!(!std_vocabulary_types().contains(&"Recorder")); // sink + assert!(!std_vocabulary_types().contains(&"nope")); + // count guard: exactly the 22 zero-arg keys (a new arm without a list entry trips this) + assert_eq!(std_vocabulary_types().len(), 22); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-std std_vocabulary_types_lists_exactly_the_resolvable_keys` +Expected: FAIL — compile error `cannot find function `std_vocabulary_types``. + +- [ ] **Step 3: Add the enumerable companion + re-export** + +In `vocabulary.rs`, add directly after `std_vocabulary` (the third lockstep site; +it must list EXACTLY the match keys at `:32-53`): + +```rust +/// The enumerable companion to [`std_vocabulary`]: the closed set of zero-arg +/// type identities the resolver answers. A `fn(&str)->Option<…>` resolver cannot +/// be listed, so build-free vocabulary introspection (#157) reads this. Lockstep +/// with the `std_vocabulary` match arms — a type added there must be added here. +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", + ] +} +``` + +Re-export: in `aura-std/src/lib.rs`, find the existing vocabulary re-export +(`grep -n "vocabulary::" crates/aura-std/src/lib.rs`) — currently `pub use +vocabulary::std_vocabulary;` (around `:86`) — and extend it to: + +```rust +pub use vocabulary::{std_vocabulary, std_vocabulary_types}; +``` + +- [ ] **Step 4: Run the lockstep test** + +Run: `cargo test -p aura-std std_vocabulary_types_lists_exactly_the_resolvable_keys` +Expected: PASS (1 passed). + +Run: `cargo test -p aura-std std_vocabulary_resolves_known_and_rejects_unknown` +Expected: PASS (existing resolver test unchanged). + +--- + +### Task 5: §B — `Op` / `OpError` / `GraphSession` skeleton + `add`/`source`/`input` + +**Files:** +- Create: `crates/aura-engine/src/construction.rs` +- Modify: `crates/aura-engine/src/lib.rs:45-53` (add `mod construction;`) + +- [ ] **Step 1: Create the module with types, `new`, and the node/role ops** + +Create `crates/aura-engine/src/construction.rs`: + +```rust +//! Per-op-fallible blueprint construction — the eager half of the §A gate split +//! (#157). A `GraphSession` applies `Op`s one at a time, running the +//! eagerly-decidable checks (name resolution, edge kind-match, the >1-cover +//! double-wire arm, param bind) at the offending op; the only-at-end-decidable +//! checks (0-cover totality, param-namespace injectivity, root-role boundness) +//! run holistically in `finish`. Both cadences call the SAME §A predicates — no +//! second validator (C24). The node vocabulary is an injected closed resolver +//! (`Fn(&str)->Option`), so the engine stays domain-free +//! (invariant 9). + +use std::collections::HashMap; + +use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind}; + +use crate::blueprint::{ + check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite, + OutField, Role, +}; +use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError}; +use crate::harness::{Edge, Target}; +use crate::CompileError; + +/// One construction act — the engine-side op vocabulary, 1:1 with the document. +/// Port references are dotted by-identifier (`"node.field"` / `"node.slot"`), +/// split at the first `.`. (serde `Deserialize` is iteration 2.) +#[derive(Clone, Debug, PartialEq)] +pub enum Op { + /// Reserve a bound root source role of `kind`. + Source { role: String, kind: ScalarKind }, + /// Reserve an open input role (wired by an enclosing graph). + Input { role: String }, + /// Add a node of `type_id`, optionally named `as_name`, with `bind` params. + Add { type_id: String, as_name: Option, bind: Vec<(String, Scalar)> }, + /// Fan a role into one or more `"node.slot"` consumers. + Feed { role: String, into: Vec }, + /// Wire `"node.field"` -> `"node.slot"`. + Connect { from: String, to: String }, + /// Re-export `"node.field"` at the boundary under `as_name`. + Expose { from: String, as_name: String }, +} + +/// A per-op construction fault, by-identifier so the cause names the op. +#[derive(Clone, Debug, PartialEq)] +pub enum OpError { + UnknownNodeType(String), + DuplicateIdentifier(String), + DuplicateRole(String), + UnknownIdentifier(String), + UnknownRole(String), + MalformedPort(String), + UnknownInPort { node: String, name: String }, + AmbiguousInPort { node: String, name: String }, + UnknownOutPort { node: String, name: String }, + AmbiguousOutPort { node: String, name: String }, + KindMismatch { from: String, to: String, producer: ScalarKind, consumer: ScalarKind }, + SlotAlreadyWired { node: String, slot: String }, + BadParam { node: String, err: BindOpError }, + /// A holistic finalize fault (totality / injectivity / unbound root role), + /// wrapping the unchanged engine gate's `CompileError`. + Incomplete(CompileError), +} + +/// A per-op-fallible blueprint accumulator. Holds the same interior data a +/// `Composite` is built from, plus the incremental coverage map (eager +/// double-wire front-run) and the identifier→index maps the document references. +pub struct GraphSession<'v> { + name: String, + vocab: &'v dyn Fn(&str) -> Option, + nodes: Vec, + schemas: Vec, + ids: Vec, + names: HashMap, + edges: Vec, + roles: Vec<(String, Option, Vec)>, + role_names: HashMap, + out: Vec, + out_names: HashMap, + coverage: HashMap<(usize, usize), usize>, +} + +impl<'v> GraphSession<'v> { + /// Start a session for a composite named `name`, resolving node types through + /// the injected closed vocabulary `vocab`. + pub fn new(name: impl Into, vocab: &'v dyn Fn(&str) -> Option) -> Self { + Self { + name: name.into(), + vocab, + nodes: Vec::new(), + schemas: Vec::new(), + ids: Vec::new(), + names: HashMap::new(), + edges: Vec::new(), + roles: Vec::new(), + role_names: HashMap::new(), + out: Vec::new(), + out_names: HashMap::new(), + coverage: HashMap::new(), + } + } + + /// Apply one op, running the eager checks. The first `Err` is the op-script's + /// stop point (`replay` attributes it to the op index). + pub fn apply(&mut self, op: Op) -> Result<(), OpError> { + match op { + Op::Source { role, kind } => self.add_role(role, Some(kind)), + Op::Input { role } => self.add_role(role, None), + Op::Add { type_id, as_name, bind } => self.add_node(type_id, as_name, bind), + Op::Feed { role, into } => self.feed(role, into), + Op::Connect { from, to } => self.connect(from, to), + Op::Expose { from, as_name } => self.expose(from, as_name), + } + } + + fn add_role(&mut self, role: String, kind: Option) -> Result<(), OpError> { + if self.role_names.contains_key(&role) { + return Err(OpError::DuplicateRole(role)); + } + let idx = self.roles.len(); + self.role_names.insert(role.clone(), idx); + self.roles.push((role, kind, Vec::new())); + Ok(()) + } + + fn add_node( + &mut self, + type_id: String, + as_name: Option, + bind: Vec<(String, Scalar)>, + ) -> Result<(), OpError> { + let mut builder = + (self.vocab)(&type_id).ok_or_else(|| OpError::UnknownNodeType(type_id.clone()))?; + let id = as_name.unwrap_or_else(|| builder.node_name()); + for (slot, value) in bind { + builder = builder + .try_bind(&slot, value) + .map_err(|err| OpError::BadParam { node: id.clone(), err })?; + } + if self.names.contains_key(&id) { + return Err(OpError::DuplicateIdentifier(id)); + } + let node = BlueprintNode::from(builder); + let schema = node.signature(); + let idx = self.nodes.len(); + self.names.insert(id.clone(), idx); + self.ids.push(id); + self.nodes.push(node); + self.schemas.push(schema); + Ok(()) + } + + // feed / connect / expose / finish / unwired follow in Task 6 and Task 7. +} +``` + +> Confirm `BlueprintNode: From` and `BlueprintNode::signature` +> exist (`grep -n "impl From\|fn signature" crates/aura-engine/src/blueprint.rs`). +> The `builder.into()` in `builder.rs:93` and `node.signature()` at `:94` prove +> both; `BlueprintNode::from(builder)` is the same conversion. + +Add the module to `lib.rs` (`:45-53` block): insert `mod construction;` after +`mod builder;`. + +- [ ] **Step 2: Write the failing tests for `add` / `source`** + +Append a `#[cfg(test)] mod tests` to `construction.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::{GraphSession, Op, OpError}; + use aura_core::{BindOpError, Scalar, ScalarKind}; + use aura_std::std_vocabulary; + + fn session(name: &str) -> GraphSession<'static> { + GraphSession::new(name, &std_vocabulary) + } + + #[test] + fn add_unknown_type_rejected_at_op() { + let mut s = session("g"); + assert_eq!( + s.apply(Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }), + Err(OpError::UnknownNodeType("Nope".into())) + ); + } + + #[test] + fn add_duplicate_identifier_rejected() { + let mut s = session("g"); + s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }).unwrap(); + assert_eq!( + s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }), + Err(OpError::DuplicateIdentifier("x".into())) + ); + } + + #[test] + fn add_bad_bind_param_rejected_at_op() { + let mut s = session("g"); + assert_eq!( + s.apply(Op::Add { + type_id: "SMA".into(), + as_name: Some("fast".into()), + bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param) + }), + Err(OpError::BadParam { + node: "fast".into(), + err: BindOpError::KindMismatch { + param: "length".into(), + expected: ScalarKind::I64, + got: ScalarKind::F64, + }, + }) + ); + } + + #[test] + fn duplicate_role_rejected() { + let mut s = session("g"); + s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap(); + assert_eq!( + s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }), + Err(OpError::DuplicateRole("price".into())) + ); + } +} +``` + +- [ ] **Step 3: Run to verify they fail, then pass** + +Run: `cargo build -p aura-engine` +Expected: clean build (the module + ops compile). + +Run: `cargo test -p aura-engine add_unknown_type_rejected_at_op` +Expected: PASS. + +Run: `cargo test -p aura-engine add_bad_bind_param_rejected_at_op` +Expected: PASS. + +> The four tests in this module are all driven by the code written in Step 1. +> Run each individually (one filter per Run step): `add_duplicate_identifier_rejected`, +> `duplicate_role_rejected` — both Expected: PASS. + +--- + +### Task 6: §B — eager `connect` / `feed` / `expose` + +**Files:** +- Modify: `crates/aura-engine/src/construction.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to `construction.rs`'s `mod tests`: + +```rust + // helper: a fast/slow/sub scaffold sharing a price source + fn scaffold() -> GraphSession<'static> { + let mut s = session("g"); + s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap(); + s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }).unwrap(); + s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }).unwrap(); + s.apply(Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }).unwrap(); + s + } + + #[test] + fn connect_unknown_out_port_rejected_at_op() { + let mut s = scaffold(); + assert_eq!( + s.apply(Op::Connect { from: "fast.nope".into(), to: "sub.lhs".into() }), + Err(OpError::UnknownOutPort { node: "fast".into(), name: "nope".into() }) + ); + } + + #[test] + fn connect_double_wire_rejected_at_op() { + let mut s = scaffold(); + s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap(); + assert_eq!( + s.apply(Op::Connect { from: "slow.value".into(), to: "sub.lhs".into() }), + Err(OpError::SlotAlreadyWired { node: "sub".into(), slot: "lhs".into() }) + ); + } + + #[test] + fn connect_kind_mismatch_rejected_at_op() { + // Gt produces bool; Bias.signal consumes f64 -> eager kind mismatch + let mut s = session("g"); + s.apply(Op::Add { type_id: "Gt".into(), as_name: None, bind: vec![] }).unwrap(); + s.apply(Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }).unwrap(); + let r = s.apply(Op::Connect { from: "gt.value".into(), to: "bias.signal".into() }); + assert!(matches!( + r, + Err(OpError::KindMismatch { producer: ScalarKind::Bool, consumer: ScalarKind::F64, .. }) + )); + } + + #[test] + fn feed_into_unknown_role_rejected() { + let mut s = scaffold(); + assert_eq!( + s.apply(Op::Feed { role: "ghost".into(), into: vec!["fast.series".into()] }), + Err(OpError::UnknownRole("ghost".into())) + ); + } +``` + +> Verify `Gt`'s output field is named `value` and is `bool`, and `Bias.signal` +> is `f64`: `grep -rn "value\|signal\|Bool\|F64" crates/aura-std/src/gt.rs +> crates/aura-std/src/bias.rs`. If the field name differs, fix the test literal. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p aura-engine connect_double_wire_rejected_at_op` +Expected: FAIL — compile error `no method `connect`` / missing arm. + +- [ ] **Step 3: Implement `connect` / `feed` / `expose` + the dotted-port helper** + +Add these methods inside `impl<'v> GraphSession<'v>` (after `add_node`): + +```rust + /// Split a dotted `"node.port"` reference at the first `.`. + fn split_port(&self, dotted: &str) -> Result<(String, String), OpError> { + match dotted.split_once('.') { + Some((node, port)) if !node.is_empty() && !port.is_empty() => { + Ok((node.to_string(), port.to_string())) + } + _ => Err(OpError::MalformedPort(dotted.to_string())), + } + } + + fn node_index(&self, id: &str) -> Result { + self.names.get(id).copied().ok_or_else(|| OpError::UnknownIdentifier(id.to_string())) + } + + /// Mark `(node, slot)` covered, rejecting a second cover eagerly (the >1 arm + /// of the exactly-once invariant — the holistic 0 arm is deferred to finish). + fn cover(&mut self, node: usize, node_id: &str, slot: usize, slot_name: &str) -> Result<(), OpError> { + let c = self.coverage.entry((node, slot)).or_insert(0); + if *c >= 1 { + return Err(OpError::SlotAlreadyWired { node: node_id.to_string(), slot: slot_name.to_string() }); + } + *c += 1; + Ok(()) + } + + fn connect(&mut self, from: String, to: String) -> Result<(), OpError> { + let (from_node, from_field_name) = self.split_port(&from)?; + let (to_node, to_slot_name) = self.split_port(&to)?; + let fi = self.node_index(&from_node)?; + let ti = self.node_index(&to_node)?; + let from_field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e { + PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() }, + PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() }, + })?; + let slot = resolve_input_slot(&self.schemas[ti], &to_slot_name).map_err(|e| match e { + PortResolveError::Unknown => OpError::UnknownInPort { node: to_node.clone(), name: to_slot_name.clone() }, + PortResolveError::Ambiguous => OpError::AmbiguousInPort { node: to_node.clone(), name: to_slot_name.clone() }, + })?; + edge_kind_check(&self.schemas[fi], from_field, &self.schemas[ti], slot).map_err(|e| match e { + CompileError::Bootstrap(crate::BootstrapError::KindMismatch { producer, consumer }) => { + OpError::KindMismatch { from: from.clone(), to: to.clone(), producer, consumer } + } + other => OpError::Incomplete(other), + })?; + self.cover(ti, &to_node, slot, &to_slot_name)?; + self.edges.push(Edge { from: fi, to: ti, slot, from_field }); + Ok(()) + } + + fn feed(&mut self, role: String, into: Vec) -> Result<(), OpError> { + let ri = *self.role_names.get(&role).ok_or_else(|| OpError::UnknownRole(role.clone()))?; + for target in into { + let (to_node, to_slot_name) = self.split_port(&target)?; + let ti = self.node_index(&to_node)?; + let slot = resolve_input_slot(&self.schemas[ti], &to_slot_name).map_err(|e| match e { + PortResolveError::Unknown => OpError::UnknownInPort { node: to_node.clone(), name: to_slot_name.clone() }, + PortResolveError::Ambiguous => OpError::AmbiguousInPort { node: to_node.clone(), name: to_slot_name.clone() }, + })?; + self.cover(ti, &to_node, slot, &to_slot_name)?; + self.roles[ri].2.push(Target { node: ti, slot }); + } + Ok(()) + } + + fn expose(&mut self, from: String, as_name: String) -> Result<(), OpError> { + let (from_node, from_field_name) = self.split_port(&from)?; + let fi = self.node_index(&from_node)?; + let field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e { + PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() }, + PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() }, + })?; + if self.out_names.insert(as_name.clone(), ()).is_some() { + return Err(OpError::DuplicateIdentifier(as_name)); + } + self.out.push(OutField { node: fi, field, name: as_name }); + Ok(()) + } +``` + +> `crate::BootstrapError` is re-exported at `lib.rs:68`; confirm the +> `KindMismatch { producer, consumer }` field names with `grep -n "KindMismatch" +> crates/aura-engine/src/harness.rs`. + +- [ ] **Step 4: Run the eager-op tests** + +Run: `cargo test -p aura-engine connect_double_wire_rejected_at_op` +Expected: PASS. + +Run: `cargo test -p aura-engine connect_kind_mismatch_rejected_at_op` +Expected: PASS. + +Run: `cargo test -p aura-engine connect_unknown_out_port_rejected_at_op` +Expected: PASS. + +Run: `cargo test -p aura-engine feed_into_unknown_role_rejected` +Expected: PASS. + +--- + +### Task 7: §B — `finish` (holistic gates), `replay`, `unwired` + +**Files:** +- Modify: `crates/aura-engine/src/construction.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to `construction.rs`'s `mod tests`: + +```rust + #[test] + fn finish_reports_incomplete_on_unwired_slot() { + // a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm + let mut s = scaffold(); + s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }).unwrap(); + s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap(); + // sub.rhs deliberately left unwired + let err = s.finish().unwrap_err(); + assert!(matches!(err, OpError::Incomplete(CompileError::UnconnectedPort { .. }))); + } + + #[test] + fn unwired_lists_open_slots() { + let mut s = scaffold(); + s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap(); + let open = s.unwired(); + // fast.series, slow.series, sub.rhs are still open; sub.lhs is covered + assert!(open.iter().any(|(p, _)| p == "sub.rhs")); + assert!(open.iter().any(|(p, _)| p == "fast.series")); + assert!(!open.iter().any(|(p, _)| p == "sub.lhs")); + } + + #[test] + fn replay_stops_at_first_failing_op_naming_index() { + let ops = vec![ + Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, + Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // op 1 fails + Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, + ]; + let err = super::replay("g", ops, &aura_std::std_vocabulary).unwrap_err(); + assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into()))); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p aura-engine replay_stops_at_first_failing_op_naming_index` +Expected: FAIL — compile error `cannot find function `replay`` / `no method +`finish``. + +- [ ] **Step 3: Implement `finish`, `unwired`, and `replay`** + +Add to `impl<'v> GraphSession<'v>`: + +```rust + /// The still-unwired interior input slots (build-free introspection): every + /// added node's declared input slots minus the covered ones, by-identifier. + pub fn unwired(&self) -> Vec<(String, ScalarKind)> { + let mut open = Vec::new(); + for (n, schema) in self.schemas.iter().enumerate() { + for (slot, port) in schema.inputs.iter().enumerate() { + if self.coverage.get(&(n, slot)).copied().unwrap_or(0) == 0 { + open.push((format!("{}.{}", self.ids[n], port.name), port.kind)); + } + } + } + open + } + + /// Assemble the `Composite` and run the holistic gates (totality, + /// param-namespace injectivity, root-role boundness) — the SAME engine + /// predicates the eager path shares, now at end-of-document cadence. Any + /// fault wraps as `OpError::Incomplete`. + pub fn finish(self) -> Result { + let roles: Vec = self + .roles + .into_iter() + .map(|(name, source, targets)| Role { name, targets, source }) + .collect(); + let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out); + check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?; + validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(OpError::Incomplete)?; + for (r, role) in c.input_roles().iter().enumerate() { + if role.source.is_none() { + return Err(OpError::Incomplete(CompileError::UnboundRootRole { role: r })); + } + } + Ok(c) + } +} + +/// Replay a document of ops from scratch, stopping at the first failing op and +/// naming it by `(op_index, OpError)`; a holistic finalize fault is attributed to +/// index `ops.len()` (the implicit finalize step). +pub fn replay( + name: impl Into, + ops: Vec, + vocab: &dyn Fn(&str) -> Option, +) -> Result { + let mut s = GraphSession::new(name, vocab); + let n = ops.len(); + for (i, op) in ops.into_iter().enumerate() { + s.apply(op).map_err(|e| (i, e))?; + } + s.finish().map_err(|e| (n, e)) +} +``` + +> `Composite::param_space` (`blueprint.rs:195`), `nodes()`/`edges()`/ +> `input_roles()`/`output()` accessors (`:166-187`), and `CompileError:: +> UnboundRootRole`/`UnconnectedPort` (`:614`/`:617`) are all already in scope via +> the Task-1 `use` lines + the `pub(crate)` widening from Task 1. + +- [ ] **Step 4: Run the finalize/replay/introspection tests** + +Run: `cargo test -p aura-engine finish_reports_incomplete_on_unwired_slot` +Expected: PASS. + +Run: `cargo test -p aura-engine unwired_lists_open_slots` +Expected: PASS. + +Run: `cargo test -p aura-engine replay_stops_at_first_failing_op_naming_index` +Expected: PASS. + +--- + +### Task 8: §B — acceptance-1 (replay ≡ GraphBuilder) + public exports + +**Files:** +- Modify: `crates/aura-engine/src/construction.rs` (the integration test), + `crates/aura-engine/src/lib.rs:55-88` (public `pub use`) + +- [ ] **Step 1: Write the failing acceptance-1 test** + +Add to `construction.rs`'s `mod tests` — a flat, sink-free `price → fast/slow +SMA → Sub → Bias` signal root authored two ways, compiled, and compared (models +the green `builder_harness_compiles_identically_to_hand_wired`, `builder.rs:234`; +no SimBroker/Recorder, which are outside the zero-arg vocabulary): + +```rust + #[test] + fn replay_built_signal_compiles_identically_to_graphbuilder() { + use crate::GraphBuilder; + use aura_core::Scalar; + use aura_std::{Bias, Sma, Sub}; + + // param space of the flat root: fast.length(i64), slow.length(i64), + // bias.scale(f64) — same vector applied to both sides, so it cancels. + let params = [Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)]; + + // (a) op-script replay + let ops = vec![ + Op::Source { role: "price".into(), kind: ScalarKind::F64 }, + Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, + Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, + Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, + Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, + Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, + Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, + Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }, + Op::Connect { from: "sub.value".into(), to: "bias.signal".into() }, + Op::Expose { from: "bias.bias".into(), as_name: "bias".into() }, + ]; + let replay_flat = super::replay("root", ops, &aura_std::std_vocabulary) + .expect("replay resolves") + .compile_with_params(¶ms) + .expect("replay compiles"); + + // (b) the GraphBuilder twin — identical topology + let mut g = GraphBuilder::new("root"); + let fast = g.add(Sma::builder().named("fast")); + let slow = g.add(Sma::builder().named("slow")); + let sub = g.add(Sub::builder()); + let bias = g.add(Bias::builder()); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [fast.input("series"), slow.input("series")]); + g.connect(fast.output("value"), sub.input("lhs")); + g.connect(slow.output("value"), sub.input("rhs")); + g.connect(sub.output("value"), bias.input("signal")); + g.expose(bias.output("bias"), "bias"); + let builder_flat = g.build().expect("root resolves").compile_with_params(¶ms).expect("compiles"); + + assert_eq!(replay_flat.edges, builder_flat.edges); + assert_eq!(replay_flat.sources, builder_flat.sources); + } +``` + +> Port names confirmed from `builder.rs` tests: SMA `series`→`value`, Sub +> `lhs`/`rhs`→`value`, Bias `signal`→`bias`. If `compile_with_params` rejects on +> arity, print `replay(...).unwrap().param_space()` to read the actual space and +> adjust `params` (same vector both sides — the comparison is param-independent). + +- [ ] **Step 2: Run to verify it fails (then passes after exports compile)** + +Run: `cargo test -p aura-engine replay_built_signal_compiles_identically_to_graphbuilder` +Expected: PASS (all of §B is implemented by Task 7; this test only adds an +assertion over existing code). If it FAILs on a param-arity mismatch, follow the +note above. + +- [ ] **Step 3: Add the public exports** + +In `lib.rs`'s `pub use` region (`:55-88`), add: + +```rust +pub use construction::{replay, GraphSession, Op, OpError}; +``` + +(`BindOpError` is exported from `aura-core`; re-export it from the engine root +too for the CLI's convenience by adding `BindOpError` to the existing `pub use +aura_core::{...}` line at `lib.rs:88`.) + +- [ ] **Step 4: Full workspace gates** + +Run: `cargo build --workspace` +Expected: clean build. + +Run: `cargo test --workspace` +Expected: all pass (the prior `748` + the new tests; no regressions). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean (no warnings). + +--- + +## Self-review (planner Step 5) + +1. **Spec coverage:** §A → Tasks 1 (edge_kind_check) + 2 (resolution) + 3 + (try_bind); §C companion → Task 4; §B `Op`/`OpError`/`GraphSession`/eager → + Tasks 5+6; `finish`/`replay`/`unwired` introspection → Task 7; + acceptance-1 + exports → Task 8. Acceptance criteria 1/2/3 of the spec all + have tests. JSON op-list + CLI = iteration 2, correctly excluded. ✓ +2. **Placeholder scan:** no "TBD"/"TODO"/"implement later"/"similar to Task"/ + "add appropriate". The one forward-reference ("feed/connect/expose follow in + Task 6") is a code comment marking a deliberate task boundary, not a plan + placeholder — the methods are fully specified in Task 6. ✓ +3. **Type consistency:** `GraphSession`, `Op`, `OpError`, `replay`, + `edge_kind_check`, `resolve_input_slot`/`resolve_output_field`, + `PortResolveError`, `try_bind`, `BindOpError`, `std_vocabulary_types` — names + identical across every task that references them. ✓ +4. **Step granularity:** each step is one test, one extraction, or one run. ✓ +5. **No commit steps:** none present. ✓ +6. **Pin/replacement contiguity:** Task 4's count guard (`len() == 22`) pins the + list in Task 4's own replacement body (the 22 entries) — contiguous, same + task. No cross-task pin/replacement pair split. ✓ +7. **Compile-gate vs deferred-caller ordering:** Task 1's edge-loop replacement + threads its only caller (`validate_wiring` itself) in-task; Task 2's + resolution extraction rewrites both call sites (`resolve_slot`/`resolve_field`) + in-task. No signature change leaves a caller unthreaded across a build gate. + The new `Op`/`OpError`/`BindOpError` enums have no pre-existing match sites. + Task 5 adds `mod construction;` in the same task that creates the file (the + module compiles standalone; `feed`/`connect`/`expose`/`finish` are added in + Tasks 6-7 but Task 5's code does not call them, so each task's tree builds). ✓ +8. **Verification-filter strings resolve:** every Run filter is a test named in + this plan (Tasks 1-8) or a verified existing green test — + `compile_rejects_kind_mismatch_without_building`, + `unknown_in_port_is_reported_at_build`, `ambiguous_in_port_is_reported`, + `bind_unknown_slot_panics`, `bind_kind_mismatch_panics`, + `std_vocabulary_resolves_known_and_rejects_unknown`, + `builder_harness_compiles_identically_to_hand_wired` (all confirmed present in + the tree this session). The full-workspace gate (Task 8) is unfiltered with a + no-regression expectation. ✓