feat(aura-cli, aura-engine): graph introspect --taps — positive declared-tap discovery
Harvest sweep, batch 3 of 6 (first half). Composite::declared_taps() walks the blueprint depth-first collecting (tap name, source wire, column kind), names bare at every depth — mirroring inline_composite's hoist, where a tap's name IS the flat graph's load-bearing identifier — and bounds-total over invalid wires (compile's validate_wiring stays the real gate). The CLI view loads like --params (FILE gates the authored root name, a content id does not), prints one 'name node.field Kind' row per tap, and renders the tap-less case as a stderr note with empty stdout, exit 0. Closes the recovery-only discovery loop: since #333 the only way to learn a blueprint's declared tap names was provoking the undeclared-tap refusal roster. closes #337
This commit is contained in:
@@ -589,10 +589,11 @@ fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, Stri
|
||||
}
|
||||
|
||||
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
|
||||
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
|
||||
/// group must be set; zero or more than one is the usage error (exit 2). The id
|
||||
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
|
||||
/// combine (one build, both ids, content id first).
|
||||
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` /
|
||||
/// `--taps <FILE|ID>` / the id group must be set; zero or more than one is the
|
||||
/// usage error (exit 2). The id group is `--content-id [FILE]` and/or
|
||||
/// `--identity-id` — the two id flags may combine (one build, both ids,
|
||||
/// content id first).
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() as usize
|
||||
@@ -600,10 +601,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
+ cmd.folds as usize
|
||||
+ cmd.registered as usize
|
||||
+ cmd.params.is_some() as usize
|
||||
+ cmd.taps.is_some() as usize
|
||||
+ (cmd.content_id.is_some() || cmd.identity_id) as usize;
|
||||
if count != 1 {
|
||||
eprintln!(
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --taps <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -675,6 +677,18 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else if let Some(target) = cmd.taps.as_deref() {
|
||||
// --taps <FILE|ID> (#337): the positive declared-tap discovery view —
|
||||
// #333 shipped only the refusal roster (`bind_tap_plan`'s
|
||||
// `UnknownTap`), so provoking that refusal was the only way to learn
|
||||
// a blueprint's declared tap names.
|
||||
match taps_lines(target, env) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --content-id [FILE] / --identity-id (combinable): one build, then each
|
||||
// requested id on its own line, content id first. With a FILE value the
|
||||
@@ -997,6 +1011,31 @@ fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph introspect --taps <FILE|ID>` (#337): one row per declared tap
|
||||
/// — `<name> <node>.<field> <kind:?>` — the positive discovery view #333's
|
||||
/// refusal roster (`bind_tap_plan`'s `UnknownTap`) deliberately deferred:
|
||||
/// before this, the only way to learn a blueprint's declared tap names was
|
||||
/// provoking that refusal. Loads exactly like `--params` (FILE gates the
|
||||
/// authored root name, a store content id does not, C29). A tap-less
|
||||
/// blueprint prints nothing to stdout — nothing was declared to list — plus a
|
||||
/// stderr note (the `bind_tap_plan` unbound-this-run note's own "aura: note:"
|
||||
/// convention), exit 0: a listing, not a fault.
|
||||
fn taps_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
use std::fmt::Write as _;
|
||||
let (text, is_file) = resolve_blueprint_text(target, env)?;
|
||||
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
|
||||
let taps = composite.declared_taps();
|
||||
if taps.is_empty() {
|
||||
eprintln!("aura: note: {target} declares no taps");
|
||||
return Ok(String::new());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (name, wire, kind) in taps {
|
||||
let _ = writeln!(out, "{name} {wire} {kind:?}");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
|
||||
/// #317): parse the blueprint through the project vocabulary, canonicalize,
|
||||
/// content-address, and store — the `process register` pattern, printing
|
||||
|
||||
@@ -911,6 +911,12 @@ struct GraphIntrospectCmd {
|
||||
/// name:kind line per open param. FILE path or 64-hex store content id (#196).
|
||||
#[arg(long, value_name = "FILE|ID")]
|
||||
params: Option<String>,
|
||||
/// List a blueprint's declared taps, one row per tap (#337): name, source
|
||||
/// wire (`node.field`), column kind. FILE path or 64-hex store content id
|
||||
/// (#196), like `--params`. The positive twin of the undeclared-tap
|
||||
/// refusal roster (#333).
|
||||
#[arg(long, value_name = "FILE|ID")]
|
||||
taps: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
||||
@@ -832,6 +832,54 @@ fn graph_params_tolerates_content_prefix_on_target() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#337): `--taps <FILE>` is the positive declared-tap discovery
|
||||
/// view — before this, the only way to learn a blueprint's declared tap names
|
||||
/// was provoking #333's undeclared-tap refusal roster. One row per declared
|
||||
/// tap: name, source wire (`node.field`), column kind, in declaration order.
|
||||
#[test]
|
||||
fn graph_introspect_taps_lists_declared_taps_wire_and_kind() {
|
||||
let dir = temp_cwd("taps-listed");
|
||||
let ops = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"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":"tap","from":"fast.value","as":"fast_ma"},
|
||||
{"op":"tap","from":"sub.value","as":"spread"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
let (build_out, build_err, build_code) = run_in_stdin(&dir, &["graph", "build"], ops);
|
||||
assert_eq!(build_code, Some(0), "graph build: {build_out} {build_err}");
|
||||
let bp = dir.join("tapped.json");
|
||||
std::fs::write(&bp, &build_out).expect("write built blueprint");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--taps", bp.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "fast_ma fast.value F64\nspread sub.value F64\n",
|
||||
"one row per declared tap: name, source wire, column kind"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#337): a blueprint with no declared taps prints nothing to
|
||||
/// stdout (nothing was declared to list) plus a one-line stderr note — a
|
||||
/// listing, not a fault, exit 0.
|
||||
#[test]
|
||||
fn graph_introspect_taps_reports_no_taps_on_stderr_when_none_declared() {
|
||||
let dir = temp_cwd("taps-empty");
|
||||
let bp = fixture("r_sma_open.json");
|
||||
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--taps", &bp]);
|
||||
assert_eq!(code, Some(0), "a tap-less blueprint is a listing, not a fault: {stderr}");
|
||||
assert_eq!(stdout, "", "no declared taps to list: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("declares no taps"),
|
||||
"stderr carries the one-line note: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
|
||||
/// that was never registered refuses with a store-miss message, not a panic
|
||||
/// or a silent empty namespace — the registry lookup branch of
|
||||
|
||||
@@ -361,6 +361,24 @@ impl Composite {
|
||||
out
|
||||
}
|
||||
|
||||
/// Every declared measurement tap across the whole blueprint, depth-first
|
||||
/// through nested composites — the taps-as-data twin of `param_space()`
|
||||
/// (#337, the positive discovery view #333's refusal roster deliberately
|
||||
/// deferred). Each entry is `(tap name, source wire, column kind)`, the
|
||||
/// wire rendered `<node>.<field>` in the LOCAL frame the tap was declared
|
||||
/// in. Unlike a param's path, neither the tap's own name nor its wire's
|
||||
/// node identifier is prefixed by an enclosing composite: a tap hoists
|
||||
/// BARE to the flat graph (`inline_composite` keeps `tap.name` verbatim),
|
||||
/// so this view renders exactly the load-bearing name `compile` keeps.
|
||||
/// Bounds-total like `derive_signature`: a structurally-invalid wire (out
|
||||
/// of a producer's output arity) yields no row rather than a panic — the
|
||||
/// real gate is `compile`'s own `validate_wiring`.
|
||||
pub fn declared_taps(&self) -> Vec<(String, String, ScalarKind)> {
|
||||
let mut out = Vec::new();
|
||||
collect_taps(self, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Re-open ONE bound param at its `param_space()`-style path (#246): the
|
||||
/// param returns to the open surface at the slot `collect_params` order
|
||||
/// dictates; its bound value is forgotten by this value (the authored
|
||||
@@ -1131,6 +1149,31 @@ fn collect_params(items: &[BlueprintNode], gangs: &[Gang], prefix: &str, out: &m
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive walk for `Composite::declared_taps` (#337): this level's OWN
|
||||
/// declared taps first (resolved against this level's OWN `nodes` — a tap's
|
||||
/// `from` wire is always local to the frame it was declared in), then
|
||||
/// recurse into every nested composite's own tap list. No prefix threading
|
||||
/// (unlike `collect_params`): a tap's name and its wire's node identifier
|
||||
/// stay bare at every depth, mirroring `inline_composite`'s own hoist.
|
||||
fn collect_taps(c: &Composite, out: &mut Vec<(String, String, ScalarKind)>) {
|
||||
for tap in &c.taps {
|
||||
if let Some(item) = c.nodes.get(tap.from.node) {
|
||||
let node_name = match item {
|
||||
BlueprintNode::Primitive(b) => b.node_name(),
|
||||
BlueprintNode::Composite(inner) => inner.name().to_string(),
|
||||
};
|
||||
if let Some(field) = item.signature().output.get(tap.from.field) {
|
||||
out.push((tap.name.clone(), format!("{node_name}.{}", field.name), field.kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
for item in &c.nodes {
|
||||
if let BlueprintNode::Composite(inner) = item {
|
||||
collect_taps(inner, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive mutable walk for `Composite::reopen` (#246): mirrors
|
||||
/// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns
|
||||
/// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns
|
||||
@@ -3954,4 +3997,60 @@ mod tests {
|
||||
// the inner Sub lowered to flat node 0; the hoisted tap points at it
|
||||
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "inner_d".into(), node: 0, field: 0 }]);
|
||||
}
|
||||
|
||||
/// The build-free discovery twin (#337) of the compile-time hoist above:
|
||||
/// `declared_taps()` finds the SAME nested-composite tap without ever
|
||||
/// compiling — its wire is rendered in the LOCAL frame it was declared in
|
||||
/// (`sub.value`, the un-named Sub's default node name + its own output
|
||||
/// field), never composite-path-prefixed (unlike a param's path).
|
||||
#[test]
|
||||
fn declared_taps_finds_a_nested_composites_tap_without_compiling() {
|
||||
let inner = Composite::new(
|
||||
"inner",
|
||||
vec![Sub::builder().into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "x".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
source: None,
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "o".into() }],
|
||||
)
|
||||
.with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]);
|
||||
let root = Composite::new(
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "a".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(
|
||||
root.declared_taps(),
|
||||
vec![("inner_d".to_string(), "sub.value".to_string(), ScalarKind::F64)]
|
||||
);
|
||||
}
|
||||
|
||||
/// Bounds-total (mirrors `derive_signature`): a tap wire naming a field
|
||||
/// beyond its producer's output arity yields no row rather than a panic —
|
||||
/// the real gate stays `compile`'s own `TapWireOutOfRange`.
|
||||
#[test]
|
||||
fn declared_taps_is_bounds_total_over_an_out_of_range_wire() {
|
||||
let bp = Composite::new(
|
||||
"m",
|
||||
vec![Sub::builder().into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "a".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
)
|
||||
.with_taps(vec![Tap { name: "bad".into(), from: TapWire { node: 0, field: 9 } }]);
|
||||
assert_eq!(bp.declared_taps(), Vec::new(), "an out-of-range wire yields no row, not a panic");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user