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
+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}");
}