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).
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# 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 `String`s), 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 `CompileError`s 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:
|
||||
```rust
|
||||
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):
|
||||
```rust
|
||||
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`)
|
||||
```rust
|
||||
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`)
|
||||
```rust
|
||||
// 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`)
|
||||
```rust
|
||||
// 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 `CompileError`s to them. No other engine change;
|
||||
the holistic gate functions are untouched.
|
||||
- `crates/aura-cli/src/graph_construct.rs` — `format_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.
|
||||
Reference in New Issue
Block a user