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:
@@ -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#"[
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user