From 8ef829b8ccd647bdc72a9c7f1c954a9ede2c5140 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 3 Jul 2026 23:15:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20process|campaign=20introspect=20--?= =?UTF-8?q?metrics=20=E2=80=94=20the=20roster=20split=20becomes=20discover?= =?UTF-8?q?able=20(0107=20F9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 17-name metric vocabulary lists with applicability tags derived from aura-campaign's existing pub rosters (rankable | gate | annotation) — no fourth roster site (#190); the mode rides the shared introspect struct and answers for both doc families. The three metric refusals (intrinsic unknown-metric, executor unrankable and gate-not-per-member) now point at the mode, so the exists-but-not-rankable split (profit_factor is emitted per member yet not selectable) is no longer trial-and-error. RED-first (tdd-author handoff): six same-seam pins (roster lines + tags, one-mode guard incl. the extended usage line, both-families parity, three refusal pointers) observed failing, then the mode + hints landed. The worked-examples half of #197 stays open (docwriter territory post-stability, per the issue itself). Gates: workspace 1001/0, clippy -D warnings clean. refs #197 --- crates/aura-cli/src/campaign_run.rs | 40 +++++++++++- crates/aura-cli/src/research_docs.rs | 57 ++++++++++++++++- crates/aura-cli/tests/research_docs.rs | 85 ++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 15fe8de..7b57c95 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -53,11 +53,13 @@ fn exec_fault_prose(f: &ExecFault) -> String { } ExecFault::UnrankableMetric { stage, metric } => format!( "process stage {stage}: metric \"{metric}\" is not rankable (winner \ - selection needs one of the registry's rankable metrics)" + selection needs one of the registry's rankable metrics; see: aura \ + process introspect --metrics)" ), ExecFault::GateMetricNotPerMember { stage, metric } => format!( "process stage {stage}: gate metric \"{metric}\" is not a per-member \ - scalar (selection annotations cannot gate members)" + scalar (selection annotations cannot gate members; see: aura process \ + introspect --metrics)" ), ExecFault::PlateauInWalkForward { stage } => format!( "process stage {stage}: walk_forward cannot use a plateau select (a \ @@ -448,6 +450,40 @@ mod tests { ParamSpec { name: name.to_string(), kind: ScalarKind::I64 } } + #[test] + /// #197 (fieldtest 0107 F9): the executor's preflight refusal for a + /// non-rankable selection metric points the author at the verb that + /// enumerates the applicable roster, so the exists-but-not-rankable split + /// (a metric emitted in every member's block yet refused for winner + /// selection) is resolvable without trial and error. The intrinsic message + /// is unchanged; only a discovery pointer is appended. + fn unrankable_metric_prose_points_at_the_metrics_introspection() { + let prose = exec_fault_prose(&ExecFault::UnrankableMetric { + stage: 0, + metric: "profit_factor".into(), + }); + assert!(prose.contains("is not rankable"), "intrinsic message kept: {prose}"); + assert!( + prose.contains("aura process introspect --metrics"), + "the refusal must point at the enumeration verb: {prose}" + ); + } + + #[test] + /// #197: the twin refusal — a gate predicate over a non-per-member scalar + /// (a selection annotation) — likewise points at the enumeration verb. + fn gate_metric_not_per_member_prose_points_at_the_metrics_introspection() { + let prose = exec_fault_prose(&ExecFault::GateMetricNotPerMember { + stage: 1, + metric: "deflated_score".into(), + }); + assert!(prose.contains("is not a per-member"), "intrinsic message kept: {prose}"); + assert!( + prose.contains("aura process introspect --metrics"), + "the refusal must point at the enumeration verb: {prose}" + ); + } + #[test] /// The only shape treated as a direct store address is a bare 64-char /// lowercase-hex token; anything else (wrong length, uppercase, non-hex) diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 79aacf6..9394cad 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -45,6 +45,9 @@ pub struct DocIntrospectCmd { /// Print the content id of a valid document #[arg(long, value_name = "FILE")] pub content_id: Option, + /// List the metric vocabulary with applicability tags + #[arg(long)] + pub metrics: bool, } impl DocIntrospectCmd { @@ -53,6 +56,7 @@ impl DocIntrospectCmd { + usize::from(self.block.is_some()) + usize::from(self.unwired.is_some()) + usize::from(self.content_id.is_some()) + + usize::from(self.metrics) } } @@ -61,12 +65,35 @@ impl DocIntrospectCmd { fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) { if cmd.selected_modes() != 1 { eprintln!( - "aura: Usage: aura {family} introspect --vocabulary | --block | --unwired | --content-id " + "aura: Usage: aura {family} introspect --vocabulary | --block | --unwired | --content-id | --metrics" ); std::process::exit(2); } } +/// `--metrics`: the 17-name metric vocabulary, tagged by where a name may be +/// used — `rankable` (sweep/walk_forward `select` metrics), `gate` +/// (per-member gate predicates), `annotation` (selection annotations, +/// neither). The tags derive from aura-campaign's existing pub rosters, so +/// this mode adds no fourth roster site (#190); metrics are process-side +/// vocabulary, but the mode rides the shared introspect struct and answers +/// for both families. +fn print_metric_roster() { + for name in aura_research::metric_vocabulary() { + let rankable = aura_campaign::RANKABLE_METRICS.contains(name); + let gate = aura_campaign::PER_MEMBER_METRICS.contains(name); + let tag = match (rankable, gate) { + (true, true) => "rankable | gate", + (false, true) => "gate", + // Unreachable today (the rankable roster is a subset of the + // per-member roster) — kept total so a roster shift stays honest. + (true, false) => "rankable", + (false, false) => "annotation", + }; + println!("{name:<24} {tag}"); + } +} + pub(crate) fn doc_error_prose(what: &str, e: &DocError) -> String { match e { DocError::NotJson(msg) => format!("{what} is not JSON: {msg}"), @@ -88,7 +115,9 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String { match f { DocFault::EmptyPipeline => "the pipeline is empty — a process needs at least one stage".into(), DocFault::UnknownMetric { stage, metric } => { - format!("stage {stage}: unknown metric \"{metric}\"") + format!( + "stage {stage}: unknown metric \"{metric}\" (see: aura process introspect --metrics)" + ) } DocFault::EmptyConjunction { stage } => { format!("stage {stage}: a gate needs at least one predicate") @@ -186,6 +215,10 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> { } fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> { + if cmd.metrics { + print_metric_roster(); + return Ok(()); + } if cmd.vocabulary { for b in process_vocabulary() { println!("{:<18} {}", b.id, b.doc); @@ -365,6 +398,10 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> { } fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> { + if cmd.metrics { + print_metric_roster(); + return Ok(()); + } if cmd.vocabulary { for b in campaign_vocabulary() { println!("{:<18} {}", b.id, b.doc); @@ -443,6 +480,22 @@ mod tests { assert!(!bare.contains("drop the 'content:' prefix"), "bare id must not carry the hint: {bare}"); } + #[test] + /// #197 (fieldtest 0107 F9): the intrinsic `unknown metric` refusal points + /// the author at the verb that enumerates the valid names, instead of + /// leaving the 17-name vocabulary discoverable only via the glossary. The + /// name itself is still quoted (the existing contract, pinned by the e2e + /// `process_validate_refuses_unknown_metric_*` test); only a discovery + /// pointer is appended. + fn unknown_metric_prose_points_at_the_metrics_introspection() { + let prose = doc_fault_prose(&DocFault::UnknownMetric { stage: 0, metric: "netto_r".into() }); + assert!(prose.contains("unknown metric \"netto_r\""), "name still quoted: {prose}"); + assert!( + prose.contains("aura process introspect --metrics"), + "the refusal must point at the enumeration verb: {prose}" + ); + } + #[test] fn zero_walk_forward_length_prose_is_path_addressed_and_debug_free() { let prose = doc_fault_prose(&DocFault::ZeroWalkForwardLength { stage: 2, field: "step_ms" }); diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 9b481dc..7de84b0 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -185,6 +185,68 @@ fn process_introspect_no_flag_is_usage_exit_2() { assert_eq!(code, Some(2)); } +/// #197 (fieldtest 0107 F9): the metric roster is enumerable from the public +/// surface. `aura process introspect --metrics` lists every name in +/// `aura_research::metric_vocabulary()` (17), each annotated with where it is +/// applicable — `rankable` (a sweep/walk_forward `select` metric, from +/// `aura_campaign::RANKABLE_METRICS`), `gate` (a gate-predicate per-member +/// scalar, from `aura_campaign::PER_MEMBER_METRICS`), or `annotation` (in +/// neither roster — a selection annotation that can neither rank nor gate). The +/// split the executor enforces at preflight (a metric may exist yet not be +/// rankable, F9) is thereby readable before any trial run. Substrings are +/// pinned, not exact column widths. +#[test] +fn process_introspect_metrics_lists_vocabulary_with_applicability() { + let (out, code) = run_code(&["process", "introspect", "--metrics"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + + // One line per vocabulary name, nothing else — the whole 17-name roster. + let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!(lines.len(), 17, "one line per metric_vocabulary() name: {out}"); + + // Locate a metric's line by its exact name column (first whitespace token), + // so `expectancy_r` never matches `net_expectancy_r`'s line. + let line_for = |name: &str| -> String { + out.lines() + .find(|l| l.split_whitespace().next() == Some(name)) + .unwrap_or_else(|| panic!("no line for {name}: {out}")) + .to_string() + }; + + // expectancy_r is BOTH rankable and a per-member gate scalar. + let er = line_for("expectancy_r"); + assert!(er.contains("rankable") && er.contains("gate"), "expectancy_r tags: {er}"); + + // total_pips likewise (a rankable scalar that is also per-member). + let tp = line_for("total_pips"); + assert!(tp.contains("rankable") && tp.contains("gate"), "total_pips tags: {tp}"); + + // win_rate is per-member (gate) but NOT rankable. + let wr = line_for("win_rate"); + assert!(wr.contains("gate") && !wr.contains("rankable"), "win_rate tags: {wr}"); + + // deflated_score is a selection annotation — in neither roster. + let ds = line_for("deflated_score"); + assert!(ds.contains("annotation"), "deflated_score tag: {ds}"); +} + +/// `--metrics` joins the existing one-mode guard: exactly one introspect mode +/// is allowed, so `--metrics` alongside another mode refuses with the usage +/// line (exit 2), and that usage line is extended to advertise `--metrics` as +/// one of the modes. (The guard's own `Usage: aura process introspect +/// --vocabulary …` line is asserted — never clap's unknown-arg error, which +/// spells the flag list as `[OPTIONS]`.) +#[test] +fn process_introspect_metrics_plus_another_mode_is_usage_exit_2() { + let (out, code) = run_code(&["process", "introspect", "--metrics", "--vocabulary"]); + assert_eq!(code, Some(2), "stdout/stderr: {out}"); + assert!( + out.contains("Usage: aura process introspect --vocabulary"), + "the one-mode guard usage line must fire: {out}" + ); + assert!(out.contains("--metrics"), "the usage line must advertise --metrics: {out}"); +} + #[test] fn process_register_stores_content_addressed_under_runs_root() { let dir = temp_cwd("process-register"); @@ -507,6 +569,29 @@ fn campaign_introspect_two_flags_is_usage_exit_2() { assert!(out.contains("Usage: aura campaign introspect"), "stdout/stderr: {out}"); } +/// #197: the metrics roster is process-side R-vocabulary, but `--metrics` rides +/// the shared `DocIntrospectCmd` both families route through, so `aura campaign +/// introspect --metrics` WORKS too rather than usage-refusing. Ground for the +/// pin: a mode the shared struct carries needs no per-family exclusion, and the +/// vocabulary a campaign's referenced process must name is thus discoverable +/// from the campaign verb as well (simplest honest reading). Same annotated +/// roster as the process family. +#[test] +fn campaign_introspect_metrics_also_lists_the_vocabulary() { + let (out, code) = run_code(&["campaign", "introspect", "--metrics"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let er = out + .lines() + .find(|l| l.split_whitespace().next() == Some("expectancy_r")) + .unwrap_or_else(|| panic!("no expectancy_r line: {out}")); + assert!(er.contains("rankable") && er.contains("gate"), "expectancy_r tags: {er}"); + assert!( + out.lines() + .any(|l| l.split_whitespace().next() == Some("deflated_score") && l.contains("annotation")), + "the annotation roster is reachable from the campaign family too: {out}" + ); +} + /// Register must be a gate, not a passthrough: an invalid document is /// refused with prose (exit 1) and — the property that matters for a /// content-addressed store — no file is ever written under `runs/campaigns/`