Files
Aura/docs/specs/0089-construction-diagnostics-polish.md
T
Brummel 365f112451 spec: 0089 construction diagnostics polish (boss-signed) (refs #162)
Three presentation-only defects the cycle-0088 fieldtest found on the
construction op-script surface: the holistic finalize fault leaks raw machine
indices (UnconnectedPort { node: 2, slot: 1 }) where the by-identifier mapping
demonstrably exists; the bind-kind mismatch leaks a Debug struct; the typed-Scalar
bind form is invisible from introspect --node. Boss-signed on a grounding-check
PASS (every load-bearing assumption ratified by a currently-green test or the
defining code).
2026-06-30 11:00:48 +02:00

9.9 KiB

Construction diagnostics polish (#162) — Design Spec

Date: 2026-06-30 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Make the construction op-script surface (#157) read consistently by-identifier and as prose at the three edges where it currently leaks raw machine indices or #[derive(Debug)] structs. The cycle-0088 fieldtest (fieldtests/cycle-0088-construction-op-script/FINDINGS.md) confirmed the happy path, --unwired, and the eager per-op diagnostics already read well; these three defects are the remaining inconsistencies. All three are presentation only — every fault is already detected correctly and exits non-zero; this spec changes what the message says, never whether the fault fires.

Architecture

The construction surface keeps its two-layer split: the engine (construction.rs) carries faults as OpError data (by-identifier Strings), and the CLI (graph_construct.rs::format_op_error) is a thin presenter that renders that data — the engine error types are Display-free by convention. Every OpError variant already carries by-identifier data except the holistic-finalize arm Incomplete(CompileError), which wraps the engine's index-based compile error and is rendered with {:?}. The fix preserves the split:

  • Item 1 translates the index-carrying finalize CompileErrors into new by-identifier OpError variants inside GraphSession::finish() (the layer that owns the id↔name map), leaving the holistic gates (validate_wiring, check_param_namespace_injective, check_root_roles_bound) byte-for-byte unchanged. The CLI gains presenter arms for the new variants.
  • Item 2 & 3 are CLI-presenter-only: format_op_error's BadParam arm and introspect_node's param line.

Concrete code shapes

Worked author example — before → after (the acceptance evidence)

Driven through the built CLI on the fieldtest's own fixtures (fieldtests/cycle-0088-construction-op-script/):

Item 1 — open interior slot (c0088_2_partial.json, sub.rhs left unwired):

# before
$ aura graph build < c0088_2_partial.json
aura: finalize: UnconnectedPort { node: 2, slot: 1 }
# after
aura: finalize: slot sub.rhs is unconnected

(introspect --unwired already reports the same slot as sub.rhs:F64; the build finalize fault now agrees instead of forcing the author to count nodes.)

Item 2 — bad bind kind (c0088_3g_bad_bind_kind.json):

# before
aura: op 1 (add): bad param on fast: KindMismatch { param: "length", expected: I64, got: F64 }
# after
aura: op 1 (add): param fast.length expects I64 but got F64

(reads like the parallel connect mismatch kind mismatch g.value -> fast.series (producer Bool, consumer F64).)

Item 3 — discover the bind form:

# before
$ aura graph introspect --node SMA
SMA
  in   series:F64
  out  value:F64
  param length:I64
# after
SMA
  in   series:F64
  out  value:F64
  param length:I64  (bind {"I64": <v>})

(the naive "bind":{"length":2} no longer needs source-reading to repair — the wrapped-Scalar form is shown where the param is declared.)

Item 1 — engine: by-identifier finalize variants

New OpError variants (construction.rs), carrying String ids like every sibling variant:

pub enum OpError {
    // … existing …
    /// Holistic totality fault, by-identifier: an interior input slot covered by
    /// no edge or role target.
    UnconnectedPort { node: String, slot: String },
    /// Holistic role-kind fault, by-identifier: a 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 },
    // Incomplete(CompileError) stays — see "stays Incomplete" below.
}

finish() translates the three index-carrying, reachable holistic faults using the session's still-owned ids / schemas and the composite's input_roles() (the holistic gate calls themselves are unchanged):

pub fn finish(self) -> Result<Composite, OpError> {
    let roles: Vec<Role> = self.roles.into_iter()
        .map(|(name, source, targets)| Role { name, targets, source }).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)?;                       // DuplicateParamPath: already by-identifier
    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),              // internal/unreachable-on-valid-replay: BadInteriorIndex/OutputPortOutOfRange/Bootstrap/DoubleWiredPort
        })?;
    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)
}

