Cost-model graph (in R): composable cost nodes producing the net-R curve #148
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Per the 2026-06-28 C10 rework (ledger commit
29cdc8c, supersedes the retired realistic-broker milestone): cost in research is a composable C9 graph of cost nodes, in R, that approximates (never claims) realism.Scope:
aura-std(+ a cost-graph composite-builder inaura-composites), never in the domain-freeaura-engine.round_trip_cost:summarize_rfolds the cost-model's net-R rather than recomputing a scalar (one home for cost, no double-count).net_r_equitytap/sink (sibling ofr_equity). A bare gross-R run (no cost model) stays valid.cost_in_R = cost_in_currency / (size·stop_dist)), so the model is R-pure without holding equity.Σ realized_rvs conviction-weightedΣ |bias|·realized_r), distinct from the removed money-Sizer; tested via the existingconviction_at_entry/conviction_terciles_r.Honesty: the net-R curve is a research / ranking hypothesis; the forward / live run is the ground truth (see the live/deploy-edge issue).
Likely a milestone container (cost-node abstraction → specific cost nodes →
net_r_equitytap & charting → conviction axis); a brainstorm/specify scopes the first cycle.Cycle 1 scope — fork decisions (recorded under
/boss, autonomous)Routing: the design is settled in the sources (ledger C10 lines 480–679 + this issue), so this run enters through
specifydirect-entry, not an interactive brainstorm. Reference issue: this one. Session anchor: maina517899. Grounded by a 7-subsystem code recon (round_trip_cost, summarize_r/RMetrics, the r_equity tap, RiskExecutor/PM dense record, the aura-std Node substrate, the aura-composites builder idiom, ingestion geometry).Decisions:
A — Cost is an in-graph node, not a post-run scalar. A cost node emits a
cost_in_rstream;net_r_equityis its sink;summarize_rfolds that same stream. Why: C10 mandates a composable C9 graph of cost nodes and namesnet_r_equitya sink (C8/C18 — only a sink can display), so the cost node is the single producer both consumers read (one home for cost, no double-count).B — One concrete cost node + the integration seam this cycle; defer the general
CostNodetrait and the multi-node cost-graph composite-builder. Why: a one-implementor abstraction is speculative generality (the exact smell #147 was deferred for, 2026-06-27), and the issue's "abstraction + composite-builder" wording is the milestone breakdown, not binding cycle-1 scope ("scope the FIRST cycle minimally"). Checked with an adversarial scope-fork skeptic → SOUND: the seam is edge-wired, so an N-node cost graph is an additive upstream LinComb feeding the same one-edge seam unchanged — no corner.C — First node:
ConstantCost(a flat round-trip cost per trade, a labelled stress-parameter). Why: C10's "default simple" floor; it is the degenerate constant-per-trade case that subsumes the scalarround_trip_costexactly (size = 1 ⇒cost_in_r = cost_currency / latched_dist), keeping the C18 goldens byte-identical at cost = 0.D —
net_r_equity = r_equity − Σ cost_in_r, built with the sameLinComb+Recorderidiom (a third summed term, weight −1, over a running cost sum). Why: C10 names it "sibling of the existing r_equity tap", so reuse the proven two-node tap construction.E — R-pure normalization, no equity.
cost_in_r = cost_currency / (size · latched_dist), with size flat-1R (unit) andlatched_dist = |entry_price − stop_price|read from the dense record. Why: C10 — notional cancels, so the model is R-pure without ever holding equity.F — A run with no cost node stays a valid gross-R baseline. Why: C10 — the cost layer is optional and additively composed-on; this is also what keeps the existing goldens byte-identical.
Deferred to later cycles (recorded, not built): data-grounded cost nodes (realized-vol → slippage, recorded-rate → swap), per-cycle-held accrual (carry / funding), the general
CostNodetrait + the cost-graph composite-builder (emerge when a second grounded factor exists), the conviction-weighting R-aggregation axis (flat Σ realized_r vs Σ |bias|·realized_r), percentage-of-notional cost (needs pip-value from InstrumentGeometry), the Veto seam, and the Sizer-port-drop cleanup (orthogonal).Open implementation ambiguity handed to
specify/planner(flagged by the skeptic, outside the scope-fork lens): how a per-cyclecost_in_rstream maps ontosummarize_r's per-closed-trade fold — fire-only-on-close vs fire-every-cycle-then-filter — while preserving the byte-pinned net token formrealized_r − cost_currency/latched_dist.Derived implementation decisions (specify Step 1.5) — the cost↔trade alignment
Grounding the seam against the live tree surfaced one load-bearing mechanic: the window-end trade is synthesized post-run by
summarize_r(aura-analysis/src/lib.rs 174–184 — it scansrecord.last()and force-closes an open position fromunrealized_r), it is not an in-graph close PM emits. A naive in-graph cost node (cost only on real closes) would therefore miss the window-end trade, misaligning the cost stream againstsummarize_r's ledger.G —
ConstantCostemits a 3-field record{cost_in_r, cum_cost_in_r, open_cost_in_r}, structurally isomorphic to PM'srealized_r / cum_realized_r / unrealized_rtriple.cost_in_ris charged on a real close (cost_per_trade/latched, else 0),cum_cost_in_ris its running sum,open_cost_in_ris the open position's would-be cost each cycle (else 0). Why: this is the one shape that feeds both consumers from a single producer (one home) AND letssummarize_rcharge the synthesized window-end trade — so the node subsumes the scalarround_trip_costexactly (charges every trade incl. window-end), which is a testable equivalence, not just a byte-identical cost=0 floor.H —
net_r_equity = LinComb(4, [1,1,−1,−1])over[cum_realized_r, unrealized_r, cum_cost_in_r, open_cost_in_r](r_equity's two-term sum plus the two cost terms). Why: its final point then equalssummarize_r's net total algebraically —Σ_closed(r − cost) + (r_we − cost_we)— so the in-graph headline curve and the post-run scalar agree by construction.I —
summarize_r's scalarround_trip_cost: f64is replaced by consuming the co-temporal cost stream (joined on ts; for each closed row readcost_in_r, for the window-end open last row readopen_cost_in_r). A no-cost run passes an empty/absent cost stream ⇒ cost 0 ⇒ net token formrealized_r − 0.0⇒ C18 goldens byte-identical.r_metrics_from_rs(the pooled-OOS copy) stays cost-free this cycle (cost in walk-forward pooling is deferred); the cross-reducer equality test still holds at cost=0.Edge case noted for the plan (not load-bearing): on a reversal row (
closed ∧ open) PM'sentry/stopcolumns describe the CLOSED leg, soopen_cost_in_rborrows the closed leg's latched for that one cycle (self-corrects next cycle); only observable if window-end coincides with a reversal.Plan-level derived decisions (planner, cycle 1 / spec 0081)
A file-structure recon surfaced two points the plan resolves (neither a design change; recorded for the audit trail):
J — Acceptance evidence is the JSON
net_expectancy_rfield + the chartablenet_r_equitytrace; no pretty-printer is added. The spec's worked console line (net E[R]: 0.31) is illustrative —aura runemits JSON ("net_expectancy_r": …), and there is no human-readable E[R] printer today. Adding one is out of cycle-1 scope; the cost-adjusted JSON field is the empirical evidence.K —
--cost-per-tradethreads through the run AND sweep paths; walkforward / mc passNone.stage1_r_graphis shared by run / sweep / walkforward / mc. Run and sweep fold cost per-member viasummarize_r(straightforward). Walkforward/mc pool out-of-sample R viar_metrics_from_rs, which stays cost-free this cycle (cost in OOS pooling is the deferredr_metrics_from_rsquestion), so those callers passNone. This honours the spec's "run / sweep / chart" scope while keeping the deferred boundary clean.Blast-radius note (not a fork, recorded for accuracy): the
summarize_rsignature change (scalar → cost stream) touches 43 call expressions across 5 crates (9 production in aura-cli, 34 in tests), not the spec's "10". All migrate in the one signature-change task (the workspace will not compile until they do — a single compile unit); the 39 cost-0 sites become the empty-stream form, the 4 non-zero test sites (analysis + e2e) are rewritten to drive cost via the node / a constructed stream.Implement-time decisions ratified (cycle 1, spec 0081 — shipped, suite green)
The implement loop flagged two deviations from the plan's literal text for ratification; both are correct and accepted:
G' — the cost stream is recorded 3-wide (the node's full output), not 2-wide. The plan's Step 3 wired a 2-column cost Recorder
[cost_in_r, open_cost_in_r], but Task 2'ssummarize_rreadsopen_cost_in_rat col 2 (the node's 3-field schema{cost_in_r=0, cum_cost_in_r=1, open_cost_in_r=2}). A 2-wide stream would index-out-of-bounds at col 2. Resolved 3-wide: the cost Recorder records the node's full output;summarize_r'scost_colreads col 0 (per-close) + col 2 (window-end open); col 1 (cum) feeds the in-graphnet_r_equity. This is cleaner than cherry-picking columns (the lockstep contract is just "the node's schema"). The 2-wide block in the plan's Step 3 was the inconsistency, not the diff.H' —
Tradedrops itslatchedfield. With cost arriving already in R from the stream (net = r − cost),summarize_rno longer recoverslatched_dist; the dead field would tripclippy -D warnings.r_col::ENTRY_PRICE/STOP_PRICEkeep#[allow(dead_code)](still the documented layout contract + exercised by the fixtures). Correctness-neutral, cleaner.Also accepted (sensible implementer additions): a
cost_per_trade >= 0constructor guard (+ panic test) and a parameterised label"ConstantCost(2)"(the sibling convention, cf.SMA(2)).Verified by the orchestrator:
cargo build --workspace --all-targetsclean,cargo test --workspacegreen (incl. the exact-subsumption test, the net-R E2E, and the byte-identical C18 no-cost golden),cargo clippy --workspace --all-targets -D warningsclean.Pre-existing concern logged for the audit (not this cycle):
aura-std/src/lib.rsmodule doc still names "realistic broker profiles", retired by the 2026-06-28 C10 rework.Cycle 2 design record (specify, derived under /boss)
Second cost node for the "Cost-model graph (in R)" milestone. The design is
derived from the cycle-1 seam (the 3-field cost-in-R record, the
net_r_equitytap, the
summarize_rfold) plus the C10 discipline; the genuinely-openCostNodetrait stays deferred. Each load-bearing fork below is a derivedorchestrator decision (not a user decision), recorded for after-the-fact audit.
Session anchor: main
e82725f.Fork: which 2nd cost node →
VolSlippageCost(slippage proportional torecent realized volatility).
Basis: derived — the canonical data-grounded example named in the C10 rework;
structurally different from
ConstantCost(state-dependent, reads a voltap), so it genuinely stress-tests whether the cost seam composes rather than
cloning a constant.
Fork: honesty framing of the coefficient → a labelled stress-parameter
slip_vol_mult(k); the volatility input itself is data-grounded (measuredfrom price).
Basis: derived from C10 — "real friction is not historically knowable; each
factor is a labelled stress-parameter OR data-grounded/falsifiable." Slippage
as a fraction of recent vol is a stress assumption, honestly labelled; the vol
it scales is measured.
Fork: vol input source → the node reads a
volatilityf64 input stream(price units) from upstream, not computed internally; the E2E wires a simple
rolling-range proxy (
rolling_max − rolling_minof close) from existing nodes.Basis: derived from C9 composability — the cost node stays a pure cost-in-R
transducer reading {trade signals} + {a vol tap}, mirroring how
ConstantCostreads {closed, open, entry, stop}; the vol estimator stays swappable and no new
indicator primitive is needed this cycle.
Fork: R-pure cost form →
cost_in_r = k·vol / |entry − stop|, charged onclose (and open would-be on the window-end open row), emitted as the same
3-field record {cost_in_r, cum_cost_in_r, open_cost_in_r} as
ConstantCost.Basis: derived from C10's currency→R normalization — notional/size/currency
cancel exactly as in
ConstantCost; reusing the record shape keeps the cycle-1seam (
summarize_rfold +net_r_equitytap) unchanged.Fork: composition mechanism → when >1 cost node is present, their cost-in-R
streams are summed per-field into one aggregate cost stream (via existing
combinators);
summarize_randnet_r_equityread the aggregate, unchanged.Basis: derived from C10 "composable C9 graph of cost nodes" + cycle-1's "one
home for cost" — the aggregate is the cost-graph output; adding an Nth cost
node later just adds an input to the aggregator, and
summarize_r'ssingle-stream positional-join contract is preserved (it now reads the
aggregate). This is the cycle's real architectural deliverable: the proof that
the cost graph composes.
Fork: general
CostNodetrait → deferred to a later cycle (cycle 3).Basis: derived — even with two nodes the trait is best designed against two
shipped, working nodes rather than co-designed with the second; the seam
already consumes the 3-field cost-in-R shape structurally, so the nodes compose
without a trait, and formalizing it later is a clean behaviour-preserving
refactor. One more cycle of "wait for concrete nodes" buys a better abstraction
boundary.
Fork: CLI / scope → add
--slip-vol-mult <f64>(analogous to--cost-per-trade), run-path-scoped; combinable with--cost-per-tradesocomposition is demonstrable end-to-end. Sweep/walkforward/mc still pass None.
Basis: derived — mirrors cycle-1's run-path scoping; the reduce-mode sweep-path
cost remains the named deferral, untouched here.
Acceptance: the cycle ships green when (a) the vol-slippage net == hand-computed
mean(rᵢ − k·volᵢ/|entry−stop|ᵢ) over all trades incl. the window-end one; (b)
two-node composition net == the exact per-trade sum of both costs; (c) the
no-cost baseline stays byte-identical (C18 golden held); (d) a single
vol-slippage run still folds correctly through the aggregate seam.
Cycle 2 design correction (discovered in implementation) — co-temporality contract
The implement-loop correctly blocked Task 3 on a real design flaw in the
signed spec (the gate did its job — no green-but-wrong shipped). Recording the
derived re-decision that resolved it.
Flaw: the spec had
VolSlippageCostwithhold (mode-A) until all fiveinputs are present, including
volatility. The vol input is aRollingMax−RollingMinproxy that warms up later than the PM record, so thecost stream became delayed/short and no longer co-temporal 1:1 with the PM
record.
summarize_rpositional-joinscost[i] ↔ record[i], so the desyncmade it read ~0 cost →
net_both == gross. (Compounding:SLIP_VOL_LENGTH=20 >the 18-tick synthetic fixture, so the proxy never warmed at all.)
Re-decision (derived): the co-temporality contract. A cost node's stream
MUST stay 1:1 with the PM record, so a cost node is gated only by the PM
trade-geometry (
closed/open/entry/stop); any not-yet-warm state input(volatility here, a recorded rate later) contributes 0 cost that cycle —
the node still emits its row. This makes co-temporality structural and
warmup-independent, preserves the C18 golden, and generalizes to any future
state-dependent cost factor.
ConstantCostsatisfies it trivially; onlyVolSlippageCostneeded the missing-factor→0 rule.Basis: derived — the positional-join co-temporality is the load-bearing seam
contract from cycle 1; charging 0 during a factor's warm-up (no estimate yet →
no charge) is the honest, contract-preserving choice. The alternative (a
key-join in
summarize_r) was rejected: it would move the C18 golden and pushcost-graph logic into the post-run fold (against C10's "cost is a graph").
SLIP_VOL_LENGTH20 → 5. A short realized-range window, still distinct fromthe stop's EWMA-3, but short enough to warm within the 18-tick synthetic smoke
fixture so the run path exercises non-zero slippage (on real M1 data any
reasonable window warms).
Verified: full workspace suite green (0 fail), clippy
-D warningsclean, theC18 no-cost golden byte-identical, the CLI composition run shows
net_both < net_flat(vol-slippage bites on top of the flat cost), and the node-levelexact-additive composition + aggregate-net-agreement +
CostSum(1)-identitytests pass. Spec 0082 + the plan were amended to the corrected contract.
Cycle 3 scope — fork decisions (recorded under
/boss, autonomous)User directive:
/boss CostNode-Trait, leg los— explicit green-light for thetwice-deferred
CostNodetrait cycle (the trait + cost-graph composite-buildernamed in this issue's scope). Session anchor: main
6c7f5dd(cycle-2 close).Routing (entry-path cascade): spec-driven (specify direct-entry), not brainstorm.
The forks are derivable — the trait's shape is dictated by the diff between the
two already-shipped cost nodes (
ConstantCost,VolSlippageCost) plusCostSum,and the
position_management::FIELD_NAMES/WIDTHprecedent. No fork hangs on pureuser preference, so this is settled-in-sources work, decided here. Grounded by a
node-authoring-surface recon (Node/Ctx/Cell/PrimitiveBuilder in aura-core, the 3
cost nodes, the main.rs cost-block wiring, the aura-composites builder idiom).
Decisions (all derived, with rationale):
CostNode+ a generic runnerCostRunner<F: CostNode>, NOTa blanket
impl<T: CostNode> Node for T. Why: the co-temporality skeleton needsshared mutable state (
cum: f64,out: [Cell;3]); a trait cannot add fields, therunner can. The recon found zero blanket impls in
crates/(the norm is concretetypes impl
Node), so a blanket impl is architecturally novel; the generic wrapperimpl<F> Node for CostRunner<F>on a concrete type is idiomatic and keeps thenumerator call inlinable (no vtable on the hot path).
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64, plusfn extra_inputs() -> Vec<PortSpec>(default none); the runner passes the full Ctx.Why: the numerator is literally the only behavioural difference between the two
nodes (
cost_per_tradevsslip_vol_mult * vol). Passing the full Ctx (it isCopy) future-proofs a notional-% cost that must readentry_pricefrom thegeometry slots, at no cost over a restricted view. The runner owns geometry gating,
latched, the closed/open charge,cum, and the 3-field emit — once.(
COST_FIELD_NAMES/COST_WIDTHin a newaura-std/src/cost.rs), read by BOTH therunner's schema helper AND
CostSum. Why: mirrors theposition_management::FIELD_NAMES/WIDTHprecedent and turns the cycle-2audit-flagged by-convention 3-field lockstep into a structural single-source
contract — the cycle's core motivation, the thing that makes the trait non-speculative.
ConstantCostandVolSlippageCostonto the trait (thinfactor impls;
new()returnsCostRunner<Self>). Why: the trait is justifiedprecisely because ≥2 implementors now exist (this is the exact condition cycle-1
deferred it for); migrating both removes the duplicated skeleton and makes the
lockstep structural. Byte-identity is preserved — the runner computes
numerator / latchedtoken-identical to the originals (cost_per_trade / latched,slip_vol_mult * vol / latched, left-associative-identical) — so every existingaura-std unit test + the aura-engine composition E2E + the C18 golden stay green
verbatim. That is the migration's built-in regression net.
wiring sugar over an already-working edge-wired seam (the cycle-1-recorded "additive
upstream feeding the same one-edge seam unchanged"); the user's explicit ask was
"CostNode-Trait"; bundling dilutes the cycle's single responsibility (abstraction +
lockstep elimination). Honors "scope the FIRST cycle minimally" and the milestone
breakdown order (cost-node abstraction first).
schemas (same input/output port names), so the main.rs wiring, the
net_r_equityseam, and
summarize_rare untouched; the new author-facing surface is "write acost node =
impl CostNodewith one numerator method."Deferred (recorded, not built): the cost-graph composite-builder (decision E), the
data-grounded nodes (recorded-rate → swap), per-cycle-held accrual (carry/funding),
the conviction-weighting R-aggregation axis, sweep-path/OOS-pooling cost.
Cycle 3 (0083) — CostNode trait + shared cost-record contract — CLOSED, drift-clean
Commits (above anchor
6c7f5dd, unpushed):16f4cbbspec (boss-signed) →c787488plan →
6b53c23feat →fc52b4faudit.The twice-deferred general
CostNodetrait shipped. Newaura-std/src/cost.rsownsthe cost-model node abstraction:
CostNodefactor trait — a cost node's only per-node difference: theprice-unit
cost_numerator(&mut self, &Ctx) -> f64(plusextra_inputsdefault-nonelabel).CostRunner<F: CostNode>adapter — holds the sharedcum/outstate, implsNode, and writes the co-temporality skeleton (geometry-only gating,numerator / latchedR-normalization, closed/open charge, cum, 3-field emit) ONCE.COST_FIELD_NAMES/COST_WIDTH,mirroring
position_management::{FIELD_NAMES, WIDTH}), read by both producers +CostSum+ the CLI wiring. The cycle-0082 by-convention triple lockstep isstructurally eliminated (four restatements collapsed to one).
ConstantCostandVolSlippageCostmigrated to thin factors (new()returnsCostRunner<Self>). Behaviour-preserving — same schemas/wiring, byte-identicalnumerator/latchedtoken form; the existing suite passes verbatim and two new CLIcharacterization goldens pin the exact flat/composed
net_expectancy_r(the priortests only asserted
net < gross, which a numerator drift would pass silently).Plan deviation (sound):
CostNode::name()was dropped as dead surface (nothingconsumes it;
label()carries the name,cost_node_buildertakes it explicitly) —honours C23. Honours C9 (a
CostRunner<F>is a plain downstreamNode).Verified:
cargo build/test --workspaceclean (0 failures),clippy -D warningsclean,
cargo doc -p aura-stdclean. Ledger C10 carries a cycle-0083 realization note.Still deferred (each its own cycle, recorded — not built):
already-working edge-wired seam; the natural next CostNode-area cycle);
summarize_rfold change);Minor carried debt:
vol_slippage_cost.rsdeclares its extra-input port twice (thecost_node_builderarg + the traitextra_inputs()); a clean unifier needs designthought (the builder is a static method, no factor instance) — left for a follow-up.
Milestone "Cost-model graph (in R)" remains OPEN.
Cycle 4 (cost-graph composite-builder) — kickoff (boss, autonomous)
/boss mach weiterafter the cycle-3 push. Per the milestone-stop-policy(the milestone is the stop-marker — run cycles toward it without a
per-cycle bounce-back) and this milestone being OPEN, this run continues
autonomously into the next cycle of #148.
Pick: the multi-node cost-graph composite-builder (decision E). The
"natural next" recorded consistently across cycles 2 and 3, and the next
item in the milestone breakdown order (cost-node abstraction → cost
nodes/wiring → ... → conviction axis). It is ripe now: three concrete
CostNodes + CostSum exist, and the CLI hand-wires them with a manual,
slot-indexed block (
CostSum::builder(n)+ aslotcounter +COST_SUM_PORTS[slot * COST_WIDTH + f]+ a hardcodedMAX_RUN_COST_NODEScap,
crates/aura-cli/src/main.rs:2750-2780). That is real existingwiring to encapsulate — not an absent consumer, so no speculative
generality.
Routing: spec-driven (specify direct-entry), not brainstorm. The design
is settled in sources: the cost-graph composite-builder in
aura-compositesis named in this issue's scope; the
aura-compositesbuilder idiom is mature(
vol_stop/RiskExecutor:GraphBuilder::new→input_role→add/connect/feed→expose→build→Composite); and the one realfork — forwarding each cost node's heterogeneous
extra_inputs()(e.g.VolSlippage's
volatility) across the composite boundary — is derivable fromthe
CostNode::extra_inputs()surface + theinput_role/exposeidiom(namespaced composite input roles). No fork hangs on pure user preference;
specify Step 1.5 derives the rest and records them here.
Acceptance: behaviour-preserving. The composite inlines at bootstrap
(C11) to the same flat graph the manual CLI block produces, so the existing
suites + the C18 no-cost golden + the two cycle-3
net_expectancy_rgoldens(
stage1_r_flat_cost/stage1_r_composed_cost) stay green verbatim;byte-identity of
net_expectancy_ris the regression net.Session anchor: main
fc52b4f(cycle-3 close, pushed).Design record (specify, cycle 4 — cost-graph composite-builder)
Spec:
0084-cost-graph-composite. All forks below are derived orchestratordecisions (rationale, not user provenance) under the bold Step-1.5 stance; the
sources (#148 scope + the mature
aura-compositesidiom + theCostNodecontract) settle them. None hangs on pure user preference, so none bounces.
Fork: composite API shape → a
pub fnreturningCompositetaking theactive cost nodes as
Vec<PrimitiveBuilder>.Basis: derived. The two precedents in
aura-compositesare bothpub-fn-returning-
Composite(vol_stop,risk_executor); the cost-node setis known at construction, so no stateful builder struct is warranted.
risk_executor's closure pattern (Box<dyn FnOnce(&mut GraphBuilder) -> NodeHandle>) exists only because its stop arms are heterogeneous constructs(a
FixedStopprimitive vs avol_stopcomposite); cost nodes are uniformlyPrimitiveBuilders (every cost node is a primitive viacost_node_builder),so a plain
Vec<PrimitiveBuilder>is strictly simpler and sufficient.Fork: extra-input forwarding / namespacing → the composite introspects
each cost node's
schema()and exposes its extra inputs (the ports beyond the4-wide geometry prefix) as composite input roles named
cost[k].<port>.Basis: derived. The
cost_node_buildercontract guarantees geometry-firstthen extras (tested:
cost_node_builder_assembles_geometry_prefix_then_extras),so
schema.inputs[GEOMETRY_WIDTH..]is the extra-port set. Thecost[k].<port>scheme mirrors
CostSum's owncost[k].<field>input naming (cost_sum.rs:45)— one consistent
cost[k].*vocabulary across the aggregator and thecomposite that wraps it. Index-namespacing (not label-namespacing) because
labels can collide (two
ConstantCosts) and are non-load-bearing debugsymbols (C23).
Fork: GEOMETRY_WIDTH visibility → re-export
GEOMETRY_WIDTHfromaura-std'slib.rs(it is alreadypubincost.rs, just not re-exported).Basis: derived.
aura-compositesis the first cross-crate consumer that mustknow where a cost node's extra inputs begin; re-exporting is demand-driven
(the cost contract's public boundary now has a real consumer), not speculative
surface. The alternative — a bespoke
extra_ports(&PrimitiveBuilder)helper —adds API for what one already-
pubconstant + the tested geometry-prefixcontract express directly.
Fork: CLI consumption →
stage1_r_graphbuilds the active cost nodes intoa
Vec<PrimitiveBuilder>(same conditional order as today's manual block),adds
cost_graph(nodes), wires the executor's geometry outputs to thecomposite's
closed/open/entry_price/stop_priceroles, feeds the vol proxy tothe vol node's
cost[k].volatilityrole, and reads the composite's aggregate3-field output into
net_r_equity+ the cost recorder. The hardcodedMAX_RUN_COST_NODES = 2cap + the internedCOST_SUM_PORTSslot table aredeleted (the composite handles any arity; the vol proxy stays outside the
composite, shared with the stop rule).
Basis: mechanical consequence of the above; the CLI tracks each node's index
exactly as it tracks
slottoday.Acceptance — behaviour-preserving at the value level. The composite inlines
at bootstrap (C11) to the same flat computation (same cost nodes, edges, params)
the manual block produces, so every value output is byte-identical: the two
cycle-3
net_expectancy_rgoldens, the C18 no-cost golden, and the full suitestay green verbatim. Node labels / graph-debug paths may change (now nested under
cost_graph) — permitted by C23 (label is a non-load-bearing debug symbol); ifany test pins a with-cost graph label/shape, that is a ratified C23 label
change, not a regression (the grounding-check confirms whether one exists).
Cycle 4 (0084) — cost-graph composite-builder (decision E) — CLOSED, drift-clean
Commits (above anchor
fc52b4f, unpushed):9fb3d8bspec (boss-signed) →550d5ceplan →d5c44ddfeat →face187audit.Decision E ships. A new
cost_graph(Vec<PrimitiveBuilder>) -> Compositeinaura-composites is the cost-model graph's authoring primitive: it fans the 4
PM-geometry inputs to
Ncost nodes, surfaces each node's extra inputs (discoveredvia
schema().inputs[GEOMETRY_WIDTH..];GEOMETRY_WIDTHre-exported from aura-std)as
cost[k].<port>composite roles, sums them throughCostSum, and exposes the3-field aggregate. The CLI's manual slot-indexed cost-wiring and the hardcoded
MAX_RUN_COST_NODES = 2cap are deleted — the composite handles arbitrary arity.Behaviour-preserving (C11): the composite inlines at bootstrap to the same flat
fan-in the hand-wired block produced, so the cycle-3
net_expectancy_rgoldens(
-614.3134020253314flat,-615.0304388396047composed) and the C18 no-cost goldenstay byte-identical. Four aura-composites unit tests pin the exposed role-set + output
triple, including an n=3 arbitrary-arity fixture (two VolSlippageCost → distinct
cost[1]/cost[2].volatility) that witnesses the per-node namespacing the retired2-node cap could not. Honours C9 / C16 / C23.
Fork decisions (all derived, logged above): composite API = a
pub fntakingVec<PrimitiveBuilder>(vol_stop/risk_executor idiom); extra-input forwarding viacost[k].<port>roles from schema introspection;GEOMETRY_WIDTHre-export(demand-driven, first cross-crate consumer); the
.leak()of runtime port names.Audit: drift_found, every item dispositioned. Resolved in
face187: the C10ledger note (decision E shipped), the cost_graph
.leak()provenance (the"risk_executor precedent" was imprecise — that leak is in risk_executor's test,
one-shot/leak-safe; the production reuse pattern is the
COL_PORTSintern), and astale
cli_run.rsgolden docstring. Filed #152 for the coupled deferred debt:the
cost[k].<port>namespacing restated acrossCostSum/cost_graph/ the CLI(a build-validated lockstep) and the per-build
.leak()that becomes an allocationregression on the per-member sweep path — both harmless on the current run-only cost
path, best fixed as one sweep-safe single-source cost-port contract with the
sweep-cost cycle (#152 depends on it).
Verified:
cargo test --workspace0 failures,clippy --workspace --all-targets -D warningsclean,cargo docclean.Still deferred (each its own cycle, recorded): data-grounded nodes (recorded-rate →
swap); per-cycle-held accrual (carry / funding — forces a
summarize_rfold change);the conviction-weighting R-aggregation axis (tdd-runnable); sweep-path / OOS-pooling
cost (now also gated by #152).
Milestone "Cost-model graph (in R)" remains OPEN.
Cycle 5 design decision (#148 — Cost-model graph in R): per-cycle-held accrual (approach B)
Fork: per-cycle-held accrual fold — A (accrue-to-close lump) vs B (honest per-held-cycle bleed).
→ B. Basis: user decision in the in-context discussion, verbatim "ja, mach B" (2026-06-28), after the A/B trade-off was laid out — A lumps the accrued cost onto the close row (
summarize_runchanged, but the net-R equity curve shows a step, not a bleed); B emits the accrual on each held cycle and sums it over the hold (an honest, continuously-bleeding curve, at the cost of asummarize_rfold change). The scalarnet_expectancy_ris identical under A and B; only the equity-curve shape differs, and the user chose the honest curve.Fork (derived): first-cycle scope — full calendar-aware overnight swap vs minimal accrual skeleton + a constant carry node.
→ Minimal: ship the accrual mechanism + a constant per-held-cycle
CarryCostnode; defer the calendar-aware overnight swap. Basis: derived — minimal-first-cycle discipline + the C10 "don't over-model" stance. The mechanism B requires is (1) a per-held-cycle charge mode on the cost-node skeleton (charge while held, not only at close, accumulating each held cycle) and (2) asummarize_rfold that sums the per-cycle cost over each trade's held span. That span-sum equals the existing single close-row charge for the per-close model (held rows charge 0, soΣ cost_in_r =close charge, exactly), so the cycle-0083/0084net_expectancy_rgoldens stay byte-identical — they are the refactor's regression net. The simplest node that exercises the mechanism is a constant per-held-cycle carry: aConstantCosttwin whose only difference is the per-held-cycle charge mode (a labelled stress parameter), wired through the existingcost_graph/CostSumaggregation (cycle 0084) with no new aggregation surface —CostSumalready sums per-field across nodes, so a per-closeConstantCostand a per-cycleCarryCostcompose correctly in one run.Deferred to follow-up cycles, each its own unit:
price × rate, reads the price tap, R-normalized through the stop distance) — the realistic financing shape, vs the flat price-unit stress parameter shipped here;Entry path: spec-driven (specify → planner). The high-level fork is user-settled; the remaining decisions (charge-mode on the
CostNodetrait vs a second runner type; the span-sum fold restructure;CarryCostas aConstantCosttwin; the byte-identity anchors) are all derivable from the existingcost.rsskeleton +summarize_r+ the C10 contract. Not a one-assertiontdditem — it touches a load-bearing fold + a new trait method + a node + a CLI flag under a byte-identity constraint.Session anchor: main
39f9387(0084 close + glossarytap). Cycle-5 commits ride above it.Cycle 5 mechanism refinement (specify Step 1.5, derived) — same outcome, cleaner mechanism
Grounding
cost.rs+summarize_rwhile writing the spec surfaced a cleaner mechanism for B than the one sketched above. The chosen outcome is unchanged — the honest, continuously-bleeding net-R equity curve the user picked ("ja, mach B"). Only the mechanism changes, and it is strictly simpler and lower-risk, so I am deciding it (derived) and recording it here for veto.Refined mechanism. The bleed lives in
open_cost_in_r(grown each held cycle = the open position's accrued-so-far, a mark-to-market term), whilecost_in_rrealizes the accrued total intocumat close. Thenet_r_equitytap already subtracts bothcum_cost_in_randopen_cost_in_r, so the curve bleeds continuously during the hold via the growingopen_cost_in_r, thencumpicks the total up at close andopen_cost_in_rdrops to 0 — no double-count. Worked for a mixed run (commission AtClosec+ carry PerHeldCycle0.1, open t1–t3, close t4):c+0.1→ net subtracts commission(would-be) + 1 night carryc+0.2→ bleeds 0.1 / cyclec+0.3c+0.4, open_cost 0 → cum takes the total, open hands off cleanlyConsequence —
summarize_ris UNCHANGED (and so is the CLInet_eqwiring). The closed-trade read at the close row seescost_in_r= the accrued total (the dump), so the per-trade cost is correct with no span-sum fold; the window-end open-trade read seesopen_cost_in_r= accrued-so-far. The 3-field record's existing semantics generalize cleanly:cost_in_r= cost realized this cycle (into cum);open_cost_in_r= the open position's cost MtM as-of-now (AtClose: would-be close cost; PerHeldCycle: accrued-so-far). All cycle-0083/0084 goldens stay byte-identical trivially (summarize_runtouched + the AtClose runner branch kept verbatim).Why this supersedes the "emit cost_in_r per held cycle + summarize_r span-sum fold" mechanism I first noted. That alternative changes the window-end open-trade fold (close-row read → span-sum), which would flip the AtClose window-end open trade from its would-be close cost to 0 — a byte-identity break if any cost golden's run ends with an open position. It is riskier and more complex for the same observable curve. Rejected on substance (lower risk + simpler + byte-clean), not effort.
Net cycle-5 change set (smaller than first scoped):
CostNode::charge_mode()+ thePerHeldCyclerunner branch (accrue intoacc, dump at close, growopen_cost_in_r); aCarryCostnode (ConstantCost twin, PerHeldCycle); a--carry-per-cycleCLI flag.summarize_rand the CLInet_eqwiring are untouched. Load-bearing B-proof tests: aCostRunnerunit test assertingopen_cost_in_rGROWS across consecutive held cycles andcost_in_rdumps the total at close (this is what distinguishes B from A — the scalarnet_expectancy_ris identical under both), plus a CLI--carry-per-cyclenet_expectancy_rgolden and an assertion that thenet_r_equitytap bleeds during the hold, not only at close.Cycle 5 (0085) — per-cycle-held accrual + CarryCost — CLOSED, drift-clean
Commits (above anchor
39f9387, unpushed):19ba1ecspec (boss-signed) →2a8ee86plan →f5e00a9feat →84ec2abaudit.C10's "per-cycle-held factors accrue over the hold" clause is first realized. A cost factor now declares when it charges via
ChargeMode { AtClose, PerHeldCycle }(a defaultedCostNode::charge_mode()), read by the one sharedCostRunner— not a second runner type. ThePerHeldCyclearm accruesperinto a per-positionacceach held cycle, dumps the accrued total intocumat close, and growsopen_cost_in_r. The first accrual node,CarryCost(aConstantCosttwin differing only incharge_mode(), a labelled stress parameter), is wired through the existingcost_graph/CostSumvia a run-path--carry-per-cycleflag — per-close and per-held-cycle nodes compose, the costs summing per-field (the composed golden is exactly additive).Approach B (honest bleed) achieved with no
summarize_rfold change (the mechanism refinement decided + logged above): the bleed lives inopen_cost_in_r, which thenet_r_equitytap already subtracts, sosummarize_rand the CLInet_eqwiring are untouched and the cycle-0083/0084net_expectancy_rgoldens stay byte-identical (the AtClose arm is the pre-cycle-5 tokens verbatim). Architect: drift-clean, no code drift, no double-count (cum/openmutually exclusive per cycle). The B-vs-A discriminator (a growing intra-holdopen_cost_in_r, invisible to the scalarnet_expectancy_r) is pinned by unit B-proofs + anet_r_equity-bleeds-over-the-hold integration test + three robustness tests (negative-rate exit-2, monotone-in-rate, zero-rate exactly-free).cargo test --workspace0 failures, clippy clean.Milestone checkpoint — the cost-model graph's core is substantively complete
The composable cost-model graph now spans both charge modes: per-close (
ConstantCost,VolSlippageCost) and per-held-cycle (CarryCost), through theCostNodetrait +CostRunner+CostSum+ thecost_graphcomposite into thenet_r_equitycurve and thesummarize_rnet fold. That is the milestone's substance: "composable cost nodes producing the net-R curve."Remaining items are refinements or separately-gated — each a new cycle needing direction, none a clear autonomous next:
price × rate, reads the price tap) — the realistic financing shape vs the flat stress parameter shipped;cost[k].<port>lockstep + the per-build.leak()to intern);Milestone remains OPEN. Closing it is a deliberate act needing its end-to-end milestone fieldtest green (the milestone-close gate). 9 commits unpushed (origin/main at
fc52b4f, the 0083 close).