Files
Aura/docs/specs/0086-cost-flag-cli-ergonomics.md
T
Brummel f26b33f345 spec: 0086 cost-flag CLI ergonomics (boss-signed)
Settled design from #153 (fieldtest friction of milestone #148): make the
`aura run` cost-flag surface honest — a units/constraints note in the usage,
a named non-negativity diagnostic, and a refuse-don't-guess rejection of cost
flags on a non-R harness (the spec_gap; decided reject/exit-2, recorded on
#153). CLI-ergonomics + one C10 ledger note only; no cost-model/R-math change.

Boss-signed: grounding-check PASS (independent fresh-context agent; 6/6
load-bearing assumptions ratified by currently-green tests).

refs #153
2026-06-29 11:24:55 +02:00

10 KiB
Raw Blame History

Cost-flag CLI ergonomics — Design Spec

Date: 2026-06-29 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Close the three non-blocking ergonomics/diagnostic gaps the milestone-close fieldtest of #148 surfaced on the aura run cost-flag surface (--cost-per-trade, --slip-vol-mult, --carry-per-cycle), plus the one named spec_gap. The cost-model-graph capability already ships end-to-end; this cycle only makes its CLI surface honest and self-describing, changing no cost-model node, no R chain, and no net_r_equity / summarize_r math.

Three observable defects, all instances of one anti-pattern (a misuse that produces no actionable feedback):

  1. Units undiscoverable. The usage string lists the three cost flags with no unit, scale, or per-close-vs-per-held-cycle note. A user must trial magnitudes to find one that moves net-R.
  2. Negative-rate diagnostic is mute. A negative rate already exits 2, but the message is the bare 1-line usage dump — it never names the cause, and the usage never states the non-negativity constraint, so a sign typo is indistinguishable from a mistyped flag.
  3. Silent no-op on a non-R harness (the spec_gap). aura run --harness sma --cost-per-trade 0.001 exits 0 with output byte-identical to the no-cost run: the flag is silently ignored. C10 defines cost only against the R chain and never constrained what a cost flag does on a harness that produces no R.

Architecture

All three changes live in crates/aura-cli/src/main.rs, in and around the pure parse_run_args grammar (line 3136) — the function the codebase deliberately keeps side-effect-free so the grammar is unit-testable; main already maps its Err(String) to eprintln!("aura: {msg}") + exit(2) (line 3277). Keeping every new rejection inside parse_run_args means each is a pure, unit-testable Result::Err, and the fail-loud exit path is the existing one — no new exit site, no new I/O. One ledger note pins the decision in C10.

Decision recorded (reference issue #153, 2026-06-29 — derived, not preference): item 3 resolves to reject, exit 2 (not warn-and-continue). Basis: aura's settled refuse-don't-guess / fail-loud discipline — C10 refuse-don't-guess (exit 2 on absent geometry), the "every error path exits 2, never panics" rule (#107), and the negative-rate guard in this very surface already exits 2. A silent no-op is the lone exception in an otherwise fail-loud surface; rejecting is the only behaviour consistent with every sibling guard.

Concrete code shapes

Worked user-facing surface (the acceptance evidence)

# Item 1 — units/constraints discoverable at the point of use (--help, exit 0)
$ aura run --help
usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] [--carry-per-cycle <f64>] | aura chart ...
cost flags (stage1-r only; all >= 0, charged in R as cost/|entrystop|): --cost-per-trade = per-trade cost in price units; --slip-vol-mult = slippage multiplier on realized vol; --carry-per-cycle = carry in price units per ENGINE cycle (not per day, not an overnight swap)

# Item 2 — a negative rate names the cause + flag (exit 2, stderr), no runs/ write
$ aura run --harness stage1-r --cost-per-trade -0.5
aura: --cost-per-trade must be non-negative, got -0.5
$ echo $?
2

# Item 3 — a cost flag on a non-R harness is a usage error (exit 2, stderr), no runs/ write
$ aura run --harness sma --cost-per-trade 0.001
aura: cost flags require an R-evaluator harness (stage1-r); --harness sma/macd produces no R to charge against
$ echo $?
2

# Unchanged: the accepted path is byte-identical to today
$ aura run --harness stage1-r --cost-per-trade 2          # → net_expectancy_r as before
$ aura run --harness stage1-r --carry-per-cycle 0         # → exactly free (zero-rate boundary)

Implementation shape (before → after, secondary)

(a) Non-negativity guard — named cause, factored (items 2). The three flags currently restate an identical guard that returns the bare usage:

// BEFORE (×3, lines 3188 / 3197 / 3206):
"--cost-per-trade" if cost.is_none() => {
    let (value, t) = t.split_first().ok_or_else(usage)?;
    let v: f64 = value.parse().map_err(|_| usage())?;
    if v < 0.0 { return Err(usage()); }      // mute: bare usage dump
    cost = Some(v);
    tail = t;
}

// AFTER: one helper, named diagnostic; the three arms call it.
fn parse_nonneg_rate(flag: &str, value: &str, usage: &impl Fn() -> String) -> Result<f64, String> {
    let v: f64 = value.parse().map_err(|_| usage())?;     // malformed → grammar usage error (unchanged)
    if v < 0.0 {
        return Err(format!("{flag} must be non-negative, got {v}"));  // named cause + offending flag
    }
    Ok(v)
}
// arm:
"--cost-per-trade" if cost.is_none() => {
    let (value, t) = t.split_first().ok_or_else(usage)?;
    cost = Some(parse_nonneg_rate("--cost-per-trade", value, &usage)?);
    tail = t;
}

A malformed value (--cost-per-trade x) stays a grammar usage error (unchanged); only the parsed-but-negative case gets the named message.

(b) Non-R-harness guard — post-parse, pure (item 3). After the parse loop, once harness is resolved (line 3215), before constructing RunArgs:

let harness = harness.unwrap_or(HarnessKind::Sma);
// Cost flags are defined only against the R chain; a non-R harness produces no R
// to charge against, so a cost flag there is a usage error (refuse-don't-guess).
if !matches!(harness, HarnessKind::Stage1R)
    && (cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some())
{
    return Err(
        "cost flags require an R-evaluator harness (stage1-r); \
         --harness sma/macd produces no R to charge against".to_string(),
    );
}

(c) Usage note — units + constraints, one appended line (item 1). A const COST_FLAGS_NOTE: &str appended to both the parse_run_args usage closure (line 3137) and the top-level USAGE (line 3244), so the same note shows on a grammar error and on --help:

const COST_FLAGS_NOTE: &str = "\ncost flags (stage1-r only; all >= 0, charged in R as cost/|entrystop|): \
--cost-per-trade = per-trade cost in price units; --slip-vol-mult = slippage multiplier on realized vol; \
--carry-per-cycle = carry in price units per ENGINE cycle (not per day, not an overnight swap)";

Ledger note (C10) — pin the spec_gap decision

Add one note to contract C10 in docs/design/INDEX.md (cost-model area), e.g.:

Realization (cost-flag harness scoping, #153). Cost flags are defined only against an R-evaluator harness (the gross-R → net-R chain). On a non-R harness (--harness sma/macd, which produce no R) a cost flag is a usage error (exit 2), not a silent no-op — refuse-don't-guess. Negative cost rates are likewise rejected (exit 2) with a named diagnostic.

Components

  • parse_nonneg_rate (new, pure, private) — parse + non-negativity, named error.
  • parse_run_args (edited) — three arms call the helper; one post-parse R-harness guard; the usage closure appends COST_FLAGS_NOTE.
  • COST_FLAGS_NOTE (new const) + USAGE (edited to append it).
  • C10 ledger note (one paragraph).

Data flow

Unchanged at runtime. parse_run_args either returns Ok(RunArgs) (the accepted path → run_dispatch → identical to today) or Err(String) (the new named messages → main's existing eprintln! + exit(2)). No cost-model node, harness, tap, or metric is touched.

Error handling

Every misuse is a single, named, fail-loud stderr line + exit(2), no runs/ write, no panic — the established CLI convention. Three messages: malformed value → existing grammar usage; negative rate → <flag> must be non-negative, got <v>; cost flag on non-R harness → cost flags require an R-evaluator harness (stage1-r); ….

Testing strategy

Unit (pure parse_run_args, in main.rs tests):

  • parse_run_args(["--cost-per-trade","-0.5"])Err containing non-negative and -0.5.
  • parse_run_args(["--carry-per-cycle","-0.001"]) and ["--slip-vol-mult","-1"]Err containing non-negative (guard symmetry across all three).
  • parse_run_args(["--harness","sma","--cost-per-trade","0.001"])Err containing R-evaluator harness.
  • parse_run_args(["--harness","macd","--slip-vol-mult","0.5"])Err.
  • parse_run_args(["--carry-per-cycle","0.5"]) (no --harness, defaults to sma) → Err (R-evaluator requirement).
  • parse_run_args(["--harness","stage1-r","--cost-per-trade","0.001"])Ok (the accepted path is unchanged).
  • Malformed value still maps to the grammar usage error (regression).

CLI integration (crates/aura-cli/tests/cli_run.rs):

  • aura run --harness sma --cost-per-trade 0.001 → exit 2, stderr contains R-evaluator harness, no runs/ write.
  • aura run --cost-per-trade -0.5 → exit 2, stderr contains non-negative.
  • aura run --help → exit 0, stdout contains the cost-flags note (units + per-engine-cycle + >= 0 + stage1-r-only).
  • Regression: the existing stage1-r cost tests (cli_run.rs:1635/1688/1716/ 1738/1759/1830/1879) stay green byte-for-byte — the accepted and negative-rate exit-code paths are unchanged in code-behaviour; only message text and the previously-unguarded sma path change.

Acceptance criteria

Judged against aura's feature-acceptance criterion (improves correctness / removes a foot-gun; reintroduces no failure class the core constraints forbid):

  • The cost-flag units, scale, per-engine-cycle semantics, non-negativity, and R-harness requirement are all discoverable from aura run --help.
  • No cost-flag misuse is silent: a negative rate and a cost flag on a non-R harness each produce a named stderr diagnostic + exit 2, never a silent no-op and never a bare usage dump for these two named causes.
  • The accepted stage1-r cost path and all its goldens are byte-unchanged.
  • C10 carries a one-line note pinning "cost flags require an R-evaluator harness; non-R harness ⇒ usage error (exit 2)", closing the spec_gap.