Files
Aura/crates/aura-cli/src/graph_construct.rs
T
Brummel f1ab81d1b8 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.
2026-06-30 11:49:03 +02:00

347 lines
15 KiB
Rust

//! The CLI-side serde front-end for the construction op-script (#157, §C): a
//! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op`
//! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph
//! build` / `aura graph introspect` subcommands here drive these ops through the
//! injected `aura_std::std_vocabulary`.
use std::collections::BTreeMap;
use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind};
use aura_std::{std_vocabulary, std_vocabulary_types};
use serde::Deserialize;
/// The wire DTO for one construction op — the document's by-identifier shape,
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
#[derive(Debug, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
enum OpDoc {
Source { role: String, kind: ScalarKind },
Input { role: String },
Add {
#[serde(rename = "type")]
type_id: String,
#[serde(rename = "as", default)]
as_name: Option<String>,
#[serde(default)]
bind: BTreeMap<String, Scalar>,
},
Feed { role: String, into: Vec<String> },
Connect { from: String, to: String },
Expose {
from: String,
#[serde(rename = "as")]
as_name: String,
},
}
impl OpDoc {
/// The op-kind label for the `op N (kind): cause` message.
fn kind_label(&self) -> &'static str {
match self {
OpDoc::Source { .. } => "source",
OpDoc::Input { .. } => "input",
OpDoc::Add { .. } => "add",
OpDoc::Feed { .. } => "feed",
OpDoc::Connect { .. } => "connect",
OpDoc::Expose { .. } => "expose",
}
}
}
impl From<OpDoc> for Op {
fn from(d: OpDoc) -> Op {
match d {
OpDoc::Source { role, kind } => Op::Source { role, kind },
OpDoc::Input { role } => Op::Input { role },
OpDoc::Add { type_id, as_name, bind } => {
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
}
OpDoc::Feed { role, into } => Op::Feed { role, into },
OpDoc::Connect { from, to } => Op::Connect { from, to },
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
}
}
}
/// Phrase one `OpError` as a human cause (the engine error types are
/// `Display`-free by convention; this is the CLI's presentation layer). The
/// exhaustive match means a new `OpError` variant forces an update here.
fn format_op_error(e: &OpError) -> String {
match e {
OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"),
OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"),
OpError::DuplicateRole(s) => format!("duplicate role {s:?}"),
OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"),
OpError::UnknownRole(s) => format!("unknown role {s:?}"),
OpError::MalformedPort(s) => format!("malformed port reference {s:?}"),
OpError::UnknownInPort { node, name } => format!("node {node:?} has no input port {name:?}"),
OpError::AmbiguousInPort { node, name } => format!("node {node:?} has ambiguous input port {name:?}"),
OpError::UnknownOutPort { node, name } => format!("node {node:?} has no output field {name:?}"),
OpError::AmbiguousOutPort { node, name } => format!("node {node:?} has ambiguous output field {name:?}"),
OpError::KindMismatch { from, to, producer, consumer } => {
format!("kind mismatch {from} -> {to} (producer {producer:?}, consumer {consumer:?})")
}
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 } => 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:?}"),
}
}
/// Parse a JSON op-list document, replay it through `std_vocabulary`, and return
/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op
/// fault, attributed by the retained op-kind list) / `finalize: cause` (a
/// holistic fault past the last op).
pub fn build_from_str(doc: &str) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
match replay("graph", ops, &std_vocabulary) {
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
Err((idx, err)) => {
let cause = format_op_error(&err);
match labels.get(idx) {
Some(kind) => Err(format!("op {idx} ({kind}): {cause}")),
None => Err(format!("finalize: {cause}")),
}
}
}
}
/// `aura graph build`: read the op-list document from stdin, build, and print the
/// blueprint to stdout — or the cause to stderr and exit non-zero.
pub fn build_cmd() {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
}
match build_from_str(&doc) {
Ok(json) => println!("{json}"),
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
}
}
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
/// closed vocabulary.
pub fn introspect_node(type_id: &str) -> Result<String, String> {
let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
let schema = builder.schema();
let mut out = format!("{}\n", builder.label());
for port in &schema.inputs {
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
}
for field in &schema.output {
out.push_str(&format!(" out {}:{:?}\n", field.name, field.kind));
}
for p in builder.params() {
out.push_str(&format!(" param {}:{:?} (bind {{\"{:?}\": <v>}})\n", p.name, p.kind, p.kind));
}
Ok(out)
}
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize).
pub fn introspect_unwired(doc: &str) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let mut session = GraphSession::new("introspect", &std_vocabulary);
for (i, d) in docs.into_iter().enumerate() {
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
}
let mut out = String::new();
for (slot, kind) in session.unwired() {
out.push_str(&format!("{slot}:{kind:?}\n"));
}
Ok(out)
}
/// `aura graph introspect`: dispatch the three read-only queries.
pub fn introspect_cmd(rest: &[&str]) {
match rest {
["--vocabulary"] => {
for t in std_vocabulary_types() {
println!("{t}");
}
}
["--node", type_id] => match introspect_node(type_id) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
},
["--unwired"] => {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
}
match introspect_unwired(&doc) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
}
}
_ => {
eprintln!("aura: usage: aura graph introspect --vocabulary | --node <T> | --unwired");
std::process::exit(2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The spec's worked-example document deserializes into the op vocabulary and
/// maps into engine `Op`s that replay into a compilable signal blueprint —
/// the DTO is a faithful front-end over the engine surface.
#[test]
fn opdoc_document_deserializes_and_replays() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
assert_eq!(docs.len(), 10);
assert_eq!(docs[1].kind_label(), "add");
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
// both SMA lengths are bound, so the only open param is bias.scale (f64).
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
}
#[test]
fn build_from_str_emits_blueprint_json_for_valid_document() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
let json = super::build_from_str(doc).expect("valid document builds");
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
}
#[test]
fn build_from_str_reports_unknown_node_at_its_op_index() {
let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#;
let err = super::build_from_str(doc).unwrap_err();
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
}
#[test]
fn build_from_str_reports_unconnected_slot_at_finalize() {
// sub.rhs left unwired -> the holistic 0-arm fires at the finalize step.
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","as":"fast"},
{"op":"add","type":"SMA","as":"slow"},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let err = super::build_from_str(doc).unwrap_err();
assert!(err.starts_with("finalize: "), "finalize fault, 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]
fn introspect_node_lists_ports_kinds_and_params() {
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary");
assert!(out.contains("series"), "lists the input port: {out}");
assert!(out.contains("value"), "lists the output field: {out}");
assert!(out.contains("length"), "lists the param path: {out}");
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#"[
{"op":"add","type":"SMA","as":"fast"},
{"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let out = super::introspect_unwired(doc).expect("partial document introspects");
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
}
}