feat(cli): generalize applicability tag + honest R-expectancy refusal prose (fieldtest 0108 F8)

--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
This commit is contained in:
2026-07-04 02:14:58 +02:00
parent 38ffe50b57
commit 891c40d64b
3 changed files with 51 additions and 12 deletions
+12 -6
View File
@@ -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}");
}
+9 -2
View File
@@ -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}");
}
}
}
+30 -4
View File
@@ -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}");