diff --git a/docs/plans/0088-construction-op-script.md b/docs/plans/0088-construction-op-script.md index b88aa16..6617c23 100644 --- a/docs/plans/0088-construction-op-script.md +++ b/docs/plans/0088-construction-op-script.md @@ -1211,3 +1211,589 @@ Expected: clean (no warnings). `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. ✓ + +--- + +# Iteration 2 — §C CLI shell (Tasks 9-12) + +**Goal:** the JSON op-list surface over the iteration-1 engine core — `aura +graph build` (replay a document → emit the #155 blueprint, or fail fast at the +op) and `aura graph introspect` (vocabulary / a node's ports+kinds / a partial +document's unwired slots), build-free. + +**Architecture:** all changes are in `aura-cli` — the engine `Op` stays +serde-free. A CLI-side serde DTO (`OpDoc`, `#[serde(tag="op", +rename_all="lowercase")]`) deserializes the document and maps into the engine +`Op`; the CLI retains the parsed list to recover the op-kind label for the +`op N (kind): cause` message; a CLI format helper phrases each `OpError` (the +engine error types stay `Display`-free). Bind values use the typed `Scalar` form +(`{"I64":2}`) and `ScalarKind` its capitalized #155 form (`"F64"`). + +**Tech Stack:** `crates/aura-cli/src/main.rs` (argv arms), new +`crates/aura-cli/src/graph_construct.rs` (DTO + subcommand cores), new +`crates/aura-cli/tests/graph_construct.rs` (E2E). `serde`/`serde_json`, +`aura-engine`, `aura-std`, `aura-core` are already deps. + +**Files this iteration creates or modifies:** + +- Create: `crates/aura-cli/src/graph_construct.rs` — the `OpDoc` DTO, the + `build`/`introspect` subcommand cores, the error formatter. +- Modify: `crates/aura-cli/src/main.rs` — `mod graph_construct;` + two argv arms. +- Test: `crates/aura-cli/tests/graph_construct.rs` — E2E driving the `aura` binary. + +> Fork decisions for this iteration are derived + recorded on #157 (the +> wire-format comment): CLI-side DTO (engine stays serde-free); typed `Scalar` +> binds; capitalized `ScalarKind`; op-kind label via the retained parsed list; +> CLI-side error phrasing. + +### Task 9: the `OpDoc` serde DTO + mapping into the engine `Op` + +**Files:** +- Create: `crates/aura-cli/src/graph_construct.rs` +- Modify: `crates/aura-cli/src/main.rs` (add `mod graph_construct;`) + +- [ ] **Step 1: Create the module with the DTO, the mapping, and a test** + +Create `crates/aura-cli/src/graph_construct.rs`: + +```rust +//! The `aura graph build` / `aura graph introspect` CLI surface (#157, §C): a +//! JSON op-list document is deserialized into a CLI-side `OpDoc` (the engine +//! `Op` stays serde-free), mapped into `aura_engine::Op`, and replayed through +//! the injected `aura_std::std_vocabulary`. The build path emits the #155 +//! blueprint or fails fast at the offending op; introspection answers +//! build-free. + +use std::collections::BTreeMap; + +use aura_engine::{blueprint_to_json, replay, GraphSession, Op, OpError, Scalar, ScalarKind}; +use aura_std::{std_vocabulary, std_vocabulary_types}; +use serde::Deserialize; + +/// The wire DTO for one construction op — the document's by-identifier shape, +/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind +/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized +/// `ScalarKind` form (`"F64"`) — both the #155 representations. +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "lowercase")] +enum OpDoc { + Source { role: String, kind: ScalarKind }, + Input { role: String }, + Add { + #[serde(rename = "type")] + type_id: String, + #[serde(rename = "as", default)] + as_name: Option, + #[serde(default)] + bind: BTreeMap, + }, + Feed { role: String, into: Vec }, + Connect { from: String, to: String }, + Expose { + from: String, + #[serde(rename = "as")] + as_name: String, + }, +} + +impl OpDoc { + /// The op-kind label for the `op N (kind): cause` message. + fn kind_label(&self) -> &'static str { + match self { + OpDoc::Source { .. } => "source", + OpDoc::Input { .. } => "input", + OpDoc::Add { .. } => "add", + OpDoc::Feed { .. } => "feed", + OpDoc::Connect { .. } => "connect", + OpDoc::Expose { .. } => "expose", + } + } +} + +impl From for Op { + fn from(d: OpDoc) -> Op { + match d { + OpDoc::Source { role, kind } => Op::Source { role, kind }, + OpDoc::Input { role } => Op::Input { role }, + OpDoc::Add { type_id, as_name, bind } => { + Op::Add { type_id, as_name, bind: bind.into_iter().collect() } + } + OpDoc::Feed { role, into } => Op::Feed { role, into }, + OpDoc::Connect { from, to } => Op::Connect { from, to }, + OpDoc::Expose { from, as_name } => Op::Expose { from, as_name }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The spec's worked-example document deserializes into the op vocabulary and + /// maps into engine `Op`s that replay into a compilable signal blueprint — + /// the DTO is a faithful front-end over the engine surface. + #[test] + fn opdoc_document_deserializes_and_replays() { + let doc = r#"[ + {"op":"source","role":"price","kind":"F64"}, + {"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}}, + {"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":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"} + ]"#; + let docs: Vec = serde_json::from_str(doc).expect("document parses"); + assert_eq!(docs.len(), 10); + assert_eq!(docs[1].kind_label(), "add"); + let ops: Vec = docs.into_iter().map(Op::from).collect(); + // both SMA lengths are bound, so the only open param is bias.scale (f64). + let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves"); + composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles"); + } +} +``` + +Add `mod graph_construct;` to `crates/aura-cli/src/main.rs` — place it with the +other `mod` declarations near the top (find them with `grep -n "^mod \|^use " +crates/aura-cli/src/main.rs | head`). + +- [ ] **Step 2: Run to verify it fails, then passes** + +Run: `cargo test -p aura-cli opdoc_document_deserializes_and_replays` +Expected: first FAIL (module/symbols absent or test red), then PASS after Step 1 +lands. (The single test in this task is created and satisfied by Step 1's code.) + +> If `compile_with_params` rejects on param arity, the bound-length assumption is +> wrong — print `replay(...).unwrap().param_space()` to read the real space and +> adjust the bound `params` vector (the spec's iteration-1 acceptance test +> confirmed bias carries one f64 `scale` param). + +### Task 10: `aura graph build` — replay a document, emit or fail fast + +**Files:** +- Modify: `crates/aura-cli/src/graph_construct.rs` (add `build_from_str`, + `format_op_error`, `build_cmd`) +- Modify: `crates/aura-cli/src/main.rs` (add the `["graph","build"]` arm) + +- [ ] **Step 1: Write the failing tests** + +Add to `graph_construct.rs`'s `mod tests`: + +```rust + #[test] + fn build_from_str_emits_blueprint_json_for_valid_document() { + let doc = r#"[ + {"op":"source","role":"price","kind":"F64"}, + {"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}}, + {"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":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"} + ]"#; + let json = super::build_from_str(doc).expect("valid document builds"); + assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}"); + assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}"); + } + + #[test] + fn build_from_str_reports_unknown_node_at_its_op_index() { + let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#; + let err = super::build_from_str(doc).unwrap_err(); + assert_eq!(err, "op 1 (add): unknown node type \"Nope\""); + } + + #[test] + fn build_from_str_reports_unconnected_slot_at_finalize() { + // sub.rhs left unwired -> the holistic 0-arm fires at the finalize step. + let doc = r#"[ + {"op":"source","role":"price","kind":"F64"}, + {"op":"add","type":"SMA","as":"fast"}, + {"op":"add","type":"SMA","as":"slow"}, + {"op":"add","type":"Sub"}, + {"op":"feed","role":"price","into":["fast.series","slow.series"]}, + {"op":"connect","from":"fast.value","to":"sub.lhs"} + ]"#; + let err = super::build_from_str(doc).unwrap_err(); + assert!(err.starts_with("finalize: "), "finalize fault, got: {err}"); + assert!(err.contains("Unconnected"), "names the unconnected slot, got: {err}"); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p aura-cli build_from_str_emits_blueprint_json_for_valid_document` +Expected: FAIL — compile error `cannot find function `build_from_str``. + +- [ ] **Step 3: Implement `build_from_str`, `format_op_error`, `build_cmd`** + +Add to `graph_construct.rs` (module scope, after the `OpDoc` impls): + +```rust +/// Phrase one `OpError` as a human cause (the engine error types are +/// `Display`-free by convention; this is the CLI's presentation layer). The +/// exhaustive match means a new `OpError` variant forces an update here. +fn format_op_error(e: &OpError) -> String { + match e { + OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"), + OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"), + OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"), + OpError::DuplicateRole(s) => format!("duplicate role {s:?}"), + OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"), + OpError::UnknownRole(s) => format!("unknown role {s:?}"), + OpError::MalformedPort(s) => format!("malformed port reference {s:?}"), + OpError::UnknownInPort { node, name } => format!("node {node:?} has no input port {name:?}"), + OpError::AmbiguousInPort { node, name } => format!("node {node:?} has ambiguous input port {name:?}"), + OpError::UnknownOutPort { node, name } => format!("node {node:?} has no output field {name:?}"), + OpError::AmbiguousOutPort { node, name } => format!("node {node:?} has ambiguous output field {name:?}"), + OpError::KindMismatch { from, to, producer, consumer } => { + format!("kind mismatch {from} -> {to} (producer {producer:?}, consumer {consumer:?})") + } + OpError::SlotAlreadyWired { node, slot } => format!("slot {node}.{slot} already wired"), + OpError::BadParam { node, err } => format!("bad param on {node}: {err:?}"), + OpError::Incomplete(ce) => format!("{ce:?}"), + } +} + +/// Parse a JSON op-list document, replay it through `std_vocabulary`, and return +/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op +/// fault, attributed by the retained op-kind list) / `finalize: cause` (a +/// holistic fault past the last op). +pub fn build_from_str(doc: &str) -> Result { + let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; + let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect(); + let ops: Vec = docs.into_iter().map(Op::from).collect(); + match replay("graph", ops, &std_vocabulary) { + Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")), + Err((idx, err)) => { + let cause = format_op_error(&err); + match labels.get(idx) { + Some(kind) => Err(format!("op {idx} ({kind}): {cause}")), + None => Err(format!("finalize: {cause}")), + } + } + } +} + +/// `aura graph build`: read the op-list document from stdin, build, and print the +/// blueprint to stdout — or the cause to stderr and exit non-zero. +pub fn build_cmd() { + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); + std::process::exit(2); + } + match build_from_str(&doc) { + Ok(json) => println!("{json}"), + Err(msg) => { + eprintln!("aura: {msg}"); + std::process::exit(2); + } + } +} +``` + +In `crates/aura-cli/src/main.rs`, add the arm to the argv `match` (right after +the bare `["graph"] => ...` arm at `:3313`): + +```rust + ["graph", "build"] => graph_construct::build_cmd(), +``` + +- [ ] **Step 4: Run the build tests** + +Run: `cargo test -p aura-cli build_from_str_emits_blueprint_json_for_valid_document` +Expected: PASS. + +Run: `cargo test -p aura-cli build_from_str_reports_unknown_node_at_its_op_index` +Expected: PASS. + +Run: `cargo test -p aura-cli build_from_str_reports_unconnected_slot_at_finalize` +Expected: PASS. + +### Task 11: `aura graph introspect` — vocabulary / node / unwired + +**Files:** +- Modify: `crates/aura-cli/src/graph_construct.rs` (add `introspect_node`, + `introspect_unwired`, `introspect_cmd`) +- Modify: `crates/aura-cli/src/main.rs` (add the `["graph","introspect", ..]` arm) + +- [ ] **Step 1: Write the failing tests** + +Add to `graph_construct.rs`'s `mod tests`: + +```rust + #[test] + fn introspect_node_lists_ports_kinds_and_params() { + let out = super::introspect_node("SMA").expect("SMA is in the vocabulary"); + assert!(out.contains("series"), "lists the input port: {out}"); + assert!(out.contains("value"), "lists the output field: {out}"); + assert!(out.contains("length"), "lists the param path: {out}"); + assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type"); + } + + #[test] + fn introspect_unwired_reports_open_slots_of_a_partial_document() { + let doc = r#"[ + {"op":"add","type":"SMA","as":"fast"}, + {"op":"add","type":"Sub"}, + {"op":"connect","from":"fast.value","to":"sub.lhs"} + ]"#; + let out = super::introspect_unwired(doc).expect("partial document introspects"); + assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}"); + assert!(out.contains("fast.series"), "fast.series is still open: {out}"); + assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}"); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p aura-cli introspect_node_lists_ports_kinds_and_params` +Expected: FAIL — compile error `cannot find function `introspect_node``. + +- [ ] **Step 3: Implement the introspection cores + `introspect_cmd`** + +Add to `graph_construct.rs`: + +```rust +/// `aura graph introspect --node `: a type's ports + kinds + param paths, +/// read off the pre-build schema (no graph built). `Err` if `T` is not in the +/// closed vocabulary. +pub fn introspect_node(type_id: &str) -> Result { + let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; + let schema = builder.schema(); + let mut out = format!("{}\n", builder.label()); + for port in &schema.inputs { + out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind)); + } + for field in &schema.output { + out.push_str(&format!(" out {}:{:?}\n", field.name, field.kind)); + } + for p in builder.params() { + out.push_str(&format!(" param {}:{:?}\n", p.name, p.kind)); + } + Ok(out) +} + +/// `aura graph introspect --unwired`: the still-open interior slots of a partial +/// op-list document, by-identifier (applies the ops, does NOT finalize). +pub fn introspect_unwired(doc: &str) -> Result { + let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; + let mut session = GraphSession::new("introspect", &std_vocabulary); + for (i, d) in docs.into_iter().enumerate() { + session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?; + } + let mut out = String::new(); + for (slot, kind) in session.unwired() { + out.push_str(&format!("{slot}:{kind:?}\n")); + } + Ok(out) +} + +/// `aura graph introspect`: dispatch the three read-only queries. +pub fn introspect_cmd(rest: &[&str]) { + match rest { + ["--vocabulary"] => { + for t in std_vocabulary_types() { + println!("{t}"); + } + } + ["--node", type_id] => match introspect_node(type_id) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + }, + ["--unwired"] => { + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); + std::process::exit(2); + } + match introspect_unwired(&doc) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + } + } + _ => { + eprintln!("aura: usage: aura graph introspect --vocabulary | --node | --unwired"); + std::process::exit(2); + } + } +} +``` + +In `crates/aura-cli/src/main.rs`, add the arm right after the +`["graph", "build"]` arm: + +```rust + ["graph", "introspect", rest @ ..] => graph_construct::introspect_cmd(rest), +``` + +- [ ] **Step 4: Run the introspection tests** + +Run: `cargo test -p aura-cli introspect_node_lists_ports_kinds_and_params` +Expected: PASS. + +Run: `cargo test -p aura-cli introspect_unwired_reports_open_slots_of_a_partial_document` +Expected: PASS. + +### Task 12: E2E — drive the `aura` binary end-to-end + +**Files:** +- Create: `crates/aura-cli/tests/graph_construct.rs` + +- [ ] **Step 1: Write the E2E integration test** + +Create `crates/aura-cli/tests/graph_construct.rs` (models the binary-driving +pattern of `crates/aura-cli/tests/cli_run.rs` + the stdin-piping of +`crates/aura-cli/tests/cli_broken_pipe.rs`): + +```rust +//! End-to-end coverage for the `aura graph build` / `aura graph introspect` +//! op-script CLI (#157, §C): drives the built `aura` binary over stdin/argv, +//! the exact surface a data-level author uses. + +use std::io::Write; +use std::process::{Command, Stdio}; + +const BIN: &str = env!("CARGO_BIN_EXE_aura"); + +/// Run `aura ` with `stdin_doc` piped in; return (stdout, stderr, success). +fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) { + let mut child = Command::new(BIN) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn aura"); + child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap(); + let out = child.wait_with_output().expect("wait aura"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.success(), + ) +} + +const SIGNAL_DOC: &str = r#"[ + {"op":"source","role":"price","kind":"F64"}, + {"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}}, + {"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":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"} +]"#; + +#[test] +fn graph_build_emits_blueprint_for_a_valid_document() { + let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC); + assert!(ok, "exit success"); + assert!(stdout.contains("\"format_version\":1"), "emits the #155 envelope: {stdout}"); + assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}"); +} + +#[test] +fn graph_build_fails_fast_at_the_offending_op() { + let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#; + let (stdout, stderr, ok) = run(&["graph", "build"], doc); + assert!(!ok, "non-zero exit"); + assert!(stdout.is_empty(), "no blueprint emitted on error: {stdout}"); + assert!(stderr.contains("op 1 (add)"), "names the failing op: {stderr}"); + assert!(stderr.contains("Nope"), "names the cause: {stderr}"); +} + +#[test] +fn graph_introspect_vocabulary_lists_the_node_types() { + let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], ""); + assert!(ok, "exit success"); + assert!(stdout.contains("SMA"), "lists SMA: {stdout}"); + assert!(stdout.contains("Bias"), "lists Bias: {stdout}"); + assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}"); +} + +#[test] +fn graph_introspect_node_answers_ports_without_a_build() { + let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], ""); + assert!(ok, "exit success"); + assert!(stdout.contains("series"), "lists the input port: {stdout}"); + assert!(stdout.contains("value"), "lists the output field: {stdout}"); +} + +#[test] +fn graph_introspect_unwired_reports_open_slots() { + let doc = r#"[ + {"op":"add","type":"SMA","as":"fast"}, + {"op":"add","type":"Sub"}, + {"op":"connect","from":"fast.value","to":"sub.lhs"} + ]"#; + let (stdout, _stderr, ok) = run(&["graph", "introspect", "--unwired"], doc); + assert!(ok, "exit success"); + assert!(stdout.contains("sub.rhs"), "sub.rhs still open: {stdout}"); + assert!(!stdout.contains("sub.lhs"), "sub.lhs is covered: {stdout}"); +} +``` + +- [ ] **Step 2: Run the E2E suite** + +Run: `cargo test -p aura-cli --test graph_construct` +Expected: PASS (5 tests). + +- [ ] **Step 3: Full workspace gates** + +Run: `cargo build --workspace` +Expected: clean build. + +Run: `cargo test --workspace` +Expected: all pass (iteration-1 tests + the new aura-cli tests; no regressions). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. + +## Self-review (iteration 2, planner Step 5) + +1. **Spec coverage:** §C maps to: DTO + serde encoding → Task 9; `aura graph + build` (replay → emit / fail-fast) → Task 10; `aura graph introspect` + (`--vocabulary` / `--node` / `--unwired`) → Task 11; E2E → Task 12. The spec's + three acceptance criteria are now demonstrated through the CLI (Task 12). ✓ +2. **Placeholder scan:** no "TBD"/"TODO"/"implement later"/"similar to"/"add + appropriate". ✓ +3. **Type consistency:** `OpDoc`, `build_from_str`, `format_op_error`, + `build_cmd`, `introspect_node`, `introspect_unwired`, `introspect_cmd`, + `graph_construct` — identical across the tasks and the `main.rs` arms. ✓ +4. **Step granularity:** each step is one test, one impl block, or one run. ✓ +5. **No commit steps:** none. ✓ +6. **Pin/replacement contiguity:** the E2E pins (`"op 1 (add)"`, + `"\"format_version\":1"`) are matched against the exact strings + `build_from_str` and `blueprint_to_json` emit — `op {idx} ({kind})` with + idx=1/kind="add", and the #155 envelope `blueprint_serde` already emits + (iteration-1 golden). No soft-wrap split. ✓ +7. **Compile-gate vs deferred-caller ordering:** each argv arm is added in the + same task as the handler fn it calls (Task 10 adds the `build` arm + `build_cmd`; + Task 11 adds the `introspect` arm + `introspect_cmd`), so every task's tree + compiles. No signature change to an existing fn (the engine is untouched; the + DTO + cores are net-new). ✓ +8. **Verification-filter strings resolve:** every Run filter names a test created + in this iteration (Tasks 9-12) — `opdoc_document_deserializes_and_replays`, + `build_from_str_*`, `introspect_*`, and the `--test graph_construct` target. + The full-workspace gate (Task 12) is unfiltered with a no-regression + expectation. The engine `Op`/`replay`/`blueprint_to_json` symbols the tests + import were all confirmed exported this session (commit `27ac4dc`). ✓