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#"[