Stays Incomplete(CompileError) (stated for the validity floor): DuplicateParamPath(String) already carries a by-identifier path; DoubleWiredPort is caught eagerly by cover()/feed (SlotAlreadyWired) so it is unreachable at finish; BadInteriorIndex / OutputPortOutOfRange / Bootstrap are construction-internal inconsistencies a validly-replayed op-list does not produce and carry no per-node identifier. After this change, every finalize fault a hand-author can actually trigger reads by-identifier.

Item 1 — CLI presenter arms (graph_construct.rs format_op_error)

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"),

(build_from_str already prefixes a finalize fault with finalize: , so the rendered line is finalize: slot sub.rhs is unconnected.)

Item 2 — CLI: bind mismatch as prose (graph_construct.rs format_op_error)

// before
OpError::BadParam { node, err } => format!("bad param on {node}: {err:?}"),
// after — match the BindOpError variants, phrased like the connect mismatch
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:?}"),
},

(BindOpError is imported alongside the existing OpError import.)

Item 3 — CLI: show the bind form (graph_construct.rs introspect_node)

// before
out.push_str(&format!("  param {}:{:?}\n", p.name, p.kind));
// after
out.push_str(&format!("  param {}:{:?}  (bind {{\"{:?}\": <v>}})\n", p.name, p.kind, p.kind));

Components

  • crates/aura-engine/src/construction.rs — three new OpError variants; finish() maps the reachable index-carrying CompileErrors to them. No other engine change; the holistic gate functions are untouched.
  • crates/aura-cli/src/graph_construct.rsformat_op_error gains the three item-1 arms and rewrites the BadParam arm (item 2); introspect_node appends the bind-form hint (item 3); imports BindOpError.

Data flow

Unchanged. A finalize fault still flows finish() -> Err(OpError) -> replay() Err((ops.len(), OpError)) -> build_from_str "finalize: {cause}" -> stderr, exit 2. Only the OpError value (now by-identifier) and its rendered {cause} differ.

Error handling

This spec is error-message handling. No new failure path is introduced and no existing fault is suppressed or downgraded: every input that errored before still errors with the same exit code and the same op/finalize attribution — only the human-readable cause changes from raw-index/Debug to by-identifier/prose.

Testing strategy

  • Engine (construction.rs): update finish_reports_incomplete_on_unwired_slot to assert the new OpError::UnconnectedPort { node, slot } with node == "sub", slot == "rhs" (was matches!(.., Incomplete(CompileError::UnconnectedPort{..}))). Add a test that an unbound Input root role finishes with OpError::UnboundRootRole { role } naming the role.
  • CLI (graph_construct.rs): update build_from_str_reports_unconnected_slot_at_finalize to assert the message starts_with("finalize: "), contains("sub.rhs"), and does NOT contain "node:" (no raw index). Add: a bad-bind-kind doc builds-errs with param fast.length expects I64 but got F64 (item 2); introspect_node("SMA") output contains (bind {"I64": <v>}) (item 3).
  • Full gate: cargo test --workspace, cargo build --workspace, cargo clippy --workspace --all-targets -- -D warnings.

Note: std_vocabulary lives in aura-std, a test-only dev-dependency of aura-engine, so the engine-side construction tests drive it the way the existing ones already do.

Acceptance criteria

  • Each of the three before→after worked examples reproduces exactly against the built CLI on the named fixtures.
  • No finalize fault reachable on a well-formed-but-incomplete authored op-list renders a raw node:/slot:/role: index or a KindMismatch { … } Debug struct; the bind form is visible from introspect --node.
  • The faults still fire identically (same exit code, same op/finalize index); only the rendered cause changes. Full workspace suite + clippy green.