diff --git a/docs/specs/0089-construction-diagnostics-polish.md b/docs/specs/0089-construction-diagnostics-polish.md new file mode 100644 index 0000000..14ebd25 --- /dev/null +++ b/docs/specs/0089-construction-diagnostics-polish.md @@ -0,0 +1,220 @@ +# Construction diagnostics polish (#162) — Design Spec + +**Date:** 2026-06-30 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Make the construction op-script surface (#157) read consistently **by-identifier** +and as **prose** at the three edges where it currently leaks raw machine indices +or `#[derive(Debug)]` structs. The cycle-0088 fieldtest +(`fieldtests/cycle-0088-construction-op-script/FINDINGS.md`) confirmed the happy +path, `--unwired`, and the eager per-op diagnostics already read well; these three +defects are the remaining inconsistencies. All three are **presentation only** — +every fault is already detected correctly and exits non-zero; this spec changes +*what the message says*, never *whether the fault fires*. + +## Architecture + +The construction surface keeps its two-layer split: the engine (`construction.rs`) +carries faults as `OpError` *data* (by-identifier `String`s), and the CLI +(`graph_construct.rs::format_op_error`) is a thin presenter that renders that data +— the engine error types are `Display`-free by convention. Every `OpError` variant +already carries by-identifier data **except** the holistic-finalize arm +`Incomplete(CompileError)`, which wraps the engine's index-based compile error and +is rendered with `{:?}`. The fix preserves the split: + +- **Item 1** translates the index-carrying finalize `CompileError`s into new + by-identifier `OpError` variants **inside `GraphSession::finish()`** (the layer + that owns the id↔name map), leaving the holistic gates (`validate_wiring`, + `check_param_namespace_injective`, `check_root_roles_bound`) byte-for-byte + unchanged. The CLI gains presenter arms for the new variants. +- **Item 2 & 3** are CLI-presenter-only: `format_op_error`'s `BadParam` arm and + `introspect_node`'s param line. + +## Concrete code shapes + +### Worked author example — before → after (the acceptance evidence) + +Driven through the built CLI on the fieldtest's own fixtures +(`fieldtests/cycle-0088-construction-op-script/`): + +**Item 1 — open interior slot (`c0088_2_partial.json`, `sub.rhs` left unwired):** +``` +# before +$ aura graph build < c0088_2_partial.json +aura: finalize: UnconnectedPort { node: 2, slot: 1 } +# after +aura: finalize: slot sub.rhs is unconnected +``` +(`introspect --unwired` already reports the same slot as `sub.rhs:F64`; the build +finalize fault now agrees instead of forcing the author to count nodes.) + +**Item 2 — bad bind kind (`c0088_3g_bad_bind_kind.json`):** +``` +# before +aura: op 1 (add): bad param on fast: KindMismatch { param: "length", expected: I64, got: F64 } +# after +aura: op 1 (add): param fast.length expects I64 but got F64 +``` +(reads like the parallel connect mismatch `kind mismatch g.value -> fast.series +(producer Bool, consumer F64)`.) + +**Item 3 — discover the bind form:** +``` +# before +$ aura graph introspect --node SMA +SMA + in series:F64 + out value:F64 + param length:I64 +# after +SMA + in series:F64 + out value:F64 + param length:I64 (bind {"I64": }) +``` +(the naive `"bind":{"length":2}` no longer needs source-reading to repair — +the wrapped-Scalar form is shown where the param is declared.) + +### Item 1 — engine: by-identifier finalize variants + +New `OpError` variants (construction.rs), carrying `String` ids like every +sibling variant: +```rust +pub enum OpError { + // … existing … + /// Holistic totality fault, by-identifier: an interior input slot covered by + /// no edge or role target. + UnconnectedPort { node: String, slot: String }, + /// Holistic role-kind fault, by-identifier: a role fans into slots of + /// differing scalar kinds. + RoleKindMismatch { role: String }, + /// Holistic root fault, by-identifier: a root input role has no bound source. + UnboundRootRole { role: String }, + // Incomplete(CompileError) stays — see "stays Incomplete" below. +} +``` + +`finish()` translates the three index-carrying, reachable holistic faults using +the session's still-owned `ids` / `schemas` and the composite's `input_roles()` +(the holistic gate calls themselves are unchanged): +```rust +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)?; // DuplicateParamPath: already by-identifier + validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()) + .map_err(|e| match e { + CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort { + node: self.ids[node].clone(), + slot: self.schemas[node].inputs[slot].name.clone(), + }, + CompileError::RoleKindMismatch { role } => OpError::RoleKindMismatch { + role: c.input_roles()[role].name.clone(), + }, + other => OpError::Incomplete(other), // internal/unreachable-on-valid-replay: BadInteriorIndex/OutputPortOutOfRange/Bootstrap/DoubleWiredPort + })?; + check_root_roles_bound(c.input_roles()) + .map_err(|e| match e { + CompileError::UnboundRootRole { role } => OpError::UnboundRootRole { + role: c.input_roles()[role].name.clone(), + }, + other => OpError::Incomplete(other), + })?; + Ok(c) +} +``` + +**Stays `Incomplete(CompileError)` (stated for the validity floor):** +`DuplicateParamPath(String)` already carries a by-identifier path; `DoubleWiredPort` +is caught eagerly by `cover()`/`feed` (`SlotAlreadyWired`) so it is unreachable at +finish; `BadInteriorIndex` / `OutputPortOutOfRange` / `Bootstrap` are +construction-internal inconsistencies a validly-replayed op-list does not produce +and carry no per-node identifier. After this change, every finalize fault a +hand-author can actually trigger reads by-identifier. + +### Item 1 — CLI presenter arms (graph_construct.rs `format_op_error`) +```rust +OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"), +OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"), +OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"), +``` +(`build_from_str` already prefixes a finalize fault with `finalize: `, so the +rendered line is `finalize: slot sub.rhs is unconnected`.) + +### Item 2 — CLI: bind mismatch as prose (graph_construct.rs `format_op_error`) +```rust +// before +OpError::BadParam { node, err } => format!("bad param on {node}: {err:?}"), +// after — match the BindOpError variants, phrased like the connect mismatch +OpError::BadParam { node, err } => match err { + BindOpError::UnknownParam(p) => format!("node {node} has no param {p:?}"), + BindOpError::AmbiguousParam(p) => format!("node {node} has ambiguous param {p:?}"), + BindOpError::KindMismatch { param, expected, got } => + format!("param {node}.{param} expects {expected:?} but got {got:?}"), +}, +``` +(`BindOpError` is imported alongside the existing `OpError` import.) + +### Item 3 — CLI: show the bind form (graph_construct.rs `introspect_node`) +```rust +// before +out.push_str(&format!(" param {}:{:?}\n", p.name, p.kind)); +// after +out.push_str(&format!(" param {}:{:?} (bind {{\"{:?}\": }})\n", p.name, p.kind, p.kind)); +``` + +## Components + +- `crates/aura-engine/src/construction.rs` — three new `OpError` variants; `finish()` + maps the reachable index-carrying `CompileError`s to them. No other engine change; + the holistic gate functions are untouched. +- `crates/aura-cli/src/graph_construct.rs` — `format_op_error` gains the three + item-1 arms and rewrites the `BadParam` arm (item 2); `introspect_node` appends + the bind-form hint (item 3); imports `BindOpError`. + +## Data flow + +Unchanged. A finalize fault still flows `finish() -> Err(OpError) -> replay() +Err((ops.len(), OpError)) -> build_from_str "finalize: {cause}" -> stderr, exit 2`. +Only the `OpError` value (now by-identifier) and its rendered `{cause}` differ. + +## Error handling + +This spec *is* error-message handling. No new failure path is introduced and no +existing fault is suppressed or downgraded: every input that errored before still +errors with the same exit code and the same op/finalize attribution — only the +human-readable cause changes from raw-index/Debug to by-identifier/prose. + +## Testing strategy + +- **Engine (construction.rs):** update `finish_reports_incomplete_on_unwired_slot` + to assert the new `OpError::UnconnectedPort { node, slot }` with `node == "sub"`, + `slot == "rhs"` (was `matches!(.., Incomplete(CompileError::UnconnectedPort{..}))`). + Add a test that an unbound `Input` root role finishes with + `OpError::UnboundRootRole { role }` naming the role. +- **CLI (graph_construct.rs):** update `build_from_str_reports_unconnected_slot_at_finalize` + to assert the message `starts_with("finalize: ")`, `contains("sub.rhs")`, and does + NOT contain `"node:"` (no raw index). Add: a bad-bind-kind doc builds-errs with + `param fast.length expects I64 but got F64` (item 2); `introspect_node("SMA")` + output contains `(bind {"I64": })` (item 3). +- Full gate: `cargo test --workspace`, `cargo build --workspace`, + `cargo clippy --workspace --all-targets -- -D warnings`. + +Note: `std_vocabulary` lives in `aura-std`, a test-only dev-dependency of +`aura-engine`, so the engine-side construction tests drive it the way the existing +ones already do. + +## Acceptance criteria + +- Each of the three before→after worked examples reproduces exactly against the + built CLI on the named fixtures. +- No finalize fault reachable on a well-formed-but-incomplete authored op-list + renders a raw `node:`/`slot:`/`role:` index or a `KindMismatch { … }` Debug + struct; the bind form is visible from `introspect --node`. +- The faults still fire identically (same exit code, same op/finalize index); only + the rendered cause changes. Full workspace suite + clippy green.