feat(aura-engine, aura-vocabulary, aura-cli): the args channel end to end
The data plane reaches non-scalar structural config (closes #271, the final Data-authorability-boundary item). Op::Add gains args (string pairs, applied via try_args after resolve and before the bind loop — no pending builder ever enters a graph; an arg-bearing type without args refuses as MissingArg). OpError::BadArg carries the precise ArgOpError; the CLI renders it as prose naming the arg, its kind, and the kind's closed hint (exit 1, #175 content-fault class). Serde: PrimitiveData.args (skip-if-empty — args-free documents stay byte-identical, golden-pinned, so every existing content id is stable per C18) with a data-driven format version: the writer emits 1 for args-free documents and 2 when any primitive recursively carries args; the loader accepts 1..=2. The old v2-refusal pin flips deliberately to v3 (named contract change). Identity JSON keeps args — they are structural, and the Rust configured() path and the args op-script bridge to the same identity id (pinned). Roster: Session / LinComb / CostSum enter (33 -> 36, both count pins; the LinComb-rejection assertion flips to the resolved side). introspect --node shows arg rows (name, kind, hint) plus the pending note; OP_REFERENCE's add row teaches the args form. Registry comment de-staled (LinComb is now roster-reachable). Sequencing note: this commit lands op seam and roster together so no intermediate tree state exposes a pending builder.
This commit is contained in:
@@ -8,8 +8,9 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use aura_engine::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
|
||||
BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, ArgOpError,
|
||||
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError,
|
||||
Scalar, ScalarKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -30,6 +31,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o
|
||||
declare an open input role (wired by an enclosing graph)
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
|
||||
instantiate a node ("name" optional; "bind" maps param -> typed scalar)
|
||||
{"op":"add","type":"Session","name":"ny","args":{"tz":"America/New_York","open":"09:30"},"bind":{"period_minutes":{"I64":15}}}
|
||||
an arg-bearing type applies "args" (closed, per-type-declared string
|
||||
pairs) BEFORE "bind" — see graph introspect --node <T> for its args
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]}
|
||||
wire a role into one or more input slots
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
@@ -64,6 +68,12 @@ enum OpDoc {
|
||||
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
|
||||
#[serde(rename = "name", default)]
|
||||
as_name: Option<String>,
|
||||
// Construction args (#271) — the typed, closed channel applied BEFORE
|
||||
// `bind`. A `BTreeMap` (deterministic iteration order into `Op::Add`'s
|
||||
// `Vec`, mirroring `bind`'s own convention) — absent for every
|
||||
// args-free type, so an old-style `add` document is unaffected.
|
||||
#[serde(default)]
|
||||
args: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
},
|
||||
@@ -151,9 +161,12 @@ impl From<OpDoc> for 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::Add { type_id, as_name, args, bind } => Op::Add {
|
||||
type_id,
|
||||
as_name,
|
||||
args: args.into_iter().collect(),
|
||||
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 },
|
||||
@@ -172,6 +185,24 @@ impl From<OpDoc> for Op {
|
||||
}
|
||||
}
|
||||
|
||||
/// Phrase one `ArgOpError` (#271) as a human cause, WITHOUT the node prefix —
|
||||
/// shared by `format_op_error`'s `BadArg` arm (the `add`-op path) and
|
||||
/// `blueprint_load_prose`'s `BadArg` arm (the blueprint LOAD path), so the two
|
||||
/// error families cannot phrase the same fault differently. Exhaustive over
|
||||
/// `ArgOpError`; `BadValue` names the arg, its declared `ArgKind`, AND the
|
||||
/// closed per-kind `hint()` line (the same hint `introspect --node` shows).
|
||||
fn arg_op_error_prose(err: &ArgOpError) -> String {
|
||||
match err {
|
||||
ArgOpError::NotArgBearing => "takes no construction args".to_string(),
|
||||
ArgOpError::UnknownArg(a) => format!("has no construction arg {a:?}"),
|
||||
ArgOpError::DuplicateArg(a) => format!("was given arg {a:?} more than once"),
|
||||
ArgOpError::MissingArg(a) => format!("is missing required arg {a:?}"),
|
||||
ArgOpError::BadValue { arg, kind, got } => {
|
||||
format!("arg {arg:?} expects {kind:?} ({}) — got {got:?}", kind.hint())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -204,6 +235,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
format!("cannot bind {node}.{param} — member of gang {gang:?}")
|
||||
}
|
||||
},
|
||||
OpError::BadArg { node, err } => format!("node {node} {}", arg_op_error_prose(err)),
|
||||
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
|
||||
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
|
||||
OpError::GangKindMismatch { member, expected, got } => {
|
||||
@@ -439,6 +471,17 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
|
||||
let schema = builder.schema();
|
||||
// C29 (#315): the head line carries the node's one-line meaning.
|
||||
let mut out = format!("{} — {}\n", builder.label(), schema.doc);
|
||||
// #271: an arg-bearing (pending) type declares its ArgSpecs but no real
|
||||
// ports/params yet — those form only once `try_args` runs (`make`). Show
|
||||
// the arg rows first (they must be supplied before anything else can be
|
||||
// discovered) plus the one-line note the spec's worked discovery example
|
||||
// pins.
|
||||
for spec in builder.arg_specs() {
|
||||
out.push_str(&format!(" arg {}: {:?} ({})\n", spec.name, spec.kind, spec.kind.hint()));
|
||||
}
|
||||
if builder.is_pending() {
|
||||
out.push_str(" note ports and params form at construction; args are required\n");
|
||||
}
|
||||
for port in &schema.inputs {
|
||||
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
||||
}
|
||||
@@ -662,6 +705,7 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
|
||||
}
|
||||
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
|
||||
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3809,6 +3809,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 (cross-path identity, extending #171's bridge pattern to an
|
||||
/// arg-bearing type): the Rust-authored `Session::configured` builder and
|
||||
/// its `add`-op `args` twin differ in canonical bytes (debug names) —
|
||||
/// distinct content ids — but project to the same identity JSON, one
|
||||
/// identity id across authoring paths even though the construction
|
||||
/// channel (Rust call vs `args` object) differs.
|
||||
#[test]
|
||||
fn identity_id_bridges_the_rust_configured_session_and_args_script() {
|
||||
use aura_market::Session;
|
||||
|
||||
let rust_built = Composite::new(
|
||||
"sess",
|
||||
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
|
||||
vec![],
|
||||
vec![aura_engine::Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![aura_engine::Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![aura_engine::OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"trigger","kind":"F64"},
|
||||
{"op":"add","type":"Session","name":"sess",
|
||||
"args":{"tz":"Europe/Berlin","open":"09:30"},
|
||||
"bind":{"period_minutes":{"I64":15}}},
|
||||
{"op":"feed","role":"trigger","into":["sess.trigger"]},
|
||||
{"op":"expose","from":"sess.bars_since_open","as":"bars"}
|
||||
]"#;
|
||||
let env = aura_runner::project::Env::std();
|
||||
let json = crate::graph_construct::build_from_str(doc, &env).expect("args op-script twin builds");
|
||||
assert_ne!(
|
||||
content_id(&json),
|
||||
content_id(&blueprint_to_json(&rust_built).expect("serializes")),
|
||||
"authoring paths keep distinct content ids (debug names differ)"
|
||||
);
|
||||
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads");
|
||||
let identity = |c: &Composite| {
|
||||
content_id(&aura_engine::blueprint_identity_json(c).expect("identity-serializes"))
|
||||
};
|
||||
assert_eq!(
|
||||
identity(&loaded),
|
||||
identity(&rust_built),
|
||||
"one topology -> one identity id, Rust-configured vs args op-script"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() {
|
||||
// Property: the `mc_r_bootstrap` output line is the full RBootstrap shape —
|
||||
|
||||
Reference in New Issue
Block a user