From f26b33f3453e2117c38432a65d3ac15f3c20fbcd Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 29 Jun 2026 11:24:55 +0200 Subject: [PATCH] spec: 0086 cost-flag CLI ergonomics (boss-signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/specs/0086-cost-flag-cli-ergonomics.md | 210 ++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/specs/0086-cost-flag-cli-ergonomics.md diff --git a/docs/specs/0086-cost-flag-cli-ergonomics.md b/docs/specs/0086-cost-flag-cli-ergonomics.md new file mode 100644 index 0000000..f11b3c9 --- /dev/null +++ b/docs/specs/0086-cost-flag-cli-ergonomics.md @@ -0,0 +1,210 @@ +# 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 ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ] | aura chart ... +cost flags (stage1-r only; all >= 0, charged in R as cost/|entry−stop|): --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 { + 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/|entry−stop|): \ +--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 → ` must be +non-negative, got `; 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.