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:
@@ -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)
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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/`
|
||||
|
||||
Reference in New Issue
Block a user