diff --git a/crates/aura-cli/tests/tap_recording.rs b/crates/aura-cli/tests/tap_recording.rs index d1a405a..c26d597 100644 --- a/crates/aura-cli/tests/tap_recording.rs +++ b/crates/aura-cli/tests/tap_recording.rs @@ -322,13 +322,17 @@ fn run_tap_selector_refuses_an_unknown_label_with_the_roster() { assert!(!cwd.join("runs").exists(), "a refused run must not write the store"); } -/// #310: a selection naming a tap the blueprint does not declare is -/// refused typed (UndeclaredTap), before any store write. +/// #310/#333: a selection naming a tap the blueprint does not declare is +/// refused typed (UndeclaredTap), before any store write, and the refusal +/// enumerates the blueprint's declared taps (the same way the unknown-fold +/// refusal enumerates the fold-registry roster) — on the two-tap fixture, +/// both `fast_tap` and `slow_tap` must be named so the author does not have +/// to reopen the blueprint JSON. #[test] fn run_tap_selector_refuses_an_undeclared_tap() { let cwd = temp_cwd("tap-selector-undeclared"); - let bp_path = cwd.join("tapped_r_sma.json"); - std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint"); + let bp_path = cwd.join("two_tap.json"); + std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint"); let out = Command::new(BIN) .args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"]) .current_dir(&cwd) @@ -337,9 +341,61 @@ fn run_tap_selector_refuses_an_undeclared_tap() { assert!(!out.status.success(), "an undeclared tap must refuse"); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}"); + assert!( + stderr.contains("fast_tap") && stderr.contains("slow_tap"), + "refusal must enumerate the blueprint's declared taps: {stderr}" + ); assert!(!cwd.join("runs").exists(), "a refused run must not write the store"); } +/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits +/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it — +/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed) +/// gets no such note; `slow_tap` (left unlisted) does. +#[test] +fn run_tap_selector_notes_unbound_declared_taps_on_stderr() { + let cwd = temp_cwd("tap-selector-unbound-note"); + let bp_path = cwd.join("two_tap.json"); + std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint"); + + let out = Command::new(BIN) + .args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"]) + .current_dir(&cwd) + .output() + .expect("spawn aura run"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"), + "stderr must note the unbound declared tap: {stderr}" + ); + assert!( + !stderr.contains("\"fast_tap\" unbound"), + "the subscribed tap must not be noted as unbound: {stderr}" + ); +} + +/// #334: the record-all default (no `--tap` flag) leaves no declared tap +/// unbound — nothing is skipped, so the note must never appear. +#[test] +fn run_with_no_tap_flag_emits_no_unbound_note() { + let cwd = temp_cwd("tap-selector-default-no-note"); + let bp_path = cwd.join("two_tap.json"); + std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint"); + + let out = Command::new(BIN) + .args(["run", bp_path.to_str().expect("utf-8 path")]) + .current_dir(&cwd) + .output() + .expect("spawn aura run"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("unbound this run"), + "the record-all default must never note a skipped tap: {stderr}" + ); +} + /// #310: a malformed or duplicate `--tap` is a usage error (exit 2) /// before anything runs. #[test] diff --git a/crates/aura-runner/src/member.rs b/crates/aura-runner/src/member.rs index 5bcc8ed..2c77f44 100644 --- a/crates/aura-runner/src/member.rs +++ b/crates/aura-runner/src/member.rs @@ -1728,9 +1728,14 @@ mod tests { Ok(_) => panic!("unknown tap must be refused"), }; assert!( - matches!(err, TapPlanError::UnknownTap { ref name } if name == "no_such_tap"), + matches!(err, TapPlanError::UnknownTap { ref name, .. } if name == "no_such_tap"), "{err:?}" ); + let msg = err.to_string(); + assert!( + msg.contains("fast_tap"), + "unknown-tap refusal enumerates the declared taps (fixture declares only fast_tap): {msg}" + ); // Unknown label — the refusal enumerates the roster. let mut flat2 = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles"); diff --git a/crates/aura-runner/src/tap_plan.rs b/crates/aura-runner/src/tap_plan.rs index 72467a3..4f15e47 100644 --- a/crates/aura-runner/src/tap_plan.rs +++ b/crates/aura-runner/src/tap_plan.rs @@ -161,17 +161,21 @@ impl FoldRegistry { }), }); for (label, doc, fold) in [ - ("count", "number of warm rows (any kind; i64 row)", FoldKind::Count), - ("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum), - ("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean), - ("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min), - ("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max), + ("count", "number of warm rows (any kind; i64 row); one row at the last warm ts", FoldKind::Count), + ("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum), + ( + "mean", + "arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts", + FoldKind::Mean, + ), + ("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min), + ("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max), ( "first", "first warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::First, ), - ("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last), + ("last", "last warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::Last), ] { r.register(FoldEntry { label, @@ -217,7 +221,7 @@ impl FoldRegistry { /// `aura: ` + exit-1 refusal register via `Display`. pub enum TapPlanError { /// The plan names a tap the blueprint does not declare. - UnknownTap { name: String }, + UnknownTap { name: String, declared: Vec }, /// The plan names a label the registry does not carry. UnknownLabel { label: String, roster: Vec<&'static str> }, /// The entry's bind rule rejects the tap's column kind. @@ -237,8 +241,12 @@ pub enum TapPlanError { impl fmt::Display for TapPlanError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - TapPlanError::UnknownTap { name } => { - write!(f, "the tap plan names '{name}', but the blueprint declares no such tap") + TapPlanError::UnknownTap { name, declared } => { + write!( + f, + "the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}", + declared.join(", ") + ) } TapPlanError::UnknownLabel { label, roster } => { write!(f, "unknown fold '{label}' — available: {}", roster.join(", ")) @@ -402,7 +410,10 @@ pub fn bind_tap_plan( // Unknown-tap guard: every plan name must be declared. for name in plan.by_name.keys() { if !declared_taps.iter().any(|t| &t.name == name) { - return Err(TapPlanError::UnknownTap { name: name.clone() }); + return Err(TapPlanError::UnknownTap { + name: name.clone(), + declared: declared_taps.iter().map(|t| t.name.clone()).collect(), + }); } } @@ -422,7 +433,18 @@ pub fn bind_tap_plan( Some((label, params)) => { Resolved::Named { label: label.clone(), params: params.clone() } } - None => continue, // unbound, inert (C27) + // Unbound, inert (C27) — but only reachable when `plan` carries + // no default (an EXPLICIT plan, e.g. from `--tap`): record-all + // (`default_named` = `Some(("record", …))`) always resolves the + // Some arm above, so this arm never fires under record-all and + // the note is exactly the C14 benign skipped-tap class (#334). + // Emitted here (aura-runner), beside the pre-existing + // runner-side `eprintln!` registers in this module/`member.rs`/ + // `measure.rs` — the runner→CLI print migration is #297. + None => { + eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name); + continue; + } }, }; if let Resolved::Named { label, params } = &sub { diff --git a/docs/design/contracts/c27-declared-taps.md b/docs/design/contracts/c27-declared-taps.md index c0b4f42..dee625f 100644 --- a/docs/design/contracts/c27-declared-taps.md +++ b/docs/design/contracts/c27-declared-taps.md @@ -84,7 +84,12 @@ consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's streaming writer in-graph — `initialize` opens (deferred acquisition), `eval` appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one -summary row; live closures run inline (`aura-std::TapLive`). The sweep/reduce +summary row, stamped with the source value's own timestamp for `first`/`last` +(they pin a single source instant) and with the finalize timestamp for the +accumulator folds (`count`/`sum`/`mean`/`min`/`max` summarise a range and have +no single source instant; `min`/`max` deliberately do not carry the extremum's +timestamp — ratified as-is, #335); live closures run inline +(`aura-std::TapLive`). The sweep/reduce path never calls `bind_tap`. The chain-pruning benefit — a sweep paying zero for the study wires behind an diff --git a/docs/glossary.md b/docs/glossary.md index f6bd469..686729c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -327,7 +327,7 @@ A named recorded stream produced by a recording `sink` — the addressable label Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura run` constructs a recorder at each and persists the series through the trace store; a sweep leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names. -Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura run` (`aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura run` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. +Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura run` (`aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura run` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is stamped with the source value's own timestamp for `first`/`last` (they pin a single source instant) and with the finalize timestamp for the accumulator folds (`count | sum | mean | min | max` summarise a range; `min`/`max` deliberately do not carry the extremum's instant — ratified #335). ### topology hash **Avoid:** —