//! 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, #[serde(default)] bind: BTreeMap, }, Feed { role: String, into: Vec }, 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 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 { let docs: Vec = 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 = 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 `: 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 { 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 {{\"{:?}\": }})\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 { let docs: Vec = 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 | --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 = serde_json::from_str(doc).expect("document parses"); assert_eq!(docs.len(), 10); assert_eq!(docs[1].kind_label(), "add"); let ops: Vec = 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": }` 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\": })"), "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}"); } }