feat(0089): construction diagnostics read by-identifier (closes #162)

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": <v>})`.

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.
This commit is contained in:
2026-06-30 11:49:03 +02:00
parent c6c181f715
commit f1ab81d1b8
4 changed files with 185 additions and 18 deletions
+54 -4
View File
@@ -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<String, String> {
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 {{\"{:?}\": <v>}})\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": <v>}` 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\": <v>})"), "shows the bind-value form: {out}");
}
#[test]
fn introspect_unwired_reports_open_slots_of_a_partial_document() {
let doc = r#"[
+62 -1
View File
@@ -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 `{"<Kind>": <v>}` 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\": <v>})"),
"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}");
}
+64 -6
View File
@@ -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<Composite, OpError> {
let roles: Vec<Role> = 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]
+5 -7
View File
@@ -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:?}");
}