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:
2026-07-26 12:47:33 +02:00
parent d2b0cdf64c
commit 74281842b8
4 changed files with 184 additions and 51 deletions
+77 -33
View File
@@ -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() {
+37 -1
View File
@@ -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]
+43 -17
View File
@@ -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()) })
);
}
+27
View File
@@ -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