feat(aura-cli, aura-engine): use-aware unwired introspection; gang-rule prose; LinComb discovery
Harvest sweep, batch 5 of 6. - introspect --unwired resolves use refs through the registry store: the parse+resolve phase is extracted from graph build's path into the shared parse_and_resolve_ops, so a use-bearing document introspects identically to how it builds (fetch, C29 gate, label resolution) — retiring both the hard miss and the 'content id' mislabeling of by-name refs. The engine's subgraph closure contract is unchanged (store-freedom binds the engine, not the CLI caller). - ganging a spliced instance's member path refuses with the rule (OpError::GangOfSplicedInstance) instead of the typo-shaped no-such-param prose; the leaf-typo case keeps its UnknownParam shape under a new sibling pin. - the --node pending note names the follow-up moves on the current surface (build, then introspect --params / --unwired), and the authoring guide gains a worked LinComb op-script example (args-then-bind order, term[i]/weights[i], verified by building it). refs #339 refs #341
This commit is contained in:
@@ -156,16 +156,17 @@ impl OpDoc {
|
||||
}
|
||||
|
||||
impl From<OpDoc> for Op {
|
||||
/// Infallible, context-free conversion — used directly only by
|
||||
/// build-free introspection paths (`introspect --unwired`, #317's
|
||||
/// spec: "Build-free introspection paths pass a `|_| None` closure"),
|
||||
/// which never resolve a `use` ref through the registry. A bare
|
||||
/// `OpDoc::Use` therefore maps its `UseRef` payload verbatim into
|
||||
/// `ref_id` (unresolved) — that session's `subgraph` closure is always
|
||||
/// `&|_| None`, so any `use` op there faults `UnknownSubgraph`
|
||||
/// regardless of the exact `ref_id` text; `graph build`'s real path
|
||||
/// (`composite_from_str`) never reaches this arm — it resolves and
|
||||
/// replaces each `Op::Use` before conversion (see `resolve_use_op`).
|
||||
/// Infallible, context-free conversion. Neither production caller
|
||||
/// (`composite_from_str`'s / `introspect_unwired`'s shared
|
||||
/// [`parse_and_resolve_ops`] phase, #339 item 4 harvest) reaches the
|
||||
/// `OpDoc::Use` arm below with an unresolved ref: both intercept every
|
||||
/// `Op::Use` op and route it through `resolve_use_op` (registry fetch +
|
||||
/// C29 gate + full-vocabulary resolve) BEFORE ever calling `Op::from` —
|
||||
/// only the remaining, `use`-free op kinds reach this conversion as-is.
|
||||
/// The `OpDoc::Use` arm stays for match exhaustiveness (a bare,
|
||||
/// registry-free conversion, `ref_id` verbatim off the `UseRef`); a
|
||||
/// hypothetical direct caller would still get one, but every op-script
|
||||
/// entry point in this file resolves first.
|
||||
fn from(d: OpDoc) -> Op {
|
||||
match d {
|
||||
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
||||
@@ -255,14 +256,20 @@ fn format_op_error(e: &OpError) -> String {
|
||||
format!("gang: `{node}.{param}` is already ganged")
|
||||
}
|
||||
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
|
||||
OpError::GangOfSplicedInstance { node, member } => format!(
|
||||
"gang: {node}.{member} is a spliced instance's member — ganging a used instance's member params is not yet supported"
|
||||
),
|
||||
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
|
||||
// #317: `graph build`'s real path (`composite_from_str`) resolves and
|
||||
// fetches every `use` op before replay, so this never fires there —
|
||||
// but `introspect --unwired` stays build-free/subgraph-free by spec
|
||||
// (`&|_| None`, see `From<OpDoc> for Op`), so a `use` op in a partial
|
||||
// document reaches this arm through THAT path, by-identifier on the
|
||||
// (unresolved) `ref_id` text.
|
||||
// #317, extended #339 item 4 harvest: BOTH CLI entry points
|
||||
// (`composite_from_str`'s and `introspect_unwired`'s shared
|
||||
// `parse_and_resolve_ops` phase) resolve and fetch every `use` op
|
||||
// before replay/apply, so neither ever constructs an `Op::Use` whose
|
||||
// `ref_id` is anything but an already-resolved content id — this
|
||||
// arm is unreachable from the CLI surface. It stays reachable only
|
||||
// against a bare `GraphSession`/`replay` caller that hands `Op::Use`
|
||||
// a `ref_id` its own `subgraph` closure doesn't resolve (an engine-
|
||||
// level API misuse, not a document-authoring fault).
|
||||
OpError::UnknownSubgraph { ref_id } => {
|
||||
format!("use: no subgraph for content id {ref_id:?}")
|
||||
}
|
||||
@@ -408,13 +415,21 @@ fn resolve_use_op(
|
||||
Ok(Op::Use { ref_id: full_id, name, bind: bind.into_iter().collect() })
|
||||
}
|
||||
|
||||
/// Parse a JSON op-list document and replay it through the env's vocabulary into
|
||||
/// a built `Composite` — 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). Every `use` op resolves through the registry HERE, before
|
||||
/// replay (#317): a resolution/doc-gate fault is attributed exactly like any
|
||||
/// other per-op construction fault (same `op N (kind): cause` shape, exit 1).
|
||||
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
/// [`parse_and_resolve_ops`]'s success payload: the parsed `Op`s, their
|
||||
/// by-index kind labels (`op N (kind): cause` attribution), and the id -> json
|
||||
/// cache the caller's own `subgraph` closure reads.
|
||||
type ResolvedOps = (Vec<Op>, Vec<String>, std::collections::HashMap<String, String>);
|
||||
|
||||
/// Parse a JSON op-list document into `Op`s, resolving every `use` op through
|
||||
/// the registry (fetch, C29-gate, full-vocabulary resolve) HERE, before either
|
||||
/// caller ever builds a session (#317, extended #339 item 4 harvest): the
|
||||
/// shared first phase of `composite_from_str` (`graph build`'s real path) AND
|
||||
/// `introspect_unwired`'s build-free path, so a `use`-bearing document
|
||||
/// resolves identically under both — introspection no longer treats a
|
||||
/// resolvable ref as a hard miss. A resolution/doc-gate fault is attributed
|
||||
/// exactly like any other per-op construction fault (same `op N (kind):
|
||||
/// cause` shape).
|
||||
fn parse_and_resolve_ops(doc: &str, env: &aura_runner::project::Env) -> Result<ResolvedOps, String> {
|
||||
// #336: deserialize element-by-element off the untyped `Value` array, not in
|
||||
// one `Vec<OpDoc>` shot. For an internally-tagged enum, serde_json's
|
||||
// `deny_unknown_fields` fault fires only once the parser has consumed the
|
||||
@@ -450,6 +465,15 @@ fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Comp
|
||||
};
|
||||
ops.push(op);
|
||||
}
|
||||
Ok((ops, labels, cache))
|
||||
}
|
||||
|
||||
/// Parse a JSON op-list document and replay it through the env's vocabulary into
|
||||
/// a built `Composite` — 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).
|
||||
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
let (ops, labels, cache) = parse_and_resolve_ops(doc, env)?;
|
||||
// The injected `subgraph` lookup (#317): a pure, registry-free map read —
|
||||
// every `use` op's blueprint was already fetched, gated, AND resolved
|
||||
// (`resolve_use_op`'s eager `blueprint_from_json` check) above; this
|
||||
@@ -527,7 +551,15 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
|
||||
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");
|
||||
// #341 item 3 harvest: name the follow-up moves on the CURRENT
|
||||
// surface, not just the fact that ports/params are absent — a
|
||||
// consumer's first port guess is otherwise a coin flip, and only
|
||||
// trial refusals surface the real names.
|
||||
out.push_str(
|
||||
" note ports and params form at construction; args are required — build \
|
||||
the op-script, then `aura graph introspect --params <bp.json>` shows the open knobs and \
|
||||
`--unwired` on a partial document shows the unfilled slots\n",
|
||||
);
|
||||
}
|
||||
for port in &schema.inputs {
|
||||
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
||||
@@ -542,17 +574,29 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
|
||||
}
|
||||
|
||||
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize). A
|
||||
/// `use` op resolves through the registry via the SAME [`parse_and_resolve_ops`]
|
||||
/// phase `composite_from_str` uses (#339 item 4 harvest: this path used to pass
|
||||
/// `&|_| None` as the subgraph resolver, so a use-bearing document always
|
||||
/// missed with `OpError::UnknownSubgraph` — mislabeling a by-name ref as a
|
||||
/// "content id" in the bargain); the real store resolver threads in cleanly
|
||||
/// because `unwired()` is read straight off the (unfinished) session,
|
||||
/// unaffected by the `use` resolution happening one phase earlier.
|
||||
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let (ops, labels, cache) = parse_and_resolve_ops(doc, env)?;
|
||||
let resolver = |t: &str| env.resolve(t);
|
||||
// #317: build-free introspection stays subgraph-free by design (spec:
|
||||
// "Build-free introspection paths pass a `|_| None` closure") — a `use`
|
||||
// op here always misses (`OpError::UnknownSubgraph`, `From<OpDoc>`'s own
|
||||
// doc comment), never a registry read.
|
||||
let mut session = GraphSession::new("introspect", &resolver, &|_: &str| None);
|
||||
for (i, d) in docs.into_iter().enumerate() {
|
||||
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
|
||||
let subgraph = |ref_id: &str| {
|
||||
cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok())
|
||||
};
|
||||
let mut session = GraphSession::new("introspect", &resolver, &subgraph);
|
||||
for (i, op) in ops.into_iter().enumerate() {
|
||||
session.apply(op).map_err(|e| {
|
||||
let cause = format_op_error(&e);
|
||||
match labels.get(i) {
|
||||
Some(kind) => format!("op {i} ({kind}): {cause}"),
|
||||
None => format!("op {i}: {cause}"),
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (slot, kind) in session.unwired() {
|
||||
|
||||
@@ -282,7 +282,11 @@ fn graph_build_bad_tz_is_exit_1_naming_the_arg() {
|
||||
|
||||
/// #271: `graph introspect --node Session` self-describes its declared
|
||||
/// construction args (name, kind, hint) plus the pending note — the
|
||||
/// discovery surface an author reads before writing the `args` object.
|
||||
/// discovery surface an author reads before writing the `args` object. The
|
||||
/// note itself names the follow-up moves on the current surface (#341 item 3
|
||||
/// harvest): `graph build` + `introspect --params` for the open knobs,
|
||||
/// `--unwired` for a partial document's unfilled slots — so a consumer's
|
||||
/// first port/param guess isn't a coin flip.
|
||||
#[test]
|
||||
fn graph_introspect_node_session_lists_arg_rows() {
|
||||
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "Session"], "");
|
||||
@@ -294,6 +298,14 @@ fn graph_introspect_node_session_lists_arg_rows() {
|
||||
stdout.contains("ports and params form at construction"),
|
||||
"shows the pending note: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("aura graph introspect --params <bp.json>") && stdout.contains("open knobs"),
|
||||
"the note names the params discovery follow-up: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("--unwired") && stdout.contains("unfilled slots"),
|
||||
"the note names the unwired-slots follow-up: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2190,6 +2202,30 @@ fn graph_build_use_resolves_a_label_and_echoes_the_id() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `graph introspect --unwired` on a use-bearing document (#339 item 4
|
||||
/// harvest): the build-free introspection path resolves `use` refs through
|
||||
/// the store exactly like `graph build` does now, so a document splicing a
|
||||
/// registered pattern lists the open slots reaching THROUGH the splice (the
|
||||
/// pattern's own open input role, surfaced as `trend.x`) alongside an
|
||||
/// ordinary leaf node's open slot — instead of a hard `UnknownSubgraph` miss.
|
||||
#[test]
|
||||
fn graph_introspect_unwired_lists_open_slots_through_a_use_splice() {
|
||||
let dir = temp_cwd("unwired-use-splice");
|
||||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||||
let (reg_out, reg_err, reg_code) =
|
||||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||||
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
|
||||
|
||||
let consumer = r#"[
|
||||
{"op":"add","type":"SMA","name":"solo"},
|
||||
{"op":"use","ref":{"name":"smooth"},"name":"trend"}
|
||||
]"#;
|
||||
let (stdout, stderr, code) = run_in_stdin(&dir, &["graph", "introspect", "--unwired"], consumer);
|
||||
assert_eq!(code, Some(0), "a use-bearing document introspects: {stderr}");
|
||||
assert!(stdout.contains("trend.x"), "the splice's own open role surfaces through: {stdout}");
|
||||
assert!(stdout.contains("solo.series"), "the leaf node's own open slot still lists: {stdout}");
|
||||
}
|
||||
|
||||
/// An unknown `use` label enumerates every registered label (C29's "name the
|
||||
/// closed set" idiom) and refuses at exit 1 (op-list content fault, #175).
|
||||
#[test]
|
||||
|
||||
@@ -116,6 +116,14 @@ pub enum OpError {
|
||||
AlreadyGanged { node: String, param: String },
|
||||
/// A gang needs at least two members.
|
||||
GangArity { gang: String },
|
||||
/// A gang member path resolves onto a spliced instance (`Op::Use`'s
|
||||
/// `Composite` node), not a primitive (#339 item 1 harvest): a gang
|
||||
/// fuses a PRIMITIVE's raw `(name, pos)` param slot, and an instance's
|
||||
/// params are nested/path-qualified, so ganging a used instance's
|
||||
/// member params is unsupported today. Distinct from
|
||||
/// `BadParam::UnknownParam` so the refusal names the rule instead of
|
||||
/// reading like a typo hint on the leaf case.
|
||||
GangOfSplicedInstance { node: String, member: String },
|
||||
/// A holistic finalize fault (totality / injectivity / unbound root role),
|
||||
/// wrapping the unchanged engine gate's `CompileError`.
|
||||
Incomplete(CompileError),
|
||||
@@ -146,7 +154,11 @@ pub struct GraphSession<'v> {
|
||||
/// The engine stays store-free — CLI-side resolution (label/prefix,
|
||||
/// C29 doc gate, the store fetch itself) happens at DTO conversion,
|
||||
/// before replay; this closure is a pure lookup into an already-fetched
|
||||
/// id->`Composite` cache. Build-free introspection paths pass `&|_| None`.
|
||||
/// id->`Composite` cache. The engine places no obligation on a caller
|
||||
/// either way: a build-free CLI path MAY still resolve `use` refs
|
||||
/// through the store first and pass a real cache-backed closure here
|
||||
/// (#339 item 4 harvest: `introspect --unwired` now does); a truly
|
||||
/// resolver-less caller (a bare `replay`, most tests) passes `&|_| None`.
|
||||
subgraph: &'v dyn Fn(&str) -> Option<Composite>,
|
||||
nodes: Vec<BlueprintNode>,
|
||||
schemas: Vec<NodeSchema>,
|
||||
@@ -490,12 +502,11 @@ impl<'v> GraphSession<'v> {
|
||||
// Composite, not just a Primitive — reachable now, not a
|
||||
// defect. A gang fuses a PRIMITIVE's raw (name, pos) param
|
||||
// slot; an instance has no such flat slot (its params are
|
||||
// nested/path-qualified), so this is the ordinary
|
||||
// no-such-open-param refusal, not a panic.
|
||||
return Err(OpError::BadParam {
|
||||
node: node_name,
|
||||
err: BindOpError::UnknownParam(param_name),
|
||||
});
|
||||
// nested/path-qualified). #339 item 1 harvest: name the
|
||||
// rule here instead of falling into the ordinary
|
||||
// no-such-open-param shape, which reads as a typo hint on
|
||||
// an otherwise correct member path.
|
||||
return Err(OpError::GangOfSplicedInstance { node: node_name, member: param_name });
|
||||
};
|
||||
let hits: Vec<usize> = b
|
||||
.params()
|
||||
@@ -1700,12 +1711,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Documented residue pin (#317 audit): a `gang` op cannot fuse a spliced
|
||||
/// instance's member param — a gang fuses a PRIMITIVE's raw `(name, pos)`
|
||||
/// slot, and an instance's params are nested/path-qualified. The refusal
|
||||
/// is the ordinary no-such-open-param shape on the instance node, so the
|
||||
/// authoring-guide/C24 claim "the gang op refuses a composite instance's
|
||||
/// member path" stays an observed fact, not prose.
|
||||
/// Documented residue pin (#317 audit, prose updated #339 item 1
|
||||
/// harvest): a `gang` op cannot fuse a spliced instance's member param —
|
||||
/// a gang fuses a PRIMITIVE's raw `(name, pos)` slot, and an instance's
|
||||
/// params are nested/path-qualified. The refusal now names the rule
|
||||
/// (`OpError::GangOfSplicedInstance`) instead of the bare
|
||||
/// no-such-open-param shape, so it reads as "instance-member ganging is
|
||||
/// unsupported", not as a typo hint — matching the authoring-guide/C24
|
||||
/// claim "the gang op refuses a composite instance's member path".
|
||||
#[test]
|
||||
fn gang_of_a_spliced_instance_member_path_refuses() {
|
||||
let subgraph = |_: &str| Some(use_fixture());
|
||||
@@ -1719,10 +1732,23 @@ mod tests {
|
||||
as_name: "fused".into(),
|
||||
into: vec!["gate.sma.length".into(), "solo.length".into()],
|
||||
}),
|
||||
Err(OpError::BadParam {
|
||||
node: "gate".into(),
|
||||
err: BindOpError::UnknownParam("sma.length".into()),
|
||||
})
|
||||
Err(OpError::GangOfSplicedInstance { node: "gate".into(), member: "sma.length".into() })
|
||||
);
|
||||
}
|
||||
|
||||
/// Sibling pin to `gang_of_a_spliced_instance_member_path_refuses`: the
|
||||
/// plain-typo case — a `gang` member naming a param that never existed
|
||||
/// on a PRIMITIVE (leaf) node — stays the ordinary no-such-open-param
|
||||
/// shape (`BindOpError::UnknownParam`), unaffected by the instance-path
|
||||
/// rule above (#339 item 1 harvest).
|
||||
#[test]
|
||||
fn gang_of_a_leaf_node_with_a_wrong_param_name_refuses_as_unknown_param() {
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "typo".into(), into: vec!["a.lenght".into(), "b.length".into()] }),
|
||||
Err(OpError::BadParam { node: "a".into(), err: BindOpError::UnknownParam("lenght".into()) })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -334,6 +334,33 @@ re-run records, §3).
|
||||
(`sub.value` is tapped here purely to illustrate; any interior output field is
|
||||
a legal tap source, and a producer needs no other consumer to be tapped.)
|
||||
|
||||
### Worked example: a `LinComb` op-script
|
||||
|
||||
`LinComb` is **arg-bearing** (#271): `graph introspect --node LinComb` shows
|
||||
only the `arity` arg and the pending note until `args` supplies it — the
|
||||
ports (`term[0]`, `term[1]`, …) and params (`weights[0]`, `weights[1]`, …)
|
||||
form only once `arity` is real, one pair per term. A minimal weighted blend
|
||||
of two sources:
|
||||
|
||||
```json
|
||||
[
|
||||
{"op": "source", "role": "a", "kind": "F64"},
|
||||
{"op": "source", "role": "b", "kind": "F64"},
|
||||
{"op": "add", "type": "LinComb", "name": "combo",
|
||||
"args": {"arity": "2"}, "bind": {"weights[0]": {"F64": 0.7}}},
|
||||
{"op": "feed", "role": "a", "into": ["combo.term[0]"]},
|
||||
{"op": "feed", "role": "b", "into": ["combo.term[1]"]},
|
||||
{"op": "expose", "from": "combo.value", "as": "blend"}
|
||||
]
|
||||
```
|
||||
|
||||
`args` fixes the arity BEFORE `bind`/`feed` can address `term[i]`/`weights[i]`
|
||||
at all — the same closed-args-then-bind order every arg-bearing type follows
|
||||
(§0/#271). `weights[0]` is bound to `0.7` here, so it becomes a default
|
||||
(#246); the unbound `weights[1]` stays a required axis, appearing in `graph
|
||||
introspect --params` as `combo.weights[1]:F64` (alongside
|
||||
`combo.weights[0]:F64 default=0.7`).
|
||||
|
||||
### Session anchoring: the `SessionFrankfurt` preset
|
||||
|
||||
To anchor logic to the trading session, the closed vocabulary ships a
|
||||
|
||||
Reference in New Issue
Block a user