feat(cli): process|campaign introspect --metrics — the roster split becomes discoverable (0107 F9)

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
This commit is contained in:
2026-07-03 23:15:16 +02:00
parent 1d87397c95
commit 8ef829b8cc
3 changed files with 178 additions and 4 deletions
+38 -2
View File
@@ -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)
+55 -2
View File
@@ -45,6 +45,9 @@ pub struct DocIntrospectCmd {
/// Print the content id of a valid document
#[arg(long, value_name = "FILE")]
pub content_id: Option<PathBuf>,
/// 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 <ID> | --unwired <FILE> | --content-id <FILE>"
"aura: Usage: aura {family} introspect --vocabulary | --block <ID> | --unwired <FILE> | --content-id <FILE> | --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" });
+85
View File
@@ -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/`