From 74281842b8096e7e46b665cd22215a45023d9971 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 12:47:33 +0200 Subject: [PATCH] feat(aura-cli, aura-engine): use-aware unwired introspection; gang-rule prose; LinComb discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/graph_construct.rs | 110 ++++++++++++++++------- crates/aura-cli/tests/graph_construct.rs | 38 +++++++- crates/aura-engine/src/construction.rs | 60 +++++++++---- docs/authoring-guide.md | 27 ++++++ 4 files changed, 184 insertions(+), 51 deletions(-) diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index ff21f89..9df2de5 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -156,16 +156,17 @@ impl OpDoc { } impl From 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 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 { +/// [`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, Vec, std::collections::HashMap); + +/// 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 { // #336: deserialize element-by-element off the untyped `Value` array, not in // one `Vec` 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 Result { + 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 ` 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 { - let docs: Vec = 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`'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() { diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index c1ff1e0..6b5aaea 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -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 ") && 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] diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index cd1dd1b..a3fdbc4 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -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, nodes: Vec, schemas: Vec, @@ -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 = 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()) }) ); } diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index cf25d13..a36f998 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -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