From f1ab81d1b8b9b351faeac9714dd4d69735b241fc Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 30 Jun 2026 11:49:03 +0200 Subject: [PATCH] feat(0089): construction diagnostics read by-identifier (closes #162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle-0088 fieldtest found three presentation defects on the construction op-script surface, all where the surface dropped out of its by-identifier register. Presentation only — every fault still fires identically (same exit code, same op/finalize attribution); only the rendered cause changes. - Item 1 (finalize by-identifier). The holistic finalize fault leaked the raw index Debug form (`UnconnectedPort { node: 2, slot: 1 }`). `GraphSession::finish()` now translates the three reachable index-carrying CompileErrors into new by-identifier OpError variants (UnconnectedPort{node,slot} / RoleKindMismatch{role} / UnboundRootRole{role}) using the session's `ids`/`schemas` and the composite's `input_roles()` — the holistic gate calls (validate_wiring, check_param_namespace_injective, check_root_roles_bound) stay byte-for-byte unchanged (C24: no second validator; only the `.map_err` closures changed). `aura graph build` now prints `finalize: slot sub.rhs is unconnected`. - Item 2 (bind mismatch prose). `format_op_error`'s BadParam arm matches the BindOpError variants instead of `{:?}`: `op 1 (add): param fast.length expects I64 but got F64`, matching the connect mismatch register. - Item 3 (discoverable bind form). `introspect --node` shows the typed-Scalar bind form: `param length:I64 (bind {"I64": })`. Item 1 spans both crates atomically (adding OpError variants forces the exhaustive format_op_error match; the finish() translation flips behavioural test pins in both crates). Five test touches: the two in-file unit tests, the two E2E twins plan-recon found that the spec's testing strategy under-enumerated (construction_e2e.rs, tests/graph_construct.rs), and a new unbound-root-role test; the implementer added RoleKindMismatch / Ambiguous/UnknownParam coverage too. The CLI finalize test was tightened (orchestrator) to pin `sub.rhs` and the absence of a raw `node:` index. Spec boss-signed on a grounding-check PASS (docs/specs+plans/0089). Verified: full workspace suite + clippy green; the three messages reproduce exactly against the built binary on the fieldtest fixtures. --- crates/aura-cli/src/graph_construct.rs | 58 ++++++++++++++-- crates/aura-cli/tests/graph_construct.rs | 63 +++++++++++++++++- crates/aura-engine/src/construction.rs | 70 ++++++++++++++++++-- crates/aura-engine/tests/construction_e2e.rs | 12 ++-- 4 files changed, 185 insertions(+), 18 deletions(-) diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index add340b..7ee9593 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; -use aura_engine::{blueprint_to_json, replay, GraphSession, Op, OpError, Scalar, ScalarKind}; +use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind}; use aura_std::{std_vocabulary, std_vocabulary_types}; use serde::Deserialize; @@ -86,7 +86,16 @@ fn format_op_error(e: &OpError) -> String { } OpError::SlotAlreadyWired { node, slot } => format!("slot {node}.{slot} already wired"), OpError::WouldCycle { from, to } => format!("connecting {from} -> {to} would close a cycle"), - OpError::BadParam { node, err } => format!("bad param on {node}: {err:?}"), + 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:?}") + } + }, + 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"), OpError::Incomplete(ce) => format!("{ce:?}"), } } @@ -143,7 +152,7 @@ pub fn introspect_node(type_id: &str) -> Result { 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)); + out.push_str(&format!(" param {}:{:?} (bind {{\"{:?}\": }})\n", p.name, p.kind, p.kind)); } Ok(out) } @@ -269,7 +278,9 @@ mod tests { ]"#; 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}"); + assert!(err.contains("sub.rhs") && err.contains("is unconnected"), + "names the unconnected slot by-identifier, got: {err}"); + assert!(!err.contains("node:"), "no raw machine index in the message, got: {err}"); } #[test] @@ -281,6 +292,45 @@ mod tests { assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type"); } + /// A bind whose value-kind mismatches the param reads as prose, like the connect + /// mismatch — not a Debug struct. + #[test] + fn build_from_str_bad_bind_kind_reads_as_prose() { + let doc = r#"[{"op":"add","type":"SMA","as":"fast","bind":{"length":{"F64":2.0}}}]"#; + let err = super::build_from_str(doc).unwrap_err(); + assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64"); + } + + /// A bind to a param the node does not declare reads as prose naming the node + /// and the unknown param — not a Debug struct. + #[test] + fn build_from_str_unknown_bind_param_reads_as_prose() { + let doc = r#"[{"op":"add","type":"SMA","as":"fast","bind":{"window":{"I64":2}}}]"#; + let err = super::build_from_str(doc).unwrap_err(); + assert_eq!(err, "op 0 (add): node fast has no param \"window\""); + } + + /// The ambiguous-param branch of the bind-error presenter is phrased as prose + /// naming the node and the ambiguous param. No std primitive carries two + /// same-named params, so the branch is unreachable end-to-end and is pinned on + /// the presenter directly. + #[test] + fn ambiguous_bind_param_reads_as_prose() { + let msg = format_op_error(&OpError::BadParam { + node: "fast".to_string(), + err: BindOpError::AmbiguousParam("length".to_string()), + }); + assert_eq!(msg, "node fast has ambiguous param \"length\""); + } + + /// `introspect --node` shows the typed-Scalar bind-value form so a hand-author + /// does not have to read source to learn the `{"I64": }` wrapping. + #[test] + fn introspect_node_shows_the_bind_form() { + let out = super::introspect_node("SMA").expect("SMA is in the vocabulary"); + assert!(out.contains("(bind {\"I64\": })"), "shows the bind-value form: {out}"); + } + #[test] fn introspect_unwired_reports_open_slots_of_a_partial_document() { let doc = r#"[ diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index f6f7f68..d88a710 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -111,7 +111,8 @@ fn graph_build_reports_a_holistic_fault_at_finalize() { assert!(!ok, "non-zero exit on a finalize fault"); assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}"); assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}"); - assert!(stderr.contains("Unconnected"), "names the unconnected slot: {stderr}"); + assert!(stderr.contains("finalize: slot") && stderr.contains("is unconnected"), + "the holistic fault renders by-identifier, got: {stderr}"); } /// Property (determinism, domain invariant 1, at the CLI seam): the same op-list @@ -140,3 +141,63 @@ fn graph_introspect_node_rejects_an_unknown_type() { assert!(stdout.is_empty(), "no partial port listing emitted: {stdout}"); assert!(stderr.contains("Bogus"), "names the offending type: {stderr}"); } + +/// Property (#162 item 2, bind-mismatch prose at the binary seam): an `aura graph +/// build` whose bind value-kind disagrees with the param's declared kind exits +/// non-zero and writes the cause as prose to stderr — naming the param path and +/// both the expected and actual kind — and never leaks the raw `KindMismatch` +/// Debug struct the pre-#162 `{err:?}` presenter exposed. SMA's `length` is i64, +/// so binding an `F64` is the smallest kind disagreement. Pins the user-facing +/// bind-error ergonomics at the binary, not merely the in-process presenter. +#[test] +fn graph_build_renders_a_bind_kind_mismatch_as_prose() { + let doc = r#"[{"op":"add","type":"SMA","as":"fast","bind":{"length":{"F64":2.0}}}]"#; + let (stdout, stderr, ok) = run(&["graph", "build"], doc); + assert!(!ok, "non-zero exit on a bad bind"); + assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}"); + assert!(stderr.contains("param fast.length expects I64 but got F64"), + "phrases the bind mismatch as prose: {stderr}"); + assert!(!stderr.contains("KindMismatch"), + "does not leak the Debug struct name: {stderr}"); +} + +/// Property (#162 item 3, discoverable bind form at the binary seam): `aura graph +/// introspect --node` annotates each param with its typed-Scalar bind wrapper, so +/// a hand-author learns the `{"": }` value form from the CLI alone +/// without reading source. SMA's `length` is i64, so the shown form is the `I64` +/// wrapper. +#[test] +fn graph_introspect_node_shows_the_typed_bind_form() { + let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], ""); + assert!(ok, "exit success"); + assert!(stdout.contains("(bind {\"I64\": })"), + "shows the typed-Scalar bind-value form for the i64 param: {stdout}"); +} + +/// Property (#162 item 1, by-identifier finalize fault at the binary seam, a +/// non-unconnected variant): a fault decidable only at finalize that is NOT a +/// 0-covered slot — here one source role fanning into two slots of differing +/// scalar kind (an `f64` `Sub.lhs` and a `bool` `And.a`); `feed` runs no eager +/// kind check, so the disagreement surfaces only at the holistic finalize gate — +/// renders by *identifier*: `aura graph build` exits non-zero, routes a +/// `finalize:` fault to stderr, and names the offending role ("price") in prose, +/// never a raw machine role index. The companion unconnected-slot test pins one +/// finalize variant; this pins that the by-identifier translation generalises +/// across finalize variants. +#[test] +fn graph_build_renders_role_kind_mismatch_at_finalize_by_identifier() { + let doc = r#"[ + {"op":"source","role":"price","kind":"F64"}, + {"op":"add","type":"Sub","as":"sub"}, + {"op":"add","type":"And","as":"conj"}, + {"op":"feed","role":"price","into":["sub.lhs","conj.a"]} + ]"#; + let (stdout, stderr, ok) = run(&["graph", "build"], doc); + assert!(!ok, "non-zero exit on a finalize fault"); + assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}"); + assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}"); + assert!(stderr.contains("input role price fans into slots of differing kinds"), + "names the offending role by-identifier in prose: {stderr}"); + assert!(!stderr.contains("RoleKindMismatch"), + "does not leak the Debug variant name: {stderr}"); +} diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index c6f2541..96e071b 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -65,6 +65,14 @@ pub enum OpError { /// blueprint non-acyclic (domain invariant 5 / ledger C9). WouldCycle { from: String, to: String }, BadParam { node: String, err: BindOpError }, + /// Holistic totality fault, by-identifier: an interior input slot covered by + /// no edge or role target (the finalize 0-cover arm). + UnconnectedPort { node: String, slot: String }, + /// Holistic role-kind fault, by-identifier: a root input 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 }, /// A holistic finalize fault (totality / injectivity / unbound root role), /// wrapping the unchanged engine gate's `CompileError`. Incomplete(CompileError), @@ -301,8 +309,11 @@ impl<'v> GraphSession<'v> { /// 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`. + /// predicates the eager path shares, now at end-of-document cadence. The + /// index-carrying `CompileError`s the gates return are translated back to the + /// surface's by-identifier `OpError` variants (slot/role names via the session's + /// retained `ids`/`schemas` and the composite's roles); a fault with no + /// by-identifier mapping falls through to `OpError::Incomplete`. pub fn finish(self) -> Result { let roles: Vec = self .roles @@ -311,8 +322,22 @@ impl<'v> GraphSession<'v> { .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)?; - check_root_roles_bound(c.input_roles()).map_err(OpError::Incomplete)?; + 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), + })?; + 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) } } @@ -335,7 +360,7 @@ pub fn replay( #[cfg(test)] mod tests { - use super::{CompileError, GraphSession, Op, OpError}; + use super::{GraphSession, Op, OpError}; use aura_core::{BindOpError, Scalar, ScalarKind}; use aura_std::std_vocabulary; @@ -547,7 +572,40 @@ mod tests { // (`.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` // holds build closures and is not `Debug`, which `unwrap_err` would require.) let err = s.finish().err().unwrap(); - assert!(matches!(err, OpError::Incomplete(CompileError::UnconnectedPort { .. }))); + assert!(matches!(&err, OpError::UnconnectedPort { node, slot } if node == "sub" && slot == "rhs"), + "finalize names the open slot by-identifier, got {err:?}"); + } + + /// A reserved `Input` root role that is fed but never source-bound finishes with + /// the by-identifier `UnboundRootRole` (not a raw role index). + #[test] + fn finish_reports_unbound_input_root_role_by_identifier() { + let mut s = session("g"); + s.apply(Op::Input { role: "ext".into() }).unwrap(); + s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap(); + s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap(); + s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap(); + let err = s.finish().err().unwrap(); + assert!(matches!(&err, OpError::UnboundRootRole { role } if role == "ext"), + "an unbound Input root role finishes by-identifier, got {err:?}"); + } + + /// A root role fed into two slots of differing scalar kinds (an `f64` `Sub.lhs` + /// and a `bool` `And.a`) finishes with the by-identifier `RoleKindMismatch` + /// naming the role (not a raw role index). `feed` runs no eager kind check, so + /// this fault surfaces only at finalize and must be translated by-identifier + /// there (the role-index -> name mapping `c.input_roles()[role].name`). + #[test] + fn finish_reports_role_kind_mismatch_by_identifier() { + let mut s = session("g"); + s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap(); + s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap(); + s.apply(Op::Add { type_id: "And".into(), as_name: Some("conj".into()), bind: vec![] }).unwrap(); + // price fans into sub.lhs (f64) AND conj.a (bool): differing slot kinds. + s.apply(Op::Feed { role: "price".into(), into: vec!["sub.lhs".into(), "conj.a".into()] }).unwrap(); + let err = s.finish().err().unwrap(); + assert!(matches!(&err, OpError::RoleKindMismatch { role } if role == "price"), + "a role fanning into differing-kind slots finishes by-identifier, got {err:?}"); } #[test] diff --git a/crates/aura-engine/tests/construction_e2e.rs b/crates/aura-engine/tests/construction_e2e.rs index b808f09..4af79a6 100644 --- a/crates/aura-engine/tests/construction_e2e.rs +++ b/crates/aura-engine/tests/construction_e2e.rs @@ -14,7 +14,7 @@ //! every in-crate test green; that is the regression class this file pins, in //! addition to the per-property invariants below. -use aura_engine::{replay, CompileError, GraphBuilder, Op, OpError, Scalar, ScalarKind}; +use aura_engine::{replay, GraphBuilder, Op, OpError, Scalar, ScalarKind}; use aura_std::{std_vocabulary, Bias, Sma, Sub}; /// The flat root's open param point: fast.length(i64), slow.length(i64), @@ -110,8 +110,8 @@ fn replay_attributes_eager_fault_to_its_op_index() { /// exactly-once wiring) — passes every per-op check and is caught by the implicit /// finalize step, attributed to index `ops.len()` (6 here), strictly past any real /// op index. Every op below applies cleanly; `sub.rhs` is left unwired, so only the -/// holistic `validate_wiring` gate at finalize can reject it, as -/// `OpError::Incomplete(CompileError::UnconnectedPort { .. })`. Together with the +/// holistic `validate_wiring` gate at finalize can reject it, as the by-identifier +/// `OpError::UnconnectedPort { .. }`. Together with the /// eager-cadence test, this pins the iteration's eager/holistic gate split as /// observable error attribution across the crate boundary. #[test] @@ -127,8 +127,6 @@ fn replay_attributes_holistic_fault_to_finalize_step() { ]; let finalize_index = ops.len(); // 6 — the implicit finalize step let err = replay("g", ops, &std_vocabulary).err().unwrap(); - assert!( - matches!(err, (i, OpError::Incomplete(CompileError::UnconnectedPort { .. })) if i == finalize_index), - "an only-at-end-decidable fault must be attributed to the finalize step, got {err:?}", - ); + assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index), + "the holistic fault is still attributed to the finalize step, got {err:?}"); }