From 891c40d64b9187c65b4ccb9b97caf4ec1995e12c Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 4 Jul 2026 02:14:58 +0200 Subject: [PATCH] feat(cli): generalize applicability tag + honest R-expectancy refusal prose (fieldtest 0108 F8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --metrics now tags the four generalizable names (expectancy_r, net_expectancy_r, sqn, sqn_normalized) via check_r_metric as the predicate — the registry's own R-expectancy rule, no fourth roster site (#190). The GeneralizeNonRMetric refusal names the real rule with the roster pointer instead of mislabeling R-denominated-but- unranked metrics (max_r_drawdown is a drawdown in R, not a 'pip metric'). RED-first (tdd-author handoff): the extended --metrics pins and the flipped prose pins (unit + seam) observed failing on the old output. Gates: workspace 1026/0, clippy -D warnings clean. closes #207 --- crates/aura-cli/src/campaign_run.rs | 18 +++++++++----- crates/aura-cli/src/research_docs.rs | 11 +++++++-- crates/aura-cli/tests/research_docs.rs | 34 +++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 619e98f..f83451e 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -68,8 +68,9 @@ pub(crate) fn exec_fault_prose(f: &ExecFault) -> String { "campaign has {available} instrument(s); std::generalize needs at least 2" ), ExecFault::GeneralizeNonRMetric { metric } => format!( - "std::generalize metric \"{metric}\" is not an R metric (gross/net R \ - families generalize; pip metrics do not)" + "std::generalize metric \"{metric}\" is not in the rankable R-expectancy \ + family (expectancy_r, net_expectancy_r, sqn, sqn_normalized generalize; \ + see: aura process introspect --metrics)" ), ExecFault::ZeroBootstrapParam { stage, field } => format!( "process stage {stage}: monte_carlo {field} must be > 0" @@ -500,16 +501,21 @@ mod tests { } #[test] - /// generalize's R-metric refusal names the metric and the R-only - /// rationale (pip metrics are not comparable across instruments). + /// #207 (fieldtest 0108 F8): generalize's refusal names the REAL rule — the + /// rankable R-expectancy family that generalizes — and points at the roster + /// verb, instead of the old "pip metrics do not" mislabel (which was wrong + /// for R-denominated-but-unranked names like max_r_drawdown). The four + /// accepted names are enumerated in the prose as a fail-safe hint; the + /// durable source is the `(see: …)` roster pointer. fn generalize_non_r_metric_prose_names_the_metric() { let prose = exec_fault_prose(&ExecFault::GeneralizeNonRMetric { metric: "total_pips".into(), }); assert_eq!( prose, - "std::generalize metric \"total_pips\" is not an R metric (gross/net R \ - families generalize; pip metrics do not)" + "std::generalize metric \"total_pips\" is not in the rankable R-expectancy \ + family (expectancy_r, net_expectancy_r, sqn, sqn_normalized generalize; \ + see: aura process introspect --metrics)" ); assert!(!prose.contains("GeneralizeNonRMetric"), "Debug leak: {prose}"); } diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index fd49651..a546062 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -82,7 +82,10 @@ 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) { + // The generalize applicability is the registry's own R-expectancy + // predicate — no fourth roster site (#190/#207). + let generalize = aura_registry::check_r_metric(name).is_ok(); + let base = match (rankable, gate) { (true, true) => "rankable | gate", (false, true) => "gate", // Unreachable today (the rankable roster is a subset of the @@ -90,7 +93,11 @@ fn print_metric_roster() { (true, false) => "rankable", (false, false) => "annotation", }; - println!("{name:<24} {tag}"); + if generalize { + println!("{name:<24} {base} | generalize"); + } else { + println!("{name:<24} {base}"); + } } } diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index a39f7b5..8801a2c 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -193,8 +193,15 @@ fn process_introspect_no_flag_is_usage_exit_2() { /// 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. +/// rankable, F9) is thereby readable before any trial run. #207 (fieldtest +/// 0108 F8) adds a fourth tag `generalize` on the rankable R-expectancy family +/// (`expectancy_r`, `net_expectancy_r`, `sqn`, `sqn_normalized`) — exactly the +/// names `aura_registry::check_r_metric` accepts, i.e. std::generalize's +/// cross-instrument accept-set — so an author can read WHICH metrics generalize +/// before authoring the stage. A rankable non-R metric (`total_pips`) and an +/// R-denominated-but-unranked metric (`max_r_drawdown`) both stay OUT of it: +/// the tag tracks `check_r_metric`, not the presence of `_r` in the name. +/// Substrings are pinned, not exact column widths. #[test] fn process_introspect_metrics_lists_vocabulary_with_applicability() { let (out, code) = run_code(&["process", "introspect", "--metrics"]); @@ -228,6 +235,22 @@ fn process_introspect_metrics_lists_vocabulary_with_applicability() { // deflated_score is a selection annotation — in neither roster. let ds = line_for("deflated_score"); assert!(ds.contains("annotation"), "deflated_score tag: {ds}"); + + // #207: the four rankable R-expectancy names — std::generalize's + // cross-instrument accept-set (aura_registry::check_r_metric) — carry the + // `generalize` tag, so an author reads WHICH metrics generalize from the + // roster instead of guessing at the stage. + for name in ["expectancy_r", "net_expectancy_r", "sqn", "sqn_normalized"] { + let l = line_for(name); + assert!(l.contains("generalize"), "{name} must carry the generalize tag: {l}"); + } + + // A rankable-but-not-R metric (total_pips) and an R-DENOMINATED-but-unranked + // metric (max_r_drawdown, a drawdown IN R) both stay out of the generalize + // set: the tag tracks check_r_metric, never the `_r` in the name. + assert!(!tp.contains("generalize"), "total_pips must not carry generalize: {tp}"); + let mrd = line_for("max_r_drawdown"); + assert!(!mrd.contains("generalize"), "max_r_drawdown must not carry generalize: {mrd}"); } /// `--metrics` joins the existing one-mode guard: exactly one introspect mode @@ -940,9 +963,12 @@ fn campaign_run_refuses_generalize_non_r_metric() { write_doc(dir, "genpip.campaign.json", &campaign_doc); let (out, code) = run_code_in(dir, &["campaign", "run", "genpip.campaign.json"]); assert_eq!(code, Some(1), "stdout/stderr: {out}"); + // #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable + // R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is + // R-denominated yet unranked, so "pip metric" was always wrong. assert!( - out.contains("std::generalize metric \"total_pips\" is not an R metric"), - "the refusal names the offending metric: {out}" + out.contains("std::generalize metric \"total_pips\" is not in the rankable R-expectancy family"), + "the refusal names the offending metric and the real rule: {out}" ); assert!(!out.contains("2 instrument"), "the arity guard must not fire when arity is fine: {out}"); assert!(!out.contains("GeneralizeNonRMetric"), "Debug leak: {out}");