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

211 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```text
# 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:
```rust
// 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`:
```rust
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`:
```rust
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.