Compare commits

...

140 Commits

Author SHA1 Message Date
claude 6fb7caf929 fieldtest: milestone-stderr-honesty re-verify — panic containment + plural fix live
All three mission drivers re-run against the rebuilt release binary
after 51096a3/a20b6f6/4897919: the synthetic poison row now shows
aura: warning: + exit 3 (zero raw panic lines in the survey), the
single-window case reads "the single walk-forward window recorded
zero trades", and the marker-separability log still greps into two
clean classes. Captures refreshed in place; the milestone fieldtest
roll-up is green. refs #278, refs #313
2026-07-24 00:59:22 +02:00
claude 48979193b9 tidy(cli): pluralization-aware zero-trade note wording
Milestone-fieldtest friction: a single-OOS-window walk-forward printed
"all 1 walk-forward windows recorded zero trades" (captured RED
evidence: fieldtests/milestone-stderr-honesty/captured/
example2_singular.err). The message text moves into a pure
zero-trade_note_text builder — singular "the single walk-forward
window recorded zero trades", plural unchanged so the existing e2e
phrase pins hold — with a unit pin on both forms. refs #278
2026-07-24 00:58:20 +02:00
claude a20b6f6003 fix(runner): contain mc synthetic member panics (GREEN)
blueprint_mc_family joins its sweep/walkforward siblings on the
51096a3 machinery: catch_member_panic around the bare member run in
the monte_carlo draw closure, per-seed capture, lowest-seed
resolution after the join (seed-input order, thread-order-independent,
C1), exit via the shared aura: warning: + exit 3 path. Ordering is
load-bearing and documented at the site: the captured fault resolves
BEFORE the vacuous-mc guard, so metrics-identical placeholder reports
from faulted draws cannot masquerade as a vacuous run. All three
containment e2es green; clippy clean. refs #278
2026-07-24 00:57:33 +02:00
claude 59f547e313 test(cli): RED -- mc synthetic member panic is contained like its siblings
Orchestrator probe after 51096a3: blueprint_mc_family is the one
remaining family builder running members bare — a closed poison
blueprint (SMA length bound 0) through aura mc --seeds 2 exits 101
with a raw panic. RED e2e pins the shared contract: no 101, an
aura: warning: fault line, no panic text or crates/ path, exit 3.
Single-seed escape confirms the bare seam (not the parallelism) is
the cause. refs #278
2026-07-24 00:47:05 +02:00
claude 51096a3212 fix(runner, campaign): contain synthetic member panics like the real path (GREEN)
The #272 containment recipe (SilencedPanic + catch_unwind +
panic_message) is exposed from aura-campaign as catch_member_panic and
applied at the bare-member seam in the synthetic family builders:
blueprint_sweep_family contains per-point, resolves the lowest
enumeration index's fault after the sweep joins (thread-order-
independent, C1), and exits deliberately with the aura: warning:
class marker + exit 3 (C14) instead of the raw panic/exit-101 escape;
blueprint_sweep_over captures instead of exiting (it runs inside the
per-window walkforward parallel closure — an on-the-spot exit would
race across windows) and hands the resolved fault up. Fault prose
reuses member_fault_prose; the real path's containment is untouched.

Deliberate residue, both tracked: blueprint_mc_family's member call
site keeps the bare run (empirical probe of the reachable surface
follows this commit; a reachable escape goes the same RED-first way),
and the new eprintln+exit(3) site in aura-runner joins the #297
process::exit inventory (noted there) rather than fighting M4's
future RunnerError propagation now.

Both RED e2es green; workspace suite green via the mini-verify;
clippy -D warnings clean on the touched crates. refs #278
2026-07-24 00:43:06 +02:00
claude bd6ddf9efe test(cli): RED -- synthetic member panics are contained like the real path
Milestone-fieldtest bug (source-blind run, 2026-07-24): a member fault
on the synthetic walkforward/sweep path escapes as an uncaught panic
(exit 101, no class marker, internal source path leaked) while the
real path contains the identical fault via the #272 boundary as
aura: warning: + exit 3. Cause: the family builders in
aura-runner/src/family.rs run members bare, without the catch_unwind
containment that lives only in aura-campaign/src/exec.rs. Two RED
e2es pin the derived contract (decision log on #278): no exit 101,
a warning-marked fault line, no panic text or crates/ path on stderr,
exit 3 per the C14 partition. refs #278
2026-07-24 00:08:56 +02:00
claude 7e1dd25a76 fieldtest: milestone-stderr-honesty — 3 examples, 1 bug / 2 friction / 2 spec-gap
Milestone fieldtest for "Surface honesty on stderr", run source-blind
against the release binary only (binary-only knowledge floor, user
direction 2026-07-23): op-lists authored via the binary's own graph
build/introspect surfaces, project + real archive reached through help
alone. Markers grep-separable, zero-trade notice exact, abort paths
correctly unmarked (4x working). Findings routed: synthetic member-fault
panic (exit 101, source-path leak) -> debug; null-result and
partial-failure widening -> tracker; op-script discoverability ->
#323 evidence; silent unknown-op-field drop -> tracker; plural nit ->
tidy. refs #278, refs #313
2026-07-24 00:04:53 +02:00
claude cc683fb065 audit(cycle stderr-class-markers): drift adjudication — clean, no baseline moved
Cycle #278+#313 close (commits 34987be, 1102d77, 913d187 on cfc2d9f).

Architect (What holds): C14 exit-code partition untouched at every retag
site (exit-carrying refusal sites verified bare `aura: `, both class
markers grep-exact by codebase sweep); spec site table 9/9 shipped with
both moving pins; the #313 note on both walk-forward paths through the
one shared diag helper, pinned by three e2es; commit bodies truthful,
c28_layering roster updated, clippy green on both touched crates.

Architect drift items, adjudicated:
- [medium, dissolved on verification] Claimed contradiction between
  C14's warning-class text ("a failed cell feeding exit 3") and the
  verb_sugar sites' `return Ok(1)`. Verified against the tree: the
  sugar paths return a failed-cell COUNT into
  `exit_on_campaign_result` (main.rs:1684), whose mapping is
  `Ok(0) => {}` / `Ok(_) => exit(3)` — the sugar-path failed-cell
  warning feeds exit 3 exactly as the campaign path does. Ledger and
  code agree; no edit. Carry-on.
- [low] The cycle spec's Components line scoped diag.rs to "the two
  marker macros" while the shipped module also owns the #313 helper.
  The spec is a git-ignored working file deleted at this close; the
  durable records (C14 paragraph, commit 1102d77 body) describe the
  shipped shape correctly. Carry-on.

Regression (aura-bench, exit 0, all fingerprints OK, raw):
engine_throughput    bars_per_s 13638785   wall_s 0.733s   Δ bars_per_s -2.6% wall_s +2.7%   fingerprint OK
ingest_throughput    bars_per_s 13030599   wall_s 0.385s   Δ bars_per_s +0.2% wall_s -0.2%   fingerprint OK
campaign_sweep       cpu_percent 2194.0   peak_rss_mb 93.3   wall_s 1.402s   Δ cpu_percent +0.2% peak_rss_mb +1.2% wall_s +0.5%   fingerprint OK
campaign_heavy       cpu_percent 2146.0   peak_rss_mb 99.6   wall_s 5.620s   Δ cpu_percent -0.3% peak_rss_mb -1.7% wall_s +0.8%   fingerprint OK
cli_fixed_cost       help_ms 1.5ms   run_ms 3.6ms   Δ help_ms -1.7% run_ms -0.9%   fingerprint OK
(loadavg warning during the run: the workspace suite ran in parallel;
all deltas within tolerance regardless.) No baseline update, so no
ratify statement is due.

Cycle stderr-class-markers tidy (drift-clean). Not a milestone close:
the "Surface honesty on stderr" milestone additionally needs its green
milestone fieldtest (binary-only knowledge floor per the user direction
of 2026-07-23), which runs next; the formal close stays the user's act.

refs #278, refs #313
2026-07-23 23:41:38 +02:00
claude 913d18755e docs(ledger, glossary): the stderr class-marker grammar joins C14
Iteration stderr-markers-2 close, task 5 of the stderr-class-markers
plan. C14's Current state gains the marker paragraph — note/warning
classes for continuing-run diagnostics, bare `aura: ` for exit-carrying
error lines and plain info lines, grep-stable grammar, single source in
aura-cli/src/diag.rs — and the glossary gains the `stderr class marker`
entry. No new C-number: C14 already owns the CLI stderr conventions and
the exit-code partition this grammar completes (derivation logged on
the #278 decision thread).

With this the milestone promise is shipped: the two diagnostic classes
are machine-separable on stderr (#278), and the all-zero-trade
walk-forward announces itself instead of completing silently (#313).
Exit-code semantics unchanged throughout.

closes #278
closes #313
2026-07-23 23:36:08 +02:00
claude 1102d776df feat(runner, cli): runner-side marker literals + the zero-trade walk-forward notice
Iteration stderr-markers-2, tasks 3-4 of the stderr-class-markers plan.

aura-runner's four continuing-run diagnostics join the class grammar as
hand-written literals (the #295 boundary keeps the macro owner in the
presentation crate): the stale-dylib warning gains the `aura: ` namespace,
and the three tap/no-nominee notices become `aura: note: ` — the latter
two extracted into pure note-builder fns with unit pins. The two e2e
pins on the cost-model tap string moved with the retag
(research_docs.rs). A new project_nodes e2e exercises the real stale-
dylib load path: fresh build emits nothing, a source newer than the
dylib emits exactly one class-marked warning and still succeeds.

The #313 notice ships on both walk-forward paths: `aura: note: all N
walk-forward windows recorded zero trades` before the summary JSON,
exit 0 untouched (a null result is a valid research result, #198).
Wording and condition live in one shared diag helper taking the
per-window trade counts (orchestrator tidy of the reviewer's
duplicated-predicate residue); the call sites keep only the
path-specific field extraction. Three e2es pin it: the equal-length
degenerate on the synthetic path (exactly one note, no warnings, exit
0, summary unchanged), a traded negative twin, and the real/sugar-path
degenerate gated on the GER40 archive.

refs #278, refs #313
2026-07-23 23:35:23 +02:00
claude 34987be389 feat(cli): stderr class markers — diag module, campaign/verb-sugar retags
Iteration stderr-markers-1, tasks 1-2 of the stderr-class-markers plan
(spec signed via grounding-check PASS, decisions logged on #278).

The two stderr diagnostic classes become machine-separable (refs #278):
a new crate-internal diag module owns the grammar as note!/warning!
macros (`aura: note: <text>` benign continuing-run diagnostic,
`aura: warning: <text>` recorded fault the run survives); the four
undifferentiated sites are retagged — gate-emptied cell -> note,
failed cell / walkforward cell / mc cell -> warning — and the four
scaffold literals migrate onto the shared macro (output bytes
unchanged). The always-on record summary line gains the bare `aura: `
namespace it was missing; bare-prefix stays the vocabulary for error
lines accompanying a non-zero exit (C14 partition) and plain info
lines. Exit codes unchanged throughout.

Verification: RED->GREEN warning pin on the per-cell-fault e2e; new
gate-emptied e2e (impossible n_trades threshold) pins the note marker,
exit 0, and the summary-line prefix; scaffold e2es and the full
workspace suite green; clippy -D warnings clean on aura-cli. The C28
shell-module roster test gained the diag.rs entry.

Known residue for the next slices: aura-runner literals (stale-dylib
warning, three tap/no-nominee notes) and the #313 zero-trade notice
follow in tasks 3-5. The verb_sugar warning call sites remain e2e-
uncovered (a hostless walkforward fault fixture is structurally
unreachable — single-instrument validation refuses before the cell
loop); their grammar is pinned via the shared macro and the campaign
warning e2e.

refs #278
2026-07-23 22:52:46 +02:00
claude cfc2d9f5b1 feat(core): doc_gate refuses the namespaced short-name alibi (GREEN)
The fieldtest's spec_gap closed per the #316 decision (tighten, not
ratify): doc_gate normalizes the doc against both the full entry name
and its bare display segment after the last :: -- an extension node
ns::Scale with doc "Scale" now refuses (RestatesName), matching the
register seam's effective bare-name behaviour. Unnamespaced entries
are unchanged (the checks coincide); genuine meaning lines containing
the name as a substring stay accepted (equality, not containment).
The C29 contract sentence carries the precision.

Gates: workspace green (98 suites, 0 failed -- incl. the C29 coverage
walk over every shipped doc under the tightened gate), clippy clean.

refs #316
2026-07-23 21:12:39 +02:00
claude b04b56148d test(core): RED -- doc_gate refuses the namespaced short-name alibi
Executable spec for the fieldtest spec_gap (decision on #316: tighten,
not ratify): doc_gate("lab316_nodes::Scale", "Scale") must be
Err(RestatesName) -- a doc normalizing to the final ::-segment of a
namespaced entry name restates the name. Unnamespaced entries and
genuine meaning lines containing the name stay Ok (guard assertions
green). Currently red: the shipped check compares the full name only.

refs #316
2026-07-23 21:00:28 +02:00
claude 96e79184e0 fix(docs, registry): fieldtest follow-ups -- guide sync, doc-op documentation, surface-neutral refusal
Three of the four immediate fieldtest findings for the self-description
cycle:

- authoring-guide S0: the worked Scale literal gains the required
  NodeSchema.doc line (+ the C29 comment) -- the shipped example was a
  hard E0063 for anyone pasting it (the exact #314 teaching-failure
  class, caught from the consumer seat).
- The op table becomes "The nine ops": the C29 doc op is documented
  with its JSON shape, the register requirement, the at-most-once rule
  and the gate; the README op-kinds list names it too. The fieldtester
  had to guess the shape from commit bodies -- now it is on the public
  surface.
- The store-gate refusal's fix hint is surface-neutral: builder
  .doc(...), blueprint "doc" member, or op-script {"op":"doc"} --
  previously it prescribed the Rust builder to a JSON author.

Gates: registry, graph_construct (63) and project_load (14) suites
green; refusal-prose pins unchanged (pinned substrings untouched).

refs #316
2026-07-23 20:58:19 +02:00
claude 2d988fdf3d fieldtest: 316-self-description — 4 examples, 1 bug / 3 friction / 1 spec-gap
Consumer-seat run over all four C29 seams (load, register, op-script doc,
document description) from the public interface only. The three working
findings verify the #314 failure class removed end to end; the findings
route: guide sync + refusal-prose neutrality + doc_gate short-name parity
this run, discoverability surfaces to the tracker.

refs #316
2026-07-23 20:55:21 +02:00
claude bd7585b609 audit(cycle 316): drift disposition, bench seed docs + ratified re-pin, C29 scope paragraph
Cycle-close audit for the self-description cycle. Architect: drift_found
(mild), core holds -- the gate is pure string shape in core imported by
every seam (C28), the engine never evaluates description text (C17/inv
10), determinism pins untouched (C1); the store boundary is gated by
construction with the raw write private; Op::Doc is a closed typed
construct (C25). Regression: aura-bench green (exit 0) after one
cycle-caused infra fix and one ratified re-pin.

Infra fix: the bench campaign seeds (BP_SMA_A/B) predate C29 and were
refused by the store gate (exit 2, infrastructure-first rule) -- both
thread the authored sma_signal meaning line.

Ratify: cli_fixed_cost's run_line_fnv moved because bench_sma_a.json now
carries its doc -- the blueprint's content id shifts exactly as the spec
anticipates for fixtures gaining docs; campaign result fingerprints are
unchanged (descriptions stay out of execution, C29). Baselines re-pinned
via aura-bench pin (throughput baselines re-pinned in the same sweep,
all deltas within tolerance at pin time).

Drift disposition:
- [high] C29 guarantee overclaim vs emit/binding-column vocabularies ->
  contract now carries an explicit Scope paragraph naming the residue,
  tracked as #321 (also covers the [medium] non-roster schema walk gap).
- [low] Delay's meaning line cited the stale C5 for the delay register;
  corrected to C9.

refs #316, refs #321
2026-07-23 20:42:02 +02:00
claude 9c7f60b269 docs(ledger): C29 self-description contract -- INDEX entry + domain invariant 13
The cycle's design principle enters the ledger: every closed-vocabulary
entry the binary exposes carries a one-line meaning behind one shared
deterministic shape gate (doc_gate) at three seams -- compile/unit for
engine-shipped entries, load for native node crates, register for the
content-addressed store (documents: an additive-optional gated
description). Forbids the engine evaluating description text (C17 /
invariant 10), any influence on execution/identity/determinism (C1),
retroactive invalidation of registered artifacts, and machine-invented
meaning lines. Why: the audience is headless LLM agents (#319); field
evidence #314 showed schema knowledge being recovered by CAS forensics
from the release binary -- the removed failure class.

All spec acceptance criteria are now met across the four iterations of
this cycle (core carrier + std texts; domain threading; load seam;
register seam + op-script doc slot + document description).

closes #316
2026-07-23 20:34:33 +02:00
claude 7126886b81 feat(engine, cli): op-script doc slot -- the vocabulary grows the C29 meaning line
Iteration 3 completion (tasks 5-6): the construction vocabulary gains
the slot its own #125 scope-cut comment anticipated. Op::Doc { text }
is the op-script twin of the builder's .doc(...) -- a closed, typed
metadata construct (C25, no logic content); at most one per script, a
second refuses via OpError::DuplicateDoc (the existing duplicate-fault
family), rendered "a doc op may appear at most once". GraphSession
threads the text through Composite::with_doc at finish, so op-built
composites pass the C29 store gate like builder-built ones.

The op-script register test runs on the described variant
(SIGNAL_DOC_DESCRIBED) for both shapes; the doc-less script now pins
the C29 refusal, and the E2E phase added the RestatesName op-script
case. Full gates: cargo test --workspace green (98 suites, 0 failed);
clippy -D warnings clean.

refs #316
2026-07-23 20:33:00 +02:00
claude 593e233e66 test(cli): C29 register-refusal e2e -- doc-less root, doc-less nested, described twin
Iteration 3 task 4: register_refuses_undescribed_composites_end_to_end
pins the store gate's verb surface -- a doc-less root refuses naming
`sma_signal` and the rule, a doc-less named nested composite refuses
naming `inner`, the described shipped example registers (exit codes and
stderr through the C14 contract). The pre-existing twin pin
graph_doc_field_moves_content_id_but_not_identity_id stays green.

Checkpoint note: the loop escalated this task spec-ambiguous because
its plan gate demanded a fully green graph_construct suite while the
op-script register test is known-open until the doc-op task lands (a
plan-sequencing defect, orchestrator's authoring error); the task's own
work was verified green and is committed here, the doc-op task follows.

refs #316
2026-07-23 19:53:49 +02:00
claude 750ee93180 feat(research, registry, cli, runner): C29 register seam -- description gate, store-boundary doc gate, authored root docs
Iteration 3 of the self-description plan, tasks 1-3 (checkpoint commit
mid-iteration; the register e2e, the op-script doc slot, and the full
gates follow):

- aura-research: DocFault::BadDescription { subject, fault }; a present
  document description passes doc_gate in validate_process /
  validate_campaign (absent is never a fault and stays byte-identical);
  prose arm in doc_fault_prose; tests incl. content-id participation.
- aura-registry: the store boundary gates admission -- the public
  put_blueprint parses the canonical bytes and walks root + named
  nested composite docs (None refuses like ""), refusing via
  UndescribedComposite { name, fault } / MalformedBlueprint; the raw
  write survives as private put_blueprint_unchecked (store-mechanics
  tests + the never-retroactive witness: doc-less pre-C29 entries stay
  readable). Registry-internal seeders triaged junk-vs-real.
- authored root docs: the 4 example blueprints + their 4 open-fixture
  twins, the scaffold starter template, the scaled_signal and hl_signal
  inline fixtures, and the three programmatic member.rs builders
  (breakout, meanrev, hl_channel) via GraphBuilder::doc -- mechanical
  propagation of the authored lines.

Recovery note: the implement run's task 3 discarded tasks 1+2's edits
in three files via a file-level checkout (the issue-#23 guard shape);
the loop's boundary snapshot caught it and the content was restored
from recovery snapshot 11a6ae2, then re-verified (research 81 green,
registry 51 green, cli 58/59).

Known-open at this checkpoint: the op-script register test is red --
op-built composites have no doc surface (#125 scope cut); the op
vocabulary grows the anticipated doc slot in the next task of this
iteration (decision logged on the issue).

refs #316
2026-07-23 19:47:46 +02:00
claude d6694d0641 feat(runner, cli): C29 load seam -- undescribed extension vocabulary refuses at load
Iteration 2 of the self-description plan: field presence is compile-
enforced since iteration 1, but an extension crate can still ship the
empty-string alibi (or a name-restating doc) the compiler cannot see.
load_crate now walks every charter-checked type id, resolves its
builder, and runs aura_core::doc_gate over schema.doc; a fault refuses
the load via ProjectError::UndescribedVocabularyEntry { type_id, fault }
(exit 1 through the CLI's Display-driven surface, C14).

The variant carries the DocGateFault beyond the spec's minimal sketch
because the spec's error-handling section requires the refusal to name
entry + field + rule -- with only the type id the prose could not
distinguish the two faults (decision logged on #316).

Two gate-failing fixture crates drive the e2e, modelled on
badcharter-project: undescribed-project (und::Opaque, doc: "") for the
Empty arm, restated-project (restated::Echo, doc: "Restated Echo") for
the RestatesName arm -- the second contributed by the E2E phase to
close the only-unit-pinned gap on that arm. A runner unit test pins
both rendered Display arms directly. The described twins (demo-project,
nested-nodes-project) stay green and are now load-bearing for the gate.

Gates: cargo test -p aura-cli --test project_load (14 green, incl. both
refusal e2es); cargo test --workspace green; clippy -D warnings clean.

refs #316
2026-07-23 18:19:10 +02:00
claude ab3f16879b feat(runner, cli, research): metric/tap schema carriers + the C29 coverage walk
C29 compile/unit seam, tasks 6-10 -- iteration 1 of the self-description
plan complete: every engine-shipped vocabulary entry now carries a
gate-clean one-line meaning.

- aura-runner: the internal tap recording sink threads its doc (the
  last unthreaded NodeSchema site) -- first full workspace build since
  the field became required.
- aura-cli scaffold: the sample node's template carries a doc line plus
  a maintenance comment, so every scaffolded extension crate starts
  gate-clean; both extension-vocabulary fixture exemplars thread real
  meaning lines (the load seam walks these in iteration 2). New E2E:
  the scaffold's own doc must pass its own gate -- an alibi doc in the
  template would teach the wrong pattern to every new project, and the
  compiler checks only the field's presence, never its shape.
- aura-research: metric_vocabulary()/tap_vocabulary() promote from bare
  name arrays to MetricSchema/TapSchema carriers (id + doc, 17 + 4
  authored lines); callers adapt to .id with byte-identical output
  (introspection goldens unchanged). The #190 guard keeps its triple --
  the doc column adds no fourth roster site.
- Coverage: tests/self_description.rs walks all five vocabularies
  (blocks, metrics, taps, folds, std nodes) through doc_gate -- the
  guard that keeps any future edit from blanking a doc.

Adjudicated review residue: the 17 metric doc strings are surfaced by
no CLI path yet -- deliberate; task 8 pins callers byte-identical, the
surfacing belongs to the CLI self-description work (#315). Plan-cited
coordinates lib.rs:1003/:1283 resolved to the real caller sites
(:1019/:1299); the plan's literal test code was reconciled to the real
accessors (FoldRegistry::core().roster(), Env::std()).

Gates: cargo test --workspace green (0 failed, incl. the new coverage
walk + scaffold E2E); cargo clippy --workspace --all-targets --
-D warnings clean.

refs #316
2026-07-23 17:19:01 +02:00
claude d8c6938027 feat(engine, market, strategy, backtest): NodeSchema.doc threading across the domain crates
C29 compile/unit seam, tasks 4+5 of the self-description plan.

aura-engine (task 4): derive_signature stamps doc: "" with the stance
recorded in-code -- a derived composite signature is graph wiring, not
a vocabulary entry; no seam walks its doc, the described surface is the
composite's own doc at the register seam. All in-crate test literals
thread doc: "test-only schema".

Domain crates (task 5): the 12 production builder sites in
aura-market / aura-strategy / aura-backtest carry authored meaning
lines; test sites in aura-backtest / aura-composites / aura-ingest
thread the test-only doc.

Three texts were corrected against the actual node semantics after
quality review rather than kept from the first authoring pass:
SimBroker is the frictionless integrator of held exposure times price
return into cumulative pip equity (no fills/stops/lifecycle -- that is
PositionManagement's domain), the shared cost-node line names the
PM-geometry inputs and both charge modes (AtClose / PerHeldCycle)
instead of a "per-trade gross R" mapping, and LongOnly's line
conditions on its enabled param and speaks port-term "exposure".

Gates: cargo test green for all six touched crates (engine, market,
strategy, backtest, composites, ingest); the workspace-wide gate
follows task 6 (aura-runner is the one remaining unthreaded site,
E0063 by design until then).

refs #316
2026-07-23 16:08:26 +02:00
claude a32dc38d18 feat(std): meaning lines for all 27 shipped node schemas
C29 compile/unit seam, task 3 of the self-description plan: every
aura-std NodeSchema literal threads its one-line doc. Four texts were
corrected against the actual eval/finalize semantics rather than taken
from the plan table verbatim: Delay is a lag-N register (not one-step),
GatedRecorder flushes an ungated final row at finalize, Latch is a
level-sensitive set/reset register (captures no input), SeriesReducer
emits its single summary row at finalize (not per cycle).

Gate: cargo build -p aura-std --lib clean; full std test run follows at
the all-crates gate once strategy/backtest/engine thread their sites
(the earlier per-crate test gate was unsatisfiable in isolation --
std's dev-deps pull crates whose sites belong to later tasks).

refs #316
2026-07-23 15:25:16 +02:00
claude df3c2f06bd feat(core): doc_gate shape check + required NodeSchema.doc field
C29 compile/unit seam, core carrier (iteration 1 of the self-description
spec): DocGateFault { Empty, RestatesName } + doc_gate() — deterministic
string-shape check, never content judgement — and NodeSchema gains the
required pub doc: &'static str, so every construction site must declare
a meaning line (compiler-enumerated). Core's own 10 test literals thread
doc: "test-only schema"; doc_gate/DocGateFault re-exported at crate root.

Downstream crates are E0063-broken by design until their sites thread
the field (tasks 3-7 of the plan); gates here are crate-scoped:
cargo test -p aura-core (59 green) + clippy -p aura-core (clean).

Interim subset commit of the implement run after adjudicating a
cross-task discard: task 2's file checkout had destroyed task 1's
node.rs/lib.rs work; restored from the loop's recovery snapshot
53c55c3 and merged with task 2's field threading.

refs #316
2026-07-23 15:10:42 +02:00
claude 8c19260e8d fieldtest: tap-subscribers — 5 examples, 0 bugs / 2 friction / 3 spec-gap
Cycle-close fieldtest for #283 + #77 (tap subscribers), exercising the new
tap surface as a downstream consumer over the public interface only (design
ledger, glossary, cargo doc, example corpus — never the crate sources).

Five examples, one per axis, all built and run against worktree HEAD bd0c557:
- tap_1_record_spread.ops.json    — record → runs/traces/graph/spread.json
- tap_4_measure_ic.ops.json       — two-tap run → aura measure ic (clean IC)
- tap_consumer/src/tap_2_fold.rs  — Named{mean}/Named{count} via run_signal_r
- tap_consumer/src/tap_3_live.rs  — Live(closure); bit-identical to fold + record
- tap_consumer/examples/refusal_probe.rs — unknown-label refusal (exit 1, no write)

The consumer crate is a detached workspace (own empty [workspace], path-deps on
aura-core/-engine/-runner), exactly as a real World program; it is not a
workspace member and does not build under `cargo build --workspace`. Verified
here with a fresh `cargo build --bins --examples` (Finished, 0 errors).

No merge-blocking defect: record/fold/live/measure over the shared pair are
correct and deterministic. Follow-ups filed at cycle close:
- #309 (feature) — a plain `aura run` echoes no recorded trace handle (F1+SG1)
- #310 (idea)    — Named fold has no data/CLI reach; ratify Rust-only vs selector (SG2)
- #311 (bug)     — same-name re-run orphans still-chartable tap files (SG3, pre-existing)
- #297 (comment) — library refusal = process::exit, not Result (F2, already tracked)
2026-07-22 00:40:04 +02:00
claude bd0c557f16 docs(ledger, glossary): tap-subscribers cycle tidy — C8/C27/C28 lifts, tap subscription vocabulary
Cycle-close audit for the #283/#77 tap-subscribers cycle
(09994b8..6e3f394). Architect review: code contract-clean; what
holds — C1/C7/C8 (empty-output caller-built consumers, (Timestamp,
Cell) zero-heap payload, lifecycle hooks inside the sequential loop,
byte-identical twin-run and streamed-vs-legacy byte-equality pins),
C28 (assembly-crate placement of the writer-holding consumer,
c28_layering guard green), C25 (registry growth = new Rust entry,
data-expressible Named form, roster-enumerating refusals). All drift
items were doc lifts, resolved in this commit:

- C8: the lifecycle is now a symmetric pair — initialize() as
  finalize's start-of-stream mirror (infallible by signature;
  acquire-in-initialize, degrade-to-inert, surface-once-at-finalize);
  history sidecar records the refinement and the #77 resolution the
  old Deferred paragraph still carried as open.
- C27: Guarantee and Current state now describe the shipped model —
  tap plan (Named{label, params} | Live) over the layered fold
  registry, BOTH entry points (run_signal_r AND run_measurement) on
  the one shared wiring pair, record streaming at constant memory.
- C28: aura-runner now defines one graph node of its own (the
  in-graph record consumer — exactly the graph-meets-persistence
  assembly position); the #297 refusal-site count grows ~20 → ~24
  (the four tap-plan refusals, already typed before the exit).
- glossary §tap: the subscription/fold-registry sense added; Avoid
  line steers probe/monitor/scope away from the observation slot.

Bench (report-only, loadavg 5.8 > 2 on the box — read with care),
all fingerprints OK, no regression:
  engine_throughput    bars_per_s 14005262   wall_s 0.714s   Δ bars_per_s +3.0% wall_s -3.0%
  ingest_throughput    bars_per_s 12970670   wall_s 0.387s   Δ bars_per_s +0.5% wall_s -0.5%
  campaign_sweep       cpu_percent 2202.0   peak_rss_mb 92.2   wall_s 1.396s   Δ cpu_percent +1.5% peak_rss_mb -6.1% wall_s -1.5%
  campaign_heavy       cpu_percent 2155.0   peak_rss_mb 94.8   wall_s 5.585s   Δ cpu_percent +1.8% peak_rss_mb -3.5% wall_s -0.4%
  cli_fixed_cost       help_ms 1.5ms   run_ms 3.6ms   Δ help_ms -1.8% run_ms -2.6%
No baseline update (report-only surface; no ratify due).

Correction on 6e3f394's body: "no Probe symbol is introduced
anywhere" — introduced is the operative word; the pre-existing
aura-core test fixture `struct Probe` stays, as the cycle's design
record specified.

Follow-ups on the tracker: #308 (legacy Recorder surface migration),
#307 (binary trace format). Cycle spec/plan working files discarded.

refs #283 refs #77
2026-07-21 23:56:14 +02:00
claude 6e3f394b48 feat(aura-runner, aura-cli): run_measurement on the shared tap pair; integration + register coverage (closes #283, closes #77)
Completes the #283 registry cycle. run_measurement drops its
duplicated Recorder-bind + post-run try_iter drain for the shared
bind_tap_plan/BoundTaps pair — the mirror of run_signal_r is now the
same code, so the two declared-tap entry points cannot drift; the
measure CLI arm passes TapPlan::record_all() (semantics unchanged,
constant memory). Measure-side library tests pin a fold plan's
one-row trace (streamed mean bit-equal to the slice mean over the
synthetic prices) and the tap-free write-nothing guarantee through
the shared pair.

Run-side integration tests pin the #283 acceptance surface: fold and
live plans agree exactly with the recorded series of the same
blueprint (C1, three runs, three drain policies); a live-only plan
persists nothing; plan refusals (unknown tap, roster-enumerating
unknown label) fire typed before any store I/O; two identical
record-all runs produce byte-identical tap files. The previously
untested `aura: writing tap traces failed` register gains end-to-end
coverage on both arms: store root occupied by a file (pre-run
refusal) and a read-only run dir (deferred initialize-open failure —
the run completes, the failure surfaces terminally, no index.json is
written, the store's treat-as-absent crash shape).

Two forced deviations from the plan's scripted literals, both
verified equivalent: an explicit match replaces expect_err (BoundTaps
is deliberately not Debug — it carries a TraceStreamer), and the test
helper takes &Path, not &PathBuf (clippy::ptr_arg under -D warnings).

Closes #77 as specced: zero per-cycle heap on the tap path
((Timestamp, Cell) payload, committed earlier this branch) and the
Recorder→Probe rename retired (no Probe symbol anywhere; decision
recorded on #77, 2026-07-21).

Verification: cargo test --workspace — 1517 passed, 0 failed;
cargo clippy --workspace --all-targets -- -D warnings clean;
tap_recording 5/5 (byte-compat anchors + both register arms).

refs #283 refs #77
2026-07-21 23:47:22 +02:00
claude d8ef9de5c2 feat(aura-runner): TapPlan + layered fold registry; run_signal_r re-wired onto the shared pair
The #283 registry model's assembly seam: tap_plan.rs carries
TapSubscription (Named{label, params} — fully data-expressible — |
Live(closure), the single non-data variant), TapPlan (name →
subscription + default; the plan travels with the FoldRegistry it
resolves against, the Env-at-resolve pattern, so layered hosts inject
vocabulary as data), and the registry itself: eight core entries
(record, count, sum, mean, min, max, first, last), each with a doc
line (help + roster-enumerating refusals) and a scalar-typed param
schema (all v1 entries param-less; the seam ships now because it sits
in every entry's build signature). Typed TapPlanError refusals fire
pre-bootstrap, before any store I/O; the terminal Store variant keeps
the established "writing tap traces failed" register bytes.

bind_tap_plan/BoundTaps is the shared wiring pair both declared-tap
entry points call; run_signal_r is re-wired onto it here (Recorder
bind + post-run try_iter drain replaced — no O(cycles) retention, no
per-cycle heap), with all six callers threaded (both CLI run-arm and
the four example-identity test calls pass TapPlan::record_all()).
run_measurement follows in the next commit.

Registry growth stays a new Rust entry (C25); a layer-registered
entry is pinned by the injection test, param validation by a
schema-carrying test entry (missing/mismatched/unknown all refused
with named messages).

Verification: cargo build --workspace clean; aura-runner 85+ tests
green incl. both example-identity anchors; tap_recording byte-compat
anchors green (recorded traces byte-identical, tap-free writes
nothing, duplicate-tap refusal unchanged); clippy -D warnings clean.

refs #283 refs #77
2026-07-21 23:18:54 +02:00
claude 92e281e4ec feat(aura-registry, aura-runner): deferred writer opener; TapRecorder moves in-graph to the assembly crate
Two #283 groundwork moves for the fold-registry model:

- TraceStreamer::register_tap returns a TapWriterOpener — everything
  needed to open one tap's incremental writer later, from inside a
  consumer node's initialize, without holding the streamer. tap_writer
  is re-expressed as register_tap(..).open(), so there is exactly one
  open path; pinned by a byte-equality test between the two.

- TapRecorder leaves aura-std (which must not know aura-registry, C28
  layering) and is reworked in aura-runner as the record entry's
  consumer: it holds the TapTraceWriter in-graph — initialize opens
  (deferred; an open error is stored and the node degrades to inert),
  eval appends per fired warm cycle (zero heap, no thread, no
  channel), finalize finishes the writer and reports exactly one
  terminal outcome. The SyncSender-based aura-std variant and its
  planned writer-thread protocol are retired; newest_cell becomes pub
  for the assembly crate.

Verification: cargo clippy --workspace --all-targets -D warnings
clean; aura-core/engine/registry/std/runner suites green (new tests:
opener equivalence, happy-path capture + single Ok outcome,
failed-open inert + single Err outcome).

refs #283 refs #77
2026-07-21 22:51:10 +02:00
claude b8ba324a41 feat(aura-core, aura-engine): Node::initialize — the start-of-stream mirror of finalize
Adds the second lifecycle hook the #283 design revision pairs with
finalize: Node::initialize (default no-op) runs once per node, in
topological order, before the first source value — inside the
deterministic sequence, no within-sim concurrency (C1). Consumers
acquire run resources there (the record consumer opens its streaming
writer); the hook is infallible by signature, so an acquiring node
stores its error, degrades to inert, and surfaces the failure once,
terminally, at finalize.

Every existing node keeps the default. Pinned by the mirror test
run_initializes_every_node_once_before_the_stream (init0,init1
strictly before any eval, exactly once), the shape of the existing
finalize test. RED was observed by temporarily reverting the flush
(test fails with evals-before-inits), then re-applied.

Ledger note for cycle close: C8's lifecycle section gains initialize
as finalize's mirror.

refs #283
2026-07-21 22:51:10 +02:00
claude 4ae2297a35 feat(aura-registry): streaming trace write path — begin_run, TraceStreamer, TapTraceWriter
Adds the incremental write path the tap-subscriber cycle streams
record subscriptions through: TraceStore::begin_run opens a run
directory and returns a TraceStreamer; per tap, TapTraceWriter
appends (ts, cell) points into two temp column streams and finish()
assembles the canonical ColumnarTrace JSON by streaming
concatenation (io::copy), removing the temps. index.json is written
last by TraceStreamer::finish, preserving the store's crash
discipline (no index -> NotFound, treat-as-absent).

Compatibility is pinned as an equality property, not a frozen byte
assumption: a new test asserts the streamed output byte-identical to
TraceStore::write's for the same rows (including empty and bool
columns), so the two write paths cannot drift and every reader
(read, read_family, chart) stays untouched. TraceStore::write is
refactored onto shared write_tap_file/write_index helpers used by
both paths. Temp streams open read+write (OpenOptions), since
finish() seeks back and re-reads them.

Verification: cargo test -p aura-registry — 78 passed (3 new:
byte-equality incl. empty+bool, crash shape NotFound, caller-order
index).

refs #283
2026-07-21 21:05:52 +02:00
claude 09994b83ed feat(aura-std): tap subscriber sinks — TapFold, TapRecorder, TapLive
The three C8 sinks realizing the per-tap subscription model (#283): TapFold
(closed FoldKind vocabulary Count|Sum|Mean|Min|Max|First|Last, in-graph
accumulation, exactly one finalize row), TapRecorder (bounded
SyncSender<(Timestamp, Cell)>, lossless backpressure — a full channel blocks
the sim, wall time never state), and TapLive (consumer-owned closure, inline
on the sim thread). All three move (Timestamp, Cell) — 16 B, Copy — instead
of (Timestamp, Vec<Scalar>): zero per-cycle heap on the tap path (#77 part
2). The shared kind-dispatched newest-cell read lives in tap_cell.rs.

Known-good subset commit of iter tap-subscribers (tasks 1-3 of 7, each gated
implementer -> spec-compliance -> quality DONE; the loop blocked on a task-4
plan defect, repaired separately): the trace-store streaming writer, the
TapPlan wiring seam, and both entry-point rewires follow in this same cycle.
Verified directly: cargo test -p aura-std — 109 passed, 0 failed.

refs #283, refs #77
2026-07-21 19:55:30 +02:00
claude 8688a60ded docs(ledger): split the design ledger into an INDEX map, per-contract live files, and history sidecars
The single-file ledger had grown to 2968 lines / ~42k tokens, mixing
current design law with accreted history: 59 cycle-stamped realization
blocks, 18 [HISTORY] passages, 22 supersession markers, and the C10 /
C22 / C24 reframe sagas layered several supersessions deep. A
code-grounding audit (31 agents, adversarially verified) confirmed 11
defects stated as current truth: stale crate homes from the C28 #288
roster split (cost nodes, PositionManagement, PositionEvent, Session),
the renamed InputSpec->PortSpec, the pre-#241 project model in C16 and
the open-threads section, a stale HarnessKind retirement deferral in
C24, and three C28-internal inconsistencies.

New shape, per the ailang precedent:

- INDEX.md stays the sole addressable entry point: foundation, external
  components, a C-id-keyed contract map (one line per contract), and
  only the genuinely open architectural threads.
- contracts/cNN-<slug>.md carries each contract's current truth only:
  Guarantee / Forbids / Why with ratified refinements integrated, plus
  a code-anchored Current state. All confirmed defects are fixed here;
  crate anchors were re-verified against the tree.
- contracts/cNN-<slug>.history.md (18 sidecars) and INDEX.history.md
  preserve every superseded block verbatim, stamps and issue refs
  intact, under a frozen-record banner. Nothing was deleted: superseded
  design intent remains an addressable working-tree artifact, off the
  per-cycle audit walk.
- Ledger discipline is now stated in INDEX.md: live files are edited in
  place at cycle close, superseded text moves verbatim to the sidecar,
  and a supersession marker in a live file is itself an audit finding.

Every contract file was verified against its old text by an independent
zero-loss pass (statement-by-statement) plus a code-accuracy spot check;
C-ids and contract titles are unchanged, so existing C-id citations in
code, tests, and issues resolve as before.
2026-07-21 16:40:36 +02:00
claude fb28b831f3 feat: show resolves a unique content-id prefix (closes #302)
aura {process|campaign} show now accepts the 8-hex prefix every surface
prints: an exact match is tried first, then the prefix is resolved
against the store's candidate ids — a unique match prints the identical
stored bytes as the full id (#164 byte discipline), an ambiguous prefix
refuses listing every sharing candidate, and a genuinely-unknown id
keeps its established refusal. The resolution mirrors the semantics
reproduce shipped with #298, closing the asymmetry the #300 fieldtest
named (df_4). Registry gains list_process_ids/list_campaign_ids
(missing store dir reads as empty, matching the blueprint-scan
discipline); campaign_not_found_prose becomes the single source for
show_campaign's two not-found arms. The ambiguity arm is pinned by a
unit test at the resolution seam — hash-derived ids make a
shared-prefix E2E fixture impractical.
2026-07-21 15:23:55 +02:00
claude 6b994cead3 test: red for show resolving a unique content-id prefix (refs #302)
aura {process|campaign} show is exact-match-only while every surface
prints 8-hex prefixes and reproduce has resolved prefixes since #298;
the fieldtest's prefix paste (df_4) hits the same flat not-found prose
as a genuinely-unknown id. The E2E test pins the wanted headline: a
unique 8-hex prefix of a registered id prints byte-identically what the
full-id show prints (#164 byte discipline), both verbs. The ambiguity
refusal is deliberately left to a unit test at the resolution seam —
hash-derived ids make a shared-prefix E2E fixture impractical.
2026-07-21 15:03:56 +02:00
claude a5ca0e7481 feat: the select-rule refusal enumerates the accepted roster (closes #301)
An unknown select rule in a process document now refuses with the
accepted vocabulary beside the quoted offending value:
  block std::sweep: unknown select rule "plateau" (accepted: argmax |
  plateau:mean | plateau:worst)
mirroring the roster-naming metric-refusal shape. The roster is derived
from slot_kind_label(SlotKind::SelectRule) — CLI describe output and
the document refusal stay in sync by construction — and a companion
test pins the "select rule: " prefix that derivation trims, so a
label-wording drift breaks a test instead of silently degrading the
prose. No alias-hint sentence: the enumeration alone rescues the
bare-plateau paste case (fieldtest df_3), and the document schema stays
strict per #300 F7.
2026-07-21 14:59:35 +02:00
claude edb8c37916 test: red for the select-rule refusal enumerating the accepted roster (refs #301)
A process document carrying an unknown select rule refuses correctly
but names no accepted vocabulary; the fieldtest's bare-plateau paste
(df_3) hits an opaque wall. The unit test pins the wanted property at
the site that builds the prose (select_from): the refusal enumerates
argmax, plateau:mean, plateau:worst beside the quoted offending value.
No alias-hint sentence — the enumeration alone rescues the paste case
and the project's refusal prose stays sparse; the document schema stays
strict per #300 F7.
2026-07-21 14:44:40 +02:00
claude 0a70f67711 fix: register outside a project refuses instead of creating a stray store (closes #305)
register_process and register_campaign now carry the same
env.provenance() no-project guard as their show/runs siblings, refusing
with exit 1 and the missing-Aura.toml prose instead of letting
Env::runs_root()'s relative fallback materialise ./runs/ in an innocent
cwd. Five pre-existing register tests ran from a bare temp cwd and were
reaching their asserted behaviour only through this bug; they now run
inside the fresh_project() fixture, preserving their original property
(content addressing, intrinsic-validation refusals) in a real project.

Verified: the RED E2E test (register_outside_a_project_refuses_and_
leaves_no_store, both verbs, refusal + no side-effect store) observed
GREEN by the loop's independent verify alongside the full suite.
2026-07-21 14:41:47 +02:00
claude 27ace153f2 test: red for register's stray-store creation outside a project (refs #305)
register_process/register_campaign call env.registry() without the
no-project guard their show/runs siblings carry, so Env::runs_root()'s
relative fallback silently creates ./runs/ in an innocent cwd and
registers the document there (exit 0). The E2E test pins both verbs and
both halves of the wanted refusal: exit 1 naming the missing Aura.toml,
and no store directory created as a side effect.
2026-07-21 14:27:04 +02:00
claude da37f596f2 fix: show outside a project names the missing Aura.toml, not the id
show_process/show_campaign now guard env.provenance().is_none() before
touching the registry — the guard their siblings (campaign_runs,
validate_campaign_file) already carry — refusing with the honest
degradation prose ('needs a project ... no Aura.toml found up from
{cwd}') instead of blaming the id with 'not found in the project
store' against a store that does not exist. Refusal exit 1 unchanged.
The register-verb sibling gap (silently creating a stray ./runs/) is
deliberately NOT touched here — tracked as #305.

RED-first: the in-process pin landed at 803c970; independent verify
re-ran it green with the suite.

closes #304
2026-07-21 13:29:39 +02:00
claude 803c9704cc test: red for show's no-project refusal blaming the id (#300 fieldtest)
Outside any project, show_process/show_campaign print 'not found in the
project store' — asserting a store that does not exist and blaming the
id, where the sibling verbs (campaign_runs, validate) degrade honestly
naming the missing Aura.toml. The in-process RED pins: the refusal
stays Err, its prose names 'no Aura.toml found up from', and does NOT
claim 'not found in the project store'. Diagnosed cause: both show fns
lack the env.provenance().is_none() guard their siblings use, so the
no-project fallback store's Ok(None) masquerades as an unregistered id.

refs #304
2026-07-21 13:21:11 +02:00
claude 63711a1d3a fieldtest: document-first — 4 examples, 7 findings
Downstream-author fieldtest of the #300 surface, public interface only,
real GER40 archive. All four axes hold: the closed
generate -> show -> hand-extend -> re-register -> run loop (2-cell
extended campaign), the typed stop grammar's clap rejections, bare
plateau's content-id identity with plateau:mean, and the show read-back
(byte-identical, no framing) with a clean unknown-id refusal.

Findings routed: 1 bug (show outside a project blames the id instead of
the missing Aura.toml — RED-first fix follows), 2 friction items filed
to the tracker (opaque process-document select refusal; no prefix-id
hint on show), 4 working (carry-on). The fieldtest spec survives
git-ignored as the next planning cycle's reference input.

refs #300
2026-07-21 13:16:59 +02:00
claude a12617bb80 audit: document-first cycle close — doc verb rosters caught up
Architect review (cycle 757e3ac): core clean — show is pure shell
dispatch over Env::registry() (C28); both ledger amendments match the
F1-F8 decision log exactly; document schema untouched; the #164
no-framing pin is stricter than the spec snippet asked. Four peripheral
drift items, resolved:

- fix: docs/authoring-guide.md verb rosters now name show (process) and
  show|runs (campaign) — the guide teaches exactly the
  generate->retrieve->extend loop this cycle closed.
- fix: docs/glossary.md campaign/process document entries carry the
  settled verb sets (record-reality glossary write).
- fix: show_process's not-found refusal now sources its prose from
  ref_fault_prose(ProcessNotFound) instead of a hand-copied format!
  (byte-identical, test-pinned; the campaign-side prose has no RefFault
  sibling and stays the single campaign instance).
- ratify (log-only): the shipped show helpers use print! (no framing,
  #164) where the spec snippet sketched println! — the code is the
  stricter, correct form; recorded on the reference issue's decision
  log. The spec is a git-ignored working file swept at this close.

Regression: aura-bench report-only run, all fingerprints OK, deltas in
noise (host load 9.6 noted; engine/ingest/campaign/cli lanes all within
±5%). Full aura-cli gates re-run green after the tidy edits.

refs #300
2026-07-21 12:58:02 +02:00
claude 757e3ac1bd feat: the document-first surface closes — show read-back, typed stops, bare plateau
The #300 keystone cycle: the residual run-quintet flags were already
document vocabulary on the --real arm (#210/#220); this closes the loop
and ratifies the surface.

- `aura process|campaign show <content-id>` prints a registered
  document's canonical bytes (print!, no framing — #164), so
  generate -> retrieve -> hand-extend -> re-register needs no direct
  store filesystem access. Refusals reuse the store-hint prose, exit 1.
- walkforward/mc --stop-length/--stop-k become clap-typed i64/f64
  (generalize's form); stop_knob_or and its parse_csv_list helper are
  deleted (multi-value/invalid input is now clap's exit-2 rejection —
  the two dissolved-verb refusal tests retarget, the type-dead unit
  test is deleted).
- --select accepts bare `plateau` as the documented default
  plateau:mean (#227 carry); document schema untouched — the two
  spellings generate content-id-identical campaigns (test-pinned).
- family.rs's quadruplicated demo stop literal collapses onto one
  DEFAULT_STOP const (maintainer-lens drift finding).
- Ledger: C25's "which projection next" line resolved (document-first
  delivered, per-verb identity re-ratified — F8; host/MCP stay
  demand-driven); C18 records the read-back.

Design triage, fork decisions F1-F8, and the auto-signed spec live on
the reference issue. Verification: full workspace suite 1482 passed /
0 failed with the real GER40 archive exercised (the byte-identity
round-trips ran, not skipped); clippy -D warnings clean.

closes #300
2026-07-21 12:50:10 +02:00
claude 4ed6455b64 fix: load_family resolves the enumerated family name, refusing ambiguity (#298)
GREEN for 78e68e6's RED. aura_runner::reproduce::load_family resolved
only the derived C18 handle '{family}-{run}' while the registry's
enumeration exposes the bare name factor — the natural
list-then-reproduce library workflow dead-ended (found by the #295
fieldtest; predates the extraction byte-identically). Resolution now:
exact-handle match keeps first precedence; a bare name naming exactly
one stored run resolves as fallback; an ambiguous name (several runs,
or a name/handle collision) refuses without guessing, listing the
candidate handles. Two additional tests pin the ambiguity refusal and
the handle-precedence-on-collision semantics.

Ledger record-reality rider: the C18 #158 paragraph claimed reproduce
refuses an unknown id with exit 2; the code has always exited 1 —
correct per C14's partition (the id names missing recorded state, a
runtime failure, not a usage error). The paragraph now records the
actual behaviour and the new name-fallback semantics.

Verification: cargo test --workspace green (1477 passed, 0 failed);
clippy --workspace --all-targets -D warnings clean.

closes #298
2026-07-21 07:34:41 +02:00
claude 78e68e6002 test: red for the enumerated family id rejected by load_family (#298)
Registry::load_family_members() exposes the bare name factor while
aura_runner::reproduce::load_family exact-matches only the derived C18
handle '{family}-{run}' — the natural list-then-reproduce workflow
dead-ends on its first attempt (found by the #295 fieldtest; predates
the extraction byte-identically). The test pins: the enumerated
identity of an unambiguous single-run family resolves through
load_family to the same family the canonical handle names.

refs #298
2026-07-21 07:18:59 +02:00
claude 9df217d868 fieldtest: shell-boundary — 4 examples, 6 findings
Downstream-consumer corpus for the #295 library surface: a standalone
workspace (path-deps, no aura-cli in the link graph — cargo tree
verified) exercising the four cycle axes as a World program would,
public interface only: a DefaultMemberRunner member run over real
GER40, a hand-authored campaign document through
aura_campaign::execute, the IC + registry deflation dispatch as
library calls, and load_family/reproduce round-trips. Run transcripts
committed beside the sources.

Findings routed separately: the family-id mismatch between the
registry enumeration and the reproduce keying (list-then-reproduce
dead-ends) to debug; the reproduce-API friction (no report on the
simple path; DataSource/pip re-specification the manifest already
holds, wrong pip silently DIVERGED) to a follow-up. Four working
confirmations, chief among them the cycle's acceptance claim: all
four axes compiled first-try from the public docs alone.

refs #295
2026-07-21 07:10:09 +02:00
claude 5006766579 audit: shell-boundary cycle close — drift resolved in-commit
Cycle-close audit (architect: drift_found; design core clean). What
holds, architect-confirmed with the guard run and the diff read in
full: the shell boundary is real and enforced (full-workspace
c28_layering incl. completeness + shell-content checks, acceptance
grep clean — no member-run symbol left under crates/aura-cli/src);
aura-campaign keeps zero production dependency on the runner; the IC
move is verbatim against the anchor; the new library-only E2E pins C1
(two independent runners, byte-identical RunReport).

Drift items, all resolved as fixes in this commit:
- design ledger: the C28 status sentence "No crate exists yet for
  measurement" contradicted this cycle's own phase-3 done line — now
  records aura-measurement as seeded (#295), execution still unbuilt.
- the #147 registry-dispatch acceptance tests (IC deflation null,
  seeded determinism, cross-vocabulary refusal) were pure library
  properties stranded in the shell's test module — relocated to
  aura-measurement/tests/registry_dispatch.rs (engine/registry as
  dev-only, layering-exempt edges), per the cycle's own tests-move-
  with-their-module rule.
- aura-runner's direct production edge on the external data-server
  tree was structurally invisible (the direction table checks aura-*
  keys only) and unrecorded — the C28 assembly prose now names it, and
  a new c28_layering test pins the external tree's entry points to
  exactly {aura-ingest, aura-runner, aura-cli}.
- the C28 shell prose named three buckets that did not cover two real
  shell residents — it now names the op-script construction front-end
  (translation) and the `aura new` scaffolder (authoring-tooling,
  shell-resident like rendering until a second consumer wants it).

Deliberately deferred, tracker homes exist (not drift): #297
(process::exit sites in aura-runner's single-run verb paths), #294
(IC duplicate-timestamp semantics), #288-era rustdoc unresolved-link
warnings in aura-std/aura-backtest (pre-existing byte-identically at
the anchor).

No regression scripts are configured (the bench is report-only); the
architect review is the gate. Verification: cargo test --workspace
green (1474 passed, 0 failed) incl. the relocated registry-dispatch
tests and the new data-server guard; clippy --workspace --all-targets
-D warnings clean.

refs #295
2026-07-21 06:46:13 +02:00
claude 170c6c82dc feat: full-workspace C28 guard — the shell boundary is structural (#295 part 2)
Completes the shell-boundary cycle (tasks 10-13):

- c28_layering now enumerates the FULL workspace: every crate row is
  reconciled against its real production [dependencies], a completeness
  assertion pins the table to the on-disk crates/ set (a new crate can no
  longer escape the guard silently), the shell/assembly imported-by-
  nothing rule is asserted, and a shell-content check pins aura-cli to
  no-[lib] plus a closed allow-list of argv/translation/presentation
  modules. A new domain module in the shell now fails the suite.
- Ledger amendments: C28 gains the assembly position (aura-runner) and
  the corrected import-rule prose (the ratified aura-campaign ->
  aura-backtest production edge, #291/#292); phase 3 is marked done with
  the #297 process::exit residual named; a provenance note records this
  as structural-debt closure, not a demonstrated downstream blocker.
  C25 records the control-surface decision: the text artifact vocabulary
  is canonical, every control surface (CLI executor verbs, a future
  host, an MCP face, a World program) is a projection/executor over it —
  the which-projection-next ranking deliberately open on #295. C14 gets
  the executor-face amendment, C26 the binding-module relocation.
- aura_campaign::MemberRunner's doc names the shipped default
  implementor (aura-runner::DefaultMemberRunner) while the column keeps
  zero dependency on it.
- New library-only E2E fixture (aura-runner/tests/world_member_run_e2e):
  runs the canonical member recipe over a tiny synthetic archive with no
  aura-cli in the link graph, and pins C1 determinism (two independently
  constructed runners produce a byte-identical RunReport).
- campaign_run.rs module doc: intra-doc MemberRunner link re-anchored to
  aura_campaign::MemberRunner (the impl moved out with part 1).

Verification: cargo test --workspace green (1473 passed, 0 failed);
clippy --workspace --all-targets -D warnings clean; cargo doc clean of
NEW warnings (the five unresolved-link warnings in aura-std/aura-backtest
predate this cycle byte-identically at the anchor — #288-era doc drift,
left for the cycle audit).

refs #295
2026-07-21 06:35:31 +02:00
claude 5c2ac982bc refactor: extract the member-run recipe into library crates (#295 part 1)
The shell no longer defines what an aura backtest is. Tasks 1-9 of the
shell-boundary cycle — structural extraction, behaviour byte-identical:

- aura-runner (new; the C28 assembly position): input binding (the C26
  module, moved whole), the C1-load-bearing param<->config translators,
  harness assembly (wrap_r / run_signal_r / run_blueprint_member and the
  probe/reopen cluster), axis + risk-regime conventions (bind_axes et
  al.), the campaign family builders + MC guards, reproduce
  (process::exit -> RunnerError, shell remaps to identical bytes), the
  measurement-run orchestration, project loading (Env / cdylib load /
  charter / staleness, moved whole), the coverage gap-walk (deduplicated
  onto interior_gap_months), and DefaultMemberRunner — the public
  implementation of aura_campaign::MemberRunner (ex CliMemberRunner).
  The MemberRunner trait stays in aura-campaign; the column still
  imports no harness/data-binding machinery.
- aura-measurement (new; seeds C28 rung 3): the IcMetrics/IcKey
  vocabulary + information_coefficient, verbatim incl. serde derives
  (#294 duplicate-timestamp semantics move as-is, unresolved).
- aura-backtest: the pure per-run scaffold (point_from_params,
  wf_ms_sizes / fit_wf_ms_sizes, intersect_shared_window).
- shell residue: main.rs / campaign_run.rs keep argv/dispatch,
  argv->document translation, and presentation only;
  walkforward_summary_json_from_reports now calls the public
  aura_engine::param_stability instead of its inline twin.
- worked example (crates/aura-runner/examples/world_member_run.rs): a
  World program runs a member backtest through the library alone.

Held quality findings, adjudicated: the plan's literal pub-visibility
list for binding.rs kept (cosmetic); ~20 refusal sites inside
aura-runner (family/member/measure/translate) still process::exit —
behaviour-identical today, conversion to RunnerError is filed forward
(refs #297); rustfmt line-width drift on re-pathed call sites left for
a repo-wide fmt decision.

Verification: cargo test --workspace green (1471 passed, 0 failed);
cargo clippy --workspace --all-targets -D warnings clean. Byte-identity
is pinned by the untouched shell E2E suites (cli_run, measure_ic,
run_measurement, research_docs, run_refuses_unrunnable_blueprint,
tap_recording — zero assertion edits).

Remaining in this cycle: full-workspace c28_layering + shell-content
check, ledger amendments (C28 assembly position, C25/C14 control-surface
consequence, C26 realization note).

refs #295
2026-07-21 05:20:27 +02:00
claude 2cf4574e33 audit: cycle metric-vocabulary tidy — ledger records the retired #147 deferral
Cycle-close audit (architect: drift_found, design core clean). What holds,
architect-confirmed: C18 bit-identity for RunMetrics (prose derived
byte-identically, rng order preserved, registry suite unchanged); the C10
wall (monomorphic R-gates, r_based in the R vocabulary, cross-vocabulary
refusal tested both ways — no leak); C28 direction (trait in aura-analysis,
vocabulary supplied from the outer rungs, zero Cargo edge changes); C1
determinism pinned through the generic path.

Drift items, all resolved as fixes in this commit:
- design ledger: the C28 #147 disposition now records item 2 SHIPPED (the
  A1 cut) with A2 still deliberately deferred; the #136 one-implementor
  clause carries a supersession note (the IC is the second implementor).
- seven stale doc comments describing the pre-#147 or mid-cycle state
  (analysis trait + estimator docs, registry check_r_metric C9 claim,
  engine re-export note, campaign PER_MEMBER_METRICS roster note,
  member-seam guard comment, research vocabulary note) updated to the
  shipped state.
- R_BASED_METRICS is now oracle-pinned against RunMetricKey::r_based()
  in the vocabulary test (it feeds the NonRMetric refusal prose; a
  divergence would have misreported the R-gate silently).

Noted, not amended (history stays): commit 6744f67's body says 'three
syntax-only edits' where the test module actually took five.

No regression scripts are configured (the bench is report-only); the
architect review is the gate. Verification: extended vocabulary test,
campaign suites, analysis/research suites green; clippy --workspace
-D warnings clean.

refs #147
2026-07-20 20:03:02 +02:00
claude 0651e16146 feat: the IC becomes the second metric vocabulary; rosters single-sourced
The A1 cut of #147 item 2, part 2 of 2 (the consumers), completing what
part 1 (the MetricVocabulary substrate) set up:

- aura-campaign's RANKABLE_METRICS hand-copy becomes a re-export of the
  backtest vocabulary's roster; the #190 guard test extends to pin the
  roster nesting (rankable == vocabulary roster, rankable within
  per-member, per-member within the research vocabulary) and the legacy
  exposure_sign_flips alias, so any drift fails a test loudly. The
  research and per-member rosters themselves stay hand-copies by ratified
  isolation (#190 accepted residual) — now oracle-pinned instead of trusted.
- aura-cli gains IcMetrics + IcKey implementing MetricVocabulary: the
  second PRODUCTION implementor. The standalone `aura measure ic`
  reduction now computes its permutation p via the shared null_draw +
  one_sided_p_laplace building blocks (independent draws instead of
  chained shuffles — statistically equivalent, deterministic per seed;
  emitted p values may differ in the last digits from #290's).
- Acceptance proven at the registry boundary: an engineered
  SweepFamily<IcMetrics> deflates through optimize_deflated to a LOW
  overfit probability and a noise family to a HIGH one (the permutation
  null, dispatched by the vocabulary — the deflation machinery is no
  longer baked-in R-only); same seed reproduces the identical
  FamilySelection; cross-vocabulary names are refused with the family's
  own roster in the prose (the C10 guard). A new cli_run E2E exercises
  the legacy alias through the real `aura runs family … rank` boundary.

Deviation from the plan, forced by the compiler: IcMetrics additionally
derives Debug (unwrap_err on the deflation Result needs it; mirrors
RunMetrics).

Verification: full workspace suite green (no FAILED, no compile errors),
clippy -D warnings clean, C18 goldens (cli_run, measure_ic, c28_layering)
green unchanged. A2 — measurement runs as sweep-family citizens — stays
deliberately deferred until a concrete campaign demand exists.

closes #147
2026-07-20 19:55:24 +02:00
claude 6744f670b1 refactor: registry deflation dispatch generic over the MetricVocabulary trait
The A1 cut of #147 item 2, part 1 of 2 (the substrate): the registry's
ranking/deflation machinery no longer bakes in the R metric vocabulary.

- aura-analysis gains the deliberately narrow MetricVocabulary trait (name
  resolution, roster, direction, value read, one null draw) and the
  single-sourced one_sided_p_laplace estimator; re-exported through
  aura-engine's existing seam (no Cargo.toml edge changes anywhere).
- aura-backtest now OWNS the R vocabulary: RunMetricKey (was the registry's
  private Metric enum, exposure_sign_flips alias preserved), r_based (the
  C10 classification), R_BASED_METRICS/RANKABLE_METRICS rosters, the
  centred moving-block null_draw, and member_metric_from_rs.
- aura-registry: metric_cmp/rank_by/null_best_of_k/member_sd generic over
  M: MetricVocabulary; optimize/optimize_plateau/optimize_deflated over
  M: MetricVocabulary + Clone (SweepPoint<M>: Clone bounds on M: Clone);
  the private vocabulary deleted; check_r_metric/generalization stay
  monomorphic on the RunMetrics vocabulary (the C10 wall by type);
  RegistryError::UnknownMetric carries the vocabulary's roster so the
  refusal prose derives instead of being hand-strung (byte-identical for R).

Bit-identity (C18): rng consumption order, centring, Laplace arithmetic,
floors, tie rules, and refusal prose are all unchanged for RunMetrics
inputs — the full registry suite (75 tests) passes unchanged except three
syntax-only edits for the UnknownMetric variant reshape; clippy -D warnings
clean on the three touched crates. Alternatives considered: a vocabulary
table in aura-core (rejected: inverts C28's supplied-from-outer-rungs
direction and cannot host the null model); a parallel measurement-side
deflation path (rejected: duplicates the machinery and adds a fifth
roster). Part 2 lands the consumers (campaign roster single-sourcing, the
IC implementor, acceptance tests).

refs #147
2026-07-20 19:10:39 +02:00
claude a9d36ddd70 feat: measurement's first deflatable metric — the Information Coefficient
Give a measurement run a standalone post-run quality score: the Information
Coefficient (IC), corr(signal_t, forward_return_{t+h}), with a permutation
null model. Before this a measurement run persisted only tap names and series
(MeasurementReport) — inspectable but not rankable or deflatable. IC is the
first metric measurement supplies: the tap-to-scalar reduction the C28
process-column / metric-interface seam anticipates ("a named metric vocabulary
supplied by measurement and backtest instead of baked in R-only").

Surface: `aura measure ic <run> --signal <tap> --price <tap> [--horizon]
[--permutations] [--seed]`. It reads the run's two recorded tap traces, builds
the forward-return series offline over the recorded price spine, aligns
signal -> return by exact timestamp, computes the IC, and prints it with a
one-sided permutation-null overfit probability (Laplace-smoothed, mirroring the
R deflation arm's formula in optimize_deflated).

Placement follows the C28 ladder: the generic pieces — pearson_corr and a
Fisher-Yates permute over SplitMix64 — land in aura-analysis (the domain-free
statistics foundation, beside resample_block); the IC semantics (forward-return
horizon, the offline join) and the CLI verb land in aura-cli (the shell). The
run path (run_measurement), MeasurementReport, the trace store, and the registry
metric vocabulary are untouched — existing `aura run` output stays byte-identical
(the C18 golden run_prints_json_and_exits_zero stays green).

Causality (C2): the forward-return read close_{t+h} is over a completed run's
recorded trace, not an in-graph node. A look-ahead signal is structurally
impossible in a causal run, so the engineered-vs-noise acceptance property is
unit-tested over hand-built offline series (perfect signal -> IC ~= 1, p < 0.05;
a varying but exactly-uncorrelated signal -> IC = 0, not significant;
constant/degenerate -> the (0.0, 1.0) floor), while the E2E validates the CLI
wiring, report well-formedness, determinism, and the error paths over a real run.

This lands the second, structurally distinct null-model computation (permutation,
beside the moving-block bootstrap behind R). That makes the deferred registry
deflation-vocabulary abstraction (#147 item 2) demand-driven rather than
speculative — the next cycle.

Verified: cargo test --workspace green (incl. the new aura-analysis unit tests,
the aura-cli ic_* unit tests, and the measure_ic E2E); cargo clippy --workspace
--all-targets -D warnings clean; the C18 byte-identity golden unchanged.

closes #290
refs #147
2026-07-20 16:27:57 +02:00
claude a56ab7859d refactor: cut the engine's backtest-metrics edge via RunReport<M>
C28 phase 2 (Stratification); realizes item 1 of the deferred #147. The
engine's production surface no longer names a backtest-metric type:

- RunReport becomes generic over its metric payload M; sweep/mc/walkforward/
  blueprint thread the parameter (SweepPoint<M>, SweepFamily<M>, WindowRun<M>,
  WalkForwardResult<M>). RunManifest stays concrete and engine-owned (its
  selection: Option<FamilySelection> embeds the foundation-grade analysis type).
- summarize and the MC assembly (McDraw/McFamily/McAggregate/RBootstrap/
  r_bootstrap/monte_carlo) move to aura-backtest - McAggregate::from_draws reads
  RunMetrics fields by name, so generifying it is the phase-6 metric-vocabulary
  abstraction (#147 item 2), still deferred; wholesale relocation is the honest
  cut. The concrete instantiation lives in aura-backtest as
  `type RunReport = aura_engine::RunReport<RunMetrics>` + sibling aliases.
- the statistics kernel (MetricStats/quantile/resample_block/SplitMix64) moves
  to the aura-analysis foundation; the engine re-imports it (inner->foundation,
  legal) and re-exports it so existing consumers stay source-compatible.

Dependency inversion in one commit: aura-engine drops aura-backtest from
[dependencies] (back to dev-deps for its SimBroker/RunMetrics test fixtures);
aura-backtest gains aura-engine. Cycle-free for lib targets - the cycle closes
only through the engine's dev-dep edge, the pattern aura-vocabulary already
uses. aura-backtest reaches the kernel transitively through the engine
re-export, so no aura-backtest -> aura-analysis edge exists (the C28 ladder
permits backtest -> {core, engine} only). run_indexed / SplitMix64::next_f64
widened pub(crate) -> pub for cross-crate use.

Consumers (registry/campaign/cli/composites/ingest/bench) rewired by import
path only, no call-site logic changed. The c28_layering structural test extends
to the full ladder: aura-analysis (no aura-* deps), aura-engine ⊆ {core,
analysis}, aura-backtest ⊆ {core, engine}.

Behaviour-preserving: 1448/0 tests, clippy -D warnings clean, serde shapes
byte-identical (C18 - RunReport<M> keeps field order manifest,metrics; the
CLI pre-serialized-splice contract unchanged), moved code traceable via git
rename detection. Cycle-introduced broken intra-doc links fixed.

closes #292
2026-07-20 13:25:07 +02:00
claude 94aaa4cde8 refactor: split aura-analysis into statistics and backtest metrics
C28 phase 5 (Stratification): the backtest reductions - RunMetrics, RMetrics,
summarize_r, r_metrics_from_rs, and the position-event table - move verbatim
into aura-backtest::metrics, beside the position_management producer whose
record layout they read (the r_col/cost_col lockstep contract is now
intra-crate; its stale "aura-std" comment corrected). aura-analysis is reduced
to the domain-free half: the multiple-comparison hurdle math (inv_norm_cdf,
expected_max_of_normals) and the selection-provenance types
(FamilySelection/SelectionMode - kept beside the statistics so
RunManifest.selection embeds a foundation-grade type), [dependencies] = serde
only.

The engine re-export surface is name-unchanged (report.rs re-imports from both
crates), so no indirect consumer needed a source edit; aura-backtest moves from
aura-engine's dev-dependencies into [dependencies] as a commented TRANSIENT
widening of the C28 violation, removed by the phase-2 edge cut on this branch.
The campaign drift guard imports its pinned types from their new source
crates. Behaviour-preserving: 1448/0 tests, clippy -D warnings clean, serde
shapes byte-identical (C18), moved code traceable via git copy detection.

closes #291
2026-07-20 12:23:17 +02:00
claude b39fd63396 refactor: split the aura-std roster into C28 layer crates
Phase 4 of the Stratification milestone. aura-std held four C28 ladder
layers in one roster; this cuts them into layer-aligned, aura-core-only
node crates so the import direction is enforced by the crate graph:

- aura-std        — engine nodes only (arithmetic/logic/rolling + sinks)
- aura-market     — session, resample
- aura-strategy   — bias, stops, sizer, cost-model machinery
- aura-backtest   — sim_broker, position_management
- aura-vocabulary — the relocated closed std_vocabulary roster

Node modules move verbatim (byte-identical renames); consumers are
rewired by import path only. A new structural test
(aura-vocabulary/tests/c28_layering.rs) asserts each node crate's
[dependencies] stay within its C28-permitted inner set, catching the
acyclic-but-outward violation the compiler misses.

Behaviour byte-identical: full workspace suite green (1448 tests), no
golden edited, clippy -D warnings clean. C28 Status block updated.

closes #288
2026-07-19 20:28:20 +02:00
claude 34ff539143 feat: measurement run verb — shape-dispatch aura run on blueprint output
Phase 3 of the Stratification milestone (contract C28). `aura run` now
shape-dispatches on the loaded blueprint: a `bias` output takes the
existing strategy path (wrap_r + R evaluation), byte-identical; a
blueprint that declares taps but exposes no `bias` runs bare via the new
`run_measurement` (bootstrap, run, drain the declared taps) with no
SimBroker, no risk executor, and no per-cycle equity/exposure/r
recorders; a blueprint exposing neither is refused. This gives the
measurement layer its own run verb and removes the O(cycles) recording
retention (the measured ~2 GB peak RSS over 12y UK100 M1) the mandatory
R scaffold imposed on a measurement-shaped run.

A new `MeasurementReport { manifest, taps }` (aura-engine::report, beside
RunReport) is the stdout/record shape — no R metrics, since a bare run
has no broker. The manifest's `broker` carries an interim "measurement"
sentinel; the honest `Option<String>` model is deferred to the #147
metric-genericity block, keeping this phase #147-free and the
strategy-path C18 goldens byte-identical.

`run_signal_r` is untouched (the tap machinery is duplicated into
`run_measurement`, not extracted). The measurement arm's closed-blueprint
guard uses `signal.param_space()` rather than `blueprint_axis_probe`,
which welds wrap_r and would panic on a no-`bias` signal.

refs #286
2026-07-19 16:09:51 +02:00
claude 1c49d5dce2 design: ratify C28 — internal stratification (ladder, process column, shell)
Codify the layer model settled with the user: an inner-to-outer ladder
engine -> market -> {measurement | strategy} -> backtest -> execution,
the research-process column beside the ladder, the shell outside. States
the import rule (inner never imports outer; measurement/strategy
siblings; column imported by no ladder crate; shell imports all;
dev-deps exempt), the six seams (five already ratified contracts —
C8/C24/C27/C18/C10-C13 — only the metric interface new), and an honest
Status recording the one hard production violation (engine -> analysis
via report.rs re-exports + the R-typed mc.rs fields) and its coupling to
the deferred #147.

Also restore the "### C19" header, deleted as collateral of ebe8568
(2026-07-03 cycle-0106 tidy); its body survived orphaned under C18. C28
cites C19, so the header must resolve.

refs #286
2026-07-19 15:14:50 +02:00
claude 592750b122 audit: close #284/#285 cycle — GraphBuilder::tap twin restores C24 symmetry
Cycle-close tidy for the #284 (tap construction op) + #285 (docs) cycle. The
architect drift review (cb6211c..HEAD) came back drift-clean on the ledger
contracts the cycle touched — C27 machinery untouched, a faithful `expose`
twin (C25 preserved), lockstep op<->OpDoc / OpError<->format_op_error arms
paired, and the #285 docs verified accurate against aura-std/src/session.rs —
save one drift item, fixed here.

Drift item (fixed): the op-script gained `Op::Tap` but `GraphBuilder` had no
`tap()` twin, softening C24 (replay-equals-builder = one construction
semantics) and C17 (builder-primacy) — every other op verb has a builder
twin. Unlike the op-script's `doc` gap (a ledger-recorded #125 scope cut),
this asymmetry had no rationale; a tap is a first-class authoring act on both
surfaces, so denying only the fluent builder was an accident of incremental
delivery, not a design cut.

Fix: `GraphBuilder::tap` (builder.rs), the exact output-side mirror of
`expose` — a `taps: Vec<(String, OutPort)>` accumulator resolved at `build()`
into `Tap { name, from: TapWire }` and threaded via `.with_taps`. Plus a
construction.rs lockstep test `tap_replay_matches_builder` proving the
op-script `Op::Tap` replay and the `GraphBuilder::tap` twin serialize to
byte-identical blueprint JSON and compile to identical `FlatGraph.taps` — the
tap op is the by-identifier face of the same construction semantics, not a
second one.

Carry-on (architect debt-low, no action): commit 43a1cc4's body phrase
"op-built composites silently lost their taps" over-claims a prior drop (no
tap op existed pre-cycle); the "latent" qualifier keeps it honest, so the
wording is left as-is rather than rewriting an already-landed commit.

Verification: full `cargo test --workspace` green; `cargo clippy --workspace
--all-targets -- -D warnings` clean. The bench is report-only by project
convention and was not run — this cycle adds a construction-time op + docs,
no hot-path change.

Fieldtest: the #284 `tap` surface is exercised from the public interface by
two E2E consumer-path tests (op-script -> `aura graph build` -> `aura run` ->
persisted trace) plus two worked authoring examples hand-verified through the
built binary; #285 is documentation. The per-cycle fieldtest is satisfied.

Spec + plan working files (docs/specs/tap-construction-op.md,
docs/plans/tap-construction-op.md) discarded per audit Step 5 (git-ignored,
never committed; their design intent lives in the commit bodies + the ledger).
2026-07-18 16:42:39 +02:00
claude a5e02ff541 docs: SessionFrankfurt output semantics + declared-tap authoring (#285)
Surfaced by the measurement-milestone fieldtest: a downstream author
reaching for session anchoring or declared taps had no public statement of
either — the semantics lived only in node source / the design ledger.

SessionFrankfurt / Session output semantics:
- glossary `session node`: corrected — it claimed three streams
  (`bars_since_open`, `in_session`, `session_open_ts`), but the shipped node
  emits ONE `i64` field `bars_since_open`. Now states the real contract: the
  count of completed bar-periods since the local (tz-aware, DST-correct)
  session open, close-instant indexed (09:15->1, 09:45->3; pre-open <=0), with
  the Frankfurt 09:00 Europe/Berlin open + `period_minutes` knob + the
  value-ignored `trigger`.
- authoring-guide: a "Session anchoring: the SessionFrankfurt preset"
  subsection — schema (trigger / period_minutes / bars_since_open), the
  close-instant multiples, the EqConst gating pattern, DST handling, and the
  add-snippet. Verified against `aura graph introspect --node SessionFrankfurt`.

Declared-tap authoring (C27 / the #284 tap op):
- authoring-guide: the `tap` op-table row (seven ops -> eight) + a worked
  "declaring a measurement tap" example. The example is verified: piping it
  through `aura graph build` emits
  `taps:[{"name":"spread","from":{"node":2,"field":0}}]`.
- design ledger op-list (INDEX.md): a `tap` bullet (seven verbs -> eight);
  also corrected the `expose` "only verb that keeps `as`" claim — `tap` keeps
  it too.
- README op-kind enumeration: adds `tap`.

Docs only; no code change. Both worked examples were run through the built
`aura` binary; the SessionFrankfurt schema was checked against introspect.

closes #285
2026-07-18 16:31:17 +02:00
claude 43a1cc44d3 feat(graph): name-addressed tap construction op — declared taps without raw indices
Declared taps (C27, #282) were authorable only by hand-patching the raw
blueprint envelope's `taps` array, where `node` is a raw interior index —
an index miscount was the expected mistake and the root friction behind the
two `aura run` panics the measurement-milestone fieldtest found.

Add a `tap` op to the construction op-script vocabulary — the exact
output-side twin of `expose`. It names its source wire by the dotted
`"node.field"` identifier the op surface uses everywhere (`split_port` +
`resolve_output_field` + `node_index`), so
`{"op":"tap","from":"fast.value","as":"fast_ma"}` replaces patching
`{"node":14,"field":0}`.

Engine (construction.rs): `Op::Tap`, its `tap()` handler (a verbatim twin
of `expose` — same resolvers, same name-dedup, appending a `Tap` to a new
`taps`/`tap_names` namespace), and `OpError::DuplicateTap` (the twin of
`DuplicateOutput`, stricter than the raw envelope, which enforces no
tap-name uniqueness). `finish()` now threads `.with_taps(self.taps)` into
the built `Composite` — fixing a latent drop #282 left (it built
`.with_gangs` but never `.with_taps`, so op-built composites silently lost
their taps).

CLI (graph_construct.rs): `OpDoc::Tap` (externally tagged, `"as"`-renamed
like `expose`) + the `kind_label` / `From` / `format_op_error` arms (the
last compiler-enumerated by the exhaustive match).

No change to the `Tap` / `FlatTap` / `bind_tap` / `aura run` machinery — the
op-declared tap flows through the existing #282 compile + record path
unchanged.

Verification: 7 new tests green (4 engine: resolve+append, bad-port /
ghost-node refusal, duplicate-name refusal, finish-threads-into-FlatGraph;
1 CLI serde round-trip; 2 CLI E2E over the real `aura graph build` /
`aura run` subprocess — one pinning the exact serialized
`"taps":[{"name":"fast_ma","from":{"node":0,"field":0}}]`, one proving an
op-authored tap round-trips through build -> run -> persisted trace). Full
workspace suite green; clippy clean.

The RED-before-GREEN step was verified by a temporary stash of the
production edits (the `Tap` / `with_taps` machinery pre-existed from #282,
so a fresh RED-only test could not be authored) — the new tests fail
without the edits, pass with them.

Docs (the authoring-guide `tap` row, the ledger op-list `tap` line, and a
worked tap-authoring example) are deliberately out of this commit's scope —
they land in #285, the docs follow-up in this same cycle.

closes #284
2026-07-18 16:22:18 +02:00
claude cb6211c888 fix(cli): aura run refuses an unrunnable blueprint cleanly, not by panic (#282)
Two user-authorable blueprint shapes crashed `aura run` with an internal
.expect instead of the CLI's clean aura: refusal register:
- a tap wire pointing out of range: run_signal_r's compile now handles
  CompileError (TapWireOutOfRange) as an aura: refusal + exit 1, naming that
  the blueprint does not compile to a runnable harness.
- an exposed output not named bias: a pre-check in dispatch_run (the single
  loaded-blueprint call site, before the free-knob axis probe that welds the
  hardcoded bias port and would itself panic) refuses with a message telling
  the user aura run expects a strategy with a bias output and listing what the
  blueprint exposes. The sweep path (run_blueprint_member) is untouched.

Surfaced by the measurement-milestone fieldtest as the sharp edge of raw-index
tap authoring (#284 tracks the name-addressed tap op that removes the miscount
class at the source).

closes #282
2026-07-18 01:14:29 +02:00
claude e768072ed0 test: red for aura-run panics on an unrunnable blueprint (#282)
Two RED tests pinning that `aura run` refuses cleanly (exit 1 + an aura:
message) rather than panicking on a user-authorable blueprint: a tap wire
pointing out of range (run_signal_r's compile .expect swallows
TapWireOutOfRange into a panic, main.rs:1753) and an exposed output not named
bias (wrap_r's g.build().expect panics on UnknownOutPort from the hardcoded
bias weld, main.rs:1667). Surfaced by the measurement-milestone fieldtest.

refs #282
2026-07-18 00:53:36 +02:00
claude 9005fd53a2 docs(glossary): record the C27 author-declared sense of 'tap' (#282)
The measurement milestone overloaded 'tap': beside the closed campaign
persist_taps vocabulary (equity | exposure | r_equity | net_r_equity), the
word now also names a declared tap — a Composite.taps entry a hand-authored
blueprint declares on an interior output wire (C27), the output-side twin of
an input_role, bound run-mode-aware. Record-reality glossary update
reconciling the two senses; a worked authoring example is owed at docwriter
(#285).
2026-07-18 00:48:43 +02:00
claude ba46350da9 fieldtest: measurement milestone — 3 studies, 8 findings
Milestone acceptance corpus for 'Measurement as a first-class citizen',
authored as a downstream consumer (public interface only): a gated
rate/mux/counter over the four new cells, a SessionFrankfurt anchor study,
and the origin first-candle-momentum study on real GER40 data — each a
hand-authored blueprint plus one recorded run, zero throwaway Rust. The
three capabilities compose end-to-end and record via declared taps.

Findings routed separately: two aura-run panics-where-refusal-expected
(bad tap wire; non-bias output) to debug; a tap-authoring friction and two
doc gaps to follow-ups.
2026-07-18 00:45:38 +02:00
claude cf670caf46 audit: cycle-close tidy for the measurement milestone (#280/#281/#282)
Architect drift review over 16ca4e1..HEAD: drift-clean but for one low
citation slip, now fixed here — snap_to_nearest_minute's doc cited the
record-then-replay boundary as C6 (firing policy); it is C11. What holds
(architect-confirmed): C27 taps match the contract (pure declaration,
compile-resolve+hoist through the shared lowering remap, caller-built
aura-core-only sink, unbound-is-inert, Tier-1 additive serde); the four #281
cells are C8/C6-clean (<=1 record/eval, warm-up None, When's quiet-None is the
Resample precedent, barrier stall correct); #280's snap respects C11 (node-local
reads only, firing/barrier timestamps untouched, stream verbatim); FlatGraph and
validate_wiring lockstep complete.

Regression (report-only bench): exit 0, all five surfaces fingerprint OK, deltas
within noise on a loaded box — no baseline update, nothing to ratify.

Scaling follow-up filed as #283: the single-run tap drain buffers O(cycles) rows
(the #138 profile) — fine for the bounded acceptance run, to be folded before a
full-history declared-tap study.
2026-07-18 00:25:39 +02:00
claude 7a4e5eb99d docs(ledger): C27 — declared taps contract (#282)
Records the tap/binding contract: taps are the output-side twin of input_roles
(C26), resolved and hoisted at compile, bound run-mode-aware via a caller-built
sink (engine stays aura-core-only), recorded on the single run and inert in a
sweep. Documents the deliberate DCE-deferral — build-time elision now,
chain-pruning when DCE (C23) lands — and the unbound-is-inert non-error
asymmetry vs a mandatory input role.

refs #282
2026-07-18 00:17:01 +02:00
claude 2e8d74903c feat(cli): record declared taps on the single-run path (#282)
The payoff: a hand-authored blueprint's declared taps become recorded output
from one `aura run`. run_signal_r binds each tap (hoisted into flat.taps after
wrap_r nests the signal) to a fresh Recorder before bootstrap, drains the
series after the run, and persists each as a ColumnarTrace via the existing
env.trace_store() — the same trace surface the campaign path feeds, so the
existing chart tooling reads them. Duplicate tap names are refused (the
caller-owned DuplicateBind dedup) before anything persists. A tap-free run
writes no trace store — byte-identical to today. Sweep/reduce leaves taps
unbound (run_blueprint_member untouched). Covered end-to-end: a persisted tap
series, the tap-free no-write guard, and the duplicate-name refusal.

refs #282
2026-07-18 00:16:17 +02:00
claude 93d0ec45f2 feat(engine): FlatGraph::bind_tap + TapBindError (#282)
The run-mode-aware bind seam. bind_tap attaches a CALLER-BUILT Box<dyn Node>
sink to a declared tap — appending the sink node + an edge from the tap's
(node, field) to its input, before bootstrap so the Kahn sort orders it. The
engine constructs no aura-std type (it takes a Box<dyn Node>), keeping its
production dependency aura-core-only. bind_tap raises UndeclaredTap; duplicate
detection across binds is the caller's (the engine keeps no cross-call state),
mirroring how bind_sources' DuplicateFeed is a supply-level fault. TapBindError
is exported for the CLI. A bound tap records exactly the producer's per-cycle
output through the full public pipeline; an undeclared name is refused without
mutating the graph.

refs #282
2026-07-17 23:37:54 +02:00
claude f3daded514 feat(engine): compile resolves and hoists declared taps (#282)
The compile core. resolve_tap_wire resolves a blueprint Tap's interior
{node, field} through the lowering table exactly as OutField re-exports and
edges resolve — a leaf to its remapped index (output-arity checked), a nested
composite through its output remap. Taps are terminal (not re-exported through
the boundary): a flat_taps accumulator threads through the lowering recursion,
so an interior composite's taps hoist to the root FlatGraph.taps with remapped
wires — the case the CLI single-run wrapper relies on. validate_wiring gains a
tap leg (threaded at all three call sites incl. GraphSession::finish) and
CompileError::TapWireOutOfRange names a bad wire. Root-resolve, out-of-arity,
and nested-hoist are pinned green.

refs #282
2026-07-17 23:18:00 +02:00
claude 17e125a90a feat(engine): FlatTap type + FlatGraph.taps field (#282)
Second slice: the resolved-tap carrier. FlatTap { name, node, field } is the
output-side twin of SourceSpec — its name survives compile, load-bearing for
by-name binding (#275). FlatGraph gains a taps field, threaded (empty) through
every construction site the compiler enumerates. Bootstrap ignores it (taps
materialize as real recorder nodes/edges before bootstrap in a later slice).
Purely additive: the inert-producer pin and full suite stay green.

refs #282
2026-07-17 22:52:21 +02:00
claude 7239106f4e feat(engine): Tap/TapWire declaration on the blueprint format (#282)
First slice of declared taps: a Tap { name, from: TapWire { node, field } }
is the output-side twin of input_roles — a pure data declaration naming an
interior output wire, no sink endpoint. Composite gains a taps field
(with_taps/taps(), additive) and the serde mirror CompositeData carries it
(Tier-1 additive: no format-version bump, absent-taps documents byte-stable).
Tap/TapWire are re-exported from the crate root beside Role/OutField so
downstream crates can author tap-bearing blueprints.

refs #282
2026-07-17 22:31:41 +02:00
claude b11bcb6202 test(engine): pin that a no-out-edge producer runs inertly (#282 substrate)
Current-behaviour pin for the declared-taps design: a producer node
with a non-empty output and zero out-edges compiles, bootstraps, and
runs — its per-cycle output silently discarded — observably identical
to the same graph with the producer removed. This is the exact shape
of an unbound declared tap (a study wire left un-recorded in a sweep),
and it establishes that the engine already tolerates it: no DCE is
needed for correctness, because check_ports_connected gates only input
slots and the Kahn sort emits a no-out-edge node as a valid sink. The
build-time-elision soundness the tap mechanism will rely on.

refs #282
2026-07-17 21:31:32 +02:00
claude c302c5d5a0 test(engine): pin When's gated-reducer and barrier-stall semantics
Two integration pins for the clock-enable design (#281), at the raw
FlatGraph boot seam. The gated-reducer equivalence is the crux claim:
SMA(When(x, gate), N) equals SMA(N) fed the gate-true subsequence
directly — When's quiet cycles do not advance the reducer's freshness,
so its state moves per forwarded sample, not per cycle. The barrier
stall pins that a quiet When member keeps its whole Barrier(0) group
from firing that cycle. An in-graph AboveGate fixture derives the Bool
gate co-fresh with the value leg.

refs #281
2026-07-17 21:00:11 +02:00
claude c8dc57e2aa feat(std): CumSum and When cells; roster complete at 33
Second half of the #281 clock-enable set. CumSum is the generic
run-length accumulator with O(1) Neumaier-compensated state — the
branching Neumaier variant, not Sma's plain Kahan, so it stays exact
when a large running total absorbs a tiny sample (the standalone
accumulator has no windowed-magnitude guarantee; module doc explains
why the helper is not reused). When is the clock-enable driver: forwards
value iff gate, else a quiet cycle (eval->None), so every stateful
reducer composes gated unmodified — SMA(When(x,g),N) is the gated SMA.

Rosters CumSum and When (both count pins 31->33) and ships two engine
E2E fixtures: When forwarding/quiet-downstream through the real
GraphBuilder->compile->run seam, and a quiet When leg suppressing a
whole Resample Barrier(0) group (the deliberate stall semantics).

refs #281
2026-07-17 20:39:56 +02:00
claude 40e962db89 feat(std): Sign and Select cells — the Bool→F64 bridge pair
First half of the #281 clock-enable set: Sign promotes the private
sign0() semantics to vocabulary (strict comparisons; NaN and -0.0
fall to 0.0), Select is the stateless 2:1 mux that consumes Bool
back into the F64 plane. Both zero-arg, Firing::Any, rostered
(count pins 29→31); an engine E2E drives both through the real
GraphBuilder→compile→run seam.

refs #281
2026-07-17 19:49:13 +02:00
claude b5c82f5d16 fix(std): snap time-bucket/session reads to the nominal minute
Provider M1 stamps carry sub-second jitter around minute boundaries
(~55% of days stamp the 09:00 Berlin bar at 08:59:59.xxx), so any
consumer dividing or truncating the raw stamp mis-buckets boundary
bars: Resample and VolTfStop fold the bar into the previous bucket
instead of rolling over, Session demotes the first in-session bar to
at-open.

Fix per the decision logged on the issue: one shared
Timestamp::snap_to_nearest_minute in aura-core (round-half-up,
rem_euclid so negative epochs snap correctly), applied consumer-side
at the three membership computations. The recorded stream stays
byte-verbatim — no ingestion rewrite, record-then-replay untouched;
an ingestion-time rewrite was rejected because it would hide the raw
time axis from every consumer and could reorder the k-way merge
around boundaries.

Verified: the three formerly-RED tests pass individually; full
workspace suite green; clippy clean.

closes #280
2026-07-17 18:49:27 +02:00
claude 8db2824552 test: red for #280 — jitter-early boundary bars mis-bucketed by raw stamps
Three RED tests pinning the same root cause at its three node sites:
Resample and VolTfStop bucket by raw integer division of the jittered
stamp (fold-back instead of rollover at bucket boundaries); Session
derives wall-clock minutes from the raw stamp (first in-session bar
demoted to at-open). A bar stamped sub-second before its nominal
minute must be bucketed by the nominal minute.

refs #280
2026-07-17 18:36:52 +02:00
claude 16ca4e103e fix(bench): make the cli_fixed_cost fingerprint invariant across build commits
Caught by the harness itself on its first post-commit run: the aura run
record line embeds manifest.commit — the binary's compile-time build sha
(crates/aura-cli/build.rs, the invariant-8 audit trail) — so hashing the raw
line flipped the fingerprint on every rebuild at a new commit and reported a
phantom correctness change (exit 1) at each cycle close. The rebase onto the
#191 main exposed it; nothing in #191 touched the record.

RED-first: run_line_fingerprint_is_invariant_across_build_commits pins the
symptom (two lines differing only in manifest.commit must hash identically);
the fix extracts run_line_fingerprint, which parses the line, blanks that
one volatile field, re-serializes (serde_json maps are sorted, so the bytes
are deterministic), and hashes. A missing manifest.commit is a hard error,
so a future record-schema move fails loudly instead of silently passing.
The cli_fixed_cost baseline is re-pinned under the new definition — ratify:
the fingerprint DEFINITION changed (provenance excluded, results still
covered, pinned by run_line_fingerprint_still_detects_result_changes); no
measured behaviour of aura moved.

refs #251
2026-07-17 18:16:56 +02:00
claude 13d8500402 audit: cycle-close tidy for #251 — fingerprint honesty, lockstep + surface-list guards
Architect drift review (cycle bfb8648..0389399): no domain-invariant breach.
What holds: aura-bench is bin-only dev tooling with zero production reverse
deps (invariant 8/C13); every committed surface runs on seeded synthetic
data in scratch temp projects (invariant 9); run_reps enforces cross-rep
fingerprint identity and the campaign fingerprint carries integer ordinals
across the child boundary, consistent with C1 and its cross-command ULP
carve-out; zip/clap per-case dependency notes in place (C16), and the ledger
correctly gains no entry — tooling lives in the project facts + README.

Three drift items, resolved in this commit:
- [medium] the winner_fingerprint doc claimed walk-forward regressions fail
  the bench; the walk-forward stage in fact emits an empty realization
  record, so coverage is only indirect (via the bootstrap trade count). Doc
  now states the gap honestly; the engine-side fix is parked on #279.
- [low] the fingerprint's lenient JSON parse would silently erode on a
  registry-schema rename: a new lockstep test builds a real
  CampaignRunRecord from the aura-registry/aura-analysis/aura-engine types
  (new dev-deps) and asserts the extracted fingerprint, so a rename breaks
  this crate's tests instead.
- [low] LIBRARY_SURFACES was hand-synced with measure_surface: a new test
  pins that every listed surface measures without a binary path.

Regression gates on the closing tree: cargo test --workspace 1379 green,
clippy -D warnings clean, doc build clean, aura-bench run exit 0 with five
'fingerprint OK' at <=2% drift. No baseline moved in this commit — the
bench baselines were pinned and verified in the feature commit.

refs #251
2026-07-17 18:10:00 +02:00
claude 400105e2e2 feat(bench): CLI driver, fixed-cost surface, committed baselines + docs
Tasks 7-8 of the bench-harness plan plus the orchestrator's sizing pass —
this completes the phase-1 harness (closes #251).

Driver: run [--surface|--quick|--reps|--out] / pin over five surfaces,
report-only comparison (drift NOTICE at >=10%, never a failure), fingerprint
mismatch exits 1 with a baseline-vs-measured block, infra errors exit 2,
debug builds refuse to measure. The Bench project-facts line and the crate
README (discipline: quiet box, warmup+median, deliberate re-pins) land with
it.

Workload sizing, measured then corrected: the release engine outran the
plan's guesses by ~100x, leaving sub-second child walls where spawn jitter
reaches the 10% threshold and flaps the report. Full-size workloads now land
in whole seconds — engine 10M bars (~0.7s), ingest 24 months x 20 fresh
drains (~0.4s), campaigns on a 24-month archive with a ~22-month window and
a 5x5 member grid (sweep ~1.4s, heavy ~5.5s), fixed-cost as in-rep medians
over spawn batches (20x help, 10x run). Quick mode keeps the small
E2E-fixture shape.

The campaign fingerprint folds all three realization layers — sweep winner,
gate survivor ordinals, bootstrap trade counts (pooled or per-survivor sum)
— so a wrong-result regression in any stage fails the bench, not just a
wrong sweep winner. Baselines pinned on this host (commit-stamped), verified
by a follow-up run: five fingerprints OK at <=2% drift under load.

Verification: cargo test --workspace 1379 green (bench tests all quick-mode;
the release-binary E2Es are #[ignore]d behind -- --ignored per the
suite-wallclock discipline), clippy -D warnings clean, doc build clean.
2026-07-17 18:10:00 +02:00
claude 3a25a4ccba feat(bench): host probes, child-process runner + the two campaign surfaces
Tasks 5-6 of the bench-harness plan (refs #251). Host context probes
(hostname/nproc/loadavg/commit/date, all best-effort), the child runner
(release aura binary resolved from cargo's compiler-artifact JSON messages;
/usr/bin/time -v wraps the child for CPU% + peak RSS, wall measured
in-process), and the campaign surfaces:

- a scratch data-only project per repetition ([paths] only — no [nodes]; the
  std-vocabulary fallback covers the SMA blueprints), synthetic archives for
  4 instruments, blueprints seeded via tiny synthetic sweeps, process docs
  registered through the real CLI;
- campaign_sweep (sweep-only process — narrow per-cell fan-out) and
  campaign_heavy (sweep -> gate -> walk-forward -> 1000-resample bootstrap —
  wide fan-out): one campaign shape misreads regressions in the other;
- fingerprint = winner ordinals parsed from the always-on final
  {"campaign_run":...} stdout line.

The full campaign-sweep determinism E2E (real release binary, real scratch
runs) is #[ignore]d out of the default suite — it costs a release build plus
child campaign runs, which the suite-wallclock discipline forbids on every
cargo test; run it via `cargo test -p aura-bench -- --ignored`. Held
quality note: the heavy/quick process docs stay two verbatim consts (plan
prescription; a format! builder would trade the uniform &str contract for
cosmetic dedup).
2026-07-17 18:10:00 +02:00
claude e015317183 feat(bench): surface framework + engine/ingest throughput surfaces
Tasks 3-4 of the bench-harness plan (refs #251). The surface framework
(1 discarded warmup + N measured reps, median per metric, cross-rep
fingerprint identity enforced — a surface disagreeing with itself is
nondeterminism, never averaged away) and the two library surfaces:

- engine_throughput: 1M-bar seeded walk through an SMA-cross bias graph
  (GraphBuilder -> compile -> bootstrap -> VecSource -> run), fingerprint =
  the SeriesReducer finalize row.
- ingest_throughput: synthetic 48-byte M1 archive drained via open_columns
  with a FRESH DataServer per rep, so the measured drain pays zip inflate +
  parse instead of the in-memory FileCache; fingerprint = count + bit-XOR
  of the close column.

Quick-mode determinism is pinned in-suite for both surfaces (C1 — the
harness's premise as currently-green tests); full-size numbers wait for the
release driver glue.
2026-07-17 18:10:00 +02:00
claude 0b116e4105 feat(bench): scaffold aura-bench — baseline compare core + synthetic data plumbing
Tasks 1-2 of the bench-harness plan (refs #251): the new dev-only workspace
bin crate with the report-only comparison core (BaselineDoc serde, per-metric
relative drift, 10% NOTICE threshold, fingerprint-equality exit code) and the
deterministic synthetic inputs (seeded LCG price walk, the proven 48-byte
Delphi-record M1 zip archive writer, self-deleting scratch dirs).

The archive writer is deliberately a third copy of the test-fixture packer
(aura-cli tests / aura-ingest each carry one), byte-compatible by
construction and pinned here by a round-trip test through the real ingest
reader. The binary refuses debug builds (exit 2) — wall-clock numbers from a
debug profile measure the profile, not the code — pinned by an E2E spawn
test. Temporary #![allow(dead_code)] markers cover the not-yet-wired core
until the CLI glue lands; the surfaces and driver follow.
2026-07-17 18:10:00 +02:00
claude 07c94433f3 audit: cycle-close tidy for #191 — C18 realization note for the identity index
Cycle #191 (identity-ref index) closes drift-resolved. Architect review
held: results stay scan-identical via verify-on-hit (the same-identity-
twin choice was order-unspecified before and stays unspecified in kind);
zero caller/write-path/engine changes; the #276 append-lock discipline
correctly extended to the fourth JSONL path. No regression scripts are
configured; the architect review is the gate.

Drift items and resolution:
- C18 lacked a realization note for the new persistent sidecar store and
  its index-first/verify-on-hit/repair-walk mechanism, and the "identity
  refs by store scan" line was stale -> this commit adds the #191
  realization note and dates the scan wording (fix, ledger-only).
- The repair pass grew the sidecar without bound while same-identity
  twins coexisted (per-entry comparison against a mid-walk-stale
  snapshot) -> fixed in-cycle via RED pin 35865bb + collect-then-diff-
  append fix aa94f51; suite 1347/0, clippy clean.

Cycle spec and plan (git-ignored working files) removed per lifecycle.

refs #191
2026-07-17 14:35:54 +02:00
claude aa94f51d8a fix(registry): converge the identity-index repair pass on twin stores
Drive the RED twin-store pin green: the repair walk now collects its
identity->content mappings into a local last-wins map and appends after
the walk only the entries whose final walk value differs from the
pre-walk snapshot (collect-then-diff-append, decision on #191). The old
per-entry comparison against the mid-walk-stale snapshot appended one
alternating line per scan-triggering lookup while same-identity twins
coexisted — the sidecar now converges to zero growth there too. Lookup
answers (first read_dir match) and verify-on-hit untouched.

Verification: workspace suite 1347 passed / 0 failed; clippy -D
warnings clean.

refs #191
2026-07-17 14:33:49 +02:00
claude 35865bb934 test(registry): RED pin — twin-store identity index must converge, not grow
Executable spec for the audit follow-up on the identity-index cycle:
once one full repair walk has run over a same-identity-twin store (two
blueprints sharing one identity id, distinct content ids), a further
scan-triggering lookup must leave blueprint_identity_index.jsonl's line
count unchanged while the twin identity still resolves. RED against the
current per-entry snapshot comparison (appends one alternating line per
scan-triggering lookup while twins coexist).

refs #191
2026-07-17 14:24:42 +02:00
claude 88a1c28954 feat(registry): index-first identity resolution with self-repairing sidecar
Replace the per-call O(store) scan in find_blueprint_by_identity with a
persistent identity-id -> content-id sidecar index
(blueprint_identity_index.jsonl, sibling of runs.jsonl). Lookups consult
the index first and VERIFY every hit by loading that one blueprint under
the current roster and recomputing its identity id -- the index is a
cache, never an oracle, so results stay scan-identical under roster
drift, store surgery, or index corruption (up to the same-identity-twin
choice, unspecified in both paths and documented on the fn). Any miss or
failed verification runs the old scan as a full-store repair pass: no
early return, best-effort append of every absent-or-different mapping,
last line wins so later repairs heal stale lines. No write-path, engine,
or caller changes; a pre-index store backfills itself on the first miss;
a read-only store keeps scanning as before.

Decided against put-time index maintenance: it would need a roster-free
doc-level identity function whose equivalence to the loaded-composite
path no green test ratifies (the composite path canonicalizes
structurally on load); the repair path is the load-bearing mechanism
either way. Decision log: the issue's comments.

Verification: 7 new aura-registry tests (backfill, verified-hit-no-walk
via a poison-dir probe, one-pass legacy backfill, stale-line healing,
unloadable-under-roster preservation, garbage tolerance) plus a CLI e2e
(campaign validate resolving an identity ref cold then warm); workspace
suite 1346 passed / 0 failed; clippy -D warnings clean; doc build clean.

closes #191
2026-07-17 14:13:48 +02:00
claude bfb8648925 tidy: fieldtest follow-ups — domain prose for the zero bound, gate-emptied exit semantics documented
The two fieldtest findings, resolved:

- friction: --parallel-instruments 0 now refuses in domain terms ("must be
  a whole number of at least 1 — it bounds how many distinct instruments
  are resident in parallel") via a custom clap value-parser, instead of
  leaking Rust's NonZeroUsize wording; the zero-bound test pins the prose.
- spec_gap: the authoring guide's exit-code contract now states that a
  gate-emptied cell (a std::gate leaving no survivors) is a successful
  cell — exit 0 with an informational aura: note and a truncated
  realization — and that exit 3 is reserved for cells that could not be
  evaluated. Records observed behaviour; no code change.

Gates re-verified: workspace suite green, clippy -D warnings clean.

refs #277
2026-07-16 15:34:57 +02:00
claude 9add426a93 fieldtest: executor-throughput complex (#268/#276/#277) — 5 examples, 7 findings
Downstream-consumer fieldtest over the public surface at the close of the
maximize-throughput complex: a 4-instrument campaign authored, validated,
run and read back through the normal verbs; determinism probed across
--parallel-instruments 1/2/4/10 and a same-K repeat (byte-identical modulo
the run-counter suffix); duplicate-instrument refusal and faulted-cell
isolation (exit 3, healthy cells persist) confirmed under the parallel
loop. 5 working, 0 bugs, 1 friction (the zero-bound diagnostic leaks
clap's NonZeroUsize wording), 1 spec_gap (gate-emptied cell exit semantics
undocumented) — both resolved in the follow-up tidy.

refs #277
2026-07-16 15:32:11 +02:00
claude 69bb2fc978 audit: cycle-close tidy for #277 — preflight duplicate refusal, zero-bound pin, C1 realization note
Resolutions for the architect's four drift items (all fix/document, none
ratified away):

- [medium] execute enforced family-name uniqueness only via the CLI's
  validate tier: preflight now refuses duplicate campaign instruments
  itself (defense in depth for direct callers), RED-first
  (execute_refuses_duplicate_instruments).
- [medium] the parallel cell loop's C1 relationship lived only in the
  git-ignored spec: C1 gains a realization note (docs/design/INDEX.md)
  recording the chunked instrument-major schedule, the structural
  residency bound, and the two scheduling-dependent fatal-path carve-outs
  (fault attribution among completed cells; already-written family lines)
  — both inert and outside the success-path bit-identity.
- [low] the fatal-path orphan-line honesty is part of that note.
- [low] the --parallel-instruments zero-reject acceptance criterion had no
  protecting test: campaign_run_rejects_a_zero_parallel_instruments_bound
  pins clap's NonZeroUsize usage error (exit 2).

Gates re-verified: workspace suite green, clippy -D warnings clean.

refs #277
2026-07-16 15:19:24 +02:00
claude cf94377f30 feat(campaign): parallel cell loop on the shared rayon pool
The flip: within each K-instrument chunk (the structural residency bound
from the previous commit), all cells run concurrently on rayon's process-
global pool — the same pool the engine's member/window fan-out already
shares, so cells x members x windows feed one work-stealing scheduler and
the per-cell serial seams (ingest transpose, single-threaded bootstrap)
overlap with other cells' work. Results land in index-addressed slots and
the ordered outputs are rebuilt in document order: byte-identical outcomes
across worker counts and bounds (C1).

Fault routing, restructured for a loop that cannot mid-iterate abort:
member/window faults stay #272-contained inside run_cell; non-containable
faults (incl. registry writes — infrastructure, not cell pathology) latch
an atomic abort flag so unstarted cells never run, and after the join the
lowest document-order fault among completed cells propagates as the run's
error. Nothing partial is persisted at run level on the fatal path.

Property pins: determinism across thread counts and bounds (1-thread/K=1
== 8-thread/K=2), distinct live instruments never exceed K (refcounting
stub runner, 8-thread pool), a dead family store is run-fatal with no
partial campaign-run record, member faults stay contained under the
parallel loop. E2E fixtures (previous commit) pin the CLI prose and
document-order persistence across K.

Verified: workspace suite green, clippy -D warnings clean. rayon enters
aura-campaign under the amended C16 per-case policy (comment in
Cargo.toml, mirroring aura-engine).

closes #277
2026-07-16 15:10:31 +02:00
claude 7fa3ef4e26 feat(research,campaign,cli): chunked instrument-major cell walk + duplicate-instrument refusal
Two pieces of the parallel-cell-loop iteration (spec: parallel-cell-loop),
still sequential — behaviour-identical results, suite green unchanged:

- validate_campaign refuses a campaign document listing the same instrument
  twice (DocFault::DuplicateInstrument + CLI prose). A duplicate enumerates
  two identical cells and is the one input that could make two cells share a
  registry family name; with distinct instruments the per-cell family name
  (all four cell axes + stage ordinal) is unique by construction, so the
  upcoming parallel appends can never race one name's run-index assignment.
- aura_campaign::execute flattens the 4-nested cell loop into a document-
  order-indexed cell list, groups it by instrument ordinal, and walks
  sequential chunks of K instrument groups; results land in index-addressed
  slots and the ordered outputs (outcomes, realizations, nominee groups) are
  rebuilt in document order. K arrives as a new NonZeroUsize parameter
  (default DEFAULT_PARALLEL_INSTRUMENTS = 4), exposed as
  --parallel-instruments on aura campaign run and threaded through the
  run_campaign chain; the dissolved-verb sugar paths pass the default.

The chunk walk is the structural RAM bound for the parallel flip that
follows: no cell of an instrument outside the current chunk can be in
flight at all (chosen over a semaphore gate — blocking inside pool tasks
risks worker starvation; decision log on #277). Verified: workspace suite
green unchanged, clippy -D warnings clean, E2E fixtures pin the CLI prose
and the document-order persistence across K.

refs #277
2026-07-16 14:19:31 +02:00
claude 98ddcd3595 audit: cycle-close tidy for #276 — guard the third append path
Architect drift review for the #276 cycle (b048923..HEAD). What holds:
C1 is preserved (the append_write_lock sits at the results-persistence
edge, outside the lock-free sim loop); the mechanism is correct (the
guard spans the full read-counter-then-write critical section in both
locked functions, no re-entrancy via the load paths, poison entered by
into_inner); both fixed paths are pinned by property tests.

One [medium] drift item, resolved here on the fix path: Registry::append
(the flat runs store, runs.jsonl) was the third identical unsynchronized
OpenOptions::append + writeln! path, left standing unguarded beside its
two newly-guarded siblings — and it is public API with external
consumers (C18), so a concurrent caller would hit exactly the #276
corruption. The append_write_lock doc also enumerated the two guarded
stores as if exhaustive. Fixed RED-first: the sibling property test
failed 2/2 against the unguarded path (same "trailing characters"
merged-line corruption), then append takes the same lock and the doc now
names all three JSONL append paths. No other finding: put_blueprint /
put_doc (content-addressed idempotent writes) and TraceStore::write
(per-name whole-file writes) are race-tolerant by shape and correctly
outside the append scope.

Regression gate: no dedicated regression scripts configured; the
project-facts gates all green — cargo test --workspace no failures (the
three concurrency property tests among them), clippy --workspace
--all-targets -D warnings clean, cargo doc --workspace --no-deps clean.
Cycle #276 is drift-clean.

refs #276
2026-07-16 12:43:34 +02:00
claude 883401309a feat(registry): serialize the append write path for concurrent writers
Registry::append_family and append_campaign_run did unsynchronized
OpenOptions::append + writeln! to their shared JSONL stores; concurrent
writers through one shared &Registry interleaved one append's content
and newline writes with another's, merging records onto one physical
line and corrupting the store (the #276 RED test failed 3/3 with a
"trailing characters" parse error). The planned parallel campaign cell
loop (#277) makes exactly that caller shape reachable, so this is its
prerequisite.

Mechanism: one internal append_write_lock: Mutex<()> on Registry,
acquired by both append functions and held across the whole
read-the-run-counter-then-write critical section — serializing the
write also serializes the read-before-write counter derivation, so
concurrent appends under one name cannot double-assign a run index.
Mutual exclusion around a per-cell-per-stage microsecond write, not a
barrier: cells never rendezvous, and the sim event loop stays lock-free
(C1's "without locking" governs the sims; this lock sits at the
results-persistence edge outside the hot path). A poisoned lock is
entered anyway (PoisonError::into_inner): every store line is
independent, so one panicked writer must not wedge all later appends.

Scope, per the decision log on #276: in-process synchronization owned
by the Registry type itself; cross-process locking stays out of
contract (the corrected doc comments now state the actual guarantee).
The serial per-cell-buffer alternative stays open to #277 as an
ordering decision on top — this lock does not preclude it.

Adds the sibling test pinning append_campaign_run under the same
concurrent load. Verified: the #276 headline test green 3/3 (was red
3/3), sibling green, cargo test --workspace no failures, clippy
--workspace --all-targets -D warnings clean.

closes #276
2026-07-16 12:36:31 +02:00
claude 494b7885c7 test: red executable-spec for concurrent registry appends
Pin the #276 headline behaviour RED-first: N threads sharing one
Registry handle and concurrently appending families must leave
families.jsonl well-formed — every member of every successful append
survives as its own complete, parseable JSON line (none torn, merged
into a neighbour, or lost). Today's unsynchronized OpenOptions::append
+ writeln! interleaves concurrent writers' content and newline writes,
so the test fails deterministically (verified 3/3 runs: "trailing
characters" merged-line parse error).

The sharing topology — one shared &Registry across scoped threads —
models the planned parallel campaign cell loop's caller shape and
admits any of the issue's candidate fixes.

refs #276
2026-07-16 12:11:18 +02:00
claude b048923f1c feat(engine): shared rayon pool for the disjoint-parallel core
Replace the per-call std::thread::scope fan-out in run_indexed with one
process-wide rayon pool. run_indexed's callers nest — sweep (grid
points), monte_carlo (seeds), and walk_forward (window bounds), where a
walk_forward window runs an inner sweep — and each std::thread::scope
spawned its own available_parallelism() OS-thread batch, so the two
levels multiplied to ~available_parallelism()^2 threads (measured
150-240 concurrent on a 24-core box during a 42-cell campaign, ~10x
oversubscription). rayon's pool is process-wide and persistent: a nested
par_iter work-steals within the same N workers instead of spawning, so
live workers never exceed the pool size.

Determinism (C1) is preserved bit-for-bit: run_indexed collects via
IndexedParallelIterator in enumeration order (not completion order), and
the float aggregation (summarize_r / McAggregate / stitch) runs after
the ordered collect, so IEEE-754 non-associativity introduces no
variance. The existing _with_threads(1) == _with_threads(8) == public
determinism tests stay green, now driving local rayon pools of 1 and N
workers via ThreadPool::install.

Structural inversion: the public sweep/monte_carlo/walk_forward become
the core (calling run_indexed on the ambient pool through a shared
assemble_* helper); the _with_threads variants collapse into thin,
test-only wrappers (#[cfg(test)]) that run the same assembly inside a
local pool. run_one stays Fn + Sync (no + Send): run_indexed borrows it
in the map closure and the wrappers borrow it into install — passing it
by value would move it into rayon's Map and force F: Send, a bound the
public callers do not carry (hence the load-bearing
#[allow(clippy::redundant_closure)] on run_indexed).

Adds a nested-oversubscription regression test (asserts nested
run_indexed never exceeds the pool size — the pre-rayon shape ran
POOL^2) and an ignored wall-time benchmark. rayon admitted under the
amended-C16 per-case dependency policy; it stays within C1 (parallelism
across sims, never within one).

This is the shared pool only — the first of three steps toward
saturating the cores. The diagnosis found the larger loss is the idle
valleys (~42% of the box idle: the sequential cell loop and the
single-threaded bootstrap), which the pool alone does not fill. Two
follow-ups are filed and recorded on #268: the Registry write path is
not concurrency-safe (append_family races on a shared families.jsonl),
the prerequisite for parallelizing the C1-disjoint cell loop with a RAM
budget bounding concurrently-active instruments (the FileCache retains a
symbol's parsed files while any reference is live).

Verified: cargo test -p aura-engine (276 passed, the C1 determinism
tests green), the new oversubscription test green, the benchmark runs
(no overflow), clippy -D warnings clean, cargo test --workspace no
failures.

closes #268
2026-07-16 00:30:10 +02:00
claude 88667a65fd audit: cycle-close tidy for #275 (by-name source binding)
refs #275

Drift review (architect) over ad4249f..HEAD found the cycle drift-clean at the core
and confirmed what holds: C1 preserved (`run()`'s k-way merge / event loop has zero
diff — `run_bound` only reorders the supply via `bind_sources` then delegates to
`run`, byte-identity pinned); the C4 realization and C23 refinement ledger notes are
accurate and tightly scoped (`SourceSpec.role` is read in exactly one place, sourced
only from the lowering — no leak); and all three binding-carrying production run sites
moved to `run_bound` + `key_supply`, every remaining `.run(` being `#[cfg(test)]`. No
regression harness exists, so the architect is the sole drift gate.

One medium drift item fixed:

- FIX: the CLI's `.expect()` on `run_bound` covered Missing/Extra/Unnamed feed but not
  `DuplicateFeed`. The blueprint path does not enforce root-role-name uniqueness (only
  the construction op-script path does), so a duplicate-role blueprint would reach
  `bind_sources`' `DuplicateFeed` and panic behind the `.expect()` instead of refusing.
  Closed at the binding boundary: `resolve_binding` now refuses a duplicate input-role
  name — a named `aura:`-register refusal on every CLI run path (single run, sweep, and
  campaign all resolve the binding upfront), matching its existing price/close-ambiguity
  refusal. The `.expect()` invariant is now genuinely true on all paths. Pinned by
  `binding::duplicate_role_name_refuses`.

Two low items:

- `SourceBindError::Display` had no test (its renderer stays dead until a
  decoupled-supply caller lands, #124). Added `source_bind_error_display_names_the_role`
  pinning the message strings; reachability is deferred by design.
- The reverted fieldtest `SourceSpec` sweep leaves the corpus one more revival break
  against the current engine API — carried, as the corpus is pre-existing bit-rot
  unrelated to this cycle (documented in 9e30805 and on #275).

Suite: `cargo test --workspace` green (1321 passed, 0 failed); `cargo clippy
--workspace --all-targets -- -D warnings` clean. Spec + plan (git-ignored working
files) discarded.
2026-07-15 20:08:33 +02:00
claude 9e30805fcc feat(cli,ledger): supply CLI run sources by role key; ledger note
closes #275

The CLI half of by-name source binding, plus the ledger record.

Every production run site that carries a ResolvedBinding — `run_signal_r`,
`run_blueprint_member` (sweep / reproduction), and the campaign re-run/trace path —
now keys its opened sources by role name via `key_supply(binding, sources)` and
drives the harness through `Harness::run_bound` instead of the positional
`run(sources)`. `key_supply` pairs each opened column with its declared role from
`binding.entries()` — the one place open-order and role-order meet, made explicit so
`bind_sources` verifies the wiring↔supply role match by name rather than by a
maintained canonical-order convention.

Because the current single-binding CLI derives both the `SourceSpec` roles (via
`wrap_r`) and the supply roles (via `key_supply`) from the same `binding.entries()`,
the bind cannot fail on this path — so the call site asserts the invariant with
`.expect`, matching the adjacent `close_handle.expect("ResolvedBinding guarantees a
close entry")` idiom. The named `SourceBindError` refusal path lives in
`bind_sources` for future decoupled-supply callers (independently-built multi-feed /
recorded sources, #124), where a mismatch becomes reachable.

Ledger: a C4 realization note (supply resolved by name into declaration order, so
supply order is no longer load-bearing; the tie-break guarantee is unchanged) and a
scoped C23 refinement (a `SourceSpec.role` is load-bearing for source binding; every
other flat-graph name stays a non-load-bearing raw-index symbol).

The fieldtest-corpus `SourceSpec` sweep (planned Task 5) was reverted: the fieldtest
packages already fail to build against the current engine API for reasons unrelated
to `SourceSpec` (bootstrap arity, removed `InputSpec`, drifted
`Recorder`/`SimBroker`/`RMetrics`, renamed `Scalar` helpers) — pre-existing bit-rot
from earlier cycles. A partial `SourceSpec` migration neither revives nor further
breaks them, so the scope decision to touch them (premised on their being
otherwise-buildable) was withdrawn; reviving the corpus is separate from #275.

Verification: `cargo test --workspace` green (0 failed; the real-data OHLC channel
e2e exercises the migrated `run_bound` path byte-identically); `cargo build
--workspace --all-targets` clean; `cargo clippy --workspace --all-targets -D
warnings` clean.
2026-07-15 19:52:39 +02:00
claude 0b620e1f26 feat(engine): bind ingestion sources by role key — SourceSpec.role + bind_sources/run_bound
refs #275

The engine-side half of by-name source binding. `SourceSpec` carries a load-bearing
`role: Option<String>` — `Some(name)` for a spec lowered from a bound `Role` (carried
through the blueprint lowering, previously dropped at compile), `None` for a
hand-built raw-index spec. A pure `bind_sources(specs, keyed_supply)` resolves a
`Vec<(role, Box<dyn Source>)>` into the positional `Vec` `run` consumes, in
`SourceSpec` declaration order, erroring named (`SourceBindError`:
Missing/Extra/Duplicate/Unnamed feed) on a mis-bind. `Harness::run_bound` is the
ergonomic method the by-name production path calls.

The raw-index `run(Vec)` primitive and the k-way merge / per-cycle event loop are
untouched: a positional run stays byte-identical (C1, pinned by
`run_bound_out_of_order_matches_positional_run`), and every existing `run(Vec)` caller
keeps working (it ignores `role`). A `BTreeMap` index makes the Duplicate/Extra
diagnostics deterministic.

C4's tie-break guarantee ("source declaration order") is unchanged — `bind_sources`
emits in `SourceSpec` order, so the merge's source-index tie-break resolves on
declaration order regardless of the caller's supply order. The narrow C23 amendment
(a `SourceSpec.role` is load-bearing for source binding, the rest of the flat graph
stays raw-index) is deferred to the ledger note.

The four error-path RED tests assert via `.map(|_| ()).unwrap_err()`: the `Ok` arm
`Vec<Box<dyn Source>>` is not `Debug` (`Source` has no `Debug` supertrait), so a bare
`.unwrap_err()` would not compile — a plan-byte defect corrected here.

Also sweeps the 45 raw-index `SourceSpec { .. }` literals to `SourceSpec::raw(..)`
across the workspace (mechanical, compiler-enumerated) and pins that a bound root
role's name survives lowering.

Verification: `cargo test --workspace` green (0 failed; incl. 5 `bind_sources` cases,
the `run_bound` byte-identity test, the role-survives-lowering pin, and the four
`.sources`-equality twins under the now-stricter `role` equality);
`cargo build --workspace --all-targets` clean; `cargo clippy -p aura-engine
--all-targets` clean.
2026-07-15 18:46:25 +02:00
claude ad4249f9e6 Merge PR #270: tracker sweep + executor robustness (12 issues)
Autonomous /boss sweep over the 2026-07-13 quadriga harvest plus two owner-decided executor-robustness follow-ups. Closes #247 #256 #258 #259 #260 #261 #262 #264 #265 #266 #269 #272.
2026-07-14 17:12:56 +02:00
claude f0aadb54f8 audit: cycle-close tidy for the #256/#272 latecomer block
Drift review (architect) over a55e4cf..HEAD found the cycle substantially
clean — C25 closed vocabulary, C1 determinism/behaviour-preservation, the
additive-serde widening, exhaustive StageBlock matches, and the uniform exit-3
convention all hold. Two drift items resolved:

- RATIFY: the `SilencedPanic` member-boundary panic-hook silencer (added by the
  #272 implementer, not named in the spec) is a legitimate mechanism — it keeps
  "recorded, campaign continues" observably true on stderr by suppressing the
  default crash backtrace around each contained `catch_unwind`. Its mutex
  serialises only the ref-count/hook-swap (O(1)), never member computation, so
  C1 disjoint-parallel determinism is preserved. Documented in the ledger's
  #272 realization paragraph rather than left as undocumented global state.
- FIX: `cell_fault_kind_label` hand-wrote the snake_case strings that must match
  `CellFaultKind`'s serde `rename_all` (two sources of truth an aggregate over
  campaign_runs.jsonl could silently diverge from). Pinned with a test asserting
  each label equals the serialized form; the efficient `&'static str` stays.

(The #272 commit's own concern-driven doc fixes and the wf panic-containment
test landed in d3b1a1a; this commit carries only the two audit-phase items.)

Suite: cargo test --workspace green (1311 tests, 0 failed); clippy clean.
2026-07-14 17:01:36 +02:00
claude d3b1a1aead feat(campaign,registry,cli): per-cell fault isolation — a failed cell is recorded, never a global abort
closes #272

A member fault (no-data, bind, run, or a caught panic) is now a recorded
per-cell outcome instead of aborting the whole campaign and discarding every
already-computed cell. The incident that motivated this (a 22-instrument
campaign lost ~36 healthy cells ~6.7 min in because Copper had an archive gap)
now completes: the healthy cells persist, the gap cell is recorded as failed,
and the run exits 3.

Direction (owner decision 2026-07-14): run to completion and report
compromised results; no coverage preflight, no window synthesis.

Containment granularity:
- The CELL for a sweep-stage member fault (a grid hole structurally
  compromises winner selection, so the whole cell fails).
- The FOLD for a walk_forward member fault (independent time windows): the
  surviving folds pool into the family, failed folds are recorded as
  StageRealization.window_faults, and the summary names the ratio.

- aura-registry: additive CellFault / CellFaultKind (closed:
  no_data|bind|run|panic|window) / WindowFault / CellCoverage, plus
  fault/coverage fields on CellRealization and window_faults on
  StageRealization — all serde-default-skipped, so pre-#272 campaign_runs
  lines parse and round-trip byte-identical.
- aura-campaign: run_cell returns a fault-annotated CellRealization instead of
  Err (execute's accumulate-then-append-once tail is unchanged and now
  persists every healthy cell + the one run record); a `contain` split keeps
  ExecFault::Registry and doc-shape preflight faults global while Member/Window
  become per-cell/per-fold. Member panics are caught with
  catch_unwind(AssertUnwindSafe) at all three member-run sites (sweep IS/OOS)
  and recorded as MemberFault::Panic — a member panic no longer aborts the
  process. The wf stage partitions Registry faults (global) from Member/Window
  (per-fold) and filters faulted-fold placeholders (the
  "faulted-member-placeholder" broker sentinel) out of the persisted family.
- aura-cli: exec_fault_prose gains the Panic arm; CliMemberRunner::window_coverage
  derives effective bounds + interior gap months from the #264 archive
  primitives; present_campaign prints per-cell failure notes + a completion
  summary and threads the failed-cell count; a run with >=1 failed cell exits 3
  ("completed with failed cells") uniformly across `aura campaign run` and the
  dissolved sweep/walkforward/mc/generalize verbs (exit_on_campaign_result).
  Usage stays 2, refused-before-running stays 1, clean stays 0.

Tests: the global-abort pins flip to containment (execute + the two wf fault
tests → fold-containment + all-folds-fail-the-cell); new panic-containment
tests on both the sweep path (PanicRunner) and the wf path (this commit adds
the wf mirror the loop left uncovered); a new gapped-archive e2e (one covered
cell + one gap cell → exit 3); the ~14 CLI exit-1 pins move to the exit-3
register; a pre-#272-line byte-identical round-trip guard.

Suite: cargo test --workspace green (1309 tests, 0 failed); clippy clean.
Decision log: #272 comments (fork rationale, the fold Registry/Member split,
the placeholder sentinel, uniform exit-3).

Follow-up (minor, not blocking): the plan under-scoped Task 1 to aura-registry
though the additive fields also touch aura-campaign's exec.rs literals — the
loop absorbed it mechanically; a future plan for a cross-crate additive-field
change should scope every crate's construction sites in the first task.
2026-07-14 16:51:31 +02:00
claude ea4e79d73f feat(research,campaign,cli): std::grid — the enumerate-only leading stage
closes #256

Fork B (owner decision 2026-07-14): the dissolved walkforward/mc
translations' leading sweep executed the full grid over the whole campaign
window and persisted a Sweep family, yet only the enumerated parameter
points ever crossed the stage seam (the wf stage re-sweeps them per IS
window itself). The leading stage is now the fieldless vocabulary block
std::grid: it enumerates, executes nothing, persists nothing.

- aura-research: StageBlock::Grid ({"block":"std::grid"}), schema-strict
  parse arm (empty slot list: every key but "block" is refused by the
  generic unknown-slot check), PROCESS_BLOCKS entry, intrinsic-tier no-op
  arm; the vocabulary test's non-empty-slots guard carries a pinned
  std::grid-only exception (a nominal slot would misdescribe the
  vocabulary to describe_block consumers).
- aura-campaign: the inter-stage seam is a typed two-armed StageFlow
  (points-only vs executed members); gate / mc-per-survivor fence the
  points-only arm with defensive PipelineShape faults; preflight admits
  std::grid only as the first stage and only immediately before
  std::walk_forward (every other neighbor consumes executed reports).
- aura-cli: translate_walkforward / translate_mc lead with
  StageBlock::Grid; the two family-shape E2E pins flip to zero Sweep
  families; translate_generalize keeps its executed sweep(argmax) — its
  generalize stage consumes the argmax winner report as the cell nominee.
- docs: dated #256 amendment in the ledger's verb-dissolution narrative;
  the authoring-guide vocabulary transcript gains the std::grid line.

Behaviour preservation: the exact-grade real-data pins
(walkforward_real_e2e_pins_the_exact_current_grade,
mc_r_bootstrap_real_e2e_pins_the_exact_current_grade) pass unmodified —
survivor points reach the wf stage in the same odometer order as before.

Measured (the #256 acceptance measurement; debug build, real GER40 2025,
2x2 grid, `aura walkforward --real`, 3 runs each):
  before (a55e4cf): 6.27 / 6.18 / 6.18 s
  after:            4.52 / 4.49 / 4.45 s   (~ -27% wall clock)

Suite: cargo test --workspace green (0 failed); clippy clean.
Decision log: #256 comments (fork rationale incl. the StageFlow seam and
the slot-guard exception).
2026-07-14 13:07:40 +02:00
claude a55e4cfd10 audit: trio tidy — hoist the ns-per-minute constant
Cycle-close audit over the feature trio 75bfb93..d1b3a3d (#261 #260
#262). Architect verdict: clean — C1 (both stop variants and the
per-instrument cost round-trip through the manifest), C2 (rollover-only
emission), C18 (scalar cost wire form byte-identical; VolTf additive),
the four-scalar discipline (SessionFrankfurt bakes the timezone, no
string kind), and the match-arm lockstep across all four RiskRegime/
StopRule sites all hold; commit bodies match the diff. One low drift
item — the repeated 60·10^9 ns-per-minute literal in vol_tf_stop.rs —
fixed here as NS_PER_MINUTE rather than carried.

No regression scripts are configured (the architect is the sole gate);
full workspace suite green, clippy clean. Spec and plan working files
discarded per the audit lifecycle.

refs #262
2026-07-14 03:02:54 +02:00
claude d1b3a3dd31 feat(research,composites,cli,docs): vol_tf — the timescale-matched stop regime
The second risk-regime variant vol_tf{period_minutes, length, k}
computes the vol estimator over completed time buckets: an H1 signal
gets an H1-matched stop in the research matrix instead of a hand-scaled
minute stop (the issue's k-inflation workaround retires). Additive
externally-tagged serde (stored Vol documents keep their content ids);
the executor arm wires the VolTfStop primitive via the builder+bind
chain; validate checks period_minutes/length/k positivity per regime.

The member-manifest round-trip holds for both variants (C1): vol_tf
members stamp stop_period_minutes/stop_length/stop_k and the reproduce
path re-derives the VolTf stop whenever stop_period_minutes is present
— never a silent default-Vol fallback. The Regimes slot label and the
unit notes name the new variant (period_minutes IS the stop's
timescale); glossary, authoring guide, and design ledger record the
second variant. Default regime and sugar paths byte-untouched.

Coverage: node units (rollover-only emission, bucket-delta math, the
constant-|delta| correspondence with the per-cycle regime), executor
fold, serde/validate units, the manifest round-trip unit, and a
hostless two-regime e2e (distinct ordinals; vol_tf members stamp their
timescale, vol members do not).

closes #262
2026-07-14 02:55:02 +02:00
claude 528e8f33d6 feat(std): VolTfStop — the timescale-matched volatility stop primitive
k · sqrt(EMA(delta^2, length)) over completed period_minutes buckets —
the vol regime on a coarser clock, as a fused primitive in the Resample
mold: the in-progress bucket lives in node state, the finished bucket's
close is differenced against the previous one on rollover, and the stop
distance is emitted ONCE per completed bucket (C2: a partial bucket
never exists downstream; Firing::Any consumers hold the last value).
EMA follows Ema's exact convention (SMA seed over the first length
deltas, alpha = 2/(length+1)), pinned by a constant-|delta|
correspondence test against the per-cycle vol regime. Not rostered in
the std vocabulary (stop primitives are risk-executor internals).

First slice of #262; the regime variants, executor arm, and manifest
round-trip follow.

refs #262
2026-07-14 02:08:28 +02:00
claude e00e264252 feat(research,cli): per-instrument cost factors in one campaign document
Each cost component's knob (cost_per_trade / slip_vol_mult /
carry_per_cycle) now accepts one number for every cell — today's form,
byte-identical on the wire so stored documents keep their content ids
(CostValue's custom serde, the Axis precedent) — or an instrument-keyed
map resolved per cell at member construction. A mixed-scale matrix
(GER40 ~2e4, EURUSD ~1.1) keeps consistent cost units in ONE document:
the N-copies workaround retires and generalize-under-constant-costs
becomes expressible again.

Validation is strict both ways at the intrinsic tier: a map missing a
campaign instrument, or naming one the campaign does not list, refuses
at validate with prose naming the component and instruments (no silent
default — a partial map would reproduce exactly the silent unit
inconsistency the issue reports). The instrument threads through
cost_knob/cost_nodes_for/run_blueprint_member from the cell (run side
and persist re-run side receive the same value — the C1 drift-alarm
lockstep); the manifest stamps the resolved per-cell value under the
unchanged cost[k].<knob> key, and the sugar/reproduce paths pass an
inert instrument (their specs are scalar by construction). Fork
decisions and the discarded first attempt: issue #260 comments.

New coverage: serde round-trips (map form; scalar stays bare), validate
cross-check units, both prose directions pinned, and two hostless e2es
over the synthetic SYMA+SYMB archive — per-cell resolution visible in
the member manifests, and the validate refusal.

closes #260
2026-07-14 01:20:59 +02:00
claude ca4a89864c feat(std,engine): SessionFrankfurt — the Session node enters the closed roster
A Frankfurt-baked zero-arg Session preset (open 09:00 Europe/Berlin as
literals) joins the std vocabulary as SessionFrankfurt, declaring
period_minutes: I64 (default 15) as its one scalar knob — the
single-knob roster pattern the cost nodes established. A data-only
blueprint can now reach a session/time-of-day stream (bars_since_open),
unblocking time-of-day conditioners for the confirm/refute research
model. Roster count pins bump 28 -> 29 in both conscious-act sites.

Fork decision on the issue: the preset variant over a param-vocabulary
extension — a timezone param would need a string kind, breaching the
four-scalar discipline; the preset keeps the vocabulary closed and is
the additive extension the roster-exclusion comment anticipates. The
4-arg Session::builder stays untouched; in_session/session_open_ts
stay deferred (#154).

closes #261
2026-07-14 00:02:50 +02:00
claude 89d703dd99 test: red executable-spec for #261 — SessionFrankfurt preset reachable from blueprints
A data-only op-script naming the roster id SessionFrankfurt must
resolve to a Frankfurt-baked Session preset (09:00 Europe/Berlin baked,
one period_minutes: I64 knob, bars_since_open: I64 out) and build to a
finished Composite. Fails on std_vocabulary returning None — the preset
is absent from the roster; fork decision on the issue.

refs #261
2026-07-13 23:50:48 +02:00
claude 75bfb9326f audit: batch tidy — exhaustive bind-error prose + the carry unit note
Cycle-close audit over the /boss batch 84e1075..bdafbde (#258 #259
#264 #266 #247 #265 #269). Architect verdict: substance clean — C1
byte-identity (conduit serde-skipped, pinned floats verbatim, uncosted
bit-identical), C10 one-home-for-cost (all four conduit consumers read
the already-netted series), C18 (conduit off the wire, --version and
manifest single-sourced) all hold. Two medium debt items, both fixed
here rather than carried:

- render_bind_error's catch-all Debug-framed the remaining BindError
  variants, making #269's no-identifier-on-stderr claim argv-gated
  rather than structural. The match is now exhaustive: DuplicateBinding,
  EmptyAxis, EmptyRange render as axis-usage prose; a Compile fault — a
  blueprint defect, not a usage error — renders as a prose frame with
  the compile detail explicitly labelled internal (per-variant
  CompileError prose belongs to the graph-build surface, not this
  boundary). Unit tests pin the arms.
- The #265 knob-unit notes covered constant and vol_slippage but not
  carry.carry_per_cycle, which carries the identical price-unit-vs-R
  trap; the note set and its pins now cover all three cost components.

No regression scripts are configured (the architect is the sole gate);
full workspace suite green, clippy clean. Spec and plan working files
discarded per the audit lifecycle.

refs #265 refs #269
2026-07-13 23:39:38 +02:00
claude bdafbdef59 fix(cli): unwrap the walkforward UnknownKnob Debug frame into bare prose
render_bind_error's catch-all Debug-framed UnknownKnob although its
payload is already a fully-prosed message; the variant now renders the
payload alone. The two tests that pinned the frame verbatim repoint to
the prose fragments. Exit 2 unchanged. Completes the #247 defect
family: no Rust identifier reaches a user's stderr on either verb path.

closes #269
2026-07-13 23:28:25 +02:00
claude 3d422341eb test: red executable-spec for #269 — walkforward unknown-axis rejection as bare prose
The rejection must reach stderr as the prose payload alone — no
UnknownKnob(..) Debug frame around it. Fails on today's frame; the
prose fragments already pass, so the sole pinned defect is the frame.

refs #269
2026-07-13 23:17:59 +02:00
claude e8b6878d3b feat(research,cli,docs): state cost and risk knob units in introspect and the glossary
campaign introspect --unwired now annotates the four silently
misreadable knobs with unit/semantics sub-lines: cost_per_trade is a
price-unit numerator charged as cost/|entry−stop| in R (not a cost in
R); slip_vol_mult multiplies the per-cycle vol estimate; vol.length
smooths the estimator and does not set the stop's timescale; vol.k
scales the stop distance (the risk-unit lever). Rendered additively
via a new OpenSlot.notes field, so the sibling tests pinning the slot
hint lines verbatim stay green byte-identically.

The glossary's cost-model and risk-regime entries mirror the same
semantics inline (two-sentence convention kept), the risk entry
pointing at #262 for the timescale-matched variant.

closes #265
2026-07-13 23:12:36 +02:00
claude e12b4a730b test: red executable-spec for #265 — introspect --unwired states knob units
The four silently-misreadable cost/risk knobs (cost_per_trade,
slip_vol_mult, vol.length, vol.k) must carry one-line unit/semantics
annotations in campaign introspect --unwired — blocking the price-vs-R
and length-as-timescale misreadings that produce no error today.

refs #265
2026-07-13 22:59:07 +02:00
claude 414448bd3f fix(cli): render sweep-boundary bind errors as prose, not Debug structs
The blueprint-sweep terminal rendered BindError via {:?}, leaking
KindMismatch { .. } and MissingKnob(..) Rust Debug structs where the
sibling diagnostic on the same verb ships prose. Both now render in
that register — the axis name, the expected-vs-supplied kinds resp.
the unbound knob, and the --list-axes pointer; exit stays 2.

Scoped out (same defect family, different verb path): the walkforward
path's UnknownKnob wrapper still prints its Debug-ish frame around an
already-prose payload and is pinned verbatim by two existing tests;
filed as a follow-up rather than widened into this slice.

closes #247
2026-07-13 22:52:03 +02:00
claude ce12e7f976 test: red executable-spec for #247 — sweep-boundary bind errors render as prose
Both blueprint-sweep bind faults (wrong-kind axis value; an open param
no axis covers) must print prose in the unknown-axis sibling's register
— axis name, kind/knob specifics, a --list-axes pointer — never the raw
Debug structs KindMismatch{..}/MissingKnob(..). Exit stays 2. Fails on
the Debug leakage present today, not on scaffolding.

refs #247
2026-07-13 22:35:03 +02:00
claude be9f1cef8c feat(cli): print the engine commit in --version
aura --version now prints 'aura 0.1.0 (<commit>)', the commit sourced
from the same AURA_COMMIT build provenance the run manifest stamps
(option_env! at build time, 'unknown' fallback, dirty suffix included)
— a pre-run freshness probe: a stale binary is now distinguishable from
an engine bug at the CLI surface, and research docs pinning
'Engine: aura @ <sha>' can verify the binary they invoke.

The version string is memoized as a OnceLock-leaked &'static str:
clap 4.6's builder Str accepts only &'static str (no From<String>), so
the owned-String alternative a quality pass suggested does not build —
verified, held with evidence in the loop report.

closes #266
2026-07-13 22:26:47 +02:00
claude 5a1696891a test: red executable-spec for #266 — aura --version carries the engine commit
aura --version must print 'aura 0.1.0 (<commit>)', the commit sourced
from the same AURA_COMMIT build provenance the run manifest stamps —
a pre-run freshness probe distinguishing a stale binary from an engine
bug. Fails on the absent parenthesized commit, not on scaffolding.

refs #266
2026-07-13 22:11:03 +02:00
claude 5b70bd60a5 feat(cli): aura data list — enumerate the archive's symbols
aura data list prints the archive's known symbols sorted, one per line
(DataServer::symbols() over the project-resolved archive root — the
discovery step before aura data coverage or scoping a campaign's
instrument matrix). An empty or absent archive prints a 'no symbols'
prose line and exits 0, per the verb corpus's empty-result register.
Pure report fn with unit tests; the headline e2e runs hostless over
the synthetic SYMA+SYMB archive.

Together with the coverage verb this closes the inventory gap: an
agent operating through the CLI alone can now discover instruments
and their per-month gaps before authoring a campaign.

closes #264
2026-07-13 22:06:31 +02:00
claude a7d465d2b5 test: red executable-spec for #264 cut 2 — aura data list enumerates symbols
aura data list must print the archive's symbols sorted, one per line,
exit 0 — the discovery step before aura data coverage. Hostless RED over
the synthetic SYMA+SYMB archive; fails on the absent 'list' subcommand
(clap exit 2), not on scaffolding.

refs #264
2026-07-13 21:52:32 +02:00
claude 529a8af0d6 feat(cli,ingest): aura data coverage — month-gap inventory for a symbol
aura data coverage <SYMBOL> prints the archive's monthly file-index
coverage: a span line (first..last present month) and either an explicit
'no gaps' line or one 'missing: YYYY-MM..YYYY-MM' line per interior
contiguous hole. The Copper failure mode — a campaign aborting mid-run
because a first/last-bounds check passed while the window start had no
data — is now visible before any run (the real archive reports
'Copper missing: 2017-04..2019-09'). An unknown symbol (no archive
files) refuses on stderr with exit 1; informational absence stays
prose + exit 0 per the verb corpus convention.

Mechanics: list_m1_months goes pub in aura-ingest (archive_extent
deliberately walks past gaps and cannot report them); a new two-level
'aura data' clap namespace follows the Nodes precedent; the archive
root resolves through the normal project path (paths.data override,
data-server default otherwise). Pure report fn with unit tests for the
refusal, the gapless span, and the collapsed-range shape; the headline
e2e runs hostless over a new gapped synthetic-archive fixture.

refs #264 (cut 2, aura data list, follows separately)
2026-07-13 21:49:24 +02:00
claude 28984ba73c test: red executable-spec for #264 cut 1 — aura data coverage reports month gaps
aura data coverage <SYMBOL> must print the archive's first/last present
month and each interior missing-month range collapsed to one
'missing: YYYY-MM..YYYY-MM' line — the Copper failure mode (first/last
check passes while the window start has no data) made visible before a
campaign runs. Hostless RED over a new gapped synthetic-archive fixture
(fresh_project_with_gapped_data); fails on the absent 'data' subcommand
(clap exit 2), not on scaffolding.

refs #264
2026-07-13 21:28:58 +02:00
claude bb0b0aeac2 feat(analysis,campaign,registry,cli): bootstrap net R through the process pipeline
The OOS bootstrap conduit RMetrics.trade_rs becomes net_trade_rs and
carries the COST-NETTED per-trade R (r − cost_in_r, trade order). Every
conduit consumer is thereby net-when-costed: the monte-carlo pooled-OOS
and per-survivor bootstraps, the walk-forward oos_r pooling, and the
deflation null-max — which previously compared a net observed statistic
against a gross-resampled null whenever a costed campaign selected on
net_expectancy_r. An uncosted run is bit-identical (empty cost stream
⇒ cost 0.0 per trade), so every existing golden pin stays green.

Design: one conduit, no knob — an explicit net: knob would let a costed
campaign silently produce a gross headline again (the exact misreading
of the issue's evidence). Per-member RMetrics scalar fields stay gross;
net_expectancy_r keeps its meaning. Wire shape unchanged (serde(skip)).
The net series is materialized as a separate expression in summarize_r;
the pinned-float expressions (net_sum, the SQN pair, the lockstep
r_metrics_from_rs copies) keep their tokens verbatim. Fork decisions
and rationales: issue #259 comments.

New coverage, both hostless over the synthetic SYMA archive: a costed
campaign twin shifts the pooled-OOS bootstrap (sweep→gate→wf→mc) and
every per-survivor bootstrap (sweep→mc) below its gross sibling, with
the trade population unchanged; a unit test pins the conduit as the
cost-netted series with the empty-stream degeneracy. Existing conduit
tests renamed with the field.

Verified: full workspace suite green (71 result blocks), clippy clean,
zero bare trade_rs tokens remain, ledger conduit prose updated in place.

closes #259
2026-07-13 21:21:41 +02:00
claude 1ccce9ad32 fix(test): anchor all test sandboxes on reclaimable pid-free names
Every hand-rolled test-sandbox helper keyed its directory on
std::process::id() under env::temp_dir(), so the pre-create wipe never
matched a prior run's path: one directory stranded per helper site per
test-binary invocation, >50k dirs / 15.5 GiB on the 16 GiB tmpfs by
2026-07-13.

The remedy extends commit 84e1075's knob-lab pattern to every site:
fixed, tag-keyed, PID-FREE names under the build-tree tmp anchor, each
site keeping its pre-create remove_dir_all — which now genuinely
reclaims the previous run. Integration/bench targets use
env!("CARGO_TARGET_TMPDIR"); src-internal unit-test helpers (cargo sets
that var only for integration/bench targets) derive the same anchor
from env!("CARGO_MANIFEST_DIR") + ../../target/tmp. Residue is bounded
to one directory per site, on disk instead of the tmpfs, per checkout.

One deliberate exception: project_new.rs's tmp() stays under
env::temp_dir() because two of its tests (new_outside_a_work_tree_*)
need a base genuinely detached from any git work tree, and target/
lives inside the checkout's work tree. Its name carries a per-checkout
hash discriminator instead, so concurrently running checkouts (which
share /tmp) cannot race on the fixed name while each run still
reclaims its predecessor.

tempfile-RAII was considered and rejected (issue #258 decision log):
it cannot cover the process-lifetime OnceLock scaffolds (statics never
drop), adds a dependency, and still strands kill-leftovers on the
tmpfs.

Verified: full workspace suite green (incl. the new RED regression
test temp_cwd_sandbox_does_not_leak_under_env_temp_dir), clippy clean,
sandboxes observed under target/tmp with stable names across runs.
Stale pre-fix PID-keyed dirs in /tmp are not swept by this change and
age out only by manual cleanup.

closes #258
2026-07-13 20:15:31 +02:00
claude 05565ee8a2 test: red for #258 — temp_cwd sandbox leaks one dir per run into /tmp
The helper keys its sandbox on std::process::id() under env::temp_dir(),
so the pre-create wipe never matches a prior run's path: one directory
strands per test-binary invocation on the 16 GiB tmpfs (>50k dirs by
2026-07-13). The test pins the property at one representative site: the
returned path embeds no PID and lives off env::temp_dir().

refs #258
2026-07-13 19:40:59 +02:00
claude 84e1075176 fix(test): anchor the knob-lab scaffold under CARGO_TARGET_TMPDIR
built_scale_project() scaffolded ~66 MB (built node crate) into a
pid-suffixed tempdir, so its wipe-before-scaffold never hit a previous
run's copy: one leaked directory per test-binary run. 159 of those
(~10 GB) filled the host's 16 GB /tmp tmpfs to 100%, breaking every
std::env::temp_dir() consumer. A fixed name under CARGO_TARGET_TMPDIR
(real disk, reclaimed by cargo clean) makes the existing wipe reclaim
the previous run — steady state is exactly one directory. Trade-off:
two concurrent cargo-test invocations over the same checkout collide
on the fixed name, the documented pre-existing caveat class for this
suite (#223).

closes #257
2026-07-13 16:47:57 +02:00
claude a895891ab1 perf(ingest,cli): derive archive bounds from the monthly file index — probe_window drain retired
probe_window drained a source's entire close column just to learn its
first and last bar timestamp; both live callers (open_real_source and
campaign_window_ms on the campaign trunk) now resolve through
aura-ingest's new archive_extent(): list the symbol's SYM_YYYY_MM.m1
files, load only the boundary months via the existing loader seam, and
walk across gap months forward/backward — O(2 file loads) typical
instead of O(archive). Empty-window refusal semantics are unchanged
(pinned). Two characterization tests captured the resolved windows
against the OLD implementation over the synthetic per-test archive and
stay green unchanged after the swap.

Measured: the probe itself drops 366ms -> 13ms (~28x) on the full
2014-2026 GER40 archive; end-to-end command wall-clock moves only
~2-3% because the sim loop dominates — the fix removes an
O(archive-size) term, not the dominant cost.

closes #252
2026-07-13 16:47:57 +02:00
claude 479c6620e2 perf(cli): synthetic walkforward pre-flight validates axes without running members
blueprint_walkforward_family's pre-flight executed the full first-IS-
window sweep via blueprint_sweep_over and discarded the result, purely
to surface axis errors once before the parallel window fan-out (#177).
validate_axis_grid now drives the sweep terminal's own resolve/arity/
kind checks with a constant placeholder report — same single-sourced
rejection wording, no member run, no roller derivation needed.

Scope correction against the issue text: this path serves only the
synthetic (non---real) walkforward. The --real trunk routes through
verb_sugar into aura-campaign's [Sweep, WalkForward] process, whose
leading full-window sweep is a separate, larger duplication — re-filed
with corrected attribution in the issue's closing comment.

closes #253
2026-07-13 16:45:43 +02:00
claude a04fda3a4c fix(cli): resolve a relative paths.data against the project root
Env::data_path() handed paths.data to the data layer verbatim, so a
relative value resolved against the invoking process's cwd — aura run
from a project subdirectory silently read a different archive location
than from the root, while runs/ stayed anchored. Relative values now
join onto the project root, matching runs_root() and the [nodes]
pointer resolution; absolute values and the no-project default pass
through unchanged. RED-first unit test pins the root-anchored
resolution.

closes #254
2026-07-13 16:44:28 +02:00
claude c31e946bb7 test(cli): synthetic per-test M1 archive — no-window generalize tests go hostless
The two generalize E2E tests whose property is no-window span
resolution (shared-window fallback, intersection semantics) no longer
stream the 6.6 GB host archive: fresh_project_with_data() generates a
deterministic two-symbol M1 archive (SYMA 2024-01..08, SYMB 2024-03..06
strictly inside it, so the intersection differs from symbols[0]'s
window on both bounds) in the exact zip/48-byte-record format
data-server reads, plus geometry sidecars, under the per-test project's
paths.data. Both tests drop their local_data_present gates — they now
run on any host, data mount or not.

~35s and ~50s become 0.74s and 1.14s; the full cli_run binary lands at
~25s (211s at the branch base). Full workspace suite: 247s -> 45.7s.

refs #250
2026-07-13 16:44:28 +02:00
claude 25b8354959 test(cli): per-test project dirs via a [nodes] pointer — project_lock retired
Every E2E test that mutated the shared demo-project fixture store now
mints its own tempdir project whose 2-line Aura.toml points at the
once-built fixture crate by absolute [nodes] pointer; the runs/ store
follows the project root, so no two tests contend and libtest's
default thread parallelism applies. project_load keeps driving the
fixture directly (the suite's only proof of the inline-crate routing
arm); its single store-mutating test is that binary's only one, so no
lock is needed there either. This also closes the documented
cross-process race window (#223): there is no shared mutable store
left to race on.

Measured on the data-full host: cli_run 211s -> 57.2s (140 tests),
research_docs 21.1s -> 2.1s (73 tests), project_load green, clippy
clean.

refs #250
2026-07-13 16:43:16 +02:00
claude 00cc2fe927 build: compile dependencies at opt-level 2 in the dev profile
The E2E suite spawns the debug aura binary, whose wall-clock is
dominated by dependency code (zip inflate of the tick archive, sha2
over the project dylib, serde). Workspace crates stay at opt-level 0
for fast rebuilds and debugging; every pinned float is computed by
workspace code, so the byte-identity anchors are untouched.

refs #250
2026-07-13 16:42:18 +02:00
claude 038e1702b3 test(cli): bound three whale E2E windows instead of streaming full archives
The two dissolved-walkforward flag tests assert window-independent
properties (plateau acceptance, per-flag stop defaulting) and now use
the ~135-day window the reproduce e2e already uses. The mc roller-fit
test replaces its full-archive probe sweep with a fixed ~30-day window
inside that same span: the property needs a data-carrying window far
shorter than the 90+30-day roller, not a live-span derivation, and the
archive only grows forward. Measured on the data-full host: 19.6s,
19.6s and 14.5s+ of serialized real-data compute drop to 1.1-1.9s each.

refs #250
2026-07-13 16:42:18 +02:00
claude 1ebb94c1b8 feat(engine,cli): run manifests stamp untouched bound defaults (closes #249)
RunManifest gains defaults: Vec<(String, Scalar)> — the wrap-prefixed
bound_param_space() of the signal, read after axis reopening, so a
bound param an axis overrode has already left the space and flows
through params instead (disjoint by construction; verified end to end:
a sweep member's overridden fast.length sits in params while
slow.length/bias.scale sit in defaults). params keeps its "what varied"
semantics and stays the reproduce input. One-directional serde
widening (#[serde(default)]) per the selection/instrument/topology_hash
idiom — old records deserialize with an empty defaults; unlike the
Option fields it always serializes, mirroring params. ~20
struct-literal sites across five crates gained the field
(compile-mandated breadth, no behaviour change at those sites).

The C14 ledger records the underlying decision (2026-07-13): generated
outcome records spend redundancy on direct readability (single writer,
cannot drift); authored intent artifacts admit none (every redundancy
is a drift site) — so the fix lands in the manifest, never the
blueprint.

Verification: RED test run_manifest_stamps_untouched_bound_defaults
green; cargo build --workspace; cargo test --workspace green; clippy
-D warnings on the touched crates; binary-level sweep exclusivity
check.
2026-07-13 14:20:23 +02:00
claude 4e6d5d4688 test(cli): RED — run manifest stamps untouched bound defaults (refs #249)
Executable spec for the defaults field: aura run on the fully bound
examples/r_sma.json must report a manifest whose params stays [] (the
"what varied" record) and whose new defaults field carries the
untouched bound params as wrap-prefixed (name, Scalar) pairs in
bound_param_space() order — fast.length I64 2, slow.length I64 4,
bias.scale F64 0.5. Asserts on the stdout JSON (C14: stdout shape ==
on-disk shape), so it compiles against the current RunManifest and
fails on the absent key.
2026-07-13 13:41:21 +02:00
claude 487431d97d test(cli): migrate the _open references — closed twins for sweeps, fixtures for open semantics (closes #248)
The references to the relocated _open blueprints follow the plan's
two-way split: tests that only need a sweepable blueprint sweep the
closed twins via bound-override axes (#246) with their axis strings
byte-unchanged (the wrap prefix is the blueprint's internal name,
identical across twins); the 12 tests that genuinely exercise
open-param semantics (run/mc closed-guard refusals, subset-axes
MissingKnob, --list-axes open/bound line mix, gang dissolution and
campaign paths) read the fixtures under tests/fixtures/. The
research_docs/project_load store seeds sweep the closed twin (campaign
axis references resolve via the #246 reopen path); the INDEX.md history
note records the relocation.

Two deviations from the plan, both verified against the tree: doc
comments referencing the bare filename (no examples/ prefix) escaped
the global path swap and were reworded to stay truthful; the seed
variable open_bp was renamed closed_bp (the plan kept it, but the
rename touches only the binding and its uses and removes a
misdescription).

Verification: cargo test -p aura-cli --test cli_run (140 passed, 0
failed, real data exercised); --test research_docs; --test
project_load; the four byte-pinned exact grades unchanged; grep-clean
for examples/r_*_open.json across crates+docs; full workspace suite
green.
2026-07-13 13:36:31 +02:00
claude 6d3da424f4 test(cli): relocate the _open example twins to tests/fixtures (refs #248)
The four _open blueprints leave the user-facing gallery
(crates/aura-cli/examples/) and become test fixtures; the closed twins
are now the only example corpus (bound params are overridable defaults
since #246, so the closed files serve both run and sweep).

Compile-time embeds follow the move: the emit_* generators and the
shipped_*_serialize byte-pins target tests/fixtures/ (the open fixtures
stay builder-generated and byte-pinned — regenerating after the move
left the tree clean), load_open_r_sma and the axis-probe read the
fixture, and the walk-forward refit test plus the `aura graph` default
sample switch to the closed r_sma.json. graph_construct's open-fixture
tests swap example() -> fixture(), their names dropping the now-false
"shipped example" claim; a new e2e test pins the relocation property
(all four fixtures load and introspect, the gallery holds exactly the
four closed examples).

Intermediate state: cli_run.rs / research_docs.rs / project_load.rs
still reference the old example paths and fail at runtime until the
follow-up migration commit (MIGRATE sites move to the closed twins,
NEEDS-OPEN sites to the fixtures).

Verification: cargo build --workspace; cargo test -p aura-cli --bin
aura; cargo test -p aura-cli --test graph_construct; the three ignored
emit generators re-run byte-stable.
2026-07-13 11:42:03 +02:00
340 changed files with 32531 additions and 9458 deletions
+12
View File
@@ -159,6 +159,16 @@ design decision, not a refactor, and belongs in the ledger.
harness and is an execution viewer / trace explorer (structure before, live
streams during, recorded traces after) — never a scene editor; topology is
grown in Rust + hot-reload, runtime params are UI-tunable.
13. **Self-description: every surface explains itself.** Every closed-
vocabulary entry the binary exposes (nodes, metrics, tap slots, folds,
blocks) carries a one-line meaning, enforced at its entry seam —
compile/unit for engine-shipped entries, load for native node crates,
register for the content-addressed store (documents: an optional gated
`description`). The gate is deterministic string shape (`doc_gate`),
never content judgement: the engine never evaluates description text
(no freetext logic hole, invariant 10), descriptions never influence
execution, determinism (C1), or identity ids, and registered artifacts
are never retroactively invalidated. (C29)
## HTML surfaces
@@ -178,6 +188,8 @@ Everything else is a fixed convention (see `~/dev/skills/docs/conventions.md`).
- **Test** — `cargo test --workspace`
- **Lint** — `cargo clippy --workspace --all-targets -- -D warnings`
- **Doc build** — `cargo doc --workspace --no-deps 2>&1`
- **Bench** — `cargo run --release -p aura-bench -- run` (cycle close;
report-only — see `crates/aura-bench/README.md`)
- **Design ledger** — `docs/design/INDEX.md`
- **Glossary** — `docs/glossary.md`
- **Issue tracker** — Gitea, repo `Brummel/Aura`:
Generated
+164 -3
View File
@@ -100,20 +100,51 @@ dependencies = [
name = "aura-analysis"
version = "0.1.0"
dependencies = [
"aura-core",
"serde",
"serde_json",
]
[[package]]
name = "aura-backtest"
version = "0.1.0"
dependencies = [
"aura-core",
"aura-engine",
"aura-std",
"aura-strategy",
"rayon",
"serde",
"serde_json",
]
[[package]]
name = "aura-bench"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-core",
"aura-engine",
"aura-ingest",
"aura-registry",
"aura-std",
"clap",
"serde",
"serde_json",
"zip",
]
[[package]]
name = "aura-campaign"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-core",
"aura-engine",
"aura-registry",
"aura-research",
"rayon",
"serde",
"serde_json",
]
@@ -122,31 +153,38 @@ dependencies = [
name = "aura-cli"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-campaign",
"aura-composites",
"aura-core",
"aura-engine",
"aura-ingest",
"aura-measurement",
"aura-registry",
"aura-research",
"aura-runner",
"aura-std",
"aura-strategy",
"aura-vocabulary",
"clap",
"data-server",
"libc",
"libloading",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
[[package]]
name = "aura-composites"
version = "0.1.0"
dependencies = [
"aura-backtest",
"aura-core",
"aura-engine",
"aura-std",
"aura-strategy",
"serde_json",
]
@@ -163,10 +201,15 @@ name = "aura-engine"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-core",
"aura-market",
"aura-std",
"aura-strategy",
"aura-vocabulary",
"chrono",
"chrono-tz",
"rayon",
"serde",
"serde_json",
]
@@ -175,22 +218,50 @@ dependencies = [
name = "aura-ingest"
version = "0.1.0"
dependencies = [
"aura-backtest",
"aura-core",
"aura-engine",
"aura-market",
"aura-std",
"aura-strategy",
"chrono",
"chrono-tz",
"data-server",
"zip",
]
[[package]]
name = "aura-market"
version = "0.1.0"
dependencies = [
"aura-core",
"chrono",
"chrono-tz",
]
[[package]]
name = "aura-measurement"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-core",
"aura-engine",
"aura-registry",
"serde",
]
[[package]]
name = "aura-registry"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-core",
"aura-engine",
"aura-research",
"aura-std",
"aura-strategy",
"aura-vocabulary",
"serde",
"serde_json",
]
@@ -205,15 +276,60 @@ dependencies = [
"sha2",
]
[[package]]
name = "aura-runner"
version = "0.1.0"
dependencies = [
"aura-backtest",
"aura-campaign",
"aura-composites",
"aura-core",
"aura-engine",
"aura-ingest",
"aura-registry",
"aura-research",
"aura-std",
"aura-strategy",
"aura-vocabulary",
"data-server",
"libloading",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
[[package]]
name = "aura-std"
version = "0.1.0"
dependencies = [
"aura-backtest",
"aura-core",
"aura-strategy",
"chrono",
"chrono-tz",
]
[[package]]
name = "aura-strategy"
version = "0.1.0"
dependencies = [
"aura-core",
]
[[package]]
name = "aura-vocabulary"
version = "0.1.0"
dependencies = [
"aura-backtest",
"aura-core",
"aura-market",
"aura-std",
"aura-strategy",
"toml",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -402,6 +518,25 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -477,6 +612,12 @@ dependencies = [
"syn",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -801,6 +942,26 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.3"
+16
View File
@@ -11,6 +11,11 @@ resolver = "3"
members = [
"crates/aura-core",
"crates/aura-std",
"crates/aura-vocabulary",
"crates/aura-market",
"crates/aura-measurement",
"crates/aura-strategy",
"crates/aura-backtest",
"crates/aura-engine",
"crates/aura-analysis",
"crates/aura-composites",
@@ -18,7 +23,9 @@ members = [
"crates/aura-ingest",
"crates/aura-registry",
"crates/aura-research",
"crates/aura-runner",
"crates/aura-campaign",
"crates/aura-bench",
]
[workspace.package]
@@ -36,3 +43,12 @@ serde = { version = "1", features = ["derive"] }
# parsed back. Exercised (with a 1-ULP canary value) by aura-cli's
# `f64_blueprint_param_survives_store_round_trip_bit_identically`.
serde_json = { version = "1", features = ["float_roundtrip"] }
# Dependencies at opt-level 2, workspace crates at the dev default (0): the E2E
# suite spawns the debug `aura` binary whose wall-clock is dominated by dependency
# code (zip inflate, sha2 dylib hashing, serde) — optimizing deps is multiplicative
# there, while workspace crates stay fast-to-rebuild and debuggable. Float pins are
# unaffected: every pinned float is computed by workspace code (IEEE semantics are
# opt-level-independent in Rust regardless).
[profile.dev.package."*"]
opt-level = 2
+4 -2
View File
@@ -118,8 +118,10 @@ aura graph build < crossover.ops.json > crossover.bp.json
aura sweep crossover.bp.json --list-axes
```
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, and
`gang`. See
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
wire; `doc` declares the composite's one-line meaning, required at register —
C29; see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics.
+6 -8
View File
@@ -6,15 +6,13 @@ license.workspace = true
publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
# it gives the run-report types a typed (de)serialization path for the run
# registry (cycle 0029). serde's output is deterministic (C1-safe).
# serde gives the selection-provenance types (`FamilySelection`/`SelectionMode`,
# embedded by the engine's `RunManifest.selection`) their typed
# (de)serialization path for the run registry (C18). Deterministic output
# (C1-safe). The backtest metric types moved to aura-backtest (#291).
serde = { workspace = true }
[dev-dependencies]
# serde_json is used only by this crate's round-trip tests (the production types
# derive serde, but JSON rendering lives in aura-engine's RunReport::to_json).
# Test-only -> dev-dependencies, so it never enters the frozen artifact's library
# link (C16 frozen-artifact scrutiny).
# serde_json is used only by the moved MetricStats round-trip test (#291 Task
# 1); production JSON rendering lives at the engine/backtest boundary.
serde_json = { workspace = true }
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "aura-backtest"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
# aura-engine is the metric-generic engine this crate instantiates with
# `M = RunMetrics` (`RunReport`/`SweepFamily`/etc. aliases), whose
# `run_indexed` the moved MC assembly drives, and whose re-exported
# `MetricStats`/`resample_block`/`SplitMix64` (foundation-grade, from
# aura-analysis) the moved `mc.rs` reduces through — no direct aura-analysis
# edge, keeping the C28 ladder one-hop (#292, C28 phase 2). Outer-rung
# dependency — the engine itself only dev-depends back on this crate.
aura-engine = { path = "../aura-engine" }
# serde gives the backtest metric types (`RunMetrics`/`RMetrics`/`PositionEvent`)
# their typed (de)serialization path for the run registry (C18; moved with the
# types from aura-analysis, #291). Deterministic output (C1-safe).
serde = { workspace = true }
[dev-dependencies]
# serde_json is used only by the moved metric round-trip tests (JSON rendering
# for production lives in aura-engine's RunReport::to_json). Test-only, so it
# never enters the frozen artifact's library link (C16).
serde_json = { workspace = true }
# aura-std / aura-strategy back the moved `mc.rs`/`metrics.rs` test fixtures
# (Sma/Sub/Recorder, Bias) — the crate-private `aura-engine::test_fixtures`
# harness is unreachable from here, so the fixture is reconstructed through
# the public node vocabulary instead (test-only, mirrors the aura-engine
# integration tests' own pattern).
aura-std = { path = "../aura-std" }
aura-strategy = { path = "../aura-strategy" }
# rayon backs the thread-count-explicit `monte_carlo_with_threads` test
# wrapper (moved verbatim from aura-engine's mc.rs, #291) — test-only.
rayon = "1"
+40
View File
@@ -0,0 +1,40 @@
//! `aura-backtest` — the backtest layer (C28): simulated execution without
//! money (the `SimBroker` pip yardstick, position management), measured in R,
//! plus the backtest metric reductions over the recorded streams
//! (`metrics`, moved from `aura-analysis` by #291) and the Monte-Carlo
//! assembly (`mc`, moved from `aura-engine` by #291/#292, C28 phase 2). The
//! outer rung of the metric-generic engine (C28): `RunReport` here is the
//! concrete `M = RunMetrics` instantiation of `aura_engine::RunReport<M>`.
mod mc;
mod metrics;
mod position_management;
pub mod scaffold;
mod sim_broker;
// `assemble_mc` stays module-private (it was never `pub` in the pre-#291 engine
// lib.rs re-export either — only its two callers, `monte_carlo` and the
// `#[cfg(test)]` thread-count wrapper, are public/test surface).
pub use mc::{monte_carlo, r_bootstrap, McAggregate, McDraw, McFamily, RBootstrap};
pub use metrics::{
derive_position_events, r_metrics_from_rs, summarize, summarize_r, PositionAction,
PositionEvent, RMetrics, RunMetricKey, RunMetrics, RANKABLE_METRICS, R_BASED_METRICS,
};
pub use position_management::{
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement,
RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH,
};
pub use scaffold::{
fit_wf_ms_sizes, intersect_shared_window, point_from_params, wf_ms_sizes, WF_REAL_IS_NS,
WF_REAL_OOS_NS, WF_REAL_STEP_NS,
};
pub use sim_broker::SimBroker;
/// The trading instantiation of the engine's metric-generic run record (C28
/// phase 2, #292): source-compatible concrete names for every consumer that
/// deals in backtest metrics.
pub type RunReport = aura_engine::RunReport<RunMetrics>;
pub type SweepPoint = aura_engine::SweepPoint<RunMetrics>;
pub type SweepFamily = aura_engine::SweepFamily<RunMetrics>;
pub type WindowRun = aura_engine::WindowRun<RunMetrics>;
pub type WalkForwardResult = aura_engine::WalkForwardResult<RunMetrics>;
@@ -3,18 +3,25 @@
//! a seed set — each seed a disjoint C1 realization — and collects an
//! [`McFamily`]: the per-seed [`McDraw`]s in seed-input order plus a stored
//! [`McAggregate`] (mean + quantiles of all three run metrics across the
//! realizations). It reuses the disjoint-parallel [`run_indexed`](crate::sweep)
//! realizations). It reuses the disjoint-parallel [`run_indexed`](aura_engine::run_indexed)
//! core `sweep` drives — the varying dimension is the *seed*, not a tuning param.
//! Eager-agnostic (C12/#71): the API takes seeds + a per-draw closure, never a
//! materialized stream `Vec`; the seed -> `Source` construction is a closure-body
//! concern.
//! concern. Moved verbatim from `aura-engine::mc` (#291, C28 phase 2): the
//! engine stays metric-agnostic, so the concrete `RunMetrics` instantiation of
//! `RunReport` lives here, the outer C28 rung.
use crate::sweep::run_indexed;
use crate::{RunMetrics, RunReport, Scalar};
use crate::harness::SplitMix64;
// MetricStats/resample_block/SplitMix64 are foundation-grade (aura-analysis),
// but reached here through aura-engine's re-export rather than a direct
// aura-analysis dependency — the C28 ladder (c28_layering) permits
// aura-backtest -> {aura-core, aura-engine} only, not a same-rung-skipping
// direct edge to the foundation crate.
use aura_core::Scalar;
use aura_engine::{run_indexed, resample_block, MetricStats, SplitMix64};
use crate::{RunMetrics, RunReport};
/// One Monte-Carlo realization: the seed that drove it and the full `RunReport`.
/// Self-describing, analog to [`SweepPoint`](crate::SweepPoint).
/// Self-describing, analog to [`SweepPoint`](aura_engine::SweepPoint).
#[derive(Clone, Debug, PartialEq)]
pub struct McDraw {
pub seed: u64,
@@ -23,7 +30,7 @@ pub struct McDraw {
/// The result family of a Monte-Carlo run — one [`McDraw`] per seed, in
/// seed-**input** order (independent of thread completion), plus the stored
/// [`McAggregate`]. Analog to [`SweepFamily`](crate::SweepFamily).
/// [`McAggregate`]. Analog to [`SweepFamily`](aura_engine::SweepFamily).
#[derive(Clone, Debug, PartialEq)]
pub struct McFamily {
pub draws: Vec<McDraw>,
@@ -41,38 +48,6 @@ pub struct McAggregate {
pub bias_sign_flips: MetricStats,
}
/// Mean + a fixed quantile set of one metric across the realizations. `p50` is
/// the median (the two coincide by definition), so "mean/median/quantiles" is
/// `mean` + `p50` + the surrounding quantiles, with no redundant field.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MetricStats {
pub mean: f64,
pub p5: f64,
pub p25: f64,
pub p50: f64, // == median
pub p75: f64,
pub p95: f64,
}
impl MetricStats {
/// Mean + the fixed type-7 quantile set over a value set. Sorts a copy;
/// `values` must be finite and non-empty. The shared reduction behind both the
/// MC aggregate (a metric across draws) and the walk-forward param-stability
/// summary (a param across windows, [`crate::param_stability`]).
pub fn from_values(values: &[f64]) -> MetricStats {
let mut xs = values.to_vec();
xs.sort_by(|a, b| a.partial_cmp(b).expect("values are finite"));
MetricStats {
mean: xs.iter().sum::<f64>() / xs.len() as f64,
p5: quantile(&xs, 0.05),
p25: quantile(&xs, 0.25),
p50: quantile(&xs, 0.50),
p75: quantile(&xs, 0.75),
p95: quantile(&xs, 0.95),
}
}
}
impl McAggregate {
/// Pure reduction over the realizations — recomputable from `draws` alone (it
/// is exactly what [`monte_carlo`] stored). `draws` must be non-empty (a
@@ -91,23 +66,24 @@ impl McAggregate {
}
}
/// Linear-interpolation quantile (the numpy/Excel "type 7" default) over a
/// pre-sorted, non-empty slice. `p` in `[0, 1]`. `rank = p * (n - 1)`;
/// interpolate between the two bracketing order statistics. `n == 1` returns the
/// sole value.
fn quantile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = p * (n - 1) as f64;
let lo = rank.floor() as usize;
let frac = rank - lo as f64;
if lo + 1 < n {
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
} else {
sorted[n - 1]
}
/// The single assembly path behind both [`monte_carlo`] and the thread-count-
/// explicit test wrapper: run `run_one` over every seed via `run_indexed` (on
/// whichever pool is ambient when called), build the per-seed draws in
/// seed-input order, and reduce them into the aggregate. One source of truth so
/// the two callers can never drift on assembly logic while their pool selection
/// differs.
fn assemble_mc<F>(base_point: &[Scalar], seeds: &[u64], run_one: &F) -> McFamily
where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
{
let reports = run_indexed(seeds.len(), |i| run_one(seeds[i], base_point));
let draws: Vec<McDraw> = seeds
.iter()
.zip(reports)
.map(|(&seed, report)| McDraw { seed, report })
.collect();
let aggregate = McAggregate::from_draws(&draws);
McFamily { draws, aggregate }
}
/// Run `run_one(seed, base_point)` over every seed, disjointly in parallel (C1),
@@ -119,20 +95,23 @@ fn quantile(sorted: &[f64], p: f64) -> f64 {
///
/// Precondition: `seeds` is non-empty (a Monte-Carlo over zero realizations has
/// no defined aggregate). A `debug_assert!` guards it; the `-> McFamily`
/// signature is preserved (no `Result`), matching [`sweep`](crate::sweep).
/// signature is preserved (no `Result`), matching [`sweep`](aura_engine::sweep).
pub fn monte_carlo<F>(base_point: &[Scalar], seeds: &[u64], run_one: F) -> McFamily
where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
monte_carlo_with_threads(base_point, seeds, nthreads, run_one)
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
assemble_mc(base_point, seeds, &run_one)
}
/// The thread-count-explicit core of [`monte_carlo`]. Module-private: the public
/// `monte_carlo` derives the count, while the tests drive it at 1 and at N to pin
/// determinism under concurrency (C1). A thin adapter over
/// [`run_indexed`](crate::sweep): it runs each seed disjointly and zips the
/// reports back onto their seeds in seed-input order.
/// The thread-count-explicit wrapper of [`monte_carlo`]. Module-private: the
/// public `monte_carlo` runs on the ambient pool; the tests drive this at 1 and
/// at N to pin determinism (C1). `assemble_mc` (run_indexed + draw assembly +
/// aggregate) runs inside a local rayon pool of `nthreads` workers, sharing the
/// exact assembly path `monte_carlo` uses — no second copy to drift. Bounds
/// match `monte_carlo` (`F: Sync`): the install closure only borrows `run_one`,
/// never moves it.
#[cfg(test)]
fn monte_carlo_with_threads<F>(
base_point: &[Scalar],
seeds: &[u64],
@@ -143,14 +122,11 @@ where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
{
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
let reports = run_indexed(seeds.len(), nthreads, |i| run_one(seeds[i], base_point));
let draws: Vec<McDraw> = seeds
.iter()
.zip(reports)
.map(|(&seed, report)| McDraw { seed, report })
.collect();
let aggregate = McAggregate::from_draws(&draws);
McFamily { draws, aggregate }
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(nthreads)
.build()
.expect("rayon thread pool");
pool.install(|| assemble_mc(base_point, seeds, &run_one))
}
/// Distribution of `E[R]` under a moving-block bootstrap of an OOS per-trade R series
@@ -166,26 +142,6 @@ pub struct RBootstrap {
pub n_resamples: usize,
}
/// One moving-block resample of `rs` to length `rs.len()` (non-circular; the final
/// block is truncated so the resample has exactly `n` values). `block_len` must be
/// pre-clamped to `[1, rs.len()]` by the caller. Pure given the `rng` state — the
/// shared kernel behind `r_bootstrap` and the trials-deflation reality-check (C1).
pub fn resample_block(rs: &[f64], block_len: usize, rng: &mut SplitMix64) -> Vec<f64> {
let n = rs.len();
debug_assert!(
(1..=n).contains(&block_len),
"resample_block requires block_len in [1, rs.len()] (caller pre-clamps); \
got block_len={block_len}, rs.len()={n}",
);
let mut sample: Vec<f64> = Vec::with_capacity(n);
while sample.len() < n {
let start = (rng.next_u64() % (n - block_len + 1) as u64) as usize;
let take = block_len.min(n - sample.len());
sample.extend_from_slice(&rs[start..start + take]);
}
sample
}
/// Moving-block bootstrap of `rs` (non-circular; the final block of each resample is
/// truncated so the resample has exactly `n` values). `block_len` is clamped to
/// `[1, n]`. Empty `rs` or zero resamples -> an all-zero `RBootstrap`. Pure given
@@ -216,9 +172,83 @@ pub fn r_bootstrap(rs: &[f64], n_resamples: usize, block_len: usize, seed: u64)
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::composite_sma_cross_harness;
use crate::{f64_field, summarize, RunManifest, SyntheticSpec, Timestamp};
use aura_core::Scalar;
use aura_engine::{
f64_field, BlueprintNode, Composite, Edge, OutField, Role, RunManifest, SyntheticSpec,
Target, Timestamp,
};
use aura_core::{Firing, Scalar, ScalarKind};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use crate::{summarize, SimBroker};
use std::sync::mpsc;
// The SMA-cross signal-quality harness fixture, reconstructed here through
// the PUBLIC aura-engine/aura-std/aura-strategy/aura-backtest surface —
// `aura-engine::test_fixtures` is `#[cfg(test)]`-private to the engine crate
// itself and unreachable from here (the same reason `random_sweep_e2e.rs` /
// `list_sweep_e2e.rs` carry their own copy). Bodies verbatim from
// `aura-engine/src/test_fixtures.rs`'s `sma_cross`/`composite_sma_cross_harness`.
/// The SMA-cross signal as a reusable value-empty composite: one input role
/// (price), one output (fast-minus-slow spread). Lengths injected at compile.
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
/// The signal-quality harness as a value-empty composite blueprint with two
/// recording sinks (equity, exposure).
#[allow(clippy::type_complexity)]
fn composite_sma_cross_harness() -> (
Composite,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(sma_cross()),
Bias::builder().named("bias").into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Bias
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
name: "src".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
source: Some(ScalarKind::F64),
}],
vec![], // output: the root ends in sinks
);
(bp, rx_eq, rx_ex)
}
/// Build + bootstrap + run + drain + summarize one (seed, point) into a
/// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `monte_carlo`
@@ -239,6 +269,7 @@ mod tests {
manifest: RunManifest {
commit: "test".to_string(),
params: Vec::new(),
defaults: Vec::new(),
window,
seed,
broker: "test".to_string(),
@@ -266,6 +297,7 @@ mod tests {
manifest: RunManifest {
commit: "t".to_string(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: i as u64,
broker: "t".to_string(),
@@ -349,75 +381,6 @@ mod tests {
assert!((agg.total_pips.p95 - 3.8).abs() < 1e-9, "p95 = {}", agg.total_pips.p95);
}
#[test]
fn quantile_endpoints_and_singleton() {
// singleton returns the sole value; p=0 -> min, p=1 -> max.
assert_eq!(quantile(&[42.0], 0.0), 42.0);
assert_eq!(quantile(&[42.0], 0.5), 42.0);
assert_eq!(quantile(&[42.0], 1.0), 42.0);
let xs = [1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(quantile(&xs, 0.0), 1.0);
assert_eq!(quantile(&xs, 1.0), 5.0);
}
#[test]
fn metric_stats_from_values_matches_known_fixture() {
// type-7 quantile + mean over [0,1,2,3,4], directly on the
// extracted reduction: mean=2.0, p50=2.0, p5≈0.2, p95≈3.8 (same numbers the
// aggregate fixture pins, now on from_values).
let s = MetricStats::from_values(&[0.0, 1.0, 2.0, 3.0, 4.0]);
assert_eq!(s.mean, 2.0);
assert_eq!(s.p50, 2.0);
assert!((s.p5 - 0.2).abs() < 1e-9, "p5 = {}", s.p5);
assert!((s.p95 - 3.8).abs() < 1e-9, "p95 = {}", s.p95);
}
#[test]
fn metric_stats_serde_round_trips() {
// MetricStats gains serde (consistent with the
// report types) so the CLI summary renders it and #70 lineage can persist.
let s = MetricStats::from_values(&[1.0, 2.0, 3.0]);
let json = serde_json::to_string(&s).expect("serialize MetricStats");
let back: MetricStats = serde_json::from_str(&json).expect("deserialize MetricStats");
assert_eq!(back, s);
}
#[test]
fn resample_block_returns_n_values_from_contiguous_runs() {
// The extracted kernel's standalone contract (now a public entry point):
// given a pre-clamped block_len in [1, n], it yields exactly `n` values, each
// appearing in `rs`, arranged as contiguous runs of `rs` (the final run
// truncated to reach exactly n). Distinct powers of ten make membership a sum
// check. n=5, block_len=2 -> runs of length 2, 2, 1.
let rs = [1.0, 10.0, 100.0, 1000.0, 10000.0];
let n = rs.len();
let block_len = 2;
let mut rng = SplitMix64::new(7);
let sample = resample_block(&rs, block_len, &mut rng);
assert_eq!(sample.len(), n, "resample has exactly n values");
assert!(
sample.iter().all(|v| rs.contains(v)),
"every resampled value is drawn from rs",
);
// Reconstruct the contiguous-run structure: walk the sample in blocks of
// `block_len` (last truncated) and assert each block is a contiguous slice of rs.
let mut filled = 0usize;
while filled < n {
let take = block_len.min(n - filled);
let run = &sample[filled..filled + take];
let start = rs
.iter()
.position(|v| v == &run[0])
.expect("run head is in rs");
assert_eq!(
run,
&rs[start..start + take],
"each run is a contiguous slice of rs",
);
filled += take;
}
}
#[test]
fn r_bootstrap_empty_series_is_all_zero() {
let b = r_bootstrap(&[], 100, 1, 7);
File diff suppressed because it is too large Load Diff
@@ -87,7 +87,12 @@ impl PositionManagement {
.collect();
PrimitiveBuilder::new(
"PositionManagement",
NodeSchema { inputs, output, params: vec![] },
NodeSchema {
inputs,
output,
params: vec![],
doc: "turns bias plus protective stop into a managed position in R",
},
|_| Box::new(PositionManagement::new()),
)
}
+166
View File
@@ -0,0 +1,166 @@
//! The pure per-run scaffold: bootstrap-adjacent arithmetic that has no
//! dependency on the CLI shell — reconstructing a bootstrap point from
//! recorded params, sizing the real walk-forward roller, and intersecting
//! per-symbol data windows. Relocated verbatim from `aura-cli::main` (#295)
//! so it is reachable (and unit-testable) without the shell.
use aura_core::{Cell, ParamSpec, Scalar};
/// Reconstruct a member's bootstrap point from its recorded named params — the inverse
/// of `zip_params(space, point)`. Walks the reloaded signal's `param_space` in order
/// (deterministic for the same blueprint) and reads each knob's value from the manifest.
///
/// `Err` carries the exact refusal message a manifest missing a param the reloaded
/// space expects has always produced (corrupted-on-disk data): this crate has no
/// process-exit register of its own (#295 — a pure library function reports a
/// refusal, it does not end the process), so the caller is the one that turns this
/// into `aura:` + exit 1, byte-identical to before.
pub fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Result<Vec<Cell>, String> {
space
.iter()
.map(|ps| {
params
.iter()
.find(|(n, _)| n == &ps.name)
.map(|(_, s)| s.cell())
.ok_or_else(|| format!("manifest is missing param {}", ps.name))
})
.collect()
}
/// Real walk-forward roller sizes (Fork D/F). `WindowRoller` takes sizes in the
/// stream's epoch-unit; for real M1 that is nanoseconds. A classic 3-month
/// in-sample / 1-month out-of-sample / 1-month step (contiguous OOS tiling).
const WF_DAY_NS: i64 = 86_400_000_000_000;
pub const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
pub const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
pub const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
/// The real walk-forward roller sizes in ms (the campaign wf stage works in ms:
/// span `cell.window_ms`, sizes the `StageBlock` `_ms` fields). `WF_REAL_*_NS` are
/// nanoseconds; divide by 1e6. The ms sizes over the doc window reproduce the same
/// calendar windows the inline ns roller produces (anchor-gated).
pub fn wf_ms_sizes() -> (u64, u64, u64) {
(
(WF_REAL_IS_NS / 1_000_000) as u64,
(WF_REAL_OOS_NS / 1_000_000) as u64,
(WF_REAL_STEP_NS / 1_000_000) as u64,
)
}
/// Fit the fixed real-archive walk-forward roller (`wf_ms_sizes`, 90/30/30 days)
/// to a resolved campaign window `(from_ms, to_ms)` (#239). When the fixed
/// IS+OOS span fits inside the window, the sizes come back byte-identical (every
/// existing anchor/e2e over a year-plus window pins this branch). When the window
/// is shorter than IS+OOS, the roller scales DOWN to the window, preserving the
/// fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms` (a window of exactly IS+OOS
/// then yields exactly one roll — never zero). Pure and unit-testable; both
/// `dispatch_walkforward`/`dispatch_mc` consume it in place of the unconditional
/// `wf_ms_sizes()` stamp. The executor's own fit check (`campaign_run.rs`) stays
/// untouched — it still validates an AUTHORED document's declared window as-is.
pub fn fit_wf_ms_sizes(from_ms: i64, to_ms: i64) -> (u64, u64, u64) {
let (is_ms, oos_ms, step_ms) = wf_ms_sizes();
let span_ms = to_ms.saturating_sub(from_ms).max(0) as u64;
if is_ms + oos_ms <= span_ms {
return (is_ms, oos_ms, step_ms);
}
// Preserve the fixed IS:OOS ratio derived from the constants themselves (not a
// bare literal): OOS = span/(ratio+1) (floor), IS = ratio*OOS, so
// IS+OOS = (ratio+1)*OOS <= span always holds, and step == OOS (one roll over
// the fit window, matching the fixed-size branch's `step_ms == oos_ms`).
let ratio = is_ms / oos_ms;
let oos_fit = span_ms / (ratio + 1);
let is_fit = oos_fit * ratio;
(is_fit, oos_fit, oos_fit)
}
type SymbolSpans<'a> = Vec<(&'a str, (i64, i64))>;
/// The pure decision `generalize`'s no-explicit-window fallback reduces to once
/// every listed symbol's full window is resolved (#213): the shared window is
/// the intersection (latest start, earliest end); `Err` carries each symbol
/// paired with its own resolved window when the intersection is empty (the
/// archives never overlap), for the caller's eprintln-then-exit(1) refusal.
/// Kept separate from `dispatch_generalize` so the intersect-or-refuse
/// arithmetic is unit-testable on synthetic windows — real archives on this
/// host never disjoint (every symbol's tail reaches the live present), so the
/// refusal branch has no reachable e2e fixture.
pub fn intersect_shared_window<'a>(
symbols: &'a [String],
windows: &[(i64, i64)],
) -> Result<(i64, i64), SymbolSpans<'a>> {
let shared_from = windows.iter().map(|w| w.0).max().expect("caller passes at least one window");
let shared_to = windows.iter().map(|w| w.1).min().expect("caller passes at least one window");
if shared_from > shared_to {
Err(symbols.iter().map(String::as_str).zip(windows.iter().copied()).collect())
} else {
Ok((shared_from, shared_to))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A single listed symbol degenerates to its own full window unchanged
/// (#213) — the intersection of one window with itself is that window.
#[test]
fn intersect_shared_window_of_a_single_symbol_is_its_own_window() {
let symbols = vec!["GER40".to_string()];
let windows = vec![(100, 200)];
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
}
/// Overlapping multi-symbol windows resolve to the intersection — the
/// latest start, earliest end (#213) — never `symbols[0]`'s own window.
#[test]
fn intersect_shared_window_of_overlapping_windows_is_latest_start_earliest_end() {
let symbols = vec!["AAPL.US".to_string(), "GER40".to_string()];
let windows = vec![(0, 300), (100, 200)];
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
}
/// Disjoint archives (no shared instant across all listed symbols) refuse
/// rather than silently pooling a floor over different periods per
/// instrument (#213); the error names each symbol next to its own span,
/// which the caller eprintln's before exiting 1 — the boundary this
/// covers is otherwise unreachable in an e2e fixture on this host, since
/// every archived symbol's tail reaches the live present (no two windows
/// are ever genuinely disjoint here).
#[test]
fn intersect_shared_window_of_disjoint_windows_refuses_with_every_span() {
let symbols = vec!["A".to_string(), "B".to_string()];
let windows = vec![(0, 100), (200, 300)];
assert_eq!(
intersect_shared_window(&symbols, &windows),
Err(vec![("A", (0, 100)), ("B", (200, 300))]),
);
}
/// Property: a window that already fits the fixed 90/30/30-day roller passes
/// it through byte-identical (the year-plus anchor/e2e grade pins rely on this
/// branch never perturbing the fixed sizes).
#[test]
fn fit_wf_ms_sizes_passes_through_when_the_window_already_fits() {
let day_ms: i64 = 24 * 60 * 60 * 1_000;
let from_ms = 0;
let to_ms = 121 * day_ms; // > 90 + 30 days
assert_eq!(fit_wf_ms_sizes(from_ms, to_ms), wf_ms_sizes());
}
/// Property: a window shorter than IS+OOS scales the roller DOWN to the
/// window, preserving the fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms`
/// (one roll over the fit window) and `is_ms + oos_ms <= span_ms` always.
#[test]
fn fit_wf_ms_sizes_scales_down_to_a_short_window_at_3_to_1() {
let day_ms: i64 = 24 * 60 * 60 * 1_000;
let from_ms = 0;
let to_ms = 30 * day_ms; // far shorter than the fixed 90+30-day roller
let span_ms = (to_ms - from_ms) as u64;
let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms);
assert_eq!(is_ms, oos_ms * 3, "the fit preserves the fixed 3:1 IS:OOS ratio");
assert_eq!(step_ms, oos_ms, "one roll over the fit window: step == oos");
assert!(is_ms + oos_ms <= span_ms, "the fit roller must fit inside the window");
assert!(oos_ms > 0, "a 30-day window must yield a non-degenerate fit");
}
}
@@ -68,6 +68,7 @@ impl SimBroker {
],
output: vec![FieldSpec { name: "equity".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "frictionless sim-optimal broker: integrates held exposure times price return into cumulative pip equity",
},
move |_| Box::new(SimBroker::new(pip_size)),
)
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "aura-bench"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "aura-bench"
path = "src/main.rs"
# Dev-only measurement tool (issue #251): benches the engine / ingest / campaign
# surfaces against committed baselines under baselines/. No production crate
# depends on this crate; nothing here reaches a frozen deploy artifact (C13).
[dependencies]
aura-core = { path = "../aura-core" }
aura-engine = { path = "../aura-engine" }
aura-std = { path = "../aura-std" }
aura-ingest = { path = "../aura-ingest" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# clap: same CLI surface convention as aura-cli (usage errors exit 2).
clap = { version = "4", features = ["derive"] }
# zip: writes the synthetic 48-byte M1 archives the ingest/campaign surfaces
# measure — the same on-disk format the test suites hand-pack as a dev-dep
# (aura-cli/aura-ingest). First runtime (non-dev) use in the workspace, admitted
# under the C16 per-case policy: aura-bench is a dev tool outside every deploy
# path, and zip is already in the build graph via data-server.
zip = "2"
[dev-dependencies]
# Lockstep guard for the campaign fingerprint's stdout-schema coupling: the
# winner_fingerprint test constructs a REAL CampaignRunRecord (aura-registry)
# with a real FamilySelection (aura-analysis) and serializes it, so a field
# rename in those crates fails this crate's tests instead of silently eroding
# fingerprint coverage.
aura-registry = { path = "../aura-registry" }
aura-analysis = { path = "../aura-analysis" }
# the winner_fingerprint lockstep guard's RBootstrap literal (moved from
# aura-engine, #291/#292) — test-only, same coupling-guard rationale as the
# other three dev-deps above.
aura-backtest = { path = "../aura-backtest" }
+47
View File
@@ -0,0 +1,47 @@
# aura-bench
Wall-clock benchmarks with committed baselines (issue #251, phase 1). Catches
hot-path regressions the E2E suite structurally cannot see: every surface pins
its inputs and asserts a determinism fingerprint, so a fast-but-wrong binary
fails the bench instead of pinning a bogus baseline.
## Running
```console
cargo run --release -p aura-bench -- run # measure + compare, report-only
cargo run --release -p aura-bench -- run --surface engine_throughput
cargo run --release -p aura-bench -- pin # rewrite committed baselines
```
Debug builds refuse to measure (exit 2) — wall-clock numbers from a debug
build measure the profile, not the code.
## Surfaces
| surface | workload | metrics |
|---|---|---|
| `engine_throughput` | 10M synthetic bars through an SMA-cross harness (library) | `wall_s`, `bars_per_s` |
| `ingest_throughput` | 24 synthetic archive months x 20 fresh drains via the ingest window API (library) | `wall_s`, `bars_per_s` |
| `campaign_sweep` | 8-cell sweep-only campaign, release `aura` binary on a scratch project | `wall_s`, `cpu_percent`, `peak_rss_mb` |
| `campaign_heavy` | same cells; sweep → gate → walk-forward → 1000-resample bootstrap | `wall_s`, `cpu_percent`, `peak_rss_mb` |
| `cli_fixed_cost` | spawn floor (`aura --help`) + minimal data-only project run | `help_ms`, `run_ms` |
## Discipline
- Quiet box: the driver warns at 1-min loadavg > 2.0. Close heavy jobs first.
- 1 discarded warmup + 3 measured reps (median). Reps must agree on the
fingerprint — cross-rep drift is reported as nondeterminism, not averaged.
- Baselines are per-host context (`host` block inside each JSON): comparison on
a different host prints an informational banner and stays report-only.
- Timing drift NEVER fails the run (report-only phase, #251 cadence decision);
a NOTICE line flags drift >= 10%. A fingerprint mismatch exits 1.
- Re-pin (`pin`) deliberately, after an accepted behaviour or performance
change; the baseline diff is reviewed like any other commit.
## Deferred (follow-up on the tracker)
- Instruction-count backend (iai-callgrind) once valgrind is installed on the
measuring host — slots in as a sibling backend without reshaping baselines.
- Native-crate project-load sub-cost (dylib SHA-256) — needs a build step in
the scratch project.
- Thresholded failure, once recorded variance data justifies thresholds.
@@ -0,0 +1,17 @@
{
"surface": "campaign_heavy",
"metrics": {
"cpu_percent": 2152.0,
"peak_rss_mb": 101.3828125,
"wall_s": 5.572580056
},
"fingerprint": "cells=8 c0s0w17 c0s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c0s3b2883 c1s0w17 c1s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c1s3b2883 c2s0w17 c2s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c2s3b2883 c3s0w17 c3s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c3s3b2883 c4s0w17 c4s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c4s3b2883 c5s0w17 c5s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c5s3b2883 c6s0w17 c6s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c6s3b2883 c7s0w17 c7s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c7s3b2883",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -0,0 +1,17 @@
{
"surface": "campaign_sweep",
"metrics": {
"cpu_percent": 2190.0,
"peak_rss_mb": 92.203125,
"wall_s": 1.394470036
},
"fingerprint": "cells=8 c0s0w17 c1s0w17 c2s0w17 c3s0w17 c4s0w17 c5s0w17 c6s0w17 c7s0w17",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -0,0 +1,16 @@
{
"surface": "cli_fixed_cost",
"metrics": {
"help_ms": 1.5046899999999999,
"run_ms": 3.63305
},
"fingerprint": "run_line_fnv=6bb0d796f760d140",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -0,0 +1,16 @@
{
"surface": "engine_throughput",
"metrics": {
"bars_per_s": 14005588.039151624,
"wall_s": 0.714000724
},
"fingerprint": "last=0.417351 max_dd=8.274577 flips=265308 n=10000000",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -0,0 +1,16 @@
{
"surface": "ingest_throughput",
"metrics": {
"bars_per_s": 13000457.745164338,
"wall_s": 0.386201786
},
"fingerprint": "n=251040 xor=0019885debd3bd5e",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "9c7f60b",
"date": "2026-07-23"
}
+164
View File
@@ -0,0 +1,164 @@
//! Baseline documents and the report-only comparison core. Pure functions —
//! everything here is unit-tested without running a single benchmark.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// One committed baseline: `baselines/<surface>.json`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BaselineDoc {
pub surface: String,
/// Flat metric map, e.g. `bars_per_s`, `wall_s`, `peak_rss_mb`.
pub metrics: BTreeMap<String, f64>,
/// Opaque determinism fingerprint; compared for byte equality.
pub fingerprint: String,
pub reps: u32,
pub host: HostInfo,
pub profile: String,
pub commit: String,
pub date: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HostInfo {
pub hostname: String,
pub nproc: u32,
}
/// One measurement result, produced by a surface run.
#[derive(Clone, Debug, PartialEq)]
pub struct Measured {
pub surface: String,
pub metrics: BTreeMap<String, f64>,
pub fingerprint: String,
}
/// Timing drift at or beyond this fraction is reported as a NOTICE line.
pub const NOTICE_THRESHOLD: f64 = 0.10;
#[derive(Clone, Debug, PartialEq)]
pub struct Compared {
/// Per-metric relative delta, `(name, (measured - baseline) / baseline)`.
/// Metrics missing on either side are skipped.
pub deltas: Vec<(String, f64)>,
/// Metric names whose |delta| >= NOTICE_THRESHOLD.
pub notices: Vec<String>,
pub fingerprint_changed: bool,
pub host_mismatch: bool,
}
pub fn compare(baseline: &BaselineDoc, measured: &Measured, host: &HostInfo) -> Compared {
let mut deltas = Vec::new();
let mut notices = Vec::new();
for (name, base) in &baseline.metrics {
let Some(now) = measured.metrics.get(name) else { continue };
if *base == 0.0 {
continue; // no meaningful relative delta
}
let d = (now - base) / base;
if d.abs() >= NOTICE_THRESHOLD {
notices.push(name.clone());
}
deltas.push((name.clone(), d));
}
Compared {
deltas,
notices,
fingerprint_changed: baseline.fingerprint != measured.fingerprint,
host_mismatch: baseline.host != *host,
}
}
/// The process exit code for a full run: 1 if ANY surface's fingerprint
/// changed, else 0. (Infra errors exit 2 before this is ever consulted;
/// timing drift is report-only by design — the #251 cadence decision.)
pub fn exit_code(compared: &[Option<Compared>]) -> i32 {
if compared.iter().flatten().any(|c| c.fingerprint_changed) { 1 } else { 0 }
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(metrics: &[(&str, f64)], fp: &str) -> BaselineDoc {
BaselineDoc {
surface: "s".into(),
metrics: metrics.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
fingerprint: fp.into(),
reps: 3,
host: HostInfo { hostname: "h".into(), nproc: 24 },
profile: "release".into(),
commit: "abc".into(),
date: "2026-07-17".into(),
}
}
fn measured(metrics: &[(&str, f64)], fp: &str) -> Measured {
Measured {
surface: "s".into(),
metrics: metrics.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
fingerprint: fp.into(),
}
}
fn host() -> HostInfo {
HostInfo { hostname: "h".into(), nproc: 24 }
}
#[test]
fn compare_reports_relative_drift_per_metric() {
let c = compare(&doc(&[("wall_s", 10.0)], "f"), &measured(&[("wall_s", 10.5)], "f"), &host());
assert_eq!(c.deltas.len(), 1);
assert_eq!(c.deltas[0].0, "wall_s");
assert!((c.deltas[0].1 - 0.05).abs() < 1e-12, "5% drift, got {}", c.deltas[0].1);
assert!(c.notices.is_empty(), "5% is below the 10% notice bar");
assert!(!c.fingerprint_changed);
assert!(!c.host_mismatch);
}
#[test]
fn compare_flags_notice_at_ten_percent() {
let c = compare(&doc(&[("wall_s", 10.0)], "f"), &measured(&[("wall_s", 11.0)], "f"), &host());
assert_eq!(c.notices, vec!["wall_s".to_string()]);
}
#[test]
fn compare_detects_fingerprint_change() {
let c = compare(&doc(&[], "old"), &measured(&[], "new"), &host());
assert!(c.fingerprint_changed);
}
#[test]
fn compare_flags_host_mismatch_informationally() {
let other = HostInfo { hostname: "elsewhere".into(), nproc: 8 };
let c = compare(&doc(&[], "f"), &measured(&[], "f"), &other);
assert!(c.host_mismatch);
assert!(!c.fingerprint_changed);
}
#[test]
fn compare_skips_metrics_missing_on_either_side() {
let c = compare(
&doc(&[("only_base", 1.0)], "f"),
&measured(&[("only_measured", 2.0)], "f"),
&host(),
);
assert!(c.deltas.is_empty());
}
#[test]
fn exit_code_is_one_only_on_fingerprint_change() {
let clean = compare(&doc(&[], "f"), &measured(&[], "f"), &host());
let changed = compare(&doc(&[], "f"), &measured(&[], "g"), &host());
assert_eq!(exit_code(&[Some(clean.clone()), None]), 0);
assert_eq!(exit_code(&[Some(clean), Some(changed)]), 1);
}
#[test]
fn baseline_doc_round_trips_through_json() {
let d = doc(&[("bars_per_s", 812345.0)], "last=0.1 n=5");
let json = serde_json::to_string_pretty(&d).expect("serialize");
let back: BaselineDoc = serde_json::from_str(&json).expect("parse");
assert_eq!(d, back);
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Child-process measurement: resolve the release `aura` binary via cargo's
//! JSON messages, run it under `/usr/bin/time -v`, and parse wall/CPU/RSS
//! from the stderr report. `/usr/bin/time` missing is an infra error — the
//! campaign surfaces have no fallback RSS probe by design (one tool, one
//! parse, the practice proven in the #277 measurement session).
use std::path::{Path, PathBuf};
use std::time::Instant;
/// Workspace root: two levels above this crate's manifest dir.
pub fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("crates/aura-bench sits two levels under the workspace root")
.to_path_buf()
}
/// Build (if needed) and locate the release `aura` binary. Unmeasured setup.
pub fn resolve_aura_bin(ws_root: &Path) -> Result<PathBuf, String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let out = std::process::Command::new(cargo)
.args(["build", "--release", "-p", "aura-cli", "--message-format=json"])
.current_dir(ws_root)
.output()
.map_err(|e| format!("spawn cargo build: {e}"))?;
if !out.status.success() {
return Err(format!(
"cargo build --release -p aura-cli failed:\n{}",
String::from_utf8_lossy(&out.stderr)
));
}
parse_aura_executable(&String::from_utf8_lossy(&out.stdout))
.ok_or_else(|| "no compiler-artifact message named `aura` with an executable".to_string())
}
/// Extract the `aura` executable path from `--message-format=json` lines.
pub fn parse_aura_executable(stdout: &str) -> Option<PathBuf> {
for line in stdout.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
if v["reason"] == "compiler-artifact"
&& v["target"]["name"] == "aura"
&& v["executable"].is_string()
{
return Some(PathBuf::from(v["executable"].as_str().expect("checked string")));
}
}
None
}
pub struct TimedChild {
pub wall_s: f64,
pub cpu_percent: Option<f64>,
pub peak_rss_mb: Option<f64>,
pub stdout: String,
pub stderr: String,
pub exit: Option<i32>,
}
/// Run `argv` in `cwd` under `/usr/bin/time -v`, measuring wall in-process.
pub fn run_timed(bin: &Path, args: &[&str], cwd: &Path) -> Result<TimedChild, String> {
if !Path::new("/usr/bin/time").exists() {
return Err("/usr/bin/time is required for child-process surfaces".to_string());
}
let t = Instant::now();
let out = std::process::Command::new("/usr/bin/time")
.arg("-v")
.arg(bin)
.args(args)
.current_dir(cwd)
.output()
.map_err(|e| format!("spawn {}: {e}", bin.display()))?;
let wall_s = t.elapsed().as_secs_f64();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let (cpu_percent, peak_rss_mb) = parse_time_v(&stderr);
Ok(TimedChild {
wall_s,
cpu_percent,
peak_rss_mb,
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr,
exit: out.status.code(),
})
}
/// Parse `Percent of CPU this job got` and `Maximum resident set size` out of
/// a `/usr/bin/time -v` stderr report.
pub fn parse_time_v(stderr: &str) -> (Option<f64>, Option<f64>) {
let mut cpu = None;
let mut rss = None;
for line in stderr.lines() {
let line = line.trim();
if let Some(v) = line.strip_prefix("Percent of CPU this job got: ") {
cpu = v.trim_end_matches('%').parse::<f64>().ok();
} else if let Some(v) = line.strip_prefix("Maximum resident set size (kbytes): ") {
rss = v.parse::<f64>().ok().map(|kb| kb / 1024.0);
}
}
(cpu, rss)
}
#[cfg(test)]
mod tests {
use super::*;
/// `resolve_aura_bin` hands its `cargo build --release -p aura-cli`
/// invocation a `current_dir`, and that invocation only produces the
/// right binary if `workspace_root` actually resolves to the real
/// workspace root — the `.ancestors().nth(2)` computation is a
/// hardcoded distance that would silently point at the wrong directory
/// (and build nothing, or the wrong crate graph) if `aura-bench` were
/// ever relocated a level deeper or shallower. Pins that it lands on
/// the directory holding the actual workspace manifest, not merely
/// some ancestor.
#[test]
fn workspace_root_resolves_the_real_cargo_workspace() {
let root = workspace_root();
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap_or_else(|e| {
panic!("workspace_root ({}) must contain Cargo.toml: {e}", root.display())
});
assert!(manifest.contains("[workspace]"), "not the workspace root: {}", root.display());
}
#[test]
fn parse_time_v_extracts_cpu_and_rss() {
let s = "\tCommand being timed: \"aura --help\"\n\
\tPercent of CPU this job got: 2270%\n\
\tMaximum resident set size (kbytes): 1048576\n";
let (cpu, rss) = parse_time_v(s);
assert_eq!(cpu, Some(2270.0));
assert_eq!(rss, Some(1024.0));
}
#[test]
fn parse_aura_executable_reads_compiler_artifact_lines() {
let lines = concat!(
"{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"aura-bench\"},\"executable\":\"/x/aura-bench\"}\n",
"{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"aura\"},\"executable\":\"/x/release/aura\"}\n",
"{\"reason\":\"build-finished\",\"success\":true}\n",
);
assert_eq!(parse_aura_executable(lines), Some(PathBuf::from("/x/release/aura")));
}
#[test]
fn parse_aura_executable_ignores_null_executables() {
let lines = "{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"aura\"},\"executable\":null}\n";
assert_eq!(parse_aura_executable(lines), None);
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Host context probes. All best-effort: a probe that fails degrades to a
//! placeholder value and never fails a bench (the report stays honest via the
//! host block committed inside each baseline).
use crate::baseline::HostInfo;
pub fn host_info() -> HostInfo {
let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let nproc = std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(0);
HostInfo { hostname, nproc }
}
/// 1-minute loadavg, or `None` when unreadable.
pub fn loadavg_1m() -> Option<f64> {
let text = std::fs::read_to_string("/proc/loadavg").ok()?;
text.split_whitespace().next()?.parse().ok()
}
/// Short commit of the enclosing worktree, best-effort.
pub fn git_commit() -> String {
std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
/// UTC date `YYYY-MM-DD`, best-effort via `date -u`.
pub fn today() -> String {
std::process::Command::new("date")
.args(["-u", "+%F"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_info_reads_something() {
let h = host_info();
assert!(!h.hostname.is_empty());
}
/// #251: every committed baseline records `commit` for provenance (a
/// baseline that can't be tied to the code it measured is useless for
/// drift comparison). This test runs inside a real git worktree, so
/// `git_commit` must shell out and parse successfully — falling silently
/// back to `"unknown"` here would be exactly the failure mode the
/// best-effort fallback exists to hide, and this pins the happy path
/// rather than only the "never panics" degenerate case.
#[test]
fn git_commit_reports_the_real_short_hash_inside_a_git_worktree() {
let commit = git_commit();
assert_ne!(commit, "unknown", "must read the real worktree HEAD, not fall back silently");
assert!(
commit.len() >= 7 && commit.chars().all(|c| c.is_ascii_hexdigit()),
"not a short hex hash: {commit:?}"
);
}
}
+462
View File
@@ -0,0 +1,462 @@
//! `aura-bench` — the wall-clock benchmark harness with committed baselines
//! (issue #251, phase 1). Report-only: timing drift never fails; a determinism
//! fingerprint mismatch does (exit 1), so a fast-but-wrong binary fails the
//! bench instead of pinning a bogus baseline. Infra errors exit 2.
mod baseline;
mod child;
mod host;
mod surfaces;
mod synth;
use baseline::{compare, exit_code, BaselineDoc, Compared, Measured};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use surfaces::Sizing;
const SURFACES: &[&str] =
&["engine_throughput", "ingest_throughput", "campaign_sweep", "campaign_heavy", "cli_fixed_cost"];
/// The in-process surfaces — selecting only these never resolves (or builds)
/// the release `aura` binary. Keep in sync with `measure_surface`.
const LIBRARY_SURFACES: &[&str] = &["engine_throughput", "ingest_throughput"];
const DEFAULT_REPS: u32 = 3;
/// 1-min loadavg above which the banner warns the box is not quiet.
const LOAD_WARN_1M: f64 = 2.0;
#[derive(Parser)]
#[command(name = "aura-bench", about = "wall-clock benchmarks against committed baselines (#251)")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Measure surfaces and compare against committed baselines (report-only).
Run {
/// Only these surfaces (repeatable). Default: all.
#[arg(long)]
surface: Vec<String>,
/// Tiny workloads, results written to --out, committed baselines untouched.
#[arg(long)]
quick: bool,
/// Measured repetitions per surface (after 1 discarded warmup).
#[arg(long, default_value_t = DEFAULT_REPS)]
reps: u32,
/// Write measured results as JSON files into this directory.
#[arg(long)]
out: Option<PathBuf>,
},
/// Measure surfaces and (re)write the committed baselines.
Pin {
/// Only these surfaces (repeatable). Default: all.
#[arg(long)]
surface: Vec<String>,
/// Measured repetitions per surface (after 1 discarded warmup).
#[arg(long, default_value_t = DEFAULT_REPS)]
reps: u32,
},
}
fn baselines_dir(ws_root: &Path) -> PathBuf {
ws_root.join("crates/aura-bench/baselines")
}
fn measure_surface(
name: &str,
sizing: Sizing,
reps: u32,
aura_bin: &Path,
) -> Result<Measured, String> {
match name {
"engine_throughput" => surfaces::run_reps(name, reps, || surfaces::engine::rep(sizing)),
"ingest_throughput" => {
let fixture = surfaces::ingest::setup(sizing)?;
surfaces::run_reps(name, reps, || surfaces::ingest::rep(&fixture))
}
"campaign_sweep" => {
surfaces::run_reps(name, reps, || surfaces::campaign::sweep_rep(aura_bin, sizing))
}
"campaign_heavy" => {
surfaces::run_reps(name, reps, || surfaces::campaign::heavy_rep(aura_bin, sizing))
}
"cli_fixed_cost" => surfaces::run_reps(name, reps, || surfaces::fixed_cost::rep(aura_bin)),
other => Err(format!("unknown surface: {other}")),
}
}
fn selected(filter: &[String]) -> Result<Vec<&'static str>, String> {
if filter.is_empty() {
return Ok(SURFACES.to_vec());
}
let mut out = Vec::new();
for f in filter {
let s = SURFACES
.iter()
.find(|s| **s == f.as_str())
.ok_or_else(|| format!("unknown surface {f:?}; known: {SURFACES:?}"))?;
out.push(*s);
}
Ok(out)
}
fn fmt_metric(name: &str, v: f64) -> String {
if name.ends_with("_ms") {
format!("{name} {v:.1}ms")
} else if name == "bars_per_s" {
format!("{name} {v:.0}")
} else if name == "wall_s" {
format!("{name} {v:.3}s")
} else {
format!("{name} {v:.1}")
}
}
fn report_line(m: &Measured, c: Option<&Compared>) -> String {
let metrics = m
.metrics
.iter()
.map(|(k, v)| fmt_metric(k, *v))
.collect::<Vec<_>>()
.join(" ");
let verdict = match c {
None => "no baseline (pin to create)".to_string(),
Some(c) if c.fingerprint_changed => "fingerprint CHANGED".to_string(),
Some(c) => {
let drift = c
.deltas
.iter()
.map(|(k, d)| format!("{k} {:+.1}%", d * 100.0))
.collect::<Vec<_>>()
.join(" ");
let notice = if c.notices.is_empty() { "" } else { " NOTICE" };
format!("Δ {drift}{notice} fingerprint OK")
}
};
format!("{:<20} {metrics} {verdict}", m.surface)
}
fn load_baseline(dir: &Path, surface: &str) -> Option<BaselineDoc> {
let text = std::fs::read_to_string(dir.join(format!("{surface}.json"))).ok()?;
serde_json::from_str(&text).ok()
}
/// One `Measured` result plus a host + rep count becomes a `BaselineDoc`,
/// whether it's about to be committed (`pin`) or dropped into `--out` (`run`).
fn to_baseline_doc(m: &Measured, reps: u32, host: &baseline::HostInfo) -> BaselineDoc {
BaselineDoc {
surface: m.surface.clone(),
metrics: m.metrics.clone(),
fingerprint: m.fingerprint.clone(),
reps,
host: host.clone(),
profile: "release".to_string(),
commit: host::git_commit(),
date: host::today(),
}
}
fn main() {
if cfg!(debug_assertions) {
eprintln!("aura-bench measures nothing in a debug build; run via `cargo run --release -p aura-bench`");
std::process::exit(2);
}
let cli = Cli::parse();
let ws_root = child::workspace_root();
let code = match cli.cmd {
Cmd::Run { surface, quick, reps, out } => cmd_run(&ws_root, &surface, quick, reps, out),
Cmd::Pin { surface, reps } => cmd_pin(&ws_root, &surface, reps),
};
std::process::exit(code);
}
fn prepare(ws_root: &Path, filter: &[String]) -> Result<(Vec<&'static str>, PathBuf), String> {
let names = selected(filter)?;
let needs_bin = names.iter().any(|n| !LIBRARY_SURFACES.contains(n));
let bin = if needs_bin {
child::resolve_aura_bin(ws_root)?
} else {
PathBuf::from("aura-unused")
};
Ok((names, bin))
}
fn banner(quick: bool) {
let h = host::host_info();
let load = host::loadavg_1m().map(|l| format!("{l:.1}")).unwrap_or_else(|| "?".to_string());
let mode = if quick { " QUICK (mechanics only)" } else { "" };
println!("aura-bench: host {} ({} cores), load {load}{mode}", h.hostname, h.nproc);
if let Some(l) = host::loadavg_1m()
&& l > LOAD_WARN_1M
{
println!("WARNING: loadavg {l:.1} > {LOAD_WARN_1M} — measurements belong on a quiet box");
}
}
fn cmd_run(
ws_root: &Path,
filter: &[String],
quick: bool,
reps: u32,
out: Option<PathBuf>,
) -> i32 {
let (names, bin) = match prepare(ws_root, filter) {
Ok(v) => v,
Err(e) => return infra(&e),
};
banner(quick);
let sizing = Sizing { quick };
let reps = if quick { 1 } else { reps };
let dir = baselines_dir(ws_root);
let this_host = host::host_info();
let mut compared: Vec<Option<Compared>> = Vec::new();
let mut host_mismatch = false;
for name in &names {
let m = match measure_surface(name, sizing, reps, &bin) {
Ok(m) => m,
Err(e) => return infra(&format!("surface {name}: {e}")),
};
let b = if quick { None } else { load_baseline(&dir, name) };
let c = b.as_ref().map(|b| {
let c = compare(b, &m, &this_host);
host_mismatch |= c.host_mismatch;
c
});
println!("{}", report_line(&m, c.as_ref()));
if let (Some(b), Some(c)) = (&b, &c)
&& c.fingerprint_changed
{
println!(" baseline: {}", b.fingerprint);
println!(" measured: {}", m.fingerprint);
println!(
" The workload computed different results. If a behaviour change is\n \
intended, re-pin (aura-bench pin); otherwise this is a correctness bug."
);
}
if let Some(out_dir) = &out
&& let Err(e) = write_result(out_dir, &m, reps, &this_host)
{
return infra(&e);
}
compared.push(c);
}
if host_mismatch {
println!("HOST MISMATCH (informational): baselines were pinned on a different host");
}
let notices: usize = compared.iter().flatten().map(|c| c.notices.len()).sum();
if notices > 0 {
println!(
"{notices} NOTICE(s) (drift >= 10%) — report-only, nothing failed. Re-pin: aura-bench pin"
);
}
exit_code(&compared)
}
fn cmd_pin(ws_root: &Path, filter: &[String], reps: u32) -> i32 {
let (names, bin) = match prepare(ws_root, filter) {
Ok(v) => v,
Err(e) => return infra(&e),
};
banner(false);
let dir = baselines_dir(ws_root);
if let Err(e) = std::fs::create_dir_all(&dir) {
return infra(&format!("create baselines dir: {e}"));
}
let this_host = host::host_info();
for name in &names {
let m = match measure_surface(name, Sizing { quick: false }, reps, &bin) {
Ok(m) => m,
Err(e) => return infra(&format!("surface {name}: {e}")),
};
let doc = to_baseline_doc(&m, reps, &this_host);
let path = dir.join(format!("{name}.json"));
let json = serde_json::to_string_pretty(&doc).expect("baseline serializes");
if let Err(e) = std::fs::write(&path, json + "\n") {
return infra(&format!("write {}: {e}", path.display()));
}
println!("pinned {name} -> {}", path.display());
}
0
}
fn write_result(out_dir: &Path, m: &Measured, reps: u32, h: &baseline::HostInfo) -> Result<(), String> {
std::fs::create_dir_all(out_dir).map_err(|e| format!("create out dir: {e}"))?;
let doc = to_baseline_doc(m, reps, h);
let json = serde_json::to_string_pretty(&doc).expect("result serializes");
std::fs::write(out_dir.join(format!("{}.json", m.surface)), json + "\n")
.map_err(|e| format!("write result: {e}"))
}
fn infra(msg: &str) -> i32 {
eprintln!("aura-bench: infra error: {msg}");
2
}
#[cfg(test)]
mod tests {
use super::*;
fn measured(surface: &str, metrics: &[(&str, f64)], fp: &str) -> Measured {
Measured {
surface: surface.to_string(),
metrics: metrics.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
fingerprint: fp.to_string(),
}
}
/// selected() with no filter runs every registered surface, in registration order.
#[test]
fn selected_empty_filter_returns_all_surfaces() {
assert_eq!(selected(&[]).unwrap(), SURFACES.to_vec());
}
/// selected() with a known name narrows to exactly that surface.
#[test]
fn selected_known_name_returns_that_surface() {
assert_eq!(selected(&["engine_throughput".to_string()]).unwrap(), vec!["engine_throughput"]);
}
/// selected() rejects an unknown surface name instead of silently dropping it,
/// so a `--surface` typo fails loudly rather than measuring nothing.
#[test]
fn selected_unknown_name_is_rejected() {
let err = selected(&["not_a_surface".to_string()]).unwrap_err();
assert!(err.contains("not_a_surface"), "error should name the bad surface: {err}");
}
/// fmt_metric renders *_ms metrics with millisecond precision and unit suffix.
#[test]
fn fmt_metric_formats_ms_suffixed_metrics() {
assert_eq!(fmt_metric("help_ms", 12.345), "help_ms 12.3ms");
}
/// fmt_metric renders bars_per_s as a rounded integer (throughput, not fractional).
#[test]
fn fmt_metric_formats_bars_per_s_without_decimals() {
assert_eq!(fmt_metric("bars_per_s", 812345.6), "bars_per_s 812346");
}
/// fmt_metric renders wall_s with millisecond-precision seconds.
#[test]
fn fmt_metric_formats_wall_s_with_millis_precision() {
assert_eq!(fmt_metric("wall_s", 1.23456), "wall_s 1.235s");
}
/// fmt_metric falls back to 1-decimal generic formatting for any other metric name.
#[test]
fn fmt_metric_falls_back_to_generic_formatting() {
assert_eq!(fmt_metric("peak_rss_mb", 42.16), "peak_rss_mb 42.2");
}
/// report_line without a baseline tells the operator to pin one, rather than
/// implying a comparison happened.
#[test]
fn report_line_without_baseline_prompts_to_pin() {
let m = measured("s", &[("wall_s", 1.0)], "fp");
let line = report_line(&m, None);
assert!(line.contains("no baseline (pin to create)"), "{line}");
}
/// report_line surfaces a fingerprint mismatch distinctly from ordinary timing
/// drift — this is the correctness-vs-performance line the whole harness rests on.
#[test]
fn report_line_flags_fingerprint_change() {
let m = measured("s", &[("wall_s", 1.0)], "new");
let c = Compared { deltas: vec![], notices: vec![], fingerprint_changed: true, host_mismatch: false };
let line = report_line(&m, Some(&c));
assert!(line.contains("fingerprint CHANGED"), "{line}");
}
/// report_line prints per-metric drift percentages and an OK fingerprint when
/// nothing changed.
#[test]
fn report_line_reports_drift_and_fingerprint_ok() {
let m = measured("s", &[("wall_s", 1.0)], "fp");
let c = Compared {
deltas: vec![("wall_s".to_string(), 0.05)],
notices: vec![],
fingerprint_changed: false,
host_mismatch: false,
};
let line = report_line(&m, Some(&c));
assert!(line.contains("wall_s +5.0%"), "{line}");
assert!(line.contains("fingerprint OK"), "{line}");
}
/// `prepare` only resolves the real release `aura` binary when a selected
/// surface actually spawns a child process; a library-only selection
/// (`engine_throughput`/`ingest_throughput`) must never touch the
/// filesystem to build or locate it. Protects the fast-iteration path:
/// a regression here would force a multi-second `cargo build --release`
/// onto every `aura-bench run --surface engine_throughput`, defeating
/// the point of the in-process surfaces. Proven by using a nonexistent
/// `ws_root` — `resolve_aura_bin` would error immediately if `prepare`
/// ever called it for this filter.
/// Pins the hand-synced `LIBRARY_SURFACES` list against `measure_surface`
/// itself: every listed name must be a registered surface AND actually
/// measurable without a real binary path (quick mode, 1 rep). A surface
/// added to the list without being binary-free fails here — the silent
/// failure mode would otherwise be a needless release build on every
/// library-only invocation.
#[test]
fn library_surfaces_measure_without_the_binary() {
for name in LIBRARY_SURFACES {
assert!(SURFACES.contains(name), "{name} must be a registered surface");
let fake_bin = Path::new("/nonexistent-aura-binary-for-library-surface-test");
measure_surface(name, Sizing { quick: true }, 1, fake_bin)
.unwrap_or_else(|e| panic!("library surface {name} must run without the binary: {e}"));
}
}
#[test]
fn prepare_skips_binary_resolution_for_library_only_surfaces() {
let fake_root = Path::new("/nonexistent-ws-root-for-aura-bench-test");
let filter = vec!["engine_throughput".to_string(), "ingest_throughput".to_string()];
let (names, bin) = prepare(fake_root, &filter).expect("must not try to resolve a binary");
assert_eq!(names, vec!["engine_throughput", "ingest_throughput"]);
assert_eq!(bin, PathBuf::from("aura-unused"));
}
/// `write_result`'s `--out` artifact is schema-identical to a committed
/// baseline (round-trips through `BaselineDoc`) — the property that makes
/// a `--quick --out` result directly promotable/comparable, never a
/// shadow format that silently drifts from what `pin` writes.
#[test]
fn write_result_round_trips_measured_into_baseline_shaped_json() {
let scratch = crate::synth::ScratchDir::new("write-result-test").expect("scratch dir");
let m = measured("cli_fixed_cost", &[("help_ms", 1.5), ("run_ms", 3.0)], "run_line_fnv=abc");
let host = baseline::HostInfo { hostname: "h".to_string(), nproc: 8 };
write_result(&scratch.0, &m, 2, &host).expect("write_result must succeed");
let doc = load_baseline(&scratch.0, "cli_fixed_cost")
.expect("write_result's file must be a loadable BaselineDoc");
assert_eq!(doc.surface, "cli_fixed_cost");
assert_eq!(doc.metrics, m.metrics);
assert_eq!(doc.fingerprint, "run_line_fnv=abc");
assert_eq!(doc.reps, 2);
assert_eq!(doc.profile, "release");
}
/// The full CLI driver glue's core promise: `pin` then `run` against the
/// baseline it just wrote reports zero fingerprint drift — a scratch
/// `ws_root` keeps this fully isolated from the real committed
/// `baselines/` directory (never touches it). If `cmd_pin` and `cmd_run`
/// ever disagreed on what a surface measures (e.g. different `Sizing`,
/// different metric keys), a fresh `pin` would immediately fail its own
/// `run` — exactly the workflow the README documents ("re-pin, the
/// baseline diff is reviewed like any other commit").
///
/// Ignored in the default suite: it runs the engine surface at full size
/// (1,000,000 bars, twice) in an unoptimized `dev`-profile test binary —
/// the suite-wallclock discipline reserves full-size runs for release
/// builds. Run it explicitly: `cargo test -p aura-bench -- --ignored`.
#[test]
#[ignore = "full-size (1M bar) engine surface in a dev-profile build; run via -- --ignored"]
fn pin_then_run_round_trip_reports_zero_drift() {
let scratch = crate::synth::ScratchDir::new("pin-run-roundtrip").expect("scratch dir");
let filter = vec!["engine_throughput".to_string()];
let pin_code = cmd_pin(&scratch.0, &filter, 1);
assert_eq!(pin_code, 0, "pin must succeed against an isolated scratch ws_root");
let run_code = cmd_run(&scratch.0, &filter, false, 1, None);
assert_eq!(run_code, 0, "running right after pinning must report zero fingerprint drift");
}
}
+457
View File
@@ -0,0 +1,457 @@
//! The two campaign surfaces: `campaign_sweep` (narrow per-cell fan-out — a
//! sweep-only process) and `campaign_heavy` (wide fan-out — sweep → gate →
//! walk-forward → bootstrap), both run as the release `aura` binary on a
//! scratch data-only project over synthetic archives. Fingerprint: the winner
//! ordinals extracted from the always-on final `{"campaign_run":...}` stdout
//! line. One campaign shape misreads regressions in the other (#251), so both
//! are pinned.
use super::{RepOutcome, Sizing};
use crate::child::{run_timed, TimedChild};
use crate::synth::{write_symbol_archive, ScratchDir};
use std::collections::BTreeMap;
use std::path::Path;
pub const FULL_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB", "BSYMC", "BSYMD"];
pub const QUICK_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB"];
/// Full-size span: 24 archive months with a ~22-month campaign window
/// (2024-02-01 .. 2025-11-30 UTC), sized so the measured child runs land in
/// whole seconds — sub-second walls put ordinary spawn/scheduler jitter within
/// reach of the 10% NOTICE threshold and make the report flap.
pub const ARCHIVE_MONTHS_FULL: ((i32, u32), (i32, u32)) = ((2024, 1), (2025, 12));
pub const WINDOW_MS_FULL: (i64, i64) = (1706745600000, 1764547199999);
/// Quick span: the green E2E fixture's shape (archive 2024-01..08, window
/// Mar-01..Jun-30 — ~17 weeks gives the (14d, 7d, 7d) roller a comfortable
/// tiling, research_docs.rs:2440-2441).
pub const ARCHIVE_MONTHS_QUICK: ((i32, u32), (i32, u32)) = ((2024, 1), (2024, 8));
pub const WINDOW_MS_QUICK: (i64, i64) = (1709251200000, 1719791999999);
pub const SWEEP_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "bench-sweep-only",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }
]
}"#;
/// The heavy shape: the E2E walk-forward process with the bootstrap sized up
/// (1000 resamples — the #277 measured workload's count).
pub const HEAVY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "bench-heavy",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
{ "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 }
]
}"#;
pub const HEAVY_PROCESS_DOC_QUICK: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "bench-heavy",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
{ "block": "std::monte_carlo", "resamples": 50, "block_len": 5 }
]
}"#;
/// The two SMA strategy blueprints: byte-identical to the shipped
/// `crates/aura-cli/examples/r_sma.json` except the second variant's bound
/// lengths (3/6 instead of 2/4), which gives it a distinct content id — two
/// strategies, same axes surface.
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
fn run_in(bin: &Path, dir: &Path, args: &[&str]) -> Result<(String, Option<i32>), String> {
let out = std::process::Command::new(bin)
.args(args)
.current_dir(dir)
.output()
.map_err(|e| format!("spawn aura {args:?}: {e}"))?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
Ok((text, out.status.code()))
}
/// Blueprint stems currently in the scratch store.
fn blueprint_stems(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir.join("runs").join("blueprints"))
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().into_owned()))
.collect()
})
.unwrap_or_default()
}
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
/// E2E `seed_blueprint` pattern) and return its content id.
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
let before = blueprint_stems(dir);
let (out, code) = run_in(
bin,
dir,
&[
"sweep",
file,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
name,
],
)?;
if code != Some(0) {
return Err(format!("seed sweep failed ({code:?}): {out}"));
}
blueprint_stems(dir)
.into_iter()
.find(|s| !before.contains(s))
.ok_or_else(|| "seed sweep must store a new blueprint".to_string())
}
fn register_process(bin: &Path, dir: &Path, file: &str, doc: &str) -> Result<String, String> {
std::fs::write(dir.join(file), doc).map_err(|e| format!("write {file}: {e}"))?;
let (out, code) = run_in(bin, dir, &["process", "register", file])?;
if code != Some(0) {
return Err(format!("process register failed ({code:?}): {out}"));
}
out.lines()
.find(|l| l.starts_with("registered process "))
.map(|l| {
l.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("split yields at least one piece")
.trim_start_matches("content:")
.to_string()
})
.ok_or_else(|| format!("no register line in: {out}"))
}
fn campaign_doc(instruments: &[&str], bp_ids: &[String], proc_id: &str, quick: bool) -> String {
let instruments_json =
instruments.iter().map(|i| format!("\"{i}\"")).collect::<Vec<_>>().join(", ");
// 5x5 member grid at full size (the workload lever), the E2E-shaped 3x3
// in quick mode (mechanics only).
let (fast_vals, slow_vals) = if quick {
("[2, 3, 4]", "[8, 12, 16]")
} else {
("[2, 3, 4, 5, 6]", "[8, 10, 12, 14, 16]")
};
let window = if quick { WINDOW_MS_QUICK } else { WINDOW_MS_FULL };
let strategies_json = bp_ids
.iter()
.map(|id| {
format!(
r#"{{ "ref": {{ "content_id": "{id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": {fast_vals} }},
"slow.length": {{ "kind": "I64", "values": {slow_vals} }} }} }}"#
)
})
.collect::<Vec<_>>()
.join(",\n ");
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "bench",
"data": {{ "instruments": [{instruments_json}], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {strategies_json} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": [] }}
}}"#,
from = window.0,
to = window.1,
)
}
/// Build a fresh scratch campaign project and return (scratch, campaign doc
/// file name). Setup, unmeasured.
fn build_scratch(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<(ScratchDir, String), String> {
let scratch = ScratchDir::new("campaign").map_err(|e| format!("scratch dir: {e}"))?;
let root = &scratch.0;
std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
.map_err(|e| format!("write Aura.toml: {e}"))?;
let data_dir = root.join("data");
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
let instruments = if sizing.quick { QUICK_INSTRUMENTS } else { FULL_INSTRUMENTS };
let months = if sizing.quick { ARCHIVE_MONTHS_QUICK } else { ARCHIVE_MONTHS_FULL };
for sym in instruments {
write_symbol_archive(&data_dir, sym, months.0, months.1);
}
std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
.map_err(|e| format!("write blueprint a: {e}"))?;
let mut bp_ids = vec![seed_blueprint(bin, root, "bench_sma_a.json", "seed-a")?];
if !sizing.quick {
std::fs::write(root.join("bench_sma_b.json"), BP_SMA_B)
.map_err(|e| format!("write blueprint b: {e}"))?;
bp_ids.push(seed_blueprint(bin, root, "bench_sma_b.json", "seed-b")?);
}
let proc_id = register_process(bin, root, "bench.process.json", process_doc)?;
let doc = campaign_doc(instruments, &bp_ids, &proc_id, sizing.quick);
std::fs::write(root.join("bench.campaign.json"), doc)
.map_err(|e| format!("write campaign doc: {e}"))?;
Ok((scratch, "bench.campaign.json".to_string()))
}
/// Extract the determinism fingerprint from the always-on final
/// `{"campaign_run":...}` stdout line: per stage the selection's winner
/// ordinal (`w`), the survivor-ordinal list (`v[..]`), and the bootstrap's
/// trade count (`b`, pooled or summed across per-survivor entries). A
/// wrong-result regression in the sweep, gate, or bootstrap machinery
/// therefore fails the bench — not just a wrong sweep winner. The
/// walk-forward stage currently emits an empty realization record
/// (no selection, no survivors, no bootstrap), so it contributes nothing
/// directly and is covered only indirectly, through the bootstrap trade
/// count that depends on its out-of-sample output (coverage gap parked on
/// the tracker, #279).
pub fn winner_fingerprint(stdout: &str) -> Result<String, String> {
let line = stdout
.lines()
.find(|l| l.starts_with("{\"campaign_run\":"))
.ok_or("no campaign_run record line on stdout")?;
let v: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("campaign_run line parses: {e}"))?;
let cells = v["campaign_run"]["cells"]
.as_array()
.ok_or("campaign_run.cells is an array")?;
let mut parts = Vec::new();
for (ci, cell) in cells.iter().enumerate() {
let stages = cell["stages"].as_array().ok_or("cell.stages is an array")?;
for (si, stage) in stages.iter().enumerate() {
if let Some(w) = stage["selection"]["winner_ordinal"].as_u64() {
parts.push(format!("c{ci}s{si}w{w}"));
}
if let Some(surv) = stage["survivor_ordinals"].as_array() {
let list = surv
.iter()
.filter_map(|x| x.as_u64())
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(".");
parts.push(format!("c{ci}s{si}v[{list}]"));
}
if let Some(n) = stage["bootstrap"]["pooled_oos"]["n_trades"].as_u64() {
parts.push(format!("c{ci}s{si}b{n}"));
} else if let Some(per) = stage["bootstrap"]["per_survivor"].as_array() {
let n: u64 = per.iter().filter_map(|pair| pair[1]["n_trades"].as_u64()).sum();
parts.push(format!("c{ci}s{si}b{n}"));
}
}
}
if parts.is_empty() {
return Err("no winner ordinals in the campaign_run record".to_string());
}
Ok(format!("cells={} {}", cells.len(), parts.join(" ")))
}
fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
let timed: TimedChild = run_timed(bin, &["campaign", "run", &doc], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!(
"campaign run exited {:?}\nstdout: {}\nstderr: {}",
timed.exit, timed.stdout, timed.stderr
));
}
let fingerprint = winner_fingerprint(&timed.stdout)?;
let mut metrics = BTreeMap::new();
metrics.insert("wall_s".to_string(), timed.wall_s);
if let Some(c) = timed.cpu_percent {
metrics.insert("cpu_percent".to_string(), c);
}
if let Some(r) = timed.peak_rss_mb {
metrics.insert("peak_rss_mb".to_string(), r);
}
Ok(RepOutcome { metrics, fingerprint })
}
pub fn sweep_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
campaign_rep(bin, sizing, SWEEP_PROCESS_DOC)
}
pub fn heavy_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
let doc = if sizing.quick { HEAVY_PROCESS_DOC_QUICK } else { HEAVY_PROCESS_DOC };
campaign_rep(bin, sizing, doc)
}
/// Used by `cli_fixed_cost`: a minimal one-symbol scratch project.
pub fn build_minimal_project() -> Result<ScratchDir, String> {
let scratch = ScratchDir::new("fixedcost").map_err(|e| format!("scratch dir: {e}"))?;
let root = &scratch.0;
std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
.map_err(|e| format!("write Aura.toml: {e}"))?;
let data_dir = root.join("data");
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
write_symbol_archive(&data_dir, "BSYMA", (2024, 1), (2024, 1));
std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
.map_err(|e| format!("write blueprint: {e}"))?;
Ok(scratch)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn winner_fingerprint_extracts_ordinals_from_a_record_line() {
let stdout = "noise\n{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
\"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
\"stages\":[{\"block\":\"std::sweep\",\"selection\":{\"winner_ordinal\":4,\"params\":[],\
\"selection\":\"argmax\"}},{\"block\":\"std::gate\",\"survivor_ordinals\":[0,2]},\
{\"block\":\"std::monte_carlo\",\"bootstrap\":{\"pooled_oos\":{\"n_trades\":42}}}]}]}}\n";
let fp = winner_fingerprint(stdout).expect("parses");
assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
}
#[test]
fn winner_fingerprint_sums_per_survivor_bootstrap_trades() {
let stdout = "{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
\"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
\"stages\":[{\"block\":\"std::monte_carlo\",\"bootstrap\":{\"per_survivor\":\
[[0,{\"n_trades\":10}],[3,{\"n_trades\":5}]]}}]}]}}\n";
let fp = winner_fingerprint(stdout).expect("parses");
assert_eq!(fp, "cells=1 c0s0b15");
}
#[test]
fn winner_fingerprint_rejects_missing_record_line() {
assert!(winner_fingerprint("no record here\n").is_err());
}
#[test]
fn campaign_doc_lists_all_instruments_and_strategies() {
for quick in [true, false] {
let doc = campaign_doc(
&["BSYMA", "BSYMB"],
&["id-a".to_string(), "id-b".to_string()],
"proc-id",
quick,
);
let v: serde_json::Value = serde_json::from_str(&doc).expect("doc is valid JSON");
assert_eq!(v["data"]["instruments"].as_array().expect("instruments").len(), 2);
assert_eq!(v["strategies"].as_array().expect("strategies").len(), 2);
assert_eq!(v["seed"], 7);
assert_eq!(v["process"]["ref"]["content_id"], "proc-id");
let grid = v["strategies"][0]["axes"]["fast.length"]["values"]
.as_array()
.expect("fast axis")
.len()
* v["strategies"][0]["axes"]["slow.length"]["values"]
.as_array()
.expect("slow axis")
.len();
assert_eq!(grid, if quick { 9 } else { 25 }, "member grid, quick={quick}");
}
}
/// Lockstep guard for the stdout-schema coupling: build a REAL
/// `CampaignRunRecord` out of the aura-registry / aura-analysis /
/// aura-engine types the CLI serializes, wrap it the way the record line
/// is printed, and assert `winner_fingerprint` extracts every layer. A
/// field rename in any of those crates now breaks this test (compile or
/// assert) instead of silently eroding fingerprint coverage through the
/// lenient JSON parse.
#[test]
fn winner_fingerprint_stays_in_lockstep_with_the_registry_record_types() {
use aura_analysis::{FamilySelection, SelectionMode};
use aura_backtest::RBootstrap;
use aura_engine::MetricStats;
use aura_registry::{CampaignRunRecord, CellRealization, StageRealization, StageSelection};
let stats = MetricStats { mean: 0.1, p5: 0.0, p25: 0.05, p50: 0.1, p75: 0.15, p95: 0.2 };
let record = CampaignRunRecord {
campaign: "camp-id".into(),
process: "proc-id".into(),
run: 0,
seed: 7,
cells: vec![CellRealization {
strategy: "bp-id".into(),
instrument: "BSYMA".into(),
window_ms: (1, 2),
stages: vec![
StageRealization {
block: "std::sweep".into(),
family_id: None,
survivor_ordinals: None,
selection: Some(StageSelection {
winner_ordinal: 4,
params: vec![],
selection: FamilySelection {
selection_metric: "sqn_normalized".into(),
n_trials: 25,
raw_winner_metric: 0.5,
mode: SelectionMode::Argmax,
deflated_score: None,
overfit_probability: None,
n_resamples: None,
block_len: None,
seed: None,
neighbourhood_score: None,
n_neighbours: None,
},
}),
bootstrap: None,
window_faults: vec![],
},
StageRealization {
block: "std::gate".into(),
family_id: None,
survivor_ordinals: Some(vec![0, 2]),
selection: None,
bootstrap: None,
window_faults: vec![],
},
StageRealization {
block: "std::monte_carlo".into(),
family_id: None,
survivor_ordinals: None,
selection: None,
bootstrap: Some(aura_registry::StageBootstrap::PooledOos(RBootstrap {
e_r: stats,
prob_le_zero: 0.3,
n_trades: 42,
block_len: 5,
n_resamples: 1000,
})),
window_faults: vec![],
},
],
regime: None,
regime_ordinal: 0,
fault: None,
coverage: None,
}],
generalizations: vec![],
trace_name: None,
};
let line = format!(
"{{\"campaign_run\":{}}}",
serde_json::to_string(&record).expect("record serializes")
);
let fp = winner_fingerprint(&line).expect("the real record shape must parse");
assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
}
#[test]
fn process_docs_are_valid_json() {
for doc in [SWEEP_PROCESS_DOC, HEAVY_PROCESS_DOC, HEAVY_PROCESS_DOC_QUICK] {
let v: serde_json::Value = serde_json::from_str(doc).expect("process doc parses");
assert_eq!(v["kind"], "process");
}
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Engine throughput: bars/s through a bootstrapped harness — an SMA-cross
//! bias graph fed by the seeded synthetic walk via `VecSource`. Fingerprint:
//! the `SeriesReducer` finalize row `[last, max_drawdown, sign_flips]`.
use super::{RepOutcome, Sizing};
use crate::synth::synthetic_walk;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{GraphBuilder, Harness, Source, VecSource};
use aura_std::{SeriesReducer, Sma, Sub};
use std::collections::BTreeMap;
use std::sync::mpsc;
use std::time::Instant;
/// 10M bars lands the release-profile measurement at ~0.7 s wall — big enough
/// that in-process timing jitter sits far under the 10% NOTICE threshold.
pub const FULL_BARS: usize = 10_000_000;
pub const QUICK_BARS: usize = 2_000;
pub fn rep(sizing: Sizing) -> Result<RepOutcome, String> {
let n_bars = if sizing.quick { QUICK_BARS } else { FULL_BARS };
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let mut g = GraphBuilder::new("bench_sma_cross");
let fast = g.add(Sma::builder().bind("length", Scalar::i64(12)));
let slow = g.add(Sma::builder().bind("length", Scalar::i64(48)));
let bias = g.add(Sub::builder());
let sink = g.add(SeriesReducer::builder(Firing::Any, tx));
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [fast.input("series"), slow.input("series")]);
g.connect(fast.output("value"), bias.input("lhs"));
g.connect(slow.output("value"), bias.input("rhs"));
g.connect(bias.output("value"), sink.input("col[0]"));
let flat = g
.build()
.map_err(|e| format!("bench graph must wire: {e:?}"))?
.compile_with_params(&[])
.map_err(|e| format!("bench graph must compile: {e:?}"))?;
let mut h = Harness::bootstrap(flat).map_err(|e| format!("bench graph must bootstrap: {e:?}"))?;
let prices = synthetic_walk(n_bars);
let src: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(prices))];
let t = Instant::now();
h.run(src);
let wall = t.elapsed().as_secs_f64();
let (_, row) = rx.try_iter().last().ok_or("SeriesReducer must emit its finalize row")?;
let fingerprint = format!(
"last={:.6} max_dd={:.6} flips={} n={}",
row[0].as_f64(),
row[1].as_f64(),
row[2].as_i64(),
n_bars
);
let mut metrics = BTreeMap::new();
metrics.insert("wall_s".to_string(), wall);
metrics.insert("bars_per_s".to_string(), n_bars as f64 / wall);
Ok(RepOutcome { metrics, fingerprint })
}
#[cfg(test)]
mod tests {
use super::*;
/// The determinism smoke for the engine surface: two quick reps in-process
/// must produce byte-identical fingerprints (C1 — this is the harness's
/// entire premise, pinned as a currently-green test).
#[test]
fn engine_surface_fingerprint_is_deterministic_in_quick_mode() {
let a = rep(Sizing { quick: true }).expect("quick rep runs");
let b = rep(Sizing { quick: true }).expect("quick rep runs");
assert_eq!(a.fingerprint, b.fingerprint);
assert!(a.fingerprint.starts_with("last="), "shape: {}", a.fingerprint);
assert!(a.metrics.contains_key("bars_per_s"));
}
}
@@ -0,0 +1,122 @@
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
//! computed) and a minimal data-only project run whose fingerprint is the
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per
//! repetition, so store counters start identical.
use super::{median, RepOutcome};
use crate::child::run_timed;
use crate::surfaces::campaign::build_minimal_project;
use crate::synth::fnv1a;
use std::collections::BTreeMap;
use std::path::Path;
use std::time::Instant;
/// Fingerprint of an `aura run` record line, invariant across rebuilds: the
/// line's `manifest.commit` is the aura binary's compile-time build sha
/// (crates/aura-cli/build.rs — the invariant-8 audit trail), so hashing the
/// raw line would flip the fingerprint on EVERY new commit and report a
/// phantom correctness change at each cycle close. Parse, blank that one
/// volatile field, re-serialize (serde_json's map is sorted — deterministic
/// bytes), then hash.
pub fn run_line_fingerprint(line: &str) -> Result<String, String> {
let mut v: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("run record line parses: {e}"))?;
let commit = v
.get_mut("manifest")
.and_then(|m| m.get_mut("commit"))
.ok_or("run record line carries manifest.commit")?;
*commit = serde_json::Value::String(String::new());
let canonical = serde_json::to_string(&v).expect("re-serializing a parsed Value cannot fail");
Ok(format!("run_line_fnv={:016x}", fnv1a(canonical.as_bytes())))
}
/// Spawn samples folded into one repetition: single-digit-millisecond spawns
/// jitter well past the 10% NOTICE threshold, so each repetition reports the
/// median over a batch instead of one throw of the scheduler dice.
pub const HELP_SAMPLES: u32 = 20;
pub const RUN_SAMPLES: u32 = 10;
pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
// Spawn floor: median wall over a batch of `aura --help` spawns.
let mut help_walls = Vec::new();
for _ in 0..HELP_SAMPLES {
let t = Instant::now();
let help = std::process::Command::new(bin)
.arg("--help")
.output()
.map_err(|e| format!("spawn aura --help: {e}"))?;
help_walls.push(t.elapsed().as_secs_f64() * 1000.0);
if !help.status.success() {
return Err(format!("aura --help exited {:?}", help.status.code()));
}
}
// Project-load + minimal run: one scratch project, a batch of runs, the
// median wall. The fingerprint is the FIRST run's record line — later
// runs append to the same store, so their lines legitimately differ (run
// counters), while the first run of a fresh scratch is deterministic.
let scratch = build_minimal_project()?;
let mut run_walls = Vec::new();
let mut first_line = None;
for _ in 0..RUN_SAMPLES {
let timed = run_timed(bin, &["run", "bench_sma_a.json"], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!("aura run exited {:?}: {}", timed.exit, timed.stdout));
}
run_walls.push(timed.wall_s * 1000.0);
if first_line.is_none() {
first_line = Some(
timed
.stdout
.lines()
.next()
.ok_or("aura run must print its JSON record line")?
.to_string(),
);
}
}
let line = first_line.expect("RUN_SAMPLES >= 1 sets the first line");
let mut metrics = BTreeMap::new();
metrics.insert("help_ms".to_string(), median(help_walls));
metrics.insert("run_ms".to_string(), median(run_walls));
Ok(RepOutcome { metrics, fingerprint: run_line_fingerprint(&line)? })
}
#[cfg(test)]
mod tests {
use super::*;
/// The symptom this fixes, pinned: two record lines that differ ONLY in
/// `manifest.commit` (a rebuild at a new commit) must fingerprint
/// identically — the raw-line hash flipped here and reported a phantom
/// correctness change on the first post-commit bench run.
#[test]
fn run_line_fingerprint_is_invariant_across_build_commits() {
let at_a = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.5}}"#;
let at_b = r#"{"manifest":{"commit":"bbbb","seed":0},"metrics":{"total_pips":0.5}}"#;
assert_eq!(
run_line_fingerprint(at_a).expect("parses"),
run_line_fingerprint(at_b).expect("parses"),
);
}
/// The fingerprint still catches what it exists for: a computed-result
/// change (any non-provenance byte) yields a different hash.
#[test]
fn run_line_fingerprint_still_detects_result_changes() {
let base = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.5}}"#;
let drifted = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.6}}"#;
assert_ne!(
run_line_fingerprint(base).expect("parses"),
run_line_fingerprint(drifted).expect("parses"),
);
}
/// A line without the provenance field is malformed input, not a silent
/// pass — the guard that keeps the blanking honest if the record schema
/// ever moves.
#[test]
fn run_line_fingerprint_rejects_a_line_without_manifest_commit() {
assert!(run_line_fingerprint(r#"{"metrics":{"total_pips":0.5}}"#).is_err());
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Ingest throughput: bars/s draining one field column out of a synthetic
//! 48-byte M1 zip archive through the public ingest window API. A FRESH
//! `DataServer` per repetition, so the measured drain pays zip inflate +
//! record parse instead of the server's in-memory cache; the discarded warmup
//! rep leaves the OS page cache warm and consistent.
use super::{RepOutcome, Sizing};
use crate::synth::{write_symbol_archive, ScratchDir};
use aura_ingest::{open_columns, DataServer, M1Field};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Instant;
pub const SYMBOL: &str = "BENCHI";
pub const FULL_MONTHS: ((i32, u32), (i32, u32)) = ((2024, 1), (2025, 12));
pub const QUICK_MONTHS: ((i32, u32), (i32, u32)) = ((2024, 1), (2024, 2));
/// The archive is written once per surface run (setup, unmeasured) and shared
/// by every repetition. `passes` fresh drains per repetition lift the measured
/// wall out of the millisecond regime, where ordinary jitter would reach the
/// 10% NOTICE threshold and flap the report.
pub struct IngestFixture {
pub dir: ScratchDir,
pub passes: u32,
}
pub const FULL_PASSES: u32 = 20;
pub fn setup(sizing: Sizing) -> Result<IngestFixture, String> {
let dir = ScratchDir::new("ingest").map_err(|e| format!("scratch dir: {e}"))?;
let (from, to) = if sizing.quick { QUICK_MONTHS } else { FULL_MONTHS };
write_symbol_archive(&dir.0, SYMBOL, from, to);
Ok(IngestFixture { dir, passes: if sizing.quick { 1 } else { FULL_PASSES } })
}
/// One measured drain: a FRESH `DataServer`, so the pass pays zip inflate +
/// parse instead of hitting the in-memory FileCache.
fn drain_once(dir: &std::path::Path) -> Result<(u64, u64), String> {
let server = Arc::new(DataServer::new(dir));
let mut srcs = open_columns(&server, SYMBOL, None, None, &[M1Field::Close])
.ok_or("bench archive must be present for the close column")?;
let mut src = srcs.pop().ok_or("one source for one field")?;
let mut n: u64 = 0;
let mut xor: u64 = 0;
while let Some((_, v)) = src.next() {
n += 1;
xor ^= v.as_f64().to_bits();
}
if n == 0 {
return Err("the synthetic archive must yield bars".to_string());
}
Ok((n, xor))
}
pub fn rep(fixture: &IngestFixture) -> Result<RepOutcome, String> {
let t = Instant::now();
let (n, xor) = drain_once(&fixture.dir.0)?;
for _ in 1..fixture.passes {
let again = drain_once(&fixture.dir.0)?;
if again != (n, xor) {
return Err(format!("ingest passes disagree: {:?} vs {:?}", (n, xor), again));
}
}
let wall = t.elapsed().as_secs_f64();
let total = n * u64::from(fixture.passes);
let mut metrics = BTreeMap::new();
metrics.insert("wall_s".to_string(), wall);
metrics.insert("bars_per_s".to_string(), total as f64 / wall);
Ok(RepOutcome { metrics, fingerprint: format!("n={n} xor={xor:016x}") })
}
#[cfg(test)]
mod tests {
use super::*;
/// Determinism smoke: two drains of the same archive fold to the same
/// fingerprint, and a fresh server per rep really re-reads the bytes.
#[test]
fn ingest_surface_fingerprint_is_deterministic_in_quick_mode() {
let fixture = setup(Sizing { quick: true }).expect("fixture writes");
let a = rep(&fixture).expect("first drain");
let b = rep(&fixture).expect("second drain");
assert_eq!(a.fingerprint, b.fingerprint);
assert!(a.fingerprint.starts_with("n="), "shape: {}", a.fingerprint);
}
}
+197
View File
@@ -0,0 +1,197 @@
//! The surface framework: a surface is a closure the harness calls once per
//! repetition; `run_reps` applies the measurement discipline (1 discarded
//! warmup + N measured reps, median per metric) and enforces cross-rep
//! fingerprint identity — a surface disagreeing with itself is nondeterminism,
//! reported as an infra error, never averaged away.
pub mod campaign;
pub mod engine;
pub mod fixed_cost;
pub mod ingest;
use crate::baseline::Measured;
use std::collections::BTreeMap;
/// Workload sizing: `quick` is the test/dev mode (tiny inputs, mechanics
/// only); the committed baselines are always full-size.
#[derive(Clone, Copy, Debug)]
pub struct Sizing {
pub quick: bool,
}
/// One repetition's outcome.
pub struct RepOutcome {
pub metrics: BTreeMap<String, f64>,
pub fingerprint: String,
}
pub fn median(mut xs: Vec<f64>) -> f64 {
xs.sort_by(|a, b| a.partial_cmp(b).expect("no NaN metrics"));
xs[xs.len() / 2]
}
/// 1 discarded warmup + `reps` measured runs; median per metric key;
/// cross-rep fingerprint identity enforced.
pub fn run_reps(
surface: &str,
reps: u32,
mut rep: impl FnMut() -> Result<RepOutcome, String>,
) -> Result<Measured, String> {
let reps = reps.max(1);
let _warmup = rep()?; // page/alloc warmup, discarded
let mut runs = Vec::new();
for _ in 0..reps {
runs.push(rep()?);
}
let fingerprint = runs[0].fingerprint.clone();
for r in &runs[1..] {
if r.fingerprint != fingerprint {
return Err(format!(
"surface {surface} is nondeterministic across repetitions: {fingerprint:?} vs {:?}",
r.fingerprint
));
}
}
let mut metrics = BTreeMap::new();
for key in runs[0].metrics.keys() {
let vals: Vec<f64> = runs.iter().filter_map(|r| r.metrics.get(key).copied()).collect();
metrics.insert(key.clone(), median(vals));
}
Ok(Measured { surface: surface.to_string(), metrics, fingerprint })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn median_picks_the_middle() {
assert_eq!(median(vec![3.0, 1.0, 2.0]), 2.0);
assert_eq!(median(vec![1.0, 2.0]), 2.0);
}
/// The engine surface's actual deliverable: `run_reps` composed with the
/// real engine surface (a re-bootstrapped SMA-cross harness per call),
/// not a mock closure. Protects the wiring end to end — a warmup plus
/// three fresh bootstraps must agree on the determinism fingerprint and
/// the returned metrics map must carry both keys a `BaselineDoc` for
/// this surface needs (`wall_s`, `bars_per_s`), positive and finite.
#[test]
fn run_reps_drives_the_real_engine_surface_deterministically() {
let measured = run_reps("engine", 3, || engine::rep(Sizing { quick: true }))
.expect("the real engine surface must agree with itself across repeated bootstraps");
assert_eq!(measured.surface, "engine");
let bars_per_s = *measured.metrics.get("bars_per_s").expect("bars_per_s metric present");
assert!(bars_per_s.is_finite() && bars_per_s > 0.0, "bars_per_s: {bars_per_s}");
assert!(measured.metrics.contains_key("wall_s"));
}
/// The ingest surface's actual deliverable: `run_reps` composed with the
/// real ingest surface (a fresh `DataServer` re-opened and re-drained
/// per call).
/// Protects that a warmup plus three fresh drains of the same synthetic
/// archive agree on the fingerprint, and that the metrics map carries
/// the schema the ingest `BaselineDoc` needs.
#[test]
fn run_reps_drives_the_real_ingest_surface_deterministically() {
let fixture = ingest::setup(Sizing { quick: true }).expect("ingest fixture must write");
let measured = run_reps("ingest", 3, || ingest::rep(&fixture))
.expect("the real ingest surface must agree with itself across repeated drains");
assert_eq!(measured.surface, "ingest");
let bars_per_s = *measured.metrics.get("bars_per_s").expect("bars_per_s metric present");
assert!(bars_per_s.is_finite() && bars_per_s > 0.0, "bars_per_s: {bars_per_s}");
assert!(measured.metrics.contains_key("wall_s"));
}
/// `run_reps` composed with the real campaign sweep surface — a fresh
/// scratch project, a freshly seeded blueprint, and a genuine
/// `aura campaign run` child process spawned per repetition (not an
/// in-process shortcut). Protects that two independently built-and-run
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
/// from the child's stdout — C1's determinism carried through the
/// child-process boundary, which is the whole reason the campaign
/// surfaces exist as a distinct measurement path from the in-process
/// engine/ingest surfaces above — and that the metrics map carries the
/// wall-clock key the campaign `BaselineDoc` needs.
///
/// Ignored in the default suite: it builds the release `aura` binary and
/// spawns real campaign child runs (minutes cold) — the suite-wallclock
/// discipline forbids that cost on every `cargo test`. Run it explicitly:
/// `cargo test -p aura-bench -- --ignored`.
#[test]
#[ignore = "builds the release aura binary + real campaign scratch runs; run via -- --ignored"]
fn run_reps_drives_the_real_campaign_sweep_surface_deterministically() {
let bin = crate::child::resolve_aura_bin(&crate::child::workspace_root())
.expect("the release aura binary must build");
let measured = run_reps("campaign_sweep", 2, || campaign::sweep_rep(&bin, Sizing { quick: true }))
.expect("the real campaign sweep surface must agree with itself across independent scratch runs");
assert_eq!(measured.surface, "campaign_sweep");
assert!(measured.metrics.contains_key("wall_s"));
assert!(
measured.fingerprint.starts_with("cells="),
"fingerprint: {}",
measured.fingerprint
);
}
/// `run_reps` composed with the real CLI fixed-cost surface — a genuine
/// `aura --help` spawn-floor wall plus a fresh scratch project and a real
/// `aura run` child process per repetition (not an in-process shortcut).
/// Protects that two independent scratch runs agree on the run-line FNV
/// fingerprint extracted from the child's stdout, and that the metrics
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
///
/// Ignored in the default suite: it builds the release `aura` binary and
/// spawns real child processes — the suite-wallclock discipline forbids
/// that cost on every `cargo test`. Run it explicitly:
/// `cargo test -p aura-bench -- --ignored`.
#[test]
#[ignore = "builds the release aura binary + real fixed-cost child runs; run via -- --ignored"]
fn run_reps_drives_the_real_fixed_cost_surface_deterministically() {
let bin = crate::child::resolve_aura_bin(&crate::child::workspace_root())
.expect("the release aura binary must build");
let measured = run_reps("cli_fixed_cost", 2, || fixed_cost::rep(&bin))
.expect("the real fixed_cost surface must agree with itself across independent scratch runs");
assert_eq!(measured.surface, "cli_fixed_cost");
assert!(measured.metrics.contains_key("help_ms"));
assert!(measured.metrics.contains_key("run_ms"));
assert!(
measured.fingerprint.starts_with("run_line_fnv="),
"fingerprint: {}",
measured.fingerprint
);
}
#[test]
fn run_reps_rejects_cross_rep_fingerprint_drift() {
let mut n = 0;
let r = run_reps("t", 2, move || {
n += 1;
Ok(RepOutcome { metrics: BTreeMap::new(), fingerprint: format!("fp{n}") })
});
assert!(r.is_err(), "differing fingerprints must be an error");
}
/// The measurement discipline itself: the first (warmup) call is never
/// mixed into the reported metric — its outlier value would otherwise
/// skew the median — and the median is computed independently per
/// metric key across only the measured reps.
#[test]
fn run_reps_discards_warmup_and_medians_only_measured_reps() {
let mut n = 0;
let measured = run_reps("t", 3, move || {
n += 1;
// Call 1 is the discarded warmup: an outlier that must never
// enter the median. Calls 2..=4 (the measured reps) report
// 1.0, 2.0, 3.0, whose median is 2.0.
let v = if n == 1 { 1_000_000.0 } else { (n - 1) as f64 };
let mut metrics = BTreeMap::new();
metrics.insert("m".to_string(), v);
Ok(RepOutcome { metrics, fingerprint: "fp".to_string() })
})
.expect("agreeing fingerprints must succeed");
assert_eq!(measured.surface, "t");
assert_eq!(measured.metrics.get("m"), Some(&2.0), "warmup outlier must not pollute the median");
assert_eq!(measured.fingerprint, "fp");
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Deterministic synthetic inputs: the seeded price walk (engine surface), the
//! 48-byte M1 zip archive writer (ingest + campaign surfaces), and the scratch
//! temp-dir guard. Everything here is a pure function of its arguments — same
//! bytes on every host, every run (C1 is what makes the engine benchmarkable).
use aura_core::{Scalar, Timestamp};
use std::io::Write;
use std::path::{Path, PathBuf};
/// A seeded LCG random walk as a `VecSource` stream: 1-minute spacing in
/// epoch-ns, prices around 100. Same constants, same bytes, everywhere.
pub fn synthetic_walk(n: usize) -> Vec<(Timestamp, Scalar)> {
let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
let mut price = 100.0_f64;
(0..n)
.map(|i| {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let step = ((x >> 11) as f64 / (1u64 << 53) as f64) - 0.5;
price += step;
(Timestamp(i as i64 * 60_000_000_000), Scalar::f64(price))
})
.collect()
}
/// FNV-1a over raw bytes — the fingerprint hash for child-process stdout.
pub fn fnv1a(bytes: &[u8]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325_u64;
for b in bytes {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// A self-deleting scratch directory under the system temp dir.
pub struct ScratchDir(pub PathBuf);
impl ScratchDir {
pub fn new(tag: &str) -> std::io::Result<Self> {
let base = std::env::temp_dir().join(format!(
"aura-bench-{}-{}-{}",
std::process::id(),
tag,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&base)?;
Ok(Self(base))
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Days since the Unix epoch for a proleptic-Gregorian civil date
/// (Howard Hinnant's `days_from_civil` — dependency-free calendar math).
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = i64::from(if m <= 2 { y - 1 } else { y });
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; // [0, 399]
let mp = (i64::from(m) + 9) % 12; // [0, 11], Mar=0 .. Feb=11
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
era * 146_097 + doe - 719_468
}
/// Unix milliseconds at `y-m-d hh:mm:00` UTC.
pub fn unix_ms(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> i64 {
days_from_civil(y, m, d) * 86_400_000 + (i64::from(hh) * 3600 + i64::from(mm) * 60) * 1000
}
/// 0 = Sunday .. 6 = Saturday (1970-01-01 was a Thursday, day 0).
fn weekday(y: i32, m: u32, d: u32) -> i64 {
(days_from_civil(y, m, d) % 7 + 4 + 7) % 7
}
fn days_in_month(y: i32, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
2 => 28,
_ => unreachable!("month out of range: {m}"),
}
}
/// A deterministic price at continuous minute offset `t` (minutes since
/// 2024-01-01T00:00 UTC): a gentle upward trend plus a 180-minute sine.
fn price_at(t_minutes: f64) -> f64 {
let hours = t_minutes / 60.0;
100.0 + 0.01 * hours + 5.0 * (2.0 * std::f64::consts::PI * t_minutes / 180.0).sin()
}
/// Packs one `RawM1Record`-shaped 48-byte little-endian record: `time`
/// (Delphi `TDateTime`, days since 1899-12-30), `open/high/low/close`,
/// `spread` (f32), `volume` (i32).
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
const MS_PER_DAY: f64 = 86_400_000.0;
let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
let mut rec = [0u8; 48];
rec[0..8].copy_from_slice(&dt.to_le_bytes());
rec[8..16].copy_from_slice(&open.to_le_bytes());
rec[16..24].copy_from_slice(&high.to_le_bytes());
rec[24..32].copy_from_slice(&low.to_le_bytes());
rec[32..40].copy_from_slice(&close.to_le_bytes());
rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
rec
}
/// One month's worth of weekday, 08:00-16:00 UTC, 1-minute bars (~10k).
fn month_records(year: i32, month: u32) -> Vec<[u8; 48]> {
let epoch_ms = unix_ms(2024, 1, 1, 0, 0);
let mut records = Vec::new();
for day in 1..=days_in_month(year, month) {
if !(1..=5).contains(&weekday(year, month, day)) {
continue; // weekend
}
for hh in 8..16 {
for mm in 0..60 {
let ms = unix_ms(year, month, day, hh, mm);
let t = (ms - epoch_ms) as f64 / 60_000.0;
let (open, close) = (price_at(t), price_at(t + 1.0));
let high = open.max(close) + 0.2;
let low = open.min(close) - 0.2;
records.push(pack_record(ms, open, high, low, close));
}
}
}
records
}
/// Writes one `SYMBOL_YYYY_MM.m1` zip (a single `<SYMBOL>.bin` entry).
fn write_month_zip(data_dir: &Path, symbol: &str, year: i32, month: u32) {
let path = data_dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file::<String, ()>(format!("{symbol}.bin"), Default::default())
.expect("start synthetic m1 zip entry");
for rec in month_records(year, month) {
zip.write_all(&rec).expect("write synthetic m1 record");
}
zip.finish().expect("finish synthetic m1 zip");
}
/// Writes every month in `[from, to]` (inclusive `(year, month)` pairs) for
/// `symbol`, plus its `<SYMBOL>.meta.json` geometry sidecar (GER40-shaped).
pub fn write_symbol_archive(data_dir: &Path, symbol: &str, from: (i32, u32), to: (i32, u32)) {
let (mut y, mut m) = from;
loop {
write_month_zip(data_dir, symbol, y, m);
if (y, m) == to {
break;
}
m += 1;
if m > 12 {
m = 1;
y += 1;
}
}
std::fs::write(
data_dir.join(format!("{symbol}.meta.json")),
format!(
"{{\"schemaVersion\":2,\"symbol\":\"{symbol}\",\"digits\":1,\"pipSize\":1,\
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"{symbol}\",\"quoteAsset\":\"EUR\"}}"
),
)
.expect("write synthetic geometry sidecar");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthetic_walk_is_deterministic_and_sized() {
let a = synthetic_walk(1000);
let b = synthetic_walk(1000);
assert_eq!(a.len(), 1000);
assert_eq!(a, b, "same seed, same bytes");
assert_eq!(a[0].0, Timestamp(0));
assert_eq!(a[1].0, Timestamp(60_000_000_000));
}
#[test]
fn write_symbol_archive_names_months_and_sidecar() {
let dir = ScratchDir::new("synthtest").expect("scratch dir");
write_symbol_archive(&dir.0, "TSYM", (2024, 11), (2025, 2));
let mut names: Vec<String> = std::fs::read_dir(&dir.0)
.expect("read scratch")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(
names,
vec![
"TSYM.meta.json".to_string(),
"TSYM_2024_11.m1".to_string(),
"TSYM_2024_12.m1".to_string(),
"TSYM_2025_01.m1".to_string(),
"TSYM_2025_02.m1".to_string(),
],
"month rollover + sidecar"
);
}
#[test]
fn fnv1a_is_stable() {
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a(b"a"), fnv1a(b"a"));
assert_ne!(fnv1a(b"a"), fnv1a(b"b"));
}
/// #251: the synthetic archive is the ingest surface's benchmark input
/// (fed through the CLI wiring), so its bytes must be more than
/// internally self-consistent — they must actually parse through the
/// real production M1 reader
/// (`aura_ingest::DataServer`/`load_m1_window`), the same path a real
/// GER40 archive goes through. This is not a round trip against our own
/// `pack_record`: it is the production parser recovering `pack_record`'s
/// 48-byte layout (Delphi `TDateTime`, 4 little-endian f64 prices) and
/// the `price_at` OHLC shape (`low <= open,close <= high` on every bar) —
/// pins the record layout against future drift in either writer or
/// reader.
#[test]
fn synthetic_archive_parses_through_the_real_ingest_reader() {
let dir = ScratchDir::new("ingest-roundtrip").expect("scratch dir");
write_symbol_archive(&dir.0, "BENCHSYM", (2024, 1), (2024, 1));
let server = std::sync::Arc::new(aura_ingest::DataServer::new(&dir.0));
let cols = aura_ingest::load_m1_window(&server, "BENCHSYM", None, None)
.expect("the synthetic archive is a recognized data source");
assert!(!cols.close.is_empty(), "January 2024 has weekday 08:00-16:00 bars");
for i in 0..cols.close.len() {
assert!(
cols.high[i] >= cols.open[i] && cols.high[i] >= cols.close[i],
"bar {i}: high must dominate open/close"
);
assert!(
cols.low[i] <= cols.open[i] && cols.low[i] <= cols.close[i],
"bar {i}: low must be dominated by open/close"
);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
//! Integration test: drive the built `aura-bench` binary as a downstream
//! script would (spawned as a fresh process, no in-process shortcuts).
use std::process::Command;
/// Path to the freshly-built `aura-bench` binary (Cargo sets this env var
/// for the test crate; the binary target is named `aura-bench`).
const BIN: &str = env!("CARGO_BIN_EXE_aura-bench");
/// #251: a debug-build wall-clock number measures the build profile, not the
/// code, so the harness must refuse to run at all rather than silently
/// producing a number that could later be trusted (or committed) as a
/// baseline. This pins the profile guard's observable contract — exit code 2
/// (the usage-error convention aura-bench shares with `aura-cli`) and a
/// stderr message naming both the cause ("debug build") and the fix
/// ("--release") — so a future CLI-wiring change (the surfaces glued into
/// the CLI) cannot silently drop or weaken the guard.
#[test]
fn refuses_to_run_in_a_debug_build() {
// `cargo test` builds test-harness binaries (and their bin-target
// dependencies) in the dev profile by default, so this binary always
// has debug_assertions on — deterministic across hosts and CI.
let out = Command::new(BIN).output().expect("spawn aura-bench");
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("debug build"), "stderr: {stderr}");
assert!(stderr.contains("--release"), "stderr: {stderr}");
}
+12 -1
View File
@@ -12,8 +12,14 @@ publish.workspace = true
[dependencies]
# the scalar vocabulary: params cross the MemberRunner seam as (name, Scalar) pairs.
aura-core = { path = "../aura-core" }
# RunReport (the member result type) + the sweep/walk_forward orchestration machinery.
# the sweep/walk_forward orchestration machinery (metric-agnostic; RunReport
# itself is generic here — the M = RunMetrics instantiation is aura-backtest's).
aura-engine = { path = "../aura-engine" }
# aura-backtest carries the trading instantiation (M = RunMetrics: RunReport/
# SweepFamily/SweepPoint/WindowRun aliases, RMetrics, r_bootstrap) — production
# code uses it directly now (#291/#292, C28 phase 2), promoted from
# [dev-dependencies].
aura-backtest = { path = "../aura-backtest" }
# FamilySelection — the selection-provenance type stage selections carry.
aura-analysis = { path = "../aura-analysis" }
# family + campaign-run stores and the optimize/optimize_plateau/optimize_deflated selectors.
@@ -23,3 +29,8 @@ aura-research = { path = "../aura-research" }
# realization payloads derive serde (the registry per-case policy, INDEX.md).
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# rayon is admitted under the amended C16 per-case dependency policy (INDEX.md):
# the campaign cell loop fans C1-disjoint cells into the same process-global
# work-stealing pool the engine's run_indexed already uses — parallelism across
# sims, never within one. Direct versioned dep, mirroring aura-engine.
rayon = "1"
+533 -120
View File
@@ -7,22 +7,24 @@
//! in the crate's preflight, which `execute` runs before any member runs.
use std::collections::BTreeMap;
use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use aura_analysis::{FamilySelection, SelectionMode};
use aura_backtest::{r_bootstrap, RunMetrics, RunReport, SweepFamily, SweepPoint, WindowRun};
use aura_core::{zip_params, Cell, ParamSpec, Scalar, Timestamp};
use aura_engine::{
r_bootstrap, sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics,
RunReport, Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller,
WindowRun,
sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest,
Space, WalkForwardError, WindowBounds, WindowRoller,
};
use aura_registry::{
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
sweep_member_reports, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord,
CellRealization, FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization,
StageSelection,
sweep_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, FamilyKind,
PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection,
};
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
use rayon::prelude::*;
use crate::{
member_metric, predicate_holds, preflight, CellSpec, ExecFault, MemberFault, MemberRunner,
@@ -73,10 +75,18 @@ pub struct CampaignOutcome {
}
/// Execute a validated campaign document's process pipeline once per
/// (strategy, instrument, window) cell, in doc order, sequentially (C1:
/// parallelism lives inside the engine `sweep`, across members, never across
/// cells). `campaign_id` is the campaign document's 64-hex content id (its
/// first 8 chars prefix family names); `strategies` is index-aligned with
/// (strategy, instrument, window) cell. Cells are grouped by instrument
/// ordinal and walked in sequential chunks of `parallel_instruments` groups;
/// within a chunk, every cell runs concurrently via `rayon::par_iter` (#277:
/// parallelism now also spans cells, bounded by `parallel_instruments` — C1
/// still holds because cells are disjoint work, and RESULT order is rebuilt
/// in document order regardless of completion order). A non-containable
/// fault (see `contain`) latches a shared abort flag and short-circuits the
/// run deterministically: propagation picks the lowest document-order fault
/// among the cells that completed before the latch, which cell that is being
/// scheduling-dependent but acceptable for these external, non-input faults.
/// `campaign_id` is the campaign document's 64-hex content id (its first 8
/// chars prefix family names); `strategies` is index-aligned with
/// `campaign.strategies` as (blueprint content id, canonical blueprint json).
/// Writes one family per family-producing stage plus one campaign-run record;
/// a `MemberFault` aborts the whole run before any write of the faulted stage.
@@ -87,6 +97,7 @@ pub fn execute(
strategies: &[(String, String)],
runner: &dyn MemberRunner,
registry: &Registry,
parallel_instruments: NonZeroUsize,
) -> Result<CampaignOutcome, ExecFault> {
preflight(process, campaign)?;
if strategies.len() != campaign.strategies.len() {
@@ -116,12 +127,19 @@ pub fn execute(
}
let campaign_prefix = &campaign_id[..8];
let mut cells_out: Vec<CellOutcome> = Vec::new();
let mut cells_rec: Vec<CellRealization> = Vec::new();
// Per-(strategy_ordinal, window_ordinal) nominees across instruments, in
// cell-loop (instrument) order — the campaign-scope generalize input.
type Nominee = Option<(Vec<(String, Scalar)>, RunReport)>;
let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new();
// A slot's per-cell outcome — the same type `run_cell` returns, named here
// so the chunk-walk bindings below don't nest it raw (clippy::type_complexity).
type CellResult = Result<(CellOutcome, CellRealization), ExecFault>;
// Flatten: the same 4-level nesting enumerates every cell with its
// document-order index. Execution order below is instrument-major and
// chunked; RESULT order stays this document order (C1).
struct PlannedCell {
doc_index: usize,
instrument_ordinal: usize,
window_ordinal: usize,
cell: CellSpec,
}
// Absent/empty risk = a single default cell (regime None, ordinal 0); a
// non-empty list maps each regime to Some. The default's value is the member
// runner's — the doc is never mutated (its content id hashes with risk absent).
@@ -130,42 +148,108 @@ pub fn execute(
} else {
campaign.risk.iter().copied().enumerate().map(|(i, r)| (i, Some(r))).collect()
};
let mut planned: Vec<PlannedCell> = Vec::new();
for (strategy_ordinal, (entry, (strategy_id, blueprint_json))) in
campaign.strategies.iter().zip(strategies).enumerate()
{
for instrument in &campaign.data.instruments {
for (instrument_ordinal, instrument) in campaign.data.instruments.iter().enumerate() {
for (window_ordinal, window) in campaign.data.windows.iter().enumerate() {
for (regime_ordinal, regime) in &regimes {
let cell = CellSpec {
strategy_ordinal,
strategy_id: strategy_id.clone(),
blueprint_json: blueprint_json.clone(),
axes: entry.axes.clone(),
instrument: instrument.clone(),
window_ms: (window.from_ms, window.to_ms),
regime: *regime,
regime_ordinal: *regime_ordinal,
};
let (outcome, realization) = run_cell(
&cell,
process,
campaign_prefix,
planned.push(PlannedCell {
doc_index: planned.len(),
instrument_ordinal,
window_ordinal,
campaign.seed,
runner,
registry,
)?;
nominees
.entry((strategy_ordinal, window_ordinal, *regime_ordinal))
.or_default()
.push((instrument.clone(), outcome.nominee.clone()));
cells_out.push(outcome);
cells_rec.push(realization);
cell: CellSpec {
strategy_ordinal,
strategy_id: strategy_id.clone(),
blueprint_json: blueprint_json.clone(),
axes: entry.axes.clone(),
instrument: instrument.clone(),
window_ms: (window.from_ms, window.to_ms),
regime: *regime,
regime_ordinal: *regime_ordinal,
},
});
}
}
}
}
// Group by instrument ordinal (not name: correct even on unvalidated
// duplicate-name input), then walk sequential chunks of K groups — the
// structural residency bound: no cell of an instrument outside the
// current chunk can be in flight at all.
let mut groups: Vec<Vec<usize>> = vec![Vec::new(); campaign.data.instruments.len()];
for p in &planned {
groups[p.instrument_ordinal].push(p.doc_index);
}
let abort = AtomicBool::new(false);
let mut slots: Vec<Option<CellResult>> = (0..planned.len()).map(|_| None).collect();
for chunk in groups.chunks(parallel_instruments.get()) {
let chunk_cells: Vec<usize> = chunk.iter().flatten().copied().collect();
let results: Vec<(usize, Option<CellResult>)> = chunk_cells
.par_iter()
.map(|&doc_index| {
if abort.load(Ordering::Relaxed) {
// Never started: a fatal fault elsewhere already ends
// the run; this slot stays empty.
return (doc_index, None);
}
let p = &planned[doc_index];
let result = run_cell(
&p.cell,
process,
campaign_prefix,
p.window_ordinal,
campaign.seed,
runner,
registry,
);
if result.is_err() {
abort.store(true, Ordering::Relaxed);
}
(doc_index, Some(result))
})
.collect();
for (doc_index, result) in results {
slots[doc_index] = result;
}
}
// Non-containable faults stay run-fatal: propagate the lowest
// document-order fault among completed cells (deterministic attribution;
// which cells completed before the abort latched is scheduling-dependent,
// acceptable for external, non-input faults — see the spec).
for slot in slots.iter_mut() {
if matches!(slot, Some(Err(_))) {
let Some(Err(fault)) = slot.take() else { unreachable!() };
return Err(fault);
}
}
// Rebuild the ordered outputs in document order — identical pushes to the
// former nested loop, so nominee grouping and record layout are unchanged.
let mut cells_out: Vec<CellOutcome> = Vec::with_capacity(planned.len());
let mut cells_rec: Vec<CellRealization> = Vec::with_capacity(planned.len());
// Per-(strategy_ordinal, window_ordinal) nominees across instruments, in
// document (instrument) order — the campaign-scope generalize input.
type Nominee = Option<(Vec<(String, Scalar)>, RunReport)>;
let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new();
for (p, slot) in planned.iter().zip(slots) {
let (outcome, realization) = match slot {
Some(Ok(pair)) => pair,
// No fatal fault (checked above) means abort never latched, so
// every cell ran to Ok.
_ => unreachable!("no fatal fault, so every cell completed"),
};
nominees
.entry((p.cell.strategy_ordinal, p.window_ordinal, p.cell.regime_ordinal))
.or_default()
.push((p.cell.instrument.clone(), outcome.nominee.clone()));
cells_out.push(outcome);
cells_rec.push(realization);
}
// Campaign-scope generalize (#200 d2): for each (strategy, window), grade
// the per-instrument nominees via the shipped `generalization`; fewer than
// 2 winners is a recorded shortfall, never computed around.
@@ -232,6 +316,31 @@ pub fn execute(
Ok(CampaignOutcome { record, run, cells: cells_out })
}
/// The inter-stage population crossing `run_cell`'s seam: parameter points
/// only (from `std::grid`) or executed members (from `std::sweep`). Preflight
/// guarantees report-consuming stages (`std::gate`, `std::monte_carlo`'s
/// per-survivor arm) never see `Points`; the defensive faults below keep that
/// invariant loud if the shape rules ever drift.
enum StageFlow {
/// (ordinal into the nearest preceding family_id-bearing stage's family,
/// param point, member report) — the shape preflight established before
/// std::sweep populated it.
Members(Vec<(usize, Vec<Scalar>, RunReport)>),
Points(Vec<Vec<Scalar>>),
}
impl StageFlow {
/// Every flow can cross the seam as bare points — the walk_forward need.
fn points(&self) -> Vec<Vec<Scalar>> {
match self {
StageFlow::Points(points) => points.clone(),
StageFlow::Members(members) => {
members.iter().map(|(_, point, _)| point.clone()).collect()
}
}
}
}
/// Run one cell's pipeline: stages in doc order, a realized prefix that stops
/// at an empty gate (decision 8 — the cell truncates, the campaign continues).
fn run_cell(
@@ -247,15 +356,27 @@ fn run_cell(
let mut families: Vec<StageFamily> = Vec::new();
let mut selections: Vec<StageSelectionOut> = Vec::new();
let mut stages: Vec<StageRealization> = Vec::new();
// The surviving population: (ordinal into the nearest preceding
// family_id-bearing stage's family, param point, member report).
let mut survivors: Vec<(usize, Vec<Scalar>, RunReport)> = Vec::new();
// The surviving population crossing the stage seam (see `StageFlow`).
let mut flow = StageFlow::Members(Vec::new());
// The cell's nominated generalize candidate: last wf window's OOS report
// when a walk_forward ran, else the sweep winner; None when truncated.
let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None;
for (stage_ordinal, stage) in process.pipeline.iter().enumerate() {
match stage {
StageBlock::Grid => {
// Enumerate-only (#256 fork B): the grid's parameter points
// cross the seam; nothing executes, nothing persists.
flow = StageFlow::Points(grid.points.clone());
stages.push(StageRealization {
block: "std::grid".to_string(),
family_id: None,
survivor_ordinals: None,
selection: None,
bootstrap: None,
window_faults: vec![],
});
}
StageBlock::Sweep { selection } => {
// Deterministic, self-describing family name (the "-{run}"
// suffix is appended by the registry).
@@ -263,7 +384,14 @@ fn run_cell(
"{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}",
cell.strategy_ordinal, cell.instrument, cell.regime_ordinal,
);
let family = run_members(cell, &grid, runner)?;
let family = match run_members(cell, &grid, runner) {
Ok(family) => family,
Err(fault) => {
// Registry etc. still aborts (contain's Err arm).
let cf = contain(fault, stage_ordinal)?;
return Ok(failed_cell(cell, stages, families, selections, cf, runner));
}
};
let family_id = registry
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
.map_err(ExecFault::Registry)?;
@@ -300,6 +428,7 @@ fn run_cell(
survivor_ordinals: None,
selection: Some(StageSelection { winner_ordinal, params, selection }),
bootstrap: None,
window_faults: vec![],
});
}
None => {
@@ -313,17 +442,20 @@ fn run_cell(
survivor_ordinals: None,
selection: None,
bootstrap: None,
window_faults: vec![],
});
}
}
// The whole family flows on (decision 3: `select` names the
// recorded selection, never a filter).
survivors = family
.points
.iter()
.enumerate()
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
.collect();
flow = StageFlow::Members(
family
.points
.iter()
.enumerate()
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
.collect(),
);
families.push(StageFamily {
stage: stage_ordinal,
block: "std::sweep",
@@ -338,13 +470,21 @@ fn run_cell(
// None`) fails the member — conservative and deterministic
// (the metric NAME was already preflighted against
// `PER_MEMBER_METRICS`).
survivors.retain(|(_, _, report)| {
let StageFlow::Members(members) = &mut flow else {
return Err(ExecFault::PipelineShape {
detail: format!(
"stage {stage_ordinal}: std::gate needs executed \
members (preflight admits no std::grid predecessor)"
),
});
};
members.retain(|(_, _, report)| {
all.iter().all(|p| {
member_metric(report, &p.metric)
.is_some_and(|value| predicate_holds(&p.cmp, value, p.value))
})
});
let ordinals: Vec<usize> = survivors.iter().map(|(i, _, _)| *i).collect();
let ordinals: Vec<usize> = members.iter().map(|(i, _, _)| *i).collect();
let empty = ordinals.is_empty();
stages.push(StageRealization {
block: "std::gate".to_string(),
@@ -352,6 +492,7 @@ fn run_cell(
survivor_ordinals: Some(ordinals),
selection: None,
bootstrap: None,
window_faults: vec![],
});
if empty {
nominee = None; // a truncated cell nominates no candidate
@@ -368,9 +509,8 @@ fn run_cell(
// The seam crossing is plain points: the walk-forward stage
// re-sweeps the surviving param points and never needs the
// gate bookkeeping's ordinals or reports.
let survivor_points: Vec<Vec<Scalar>> =
survivors.iter().map(|(_, point, _)| point.clone()).collect();
let fam = run_walk_forward_stage(
let survivor_points: Vec<Vec<Scalar>> = flow.points();
match run_walk_forward_stage(
seed,
cell,
stage_ordinal,
@@ -381,22 +521,31 @@ fn run_cell(
&family_name,
runner,
registry,
)?;
stages.push(StageRealization {
block: "std::walk_forward".to_string(),
family_id: Some(fam.family_id.clone()),
survivor_ordinals: None,
selection: None,
bootstrap: None,
});
// Nominate the LAST window's OOS report (roll order per
// walkforward_member_reports) with its chosen params — the wf
// stage stamps them on the fresh OOS report's manifest.
nominee = fam
.reports
.last()
.map(|last| (last.manifest.params.clone(), last.clone()));
families.push(fam);
) {
Ok(WfStageOutcome::Ran { family: fam, window_faults }) => {
stages.push(StageRealization {
block: "std::walk_forward".to_string(),
family_id: Some(fam.family_id.clone()),
survivor_ordinals: None,
selection: None,
bootstrap: None,
window_faults,
});
// Nominate the LAST window's OOS report (roll order,
// over the surviving folds) with its chosen params —
// the wf stage stamps them on the fresh OOS report's
// manifest.
nominee = fam
.reports
.last()
.map(|last| (last.manifest.params.clone(), last.clone()));
families.push(fam);
}
Ok(WfStageOutcome::CellFailed(cf)) => {
return Ok(failed_cell(cell, stages, families, selections, cf, runner));
}
Err(e) => return Err(e), // Registry / global refusal
}
}
StageBlock::MonteCarlo { resamples, block_len } => {
// Terminal annotator (#200 d1/d3): nothing flows out. Dual
@@ -407,7 +556,7 @@ fn run_cell(
let bootstrap =
match families.iter().rev().find(|f| f.block == "std::walk_forward") {
Some(fam) => StageBootstrap::PooledOos(r_bootstrap(
&pooled_trade_rs(&fam.reports),
&pooled_net_trade_rs(&fam.reports),
*resamples as usize,
*block_len as usize,
seed,
@@ -415,28 +564,39 @@ fn run_cell(
// Zero-trade members (r: None) feed an empty slice —
// the engine's defined all-zero degenerate, recorded
// explicitly (a null result is a valid result).
None => StageBootstrap::PerSurvivor(
survivors
.iter()
.map(|(ordinal, _, report)| {
let rs = report
.metrics
.r
.as_ref()
.map(|r| r.trade_rs.as_slice())
.unwrap_or(&[]);
(
*ordinal,
r_bootstrap(
rs,
*resamples as usize,
*block_len as usize,
seed,
),
)
})
.collect(),
),
None => {
let StageFlow::Members(members) = &flow else {
return Err(ExecFault::PipelineShape {
detail: format!(
"stage {stage_ordinal}: std::monte_carlo \
per-survivor bootstrap needs executed members \
(preflight admits no std::grid predecessor)"
),
});
};
StageBootstrap::PerSurvivor(
members
.iter()
.map(|(ordinal, _, report)| {
let rs = report
.metrics
.r
.as_ref()
.map(|r| r.net_trade_rs.as_slice())
.unwrap_or(&[]);
(
*ordinal,
r_bootstrap(
rs,
*resamples as usize,
*block_len as usize,
seed,
),
)
})
.collect(),
)
}
};
stages.push(StageRealization {
block: "std::monte_carlo".to_string(),
@@ -444,6 +604,7 @@ fn run_cell(
survivor_ordinals: None,
selection: None,
bootstrap: Some(bootstrap),
window_faults: vec![],
});
}
StageBlock::Generalize { .. } => {
@@ -462,6 +623,8 @@ fn run_cell(
window_ms: cell.window_ms,
regime: cell.regime,
regime_ordinal: cell.regime_ordinal,
fault: None,
coverage: runner.window_coverage(cell),
stages,
},
))
@@ -500,6 +663,54 @@ fn enumerate_grid(axes: &BTreeMap<String, Axis>) -> SweepGrid {
SweepGrid { specs, axis_lens, points }
}
/// Ref-counted panic-hook silencer: while at least one `SilencedPanic` guard
/// is alive, the process's panic hook is a no-op, so a member panic CONTAINED
/// by one of this module's three `catch_unwind` sites (recorded as a
/// `MemberFault`/`WindowFault`, the run continuing past it) does not also
/// paint a crash-looking default backtrace to stderr — "recorded, campaign
/// continues" stays observably true, not just true in the record. Ref-counted
/// rather than a bare set/restore because `sweep`/`walk_forward` run these
/// closures on multiple OS threads concurrently (C1 disjoint-parallel
/// members): a naive per-closure set_hook/take_hook pair would race two
/// threads' install/restore against each other. The previous hook (whatever
/// it was — default or a caller's own) is saved on the 0→1 transition and
/// restored on the 1→0 transition, so this never permanently clobbers a
/// hook installed above this module.
struct SilencedPanic;
#[allow(clippy::type_complexity)]
static PANIC_HOOK_STATE: Mutex<(usize, Option<Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send>>)> =
Mutex::new((0, None));
impl SilencedPanic {
/// Enter the silenced section; drop the returned guard to leave it. Hold
/// this only around the `catch_unwind` call itself, never the whole
/// member-run closure, so a genuinely uncontained escape (this module's
/// own bug, not the member's) still surfaces normally once the guard has
/// dropped and unwinding continues past `catch_unwind`.
fn enter() -> Self {
let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock");
if state.0 == 0 {
state.1 = Some(std::panic::take_hook());
std::panic::set_hook(Box::new(|_| {}));
}
state.0 += 1;
SilencedPanic
}
}
impl Drop for SilencedPanic {
fn drop(&mut self) {
let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock");
state.0 -= 1;
if state.0 == 0
&& let Some(prev) = state.1.take()
{
std::panic::set_hook(prev);
}
}
}
/// Run every grid point through the consumer's `MemberRunner` via the engine
/// `sweep` (disjoint parallel members, C1 enumeration order). Faults are
/// captured per slot (the closure must return a report), and the LOWEST
@@ -526,9 +737,15 @@ fn run_members(
let faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
let family = sweep(&space, |point: &[Cell]| {
let params = zip_params(&grid.specs, point);
match runner.run_member(cell, &params, cell.window_ms) {
Ok(report) => report,
Err(fault) => {
let called = {
let _silence = SilencedPanic::enter();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
runner.run_member(cell, &params, cell.window_ms)
}))
};
match called {
Ok(Ok(report)) => report,
Ok(Err(fault)) => {
let index = cells
.iter()
.position(|c| c.as_slice() == point)
@@ -536,6 +753,17 @@ fn run_members(
faults.lock().expect("fault capture lock").push((index, fault));
placeholder_report(&params, cell.window_ms)
}
Err(payload) => {
let index = cells
.iter()
.position(|c| c.as_slice() == point)
.expect("the sweep only enumerates the grid's own points");
faults
.lock()
.expect("fault capture lock")
.push((index, MemberFault::Panic(panic_message(payload))));
placeholder_report(&params, cell.window_ms)
}
}
});
let mut captured = faults.into_inner().expect("fault capture lock");
@@ -546,6 +774,110 @@ fn run_members(
Ok(family)
}
/// #272: which executor faults are per-cell outcomes vs campaign-wide
/// refusals. Member and window faults are data-and-cell-local; everything else
/// (doc-shape, registry) stays a global refusal returned to the caller.
fn contain(fault: ExecFault, stage: usize) -> Result<aura_registry::CellFault, ExecFault> {
match fault {
ExecFault::Member(m) => Ok(aura_registry::CellFault {
stage,
kind: member_kind(&m),
detail: member_fault_prose(&m),
}),
ExecFault::Window { stage: s, detail } => Ok(aura_registry::CellFault {
stage: s,
kind: aura_registry::CellFaultKind::Window,
detail,
}),
other => Err(other),
}
}
fn member_kind(m: &MemberFault) -> aura_registry::CellFaultKind {
use aura_registry::CellFaultKind as K;
match m {
MemberFault::NoData { .. } => K::NoData,
MemberFault::Bind(_) => K::Bind,
MemberFault::Run(_) => K::Run,
MemberFault::Panic(_) => K::Panic,
}
}
/// Prose for a member fault. `pub` (re-exported at the crate root) so the
/// CLI's `exec_fault_prose` phrases the `ExecFault::Member` arm by calling
/// this directly instead of carrying its own duplicate of the wording.
/// Debug-leak-free, self-describing.
pub fn member_fault_prose(m: &MemberFault) -> String {
match m {
MemberFault::NoData { instrument, window_ms } => format!(
"no data for instrument {instrument} in window [{}, {}] (epoch-ms)",
window_ms.0, window_ms.1
),
MemberFault::Bind(detail) => format!("a member failed to bind: {detail}"),
MemberFault::Run(detail) => format!("a member failed to run: {detail}"),
MemberFault::Panic(detail) => format!("a member panicked: {detail}"),
}
}
/// Best-effort panic-payload message (String / &str payloads; else a fixed
/// marker). `AssertUnwindSafe` at the call sites is a documented judgement: the
/// runner is reused for later cells; the CLI runner holds read-only state.
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_else(|| "member panicked (non-string payload)".to_string())
}
/// The `SilencedPanic` + `catch_unwind` + `panic_message` recipe above
/// (this module's three call sites), exposed for reuse by a member-run caller
/// OUTSIDE this crate that drives a bare member run without going through
/// `execute` (#278: the synthetic blueprint sweep/walk-forward family
/// builders in `aura-runner`, which share this exact bare-member seam but
/// carry no #272 fault boundary of their own). Returns the panic payload's
/// best-effort message on containment; `AssertUnwindSafe` is the same
/// judgement documented on `panic_message` above.
pub fn catch_member_panic<R>(f: impl FnOnce() -> R) -> Result<R, String> {
let _silence = SilencedPanic::enter();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_message)
}
/// Assemble the (CellOutcome, CellRealization) pair for a cell that failed at a
/// stage (#272): the realized stage prefix survives in `stages`, `families`/
/// `selections` carry whatever already-persisted stages produced (empty for a
/// sweep-first fault; the sweep family/selection for a later wf-stage fault),
/// no nominee, the fault stamped on both, coverage from the runner.
fn failed_cell(
cell: &CellSpec,
stages: Vec<StageRealization>,
families: Vec<StageFamily>,
selections: Vec<StageSelectionOut>,
fault: aura_registry::CellFault,
runner: &dyn MemberRunner,
) -> (CellOutcome, CellRealization) {
(
CellOutcome { families, selections, nominee: None },
CellRealization {
strategy: cell.strategy_id.clone(),
instrument: cell.instrument.clone(),
window_ms: cell.window_ms,
stages,
regime: cell.regime,
regime_ordinal: cell.regime_ordinal,
fault: Some(fault),
coverage: runner.window_coverage(cell),
},
)
}
/// Sentinel `RunManifest::broker` value stamped onto [`placeholder_report`]'s
/// output — the single source both the setter and the survivor filter
/// (`run_members`'s `... != FAULTED_MEMBER_PLACEHOLDER_BROKER` sibling) read,
/// so the two can never drift apart into silently pooling a faulted
/// placeholder into a real family/OOS selection.
const FAULTED_MEMBER_PLACEHOLDER_BROKER: &str = "faulted-member-placeholder";
/// Slot filler for a faulted member: the engine sweep contract needs one
/// report per slot, but a captured fault aborts `execute` before any family
/// write, so this report is never persisted and never ranked.
@@ -554,9 +886,10 @@ fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> Run
manifest: RunManifest {
commit: String::new(),
params: params.to_vec(),
defaults: Vec::new(),
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0,
broker: "faulted-member-placeholder".to_string(),
broker: FAULTED_MEMBER_PLACEHOLDER_BROKER.to_string(),
selection: None,
instrument: None,
topology_hash: None,
@@ -626,15 +959,16 @@ fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec<Scalar> {
space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect()
}
/// The pooled walk-forward OOS trade-R series: the family reports' `trade_rs`
/// The pooled walk-forward OOS trade-R series: the family reports' `net_trade_rs`
/// concatenated in report order — which IS roll order (the wf stage builds its
/// family via `walkforward_member_reports`, per-window OOS reports in roll
/// order). Reports without an R block contribute nothing.
fn pooled_trade_rs(reports: &[RunReport]) -> Vec<f64> {
/// family via `walkforward_member_reports_of`, per-window OOS reports in roll
/// order, over the surviving folds). Reports without an R block contribute
/// nothing.
fn pooled_net_trade_rs(reports: &[RunReport]) -> Vec<f64> {
let mut pooled = Vec::new();
for report in reports {
if let Some(r) = &report.metrics.r {
pooled.extend_from_slice(&r.trade_rs);
pooled.extend_from_slice(&r.net_trade_rs);
}
}
pooled
@@ -677,7 +1011,7 @@ fn run_walk_forward_stage(
family_name: &str,
runner: &dyn MemberRunner,
registry: &Registry,
) -> Result<StageFamily, ExecFault> {
) -> Result<WfStageOutcome, ExecFault> {
let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } =
block
else {
@@ -756,9 +1090,16 @@ fn run_walk_forward_stage(
// with the same member fault capture the sweep stage uses.
let is_faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
let is_family = sweep(&is_space, |cells| {
match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) {
Ok(report) => report,
Err(fault) => {
let params = zip_params(specs, cells);
let called = {
let _silence = SilencedPanic::enter();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
runner.run_member(cell, &params, (w.is.0 .0, w.is.1 .0))
}))
};
match called {
Ok(Ok(report)) => report,
Ok(Err(fault)) => {
let midx = is_points
.iter()
.position(|p| p.as_slice() == cells)
@@ -766,6 +1107,14 @@ fn run_walk_forward_stage(
is_faults.lock().unwrap().push((midx, fault));
placeholder_report(&[], (0, 0))
}
Err(payload) => {
let midx = is_points
.iter()
.position(|p| p.as_slice() == cells)
.expect("sweep enumerates exactly is_points");
is_faults.lock().unwrap().push((midx, MemberFault::Panic(panic_message(payload))));
placeholder_report(&[], (0, 0))
}
}
});
let member_faults = is_faults.into_inner().expect("no thread panicked holding the lock");
@@ -788,29 +1137,93 @@ fn run_walk_forward_stage(
// OOS: run the winner over the out-of-sample bounds; the selection is
// stamped on the fresh OOS report (a new record, never a mutation of
// a stored one).
let mut oos_report = match runner.run_member(
cell,
&zip_params(specs, &winner.params),
(w.oos.0 .0, w.oos.1 .0),
) {
Ok(report) => report,
Err(fault) => {
let oos_params = zip_params(specs, &winner.params);
let oos_called = {
let _silence = SilencedPanic::enter();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
runner.run_member(cell, &oos_params, (w.oos.0 .0, w.oos.1 .0))
}))
};
let mut oos_report = match oos_called {
Ok(Ok(report)) => report,
Ok(Err(fault)) => {
faults.lock().unwrap().push((widx, ExecFault::Member(fault)));
return placeholder_window_run(specs);
}
Err(payload) => {
faults
.lock()
.unwrap()
.push((widx, ExecFault::Member(MemberFault::Panic(panic_message(payload)))));
return placeholder_window_run(specs);
}
};
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report }
});
let window_faults = faults.into_inner().expect("no thread panicked holding the lock");
if let Some((_, fault)) = window_faults.into_iter().min_by_key(|(i, _)| *i) {
return Err(fault);
// Registry faults (optimizer/persistence broken) stay a global abort;
// Member/Window faults become per-fold WindowFaults via the same
// `contain` mapping the cell-level containment uses. One drain, in
// ascending window-index order — ExecFault/RegistryError are not Clone,
// so this is a single ownership-respecting pass rather than iter()-then-
// into_iter(); the first Registry fault encountered (ascending order =
// lowest index) wins, matching the existing lowest-index-fault convention.
let mut captured = faults.into_inner().expect("no thread panicked holding the lock");
captured.sort_by_key(|(idx, _)| *idx);
let mut window_faults: Vec<aura_registry::WindowFault> = Vec::with_capacity(captured.len());
for (widx, fault) in captured {
match contain(fault, stage) {
Ok(cf) => window_faults.push(aura_registry::WindowFault {
window_ordinal: widx,
kind: cf.kind,
detail: cf.detail,
}),
Err(global) => return Err(global),
}
}
let reports = walkforward_member_reports(&result);
// Keep only real OOS reports (drop faulted-fold placeholders by the broker
// sentinel) before building the family.
let surviving: Vec<&aura_engine::WindowOutcome<RunMetrics>> = result
.windows
.iter()
.filter(|w| w.run.oos_report.manifest.broker != FAULTED_MEMBER_PLACEHOLDER_BROKER)
.collect();
if surviving.is_empty() {
// Every fold failed — the cell fails at this stage.
let first = window_faults.first().cloned();
return Ok(WfStageOutcome::CellFailed(aura_registry::CellFault {
stage,
kind: first.as_ref().map(|w| w.kind).unwrap_or(aura_registry::CellFaultKind::Run),
detail: format!(
"all {} walk_forward folds failed{}",
result.windows.len(),
first.map(|w| format!("; first: {}", w.detail)).unwrap_or_default()
),
}));
}
let reports = walkforward_member_reports_of(&surviving);
let family_id = registry
.append_family(family_name, FamilyKind::WalkForward, &reports)
.map_err(ExecFault::Registry)?;
Ok(StageFamily { stage, block: "std::walk_forward", family_id, reports })
Ok(WfStageOutcome::Ran {
family: StageFamily { stage, block: "std::walk_forward", family_id, reports },
window_faults,
})
}
/// #272: a wf stage either ran (its family + any per-fold holes) or the whole
/// cell failed (every fold faulted). A Registry fault is a separate global Err
/// (returned directly from `run_walk_forward_stage`, never wrapped here).
enum WfStageOutcome {
Ran { family: StageFamily, window_faults: Vec<aura_registry::WindowFault> },
CellFailed(aura_registry::CellFault),
}
/// The surviving (non-faulted) walk-forward windows' OOS reports, in roll
/// order — the same mapping `walkforward_member_reports` does over a full
/// result, applied to the filtered survivor slice.
fn walkforward_member_reports_of(windows: &[&aura_engine::WindowOutcome<RunMetrics>]) -> Vec<RunReport> {
windows.iter().map(|w| w.run.oos_report.clone()).collect()
}
+302 -72
View File
@@ -12,15 +12,30 @@
//! and no UI.
mod exec;
pub use exec::{execute, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut};
pub use exec::{
catch_member_panic, execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily,
StageSelectionOut,
};
use std::collections::BTreeMap;
use std::num::NonZeroUsize;
use aura_core::Scalar;
use aura_engine::RunReport;
use aura_backtest::RunReport;
use aura_registry::{check_r_metric, RegistryError};
use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, RiskRegime, SelectRule, StageBlock};
/// Default bound on distinct concurrently-active instruments in the campaign
/// cell loop (the RAM lever): the external data-server retains an
/// instrument's parsed files while any of its cells is live (~1.4 GB per
/// instrument on the reference dataset), so the resident set scales with this
/// bound, not with pool workers. 1 reproduces the sequential loop's
/// footprint.
pub const DEFAULT_PARALLEL_INSTRUMENTS: NonZeroUsize = match NonZeroUsize::new(4) {
Some(n) => n,
None => panic!("4 is nonzero"),
};
/// One structural cell of the campaign matrix: (strategy, instrument,
/// window) — #198 decision 7.
pub struct CellSpec {
@@ -47,6 +62,10 @@ pub struct CellSpec {
/// them to its harness convention and runs the member over `cell.instrument`
/// restricted to `window_ms` (inclusive epoch-ms, a sub-range of
/// `cell.window_ms` — a walk-forward stage passes sub-windows).
///
/// The shipped default implementation is `aura_runner::DefaultMemberRunner`
/// (the C28 assembly crate); this crate deliberately does not depend on it —
/// consumers pass the runner in (#295).
pub trait MemberRunner: Sync {
fn run_member(
&self,
@@ -54,6 +73,13 @@ pub trait MemberRunner: Sync {
params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault>;
/// The archive coverage of this cell's (instrument, window), when the
/// runner can know it and it deviates from the requested window (#272).
/// One call per cell — coverage is a property of the cell, not a member.
fn window_coverage(&self, _cell: &CellSpec) -> Option<aura_registry::CellCoverage> {
None
}
}
/// Display-free member faults (the consumer phrases them — the RefFault
@@ -63,6 +89,9 @@ pub enum MemberFault {
NoData { instrument: String, window_ms: (i64, i64) },
Bind(String),
Run(String),
/// A panic that unwound out of `MemberRunner::run_member`, caught at the
/// member boundary (#272) — the payload's best-effort message.
Panic(String),
}
/// Preflight + runtime refusals. Display-free and by-identifier: the
@@ -109,10 +138,11 @@ pub enum ExecFault {
/// `RunMetrics` scalars plus the 11 `RMetrics` scalars. The three
/// selection-annotation names (`deflated_score` / `overfit_probability` /
/// `neighbourhood_score`) are deliberately NOT here — they describe a
/// selection, not a member. Hand-copied roster (the third metric-roster site
/// beside aura-research's 17-name vocabulary and aura-registry's rankable
/// set, #190); drift fails safe: an unknown name is a preflight refusal,
/// never a wrong number.
/// selection, not a member. Hand-copied roster beside aura-research's 17-name
/// vocabulary (#190); the rankable set is single-sourced from the backtest
/// vocabulary since #147, and this roster's nesting is oracle-pinned by the
/// extended #190 guard. Drift fails safe: an unknown name is a preflight
/// refusal, never a wrong number.
pub const PER_MEMBER_METRICS: &[&str] = &[
"total_pips", "max_drawdown", "bias_sign_flips",
"expectancy_r", "n_trades", "win_rate", "avg_win_r", "avg_loss_r",
@@ -120,14 +150,10 @@ pub const PER_MEMBER_METRICS: &[&str] = &[
"sqn", "net_expectancy_r",
];
/// The registry's rankable roster (`resolve_metric`'s name set) — the metrics
/// a sweep/walk_forward stage may select on. Hand-copied (#190); drift fails
/// safe (a name the registry would refuse is refused here first, before any
/// member runs).
pub const RANKABLE_METRICS: &[&str] = &[
"total_pips", "max_drawdown", "bias_sign_flips",
"sqn", "sqn_normalized", "expectancy_r", "net_expectancy_r",
];
/// The rankable subset a stage's `rank_by`/`select` may name — re-exported
/// from the backtest vocabulary (single source, #147): this roster and the
/// registry's refusal prose can no longer drift.
pub use aura_backtest::RANKABLE_METRICS;
/// Deflation resample count — re-exported from `aura-registry`'s shared
/// definition (single-sourced, #199).
@@ -196,6 +222,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
fn shape_rank(stage: &StageBlock) -> usize {
match stage {
StageBlock::Sweep { .. } => 0,
StageBlock::Grid => 0,
StageBlock::Gate { .. } => 1,
StageBlock::WalkForward { .. } => 2,
StageBlock::MonteCarlo { .. } => 3,
@@ -205,27 +232,60 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
fn block_id(stage: &StageBlock) -> &'static str {
match stage {
StageBlock::Sweep { .. } => "std::sweep",
StageBlock::Grid => "std::grid",
StageBlock::Gate { .. } => "std::gate",
StageBlock::WalkForward { .. } => "std::walk_forward",
StageBlock::MonteCarlo { .. } => "std::monte_carlo",
StageBlock::Generalize { .. } => "std::generalize",
}
}
// Duplicate instruments enumerate identical cells whose registry family
// names collide (the name embeds the raw instrument string), racing one
// name's run-index assignment under the parallel cell loop — refused here
// as defense in depth beside the CLI's validate tier (#277 audit tidy).
let mut seen_instruments = std::collections::BTreeSet::new();
for instrument in &campaign.data.instruments {
if !seen_instruments.insert(instrument.as_str()) {
return Err(ExecFault::PipelineShape {
detail: format!(
"campaign.data.instruments lists \"{instrument}\" more than once — \
duplicate instruments collide on registry family names"
),
});
}
}
// shape: `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)?
// (std::generalize)?` — a monotone rank walk over adjacent pairs captures
// every violation: a rank drop is an out-of-order stage (an annotator
// before a population stage, anything after std::generalize), an adjacent
// equal rank above the gate tier is a duplicate suffix stage.
if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) {
if !matches!(
process.pipeline.first(),
Some(StageBlock::Sweep { .. } | StageBlock::Grid)
) {
return Err(ExecFault::PipelineShape {
detail: "the first stage must be std::sweep".to_string(),
detail: "the first stage must be std::sweep or std::grid".to_string(),
});
}
// #256 fork B: an enumerate-only first stage yields points, not executed
// members — every stage except std::walk_forward consumes member reports,
// so std::grid must be immediately followed by std::walk_forward (in
// particular it is never terminal).
if matches!(process.pipeline.first(), Some(StageBlock::Grid))
&& !matches!(process.pipeline.get(1), Some(StageBlock::WalkForward { .. }))
{
return Err(ExecFault::PipelineShape {
detail: "a std::grid first stage must be immediately followed by \
std::walk_forward (every other stage consumes executed \
member reports)"
.to_string(),
});
}
let mut prev = &process.pipeline[0];
for (i, stage) in process.pipeline.iter().enumerate().skip(1) {
if matches!(stage, StageBlock::Sweep { .. }) {
if matches!(stage, StageBlock::Sweep { .. } | StageBlock::Grid) {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: only the first stage may be std::sweep"),
detail: format!("stage {i}: only the first stage may be {}", block_id(stage)),
});
}
let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev));
@@ -248,6 +308,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
// per-stage slot rules (shape already established above).
for (i, stage) in process.pipeline.iter().enumerate() {
match stage {
StageBlock::Grid => {}
StageBlock::Sweep { selection } => match selection {
Some(sel) => {
if !RANKABLE_METRICS.contains(&sel.metric.as_str()) {
@@ -329,7 +390,8 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
#[cfg(test)]
mod tests {
use super::*;
use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp};
use aura_backtest::{RMetrics, RunMetrics};
use aura_engine::{RunManifest, Timestamp};
use aura_research::SweepSelection;
/// A report with every per-member scalar planted to a distinct value
@@ -339,6 +401,7 @@ mod tests {
manifest: RunManifest {
commit: "test".to_string(),
params: vec![],
defaults: vec![],
window: (Timestamp(0), Timestamp(1)),
seed: 0,
broker: "sim".to_string(),
@@ -364,7 +427,7 @@ mod tests {
sqn: 13.0,
net_expectancy_r: 14.0,
conviction_terciles_r: [0.0; 3],
trade_rs: Vec::new(),
net_trade_rs: Vec::new(),
}),
},
}
@@ -730,6 +793,67 @@ mod tests {
assert!(preflight(&process, &c).is_ok());
}
/// #256 fork B: the enumerate-only leading stage is accepted exactly in
/// the dissolved walkforward/mc shapes — first, immediately before
/// std::walk_forward.
#[test]
fn preflight_accepts_a_grid_first_stage_followed_by_walk_forward() {
let c = campaign();
let wf_shape =
process_of(vec![StageBlock::Grid, wf_stage("sqn_normalized", SelectRule::Argmax)]);
assert!(preflight(&wf_shape, &c).is_ok());
let mc_shape = process_of(vec![
StageBlock::Grid,
wf_stage("sqn_normalized", SelectRule::Argmax),
mc_stage(1000, 5),
]);
assert!(preflight(&mc_shape, &c).is_ok());
}
/// #256 fork B: every other placement is refused — grid terminal, grid
/// before a report-consuming stage (gate / monte_carlo / generalize),
/// grid anywhere but first. Every refusal is a PipelineShape prose fault.
#[test]
fn preflight_refuses_grid_placement_violations() {
let c = campaign();
let c2 = campaign_two_instruments();
let adjacency = "immediately followed by std::walk_forward";
let cases: Vec<(Vec<StageBlock>, &str, &CampaignDoc)> = vec![
(vec![StageBlock::Grid], adjacency, &c),
(
vec![
StageBlock::Grid,
gate_stage("net_expectancy_r"),
wf_stage("sqn_normalized", SelectRule::Argmax),
],
adjacency,
&c,
),
(vec![StageBlock::Grid, mc_stage(1000, 5)], adjacency, &c),
(
vec![StageBlock::Grid, generalize_stage("net_expectancy_r")],
adjacency,
&c2,
),
(
vec![
sweep_stage("sqn_normalized", SelectRule::Argmax, false),
StageBlock::Grid,
],
"only the first stage may be std::grid",
&c,
),
];
for (pipeline, needle, camp) in cases {
let err =
preflight(&process_of(pipeline), camp).expect_err("placement violation");
let ExecFault::PipelineShape { detail } = &err else {
panic!("expected PipelineShape, got {err:?}");
};
assert!(detail.contains(needle), "detail {detail:?} misses {needle:?}");
}
}
/// A selection-free sweep followed by any other stage is refused: a
/// downstream stage would have no selection/nominee to consume.
#[test]
@@ -870,8 +994,9 @@ mod wf_tests {
use std::sync::Mutex;
use aura_analysis::SelectionMode;
use aura_backtest::{RunMetrics, RunReport};
use aura_core::{Scalar, ScalarKind};
use aura_engine::{RollMode, RunManifest, RunMetrics, RunReport, Timestamp, WindowRoller};
use aura_engine::{RollMode, RunManifest, Timestamp, WindowRoller};
use aura_registry::{FamilyKind, Registry};
use aura_research::{
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation,
@@ -884,8 +1009,10 @@ mod wf_tests {
/// A per-test registry in a fresh temp dir (a Registry's family store
/// isolates per DIRECTORY, not per filename — see Registry::open).
fn wf_registry(name: &str) -> Registry {
let dir = std::env::temp_dir()
.join(format!("aura-campaign-wf-{}-{}", std::process::id(), name));
// fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
// pre-create wipe below then genuinely reclaims the previous run.
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join(format!("aura-campaign-wf-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
Registry::open(dir.join("runs.jsonl"))
@@ -1019,6 +1146,7 @@ mod wf_tests {
manifest: RunManifest {
commit: "wf-fake".to_string(),
params: params.to_vec(),
defaults: Vec::new(),
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0,
broker: "fake".to_string(),
@@ -1045,7 +1173,15 @@ mod wf_tests {
) -> Result<CampaignOutcome, ExecFault> {
let strategies = vec![("s".repeat(64), "{}".to_string())];
let campaign_id = "e".repeat(64);
execute(&campaign_id, campaign, process, &strategies, runner, registry)
execute(
&campaign_id,
campaign,
process,
&strategies,
runner,
registry,
crate::DEFAULT_PARALLEL_INSTRUMENTS,
)
}
#[test]
@@ -1161,75 +1297,169 @@ mod wf_tests {
}
}
/// A member fault raised INSIDE a window's in-sample sweep aborts the
/// walk_forward stage with that fault, and among several faulted windows
/// the LOWEST roll index wins (the per-window `min_by_key` attribution
/// mirrors the sweep stage's own lowest-enumeration-index convention) —
/// never the faulted stage's family, since a captured fault returns
/// before any registry write.
/// #272: a member fault raised INSIDE one window's in-sample sweep is
/// contained as that fold's `WindowFault` — the surviving windows still
/// run, pool into the walk_forward family, and the campaign run record
/// persists. Faulting w1's IS run out of 3 windows (w0, w1, w2) leaves
/// exactly 2 surviving OOS reports and one recorded window fault at
/// ordinal 1.
#[test]
fn wf_is_member_fault_lowest_window_wins() {
fn wf_one_faulted_fold_is_contained_survivors_pool() {
let registry = wf_registry("is-fault");
let campaign = wf_campaign((0, 99));
let process = wf_process(None, 40, 20, 20);
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault both w1's and
// w2's IS runs; the widx-1 fault must win over widx-2's.
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault only w1's IS
// run; w0 and w2 survive and pool.
let runner = WfFakeRunner::faulty(vec![(
(20, 59),
vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("is-w1".to_string()),
)]);
let outcome = run_wf(&campaign, &process, &runner, &registry)
.expect("a faulted fold is contained, not a stage abort");
let fam = outcome.cells[0]
.families
.iter()
.find(|f| f.block == "std::walk_forward")
.expect("wf family present despite the faulted fold");
assert_eq!(fam.reports.len(), 2, "only the 2 surviving windows pool");
let cell = &outcome.record.cells[0];
let wf_stage = cell
.stages
.iter()
.find(|s| s.block == "std::walk_forward")
.expect("wf stage realized");
assert_eq!(wf_stage.window_faults.len(), 1);
assert_eq!(wf_stage.window_faults[0].window_ordinal, 1);
assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Run);
assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell");
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
let members = registry.load_family_members().expect("load members");
assert!(members.iter().any(|m| m.kind == FamilyKind::WalkForward));
}
/// #272: every fold faulting (both the IS sweep AND the OOS run, across
/// all 3 windows) leaves zero surviving OOS reports — the whole cell
/// fails at the wf stage, with no WalkForward family persisted, while the
/// campaign run record still records the failed cell.
#[test]
fn wf_all_folds_faulted_fails_the_cell() {
let registry = wf_registry("all-folds-fault");
let campaign = wf_campaign((0, 99));
let process = wf_process(None, 40, 20, 20);
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault every window's
// IS run so no window reaches its OOS call.
let runner = WfFakeRunner::faulty(vec![
(
(0, 39),
vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("is-w0".to_string()),
),
(
(20, 59),
vec![("len".to_string(), Scalar::i64(3))],
vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("is-w1".to_string()),
),
(
(40, 79),
vec![("len".to_string(), Scalar::i64(2))],
vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("is-w2".to_string()),
),
]);
let err = run_wf(&campaign, &process, &runner, &registry)
.expect_err("a faulted IS member aborts the walk_forward stage");
match err {
ExecFault::Member(fault) => assert_eq!(
fault,
MemberFault::Run("is-w1".to_string()),
"the LOWEST window index's fault wins, not w2's later one",
),
other => panic!("expected ExecFault::Member, got {other:?}"),
}
let outcome = run_wf(&campaign, &process, &runner, &registry)
.expect("an all-folds-faulted stage fails the cell, not the process");
assert!(
outcome.cells[0].families.iter().all(|f| f.block != "std::walk_forward"),
"no WalkForward family is produced when every fold fails"
);
let cell = &outcome.record.cells[0];
let fault = cell.fault.as_ref().expect("the cell fails at the wf stage");
assert_eq!(fault.stage, 1, "walk_forward is pipeline stage 1 here");
assert!(fault.detail.contains("all 3 walk_forward folds failed"));
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
let members = registry.load_family_members().expect("load members");
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward));
assert!(registry.load_campaign_runs().expect("load campaign runs").is_empty());
}
/// A member fault raised running the IS winner over its OUT-of-sample
/// bounds (a call distinct from every IS-sweep call, since the OOS
/// window's bounds differ from every window's IS bounds) also aborts the
/// walk_forward stage with that fault, and — like the IS-sweep path —
/// never persists the faulted stage's family.
/// #272: a member PANIC inside a window's in-sample sweep is caught at the
/// wf member boundary (`catch_unwind`) and contained as that fold's
/// `WindowFault` with kind `panic` — the process does not abort, the
/// surviving folds pool, exactly as an `Err` fault is contained. Guards the
/// wf-path panic arm (the sweep-path arm is covered by `PanicRunner` in
/// tests/execute.rs; this is its walk_forward mirror).
#[test]
fn wf_oos_member_fault_aborts_walk_forward() {
let registry = wf_registry("oos-fault");
fn wf_member_panic_in_a_fold_is_contained() {
// Panics on w1's IS bounds (20,59); every other call returns a report.
struct WfPanicRunner;
impl MemberRunner for WfPanicRunner {
fn run_member(
&self,
_cell: &CellSpec,
params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault> {
assert_ne!(window_ms, (20, 59), "planted panic on w1's in-sample run");
let total: f64 = params
.iter()
.map(|(_, s)| match s {
Scalar::I64(v) => *v as f64,
Scalar::F64(v) => *v,
_ => 0.0,
})
.sum();
Ok(RunReport {
manifest: RunManifest {
commit: "wf-panic-fake".to_string(),
params: params.to_vec(),
defaults: Vec::new(),
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0,
broker: "fake".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics {
total_pips: total,
max_drawdown: 0.0,
bias_sign_flips: 0,
r: None,
},
})
}
}
let registry = wf_registry("is-panic");
let campaign = wf_campaign((0, 99));
let process = wf_process(None, 40, 20, 20);
// total_pips == len, so the IS winner is always len=4 (argmax over
// [1,2,3,4]); fault only window 0's OOS run of that winner (OOS
// bounds (40,59) match no window's IS bounds and no other window's
// OOS bounds).
let runner = WfFakeRunner::faulty(vec![(
(40, 59),
vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("oos-w0".to_string()),
)]);
let err = run_wf(&campaign, &process, &runner, &registry)
.expect_err("a faulted OOS member aborts the walk_forward stage");
match err {
ExecFault::Member(fault) => {
assert_eq!(fault, MemberFault::Run("oos-w0".to_string()))
}
other => panic!("expected ExecFault::Member, got {other:?}"),
}
let members = registry.load_family_members().expect("load members");
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward));
let strategies = vec![("s".repeat(64), "{}".to_string())];
let outcome = execute(
&"e".repeat(64),
&campaign,
&process,
&strategies,
&WfPanicRunner,
&registry,
crate::DEFAULT_PARALLEL_INSTRUMENTS,
)
.expect("a member panic in a fold is contained, not a process abort");
let cell = &outcome.record.cells[0];
let wf_stage = cell
.stages
.iter()
.find(|s| s.block == "std::walk_forward")
.expect("wf stage realized despite the panicked fold");
assert_eq!(wf_stage.window_faults.len(), 1);
assert_eq!(wf_stage.window_faults[0].window_ordinal, 1);
assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Panic);
assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell");
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
}
/// select_sweep_winner's Plateau arms dispatch through the public
+420 -55
View File
@@ -4,10 +4,13 @@
use std::collections::BTreeMap;
use aura_campaign::{execute, CellSpec, ExecFault, MemberFault, MemberRunner};
use aura_campaign::{
execute, CellSpec, ExecFault, MemberFault, MemberRunner, DEFAULT_PARALLEL_INSTRUMENTS,
};
use aura_backtest::{r_bootstrap, RMetrics, RunMetrics, RunReport};
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_engine::{r_bootstrap, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode};
use aura_registry::{generalization, FamilyKind, Registry, StageBootstrap};
use aura_engine::{RunManifest, SelectionMode};
use aura_registry::{generalization, FamilyKind, Registry, RegistryError, StageBootstrap};
use aura_research::{
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc,
ProcessRef, SelectRule, StageBlock, StrategyEntry, SweepSelection, WfMode, Window,
@@ -41,9 +44,9 @@ fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 {
/// The deterministic 4-trade R series a planted report carries (matches
/// `n_trades: 4`; non-constant so block resampling has structure). In-memory
/// only: `trade_rs` is serde-skipped and excluded from `PartialEq`, so every
/// only: `net_trade_rs` is serde-skipped and excluded from `PartialEq`, so every
/// existing equality and round-trip assertion stays green.
fn planted_trade_rs(net: f64) -> Vec<f64> {
fn planted_net_trade_rs(net: f64) -> Vec<f64> {
vec![net, -net, 2.0 * net, net]
}
@@ -58,6 +61,7 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64,
manifest: RunManifest {
commit: "fake".to_string(),
params: params.to_vec(),
defaults: Vec::new(),
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0,
broker: "fake".to_string(),
@@ -83,7 +87,7 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64,
sqn_normalized: net,
net_expectancy_r: net,
conviction_terciles_r: [0.0, 0.0, 0.0],
trade_rs: planted_trade_rs(net),
net_trade_rs: planted_net_trade_rs(net),
}),
},
}
@@ -125,6 +129,22 @@ impl MemberRunner for ZeroTradeRunner {
}
}
/// A runner whose `run_member` always panics — the #272 panic-containment
/// fixture (`catch_unwind` at the member boundary must turn this into a
/// `MemberFault::Panic`, never an unwind out of `execute`).
struct PanicRunner;
impl MemberRunner for PanicRunner {
fn run_member(
&self,
_cell: &CellSpec,
_params: &[(String, Scalar)],
_window_ms: (i64, i64),
) -> Result<RunReport, MemberFault> {
panic!("planted member panic")
}
}
/// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest):
/// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39.
fn axes_2x2() -> BTreeMap<String, Axis> {
@@ -201,6 +221,22 @@ fn generalize_stage() -> StageBlock {
StageBlock::Generalize { metric: "net_expectancy_r".to_string() }
}
/// A registry whose family store path is pre-occupied by a directory:
/// `append_family`'s `OpenOptions::open` then fails with a real IO error
/// (`ErrorKind::IsADirectory`) — a `RegistryError::Io` that is neither a
/// `MemberFault` nor a `WindowFault`, so `run_cell`'s `?` on `append_family`
/// returns it as a non-containable `ExecFault` (never routed through
/// `contain`), unlike the `FakeRunner`-planted member faults used elsewhere
/// in this file.
fn temp_registry_with_blocked_family_store(name: &str) -> Registry {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
.join(format!("aura-campaign-exec-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp registry dir");
std::fs::create_dir_all(dir.join("families.jsonl")).expect("occupy the family store path");
Registry::open(dir.join("runs.jsonl"))
}
fn process(pipeline: Vec<StageBlock>) -> ProcessDoc {
ProcessDoc {
format_version: 1,
@@ -218,8 +254,8 @@ fn strategies() -> Vec<(String, String)> {
/// Per-test registry DIRECTORY (family/campaign stores are per-directory
/// siblings of the runs path — the aura-registry temp-dir idiom).
fn temp_registry(name: &str) -> Registry {
let dir = std::env::temp_dir()
.join(format!("aura-campaign-exec-{}-{}", std::process::id(), name));
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
.join(format!("aura-campaign-exec-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp registry dir");
Registry::open(dir.join("runs.jsonl"))
@@ -230,7 +266,7 @@ fn execute_sweep_only_records_family_and_selection() {
let reg = temp_registry("sweep_only");
let doc = campaign(&["EURUSD"]);
let proc_doc = process(vec![sweep_stage(false)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("sweep-only campaign executes");
// outcome payloads: one cell, one family of 4 members, one selection
@@ -293,7 +329,7 @@ fn execute_selection_free_sweep_records_family_without_selection() {
let reg = temp_registry("selection_free_sweep");
let doc = campaign(&["EURUSD"]);
let proc_doc = process(vec![selection_free_sweep_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("selection-free sweep campaign executes");
// outcome payloads: one cell, one family of 4 members, NO selection
@@ -330,7 +366,7 @@ fn execute_gate_filters_per_member() {
let reg = temp_registry("gate_filters");
let doc = campaign(&["EURUSD"]);
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.3)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("gated campaign executes");
// planted nets 0.26 / 0.29 / 0.36 / 0.39 -> gt 0.3 keeps ordinals 2 and 3
@@ -349,7 +385,7 @@ fn execute_zero_survivors_truncates_cell_and_continues() {
let doc = campaign(&["AAA", "BBB"]);
// sweep -> gate(net > 0) -> gate(net > -1000): AAA dies at stage 1, BBB passes both
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), gate_stage(-1000.0)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("a zero-survivor cell is a valid result, not a fault");
assert_eq!(out.record.cells.len(), 2, "the second cell still runs");
@@ -366,14 +402,46 @@ fn execute_zero_survivors_truncates_cell_and_continues() {
assert_eq!(bbb.stages[2].survivor_ordinals, Some(vec![0, 1, 2, 3]));
}
/// #277: with `parallel_instruments = 1` (forcing 2 single-instrument
/// chunks) and 2 strategies x 2 instruments, the instrument-major chunk walk
/// executes cell (s0,AAA) then (s1,AAA) before either BBB cell — an
/// execution order that diverges from document order. The rebuild step must
/// still hand back cells in document order (C1): strategy-major, then
/// instrument, exactly as the pre-#277 nested loop did.
#[test]
fn execute_preserves_document_order_across_chunk_boundary() {
let reg = temp_registry("chunk_order");
let mut doc = campaign(&["AAA", "BBB"]);
doc.strategies.push(doc.strategies[0].clone());
let mut strats = strategies();
strats.push(strats[0].clone());
let proc_doc = process(vec![sweep_stage(false)]);
let k = std::num::NonZeroUsize::new(1).expect("1 is nonzero");
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strats, &FakeRunner::clean(), &reg, k)
.expect("chunked campaign executes");
assert_eq!(out.cells.len(), 4, "2 strategies x 2 instruments x 1 window");
let base = &CAMPAIGN_ID[..8];
let family_id = |i: usize| out.record.cells[i].stages[0].family_id.clone();
assert_eq!(family_id(0), Some(format!("{base}-0-AAA-w0-r0-s0-0")));
assert_eq!(family_id(1), Some(format!("{base}-0-BBB-w0-r0-s0-0")));
assert_eq!(family_id(2), Some(format!("{base}-1-AAA-w0-r0-s0-0")));
assert_eq!(family_id(3), Some(format!("{base}-1-BBB-w0-r0-s0-0")));
assert_eq!(
out.record.cells.iter().map(|c| c.instrument.as_str()).collect::<Vec<_>>(),
vec!["AAA", "BBB", "AAA", "BBB"],
"document order (strategy-major, then instrument) survives the K=1 chunk walk",
);
}
#[test]
fn execute_is_deterministic_twice() {
let reg = temp_registry("deterministic_twice");
let doc = campaign(&["EURUSD"]);
let proc_doc = process(vec![sweep_stage(false)]);
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("first run");
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("second run");
// run counter advances per campaign id
@@ -399,34 +467,57 @@ fn execute_is_deterministic_twice() {
assert_eq!(runs[1], second.record);
}
/// #272: a member fault is contained as a failed cell, not a global abort —
/// the campaign run record still persists (the whole point of containment),
/// while a sweep-faulted cell writes no family (`run_members` returns before
/// `append_family`).
#[test]
fn execute_member_fault_aborts_with_lowest_index() {
fn execute_member_fault_records_a_failed_cell_and_continues() {
let reg = temp_registry("member_fault");
let doc = campaign(&["EURUSD"]);
// two instruments; the planted fault is keyed by param point, so both
// cells' sweeps hit it and both fail — the containment property under test
// is that execute returns Ok and records every cell, not a global abort.
let doc = campaign(&["AAA", "BBB"]);
let proc_doc = process(vec![sweep_stage(false)]);
// faults planted at enumeration indices 1 (2,9) and 2 (3,6)
let point = |fast: i64, slow: i64| {
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
};
// FakeRunner faults are matched by param point regardless of instrument;
// plant one on the grid so AAA's (and BBB's) sweep hit it — the whole
// sweep-faulted cell fails (grid hole compromises selection).
let runner = FakeRunner {
faults: vec![
(point(2, 9), MemberFault::Run("boom at index 1".to_string())),
(point(3, 6), MemberFault::Run("boom at index 2".to_string())),
],
faults: vec![(point(2, 9), MemberFault::Run("boom".to_string()))],
};
let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg)
.expect_err("a faulted member aborts the run");
match err {
ExecFault::Member(fault) => assert_eq!(
fault,
MemberFault::Run("boom at index 1".to_string()),
"the LOWEST enumeration index's fault is reported",
),
other => panic!("expected ExecFault::Member, got {other:?}"),
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("a faulted member is recorded, not a global abort");
assert_eq!(out.record.cells.len(), 2, "both cells are recorded");
for cell in &out.record.cells {
let fault = cell.fault.as_ref().expect("sweep-fault fails the cell");
assert_eq!(fault.stage, 0);
assert_eq!(fault.kind, aura_registry::CellFaultKind::Run);
}
// the fault aborted before any family or realization write
// The run record persisted (the whole point of containment); a sweep-fault
// cell writes no family (run_members returns before append_family).
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
assert!(reg.load_family_members().expect("load members").is_empty());
assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty());
}
/// #272: a panic that unwinds out of `MemberRunner::run_member` is caught at
/// the member boundary, not propagated — the cell is recorded failed with a
/// `Panic` kind, and the run record persists.
#[test]
fn execute_member_panic_is_contained_as_a_failed_cell() {
let reg = temp_registry("member_panic");
let doc = campaign(&["AAA", "BBB"]);
let proc_doc = process(vec![sweep_stage(false)]);
let runner = PanicRunner;
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("a member panic is contained, not a process abort");
assert!(out.record.cells.iter().all(|c| c
.fault
.as_ref()
.is_some_and(|f| f.kind == aura_registry::CellFaultKind::Panic)));
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
}
#[test]
@@ -434,7 +525,7 @@ fn execute_deflate_uses_campaign_seed() {
let reg = temp_registry("deflate_seed");
let doc = campaign(&["EURUSD"]); // seed: 7
let proc_doc = process(vec![sweep_stage(true)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("deflated sweep executes");
let sel = out.record.cells[0].stages[0].selection.as_ref().expect("selection recorded");
assert_eq!(sel.selection.mode, SelectionMode::Argmax);
@@ -448,7 +539,7 @@ fn execute_refuses_malformed_campaign_id() {
let reg = temp_registry("bad_id");
let doc = campaign(&["EURUSD"]);
let proc_doc = process(vec![sweep_stage(false)]);
let err = execute("short", &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let err = execute("short", &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect_err("a non-64-hex campaign id is refused, not sliced");
match err {
ExecFault::PipelineShape { detail } => assert!(
@@ -469,7 +560,7 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
gate_stage(0.3),
StageBlock::MonteCarlo { resamples: 200, block_len: 2 },
]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("mc-after-gate campaign executes");
// planted nets .26/.29/.36/.39 -> gate gt 0.3 keeps ordinals 2 and 3
@@ -482,11 +573,11 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
assert_eq!(mc.selection, None);
// each survivor's bootstrap == a hand-called r_bootstrap on its planted
// trade_rs, seeded from the campaign doc (the deflation convention)
// net_trade_rs, seeded from the campaign doc (the deflation convention)
let net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0;
let expected = StageBootstrap::PerSurvivor(vec![
(2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)),
(3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)),
(2, r_bootstrap(&planted_net_trade_rs(net(3, 6)), 200, 2, 7)),
(3, r_bootstrap(&planted_net_trade_rs(net(3, 9)), 200, 2, 7)),
]);
assert_eq!(mc.bootstrap, Some(expected));
@@ -495,6 +586,52 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
assert_eq!(runs[0], out.record);
}
/// Property (#256 fork B): a `std::grid` first stage crosses the `run_cell`
/// seam as bare parameter points (`StageFlow::Points`) rather than executed
/// members — it persists no family of its own — and the immediately-following
/// `std::walk_forward` stage still sweeps exactly the full axis grid, picking
/// the same per-window winner a selection-carrying `std::sweep` feeding the
/// same wf stage would (`execute_mc_after_wf_pools_the_oos_series`'s
/// `sweep_stage()` + `wf_stage()` shape, minus the intermediate sweep family).
/// A regression that let `StageFlow::Points` drop or mis-order a point before
/// crossing the seam would flip the wf winner or the per-window report count
/// here even though the preflight placement tests (lib.rs) stay green.
#[test]
fn execute_grid_first_stage_feeds_walk_forward_with_all_grid_points() {
let reg = temp_registry("grid_then_wf");
let doc = campaign(&["EURUSD"]); // seed: 7, window [1_000, 9_000]
let proc_doc = process(vec![StageBlock::Grid, wf_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("grid-then-wf campaign executes");
// std::grid persists no family of its own — only std::walk_forward's does
assert_eq!(out.cells[0].families.len(), 1);
let wf = &out.cells[0].families[0];
assert_eq!(wf.block, "std::walk_forward");
// stage realization: std::grid first (no family/selection), then wf
let cell = &out.record.cells[0];
assert_eq!(cell.stages.len(), 2);
assert_eq!(cell.stages[0].block, "std::grid");
assert_eq!(cell.stages[0].family_id, None);
assert_eq!(cell.stages[0].survivor_ordinals, None);
assert_eq!(cell.stages[0].selection, None);
assert_eq!(cell.stages[1].block, "std::walk_forward");
// the roller yields 2 windows over the FULL 2x2 grid (nothing filtered
// the population before wf); both pick the planted argmax (3,9)
assert_eq!(wf.reports.len(), 2);
let winner_params =
vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))];
for report in &wf.reports {
assert_eq!(report.manifest.params, winner_params);
}
// the record round-trips through the store unchanged
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs[0], out.record);
}
#[test]
fn execute_mc_after_wf_pools_the_oos_series() {
let reg = temp_registry("mc_pooled");
@@ -504,7 +641,7 @@ fn execute_mc_after_wf_pools_the_oos_series() {
wf_stage(),
StageBlock::MonteCarlo { resamples: 200, block_len: 3 },
]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("mc-after-wf campaign executes");
// the roller yields 2 windows; both pick the planted argmax (3,9)
@@ -512,15 +649,15 @@ fn execute_mc_after_wf_pools_the_oos_series() {
assert_eq!(wf.block, "std::walk_forward");
assert_eq!(wf.reports.len(), 2);
// pooled input = the wf family reports' trade_rs concatenated in report
// pooled input = the wf family reports' net_trade_rs concatenated in report
// order (roll order per walkforward_member_reports)
let mut pooled: Vec<f64> = Vec::new();
for report in &wf.reports {
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs);
}
let net = (3 * 10 + 9) as f64 / 100.0;
let mut expected_pool = planted_trade_rs(net);
expected_pool.extend(planted_trade_rs(net));
let mut expected_pool = planted_net_trade_rs(net);
expected_pool.extend(planted_net_trade_rs(net));
assert_eq!(pooled, expected_pool);
let mc = &out.record.cells[0].stages[2];
@@ -540,7 +677,7 @@ fn execute_mc_zero_trade_member_records_degenerate() {
inner: FakeRunner::clean(),
strip: vec![("fast".to_string(), Scalar::i64(2)), ("slow".to_string(), Scalar::i64(6))],
};
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("a zero-trade member is a valid result, not a fault");
let mc = &out.record.cells[0].stages[1];
@@ -563,7 +700,7 @@ fn execute_generalize_across_two_instruments() {
// the fake negates "AAA" nets -> divergent winners across instruments
let doc = campaign(&["AAA", "BBB"]); // seed: 7
let proc_doc = process(vec![sweep_stage(false), wf_stage(), generalize_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("generalize campaign executes");
assert_eq!(out.record.generalizations.len(), 1);
@@ -622,7 +759,7 @@ fn execute_generalize_shortfall_records_missing() {
// AAA's planted nets are all negative -> the gt-0 gate empties its cell
let doc = campaign(&["AAA", "BBB"]);
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), generalize_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("a truncated cell is a recorded shortfall, not a fault");
assert!(out.cells[0].nominee.is_none(), "gate-truncated cell nominates nothing");
@@ -643,7 +780,7 @@ fn execute_generalize_only_after_sweep() {
let reg = temp_registry("generalize_sweep_only");
let doc = campaign(&["EURUSD", "GER40"]);
let proc_doc = process(vec![sweep_stage(false), generalize_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("sweep-only generalize campaign executes");
// no wf ran: each cell nominates its sweep winner (planted argmax (3,9))
@@ -676,7 +813,7 @@ fn execute_generalize_only_after_sweep() {
/// annotators compose in ONE pipeline without disturbing each other or the
/// population stages' generalize-nominee. `std::monte_carlo` sits between
/// `std::walk_forward` and `std::generalize` in the pipeline, yet its
/// per-cell bootstrap still pools exactly the wf family's OOS `trade_rs`
/// per-cell bootstrap still pools exactly the wf family's OOS `net_trade_rs`
/// (unaffected by generalize running after it), and `std::generalize`'s
/// campaign-scope nominee is still the wf stage's last-window winner
/// (unaffected by mc having annotated the cell first). A regression that
@@ -695,7 +832,7 @@ fn execute_mc_and_generalize_compose_in_one_pipeline() {
StageBlock::MonteCarlo { resamples: 200, block_len: 2 },
generalize_stage(),
]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("full v2 pipeline executes");
// both cells realize all four per-cell stages; generalize contributes no
@@ -705,13 +842,13 @@ fn execute_mc_and_generalize_compose_in_one_pipeline() {
assert_eq!(cell.stages[3].block, "std::monte_carlo");
}
// mc's bootstrap pools the wf family's OOS trade_rs exactly as it does
// mc's bootstrap pools the wf family's OOS net_trade_rs exactly as it does
// with no generalize stage after it (execute_mc_after_wf_pools_the_oos_series)
let wf = &out.cells[0].families[1];
assert_eq!(wf.block, "std::walk_forward");
let mut pooled: Vec<f64> = Vec::new();
for report in &wf.reports {
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs);
}
let expected_mc = StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 2, 7));
assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc));
@@ -753,7 +890,7 @@ fn execute_persist_taps_stamps_trace_name() {
let mut doc = campaign(&["EURUSD"]);
doc.presentation.persist_taps = vec!["equity".to_string()];
let proc_doc = process(vec![sweep_stage(false)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("persist_taps campaign executes");
// returned record: the derived name over the first run counter
@@ -771,7 +908,7 @@ fn execute_persist_taps_stamps_trace_name() {
assert_eq!(runs[0], out.record, "stored line and returned record are the same record");
// a second run of the same campaign derives the next counter's name
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("second persist_taps run");
let expected_1 = format!("{}-1", &CAMPAIGN_ID[..8]);
assert_eq!(second.record.trace_name.as_deref(), Some(expected_1.as_str()));
@@ -790,7 +927,7 @@ fn execute_runs_one_family_per_regime_with_distinct_ids() {
aura_research::RiskRegime::Vol { length: 3, k: 2.0 },
];
let proc_doc = process(vec![sweep_stage(false)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("two-regime campaign executes");
// one cell per regime, each with its own sweep family.
@@ -817,7 +954,7 @@ fn execute_generalize_keeps_regimes_separate() {
aura_research::RiskRegime::Vol { length: 3, k: 2.0 },
];
let proc_doc = process(vec![sweep_stage(false), generalize_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
.expect("two-regime generalize campaign executes");
// 2 instruments x 2 regimes -> 4 cells, but exactly 2 generalize groups
@@ -835,3 +972,231 @@ fn execute_generalize_keeps_regimes_separate() {
assert!(g.generalization.is_some(), "two instruments -> graded");
}
}
/// C1 across the parallel cell loop: the same campaign under a 1-thread pool
/// with K=1 and an 8-thread pool with K=2 (fresh registry each) yields an
/// identical campaign-run record — the campaign-path sibling of the engine's
/// `*_is_deterministic_across_thread_counts` tests.
#[test]
fn campaign_outcome_is_deterministic_across_thread_counts_and_bounds() {
let doc = campaign(&["AAA", "BBB", "CCC"]);
let proc_doc = process(vec![sweep_stage(false)]);
let run_with = |threads: usize, k: usize, name: &str| {
let reg = temp_registry(name);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("build pool");
pool.install(|| {
execute(
CAMPAIGN_ID,
&doc,
&proc_doc,
&strategies(),
&FakeRunner::clean(),
&reg,
std::num::NonZeroUsize::new(k).expect("nonzero"),
)
})
.expect("campaign run")
};
let serial = run_with(1, 1, "det_across_serial");
let parallel = run_with(8, 2, "det_across_parallel");
assert_eq!(serial.run, parallel.run);
assert_eq!(serial.record, parallel.record);
}
/// #277: a non-containable fault (here, `append_family`'s registry-level IO
/// refusal, never routed through `contain` since it is neither a
/// `MemberFault` nor a `WindowFault`) still propagates as a global `Err` out
/// of the parallel abort-latch loop, and does so with IDENTICAL content
/// regardless of thread count or pool size. Every cell's sweep hits the SAME
/// blocked family-store path, so whichever completed cell's `Err` the
/// doc-order scan in `execute` happens to pick (scheduling-dependent per the
/// exec.rs doc comment) carries the same IO error kind — exercising the
/// abort-latch + lowest-doc-order-fault-scan path (and never the
/// `unreachable!` rebuild arm, since this run returns `Err` before reaching
/// the rebuild) that the fault-free determinism test above does not.
#[test]
fn execute_propagates_a_non_containable_fault_deterministically_under_parallel_chunks() {
let doc = campaign(&["AAA", "BBB", "CCC"]);
let proc_doc = process(vec![sweep_stage(false)]);
let run_with = |threads: usize, k: usize, name: &str| {
let reg = temp_registry_with_blocked_family_store(name);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("build pool");
pool.install(|| {
execute(
CAMPAIGN_ID,
&doc,
&proc_doc,
&strategies(),
&FakeRunner::clean(),
&reg,
std::num::NonZeroUsize::new(k).expect("nonzero"),
)
})
};
let serial = run_with(1, 1, "fault_prop_serial")
.expect_err("a blocked family store is refused, not silently written");
let parallel = run_with(8, 3, "fault_prop_parallel")
.expect_err("same refusal under real cross-cell parallelism");
let kind = |e: &ExecFault| match e {
ExecFault::Registry(RegistryError::Io(io)) => io.kind(),
other => panic!("expected ExecFault::Registry(Io), got {other:?}"),
};
assert_eq!(kind(&serial), std::io::ErrorKind::IsADirectory);
assert_eq!(kind(&serial), kind(&parallel), "same fault kind regardless of scheduling");
}
/// Wraps the clean fake and tracks how many DISTINCT instruments are inside
/// `run_member` at once (refcounted per instrument; high-water mark in
/// `max_seen`). The 5 ms hold makes overlap near-certain on an 8-thread pool
/// if the residency bound is absent.
struct CountingRunner {
inner: FakeRunner,
live: std::sync::Mutex<BTreeMap<String, usize>>,
max_seen: std::sync::atomic::AtomicUsize,
}
impl MemberRunner for CountingRunner {
fn run_member(
&self,
cell: &CellSpec,
params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault> {
let distinct = {
let mut live = self.live.lock().expect("live instrument map");
*live.entry(cell.instrument.clone()).or_insert(0) += 1;
live.len()
};
self.max_seen.fetch_max(distinct, std::sync::atomic::Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(5));
let out = self.inner.run_member(cell, params, window_ms);
let mut live = self.live.lock().expect("live instrument map");
let count = live.get_mut(&cell.instrument).expect("entered above");
*count -= 1;
if *count == 0 {
live.remove(&cell.instrument);
}
out
}
}
/// The structural residency bound: with K=1 on an 8-thread pool, no two
/// distinct instruments are ever inside `run_member` at the same time.
#[test]
fn parallel_cells_never_exceed_the_instrument_bound() {
let reg = temp_registry("instrument_bound");
let doc = campaign(&["AAA", "BBB", "CCC"]);
let proc_doc = process(vec![sweep_stage(false)]);
let runner = CountingRunner {
inner: FakeRunner::clean(),
live: std::sync::Mutex::new(BTreeMap::new()),
max_seen: std::sync::atomic::AtomicUsize::new(0),
};
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().expect("build pool");
pool.install(|| {
execute(
CAMPAIGN_ID,
&doc,
&proc_doc,
&strategies(),
&runner,
&reg,
std::num::NonZeroUsize::new(1).expect("nonzero"),
)
})
.expect("campaign run");
let max = runner.max_seen.load(std::sync::atomic::Ordering::SeqCst);
assert!(max <= 1, "K=1 must bound distinct live instruments to 1, saw {max}");
}
/// A registry-write fault is infrastructure, not a cell pathology: it stays
/// run-fatal in the parallel loop (no cell containment, no partial
/// campaign-run record).
#[test]
fn a_registry_fault_is_run_fatal_in_the_parallel_loop() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
.join("aura-campaign-exec-registry_fatal");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp registry dir");
// A directory squatting on the family store makes every append_family fail.
std::fs::create_dir(dir.join("families.jsonl")).expect("squat the family store");
let reg = Registry::open(dir.join("runs.jsonl"));
let doc = campaign(&["AAA", "BBB"]);
let proc_doc = process(vec![sweep_stage(false)]);
let err = execute(
CAMPAIGN_ID,
&doc,
&proc_doc,
&strategies(),
&FakeRunner::clean(),
&reg,
DEFAULT_PARALLEL_INSTRUMENTS,
)
.expect_err("a dead family store is run-fatal");
assert!(matches!(err, ExecFault::Registry(_)));
assert!(
!dir.join("campaign_runs.jsonl").exists(),
"no partial campaign-run record on the fatal path"
);
}
/// #272 under the parallel loop: member faults stay contained as failed
/// cells across concurrently-running cells — the run record persists with
/// every cell recorded, never a global abort.
#[test]
fn member_faults_stay_contained_in_the_parallel_loop() {
let reg = temp_registry("parallel_containment");
let doc = campaign(&["AAA", "BBB", "CCC"]);
let proc_doc = process(vec![sweep_stage(false)]);
let point = |fast: i64, slow: i64| {
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
};
let runner = FakeRunner {
faults: vec![(point(2, 9), MemberFault::Run("boom".to_string()))],
};
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().expect("build pool");
let out = pool
.install(|| {
execute(
CAMPAIGN_ID,
&doc,
&proc_doc,
&strategies(),
&runner,
&reg,
std::num::NonZeroUsize::new(2).expect("nonzero"),
)
})
.expect("member faults are contained, not a global abort");
assert_eq!(out.record.cells.len(), 3, "every cell is recorded");
assert!(out.record.cells.iter().all(|c| c.fault.is_some()));
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
}
/// Defense in depth (audit tidy, #277): `execute` itself refuses duplicate
/// instruments via `preflight`, independent of the CLI's validate tier — a
/// direct caller skipping `validate_campaign` must not reach the cell loop
/// with colliding registry family names.
#[test]
fn execute_refuses_duplicate_instruments() {
let reg = temp_registry("duplicate_instruments");
let doc = campaign(&["AAA", "AAA"]);
let proc_doc = process(vec![sweep_stage(false)]);
let err = execute(
CAMPAIGN_ID,
&doc,
&proc_doc,
&strategies(),
&FakeRunner::clean(),
&reg,
DEFAULT_PARALLEL_INSTRUMENTS,
)
.expect_err("duplicate instruments collide on family names");
assert!(matches!(err, ExecFault::PipelineShape { .. }));
}
+10 -6
View File
@@ -12,7 +12,8 @@
use std::collections::BTreeMap;
use aura_campaign::{member_metric, CellSpec, MemberFault, MemberRunner, PER_MEMBER_METRICS, RANKABLE_METRICS};
use aura_engine::{summarize, RMetrics, RunManifest, RunMetrics, RunReport, Scalar, Timestamp};
use aura_backtest::{summarize, RMetrics, RunMetrics, RunReport};
use aura_engine::{RunManifest, Scalar, Timestamp};
fn cell() -> CellSpec {
CellSpec {
@@ -28,7 +29,7 @@ fn cell() -> CellSpec {
}
/// A minimal external `MemberRunner`: ignores its params and produces
/// metrics via the real `aura_engine::summarize` reduction (not a hand-typed
/// metrics via the real `aura_backtest::summarize` reduction (not a hand-typed
/// `RunMetrics` literal), echoing the `window_ms` it was called with into
/// the manifest — exactly what a real consumer's harness-binding code does.
struct FixedRunner;
@@ -46,6 +47,7 @@ impl MemberRunner for FixedRunner {
manifest: RunManifest {
commit: "e2e".to_string(),
params: vec![],
defaults: vec![],
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0,
broker: "test".to_string(),
@@ -63,7 +65,7 @@ impl MemberRunner for FixedRunner {
/// `&dyn MemberRunner` (the shape a heterogeneous, per-consumer roster of
/// runners requires); the exact sub-window given at the call site — not
/// `cell.window_ms`, a walk-forward stage passes a narrower sub-range —
/// reaches the runner unmodified; and the real `aura_engine::summarize()`
/// reaches the runner unmodified; and the real `aura_backtest::summarize()`
/// output the runner produced is what `member_metric` reads back through the
/// crate's public per-name resolver.
#[test]
@@ -87,8 +89,9 @@ fn member_runner_seam_dispatches_through_dyn_trait_object_and_carries_the_window
/// Property: every metric name in `RANKABLE_METRICS` (the roster `preflight`
/// permits a `std::sweep`/`std::walk_forward` stage to SELECT on) is also a
/// member of `PER_MEMBER_METRICS` and resolves to `Some` via `member_metric`.
/// The two rosters are independently hand-copied (documented drift risk,
/// #190); were they to diverge, `preflight` would accept a selection metric
/// Since #147 the rankable roster is a single-source re-export and the
/// nesting is guard-pinned; were the rosters still to diverge, `preflight`
/// would accept a selection metric
/// that `member_metric` silently reads back as `None` for every member — a
/// sweep/walk_forward stage that always empties, not a compile error or an
/// early refusal.
@@ -98,6 +101,7 @@ fn rankable_metrics_are_all_per_member_resolvable() {
manifest: RunManifest {
commit: "e2e".to_string(),
params: vec![],
defaults: vec![],
window: (Timestamp(0), Timestamp(1)),
seed: 0,
broker: "test".to_string(),
@@ -123,7 +127,7 @@ fn rankable_metrics_are_all_per_member_resolvable() {
sqn: 13.0,
net_expectancy_r: 14.0,
conviction_terciles_r: [0.0; 3],
trade_rs: Vec::new(),
net_trade_rs: Vec::new(),
}),
},
};
@@ -1,15 +1,17 @@
//! Cross-crate guard for #190: pins `aura_research::metric_vocabulary()` (the
//! hand-written 17-name declared metric roster) against the ACTUAL scalar
//! fields the three metric-bearing `aura-analysis` types serialize — not a
//! second hand-list that could independently drift. `aura-campaign` is the
//! one crate that already depends on both `aura-analysis` (the types) and
//! `aura-research` (the vocabulary), so it is the only place this pin can be
//! written today (`aura-research` deliberately has no `aura-analysis` dep —
//! fields the three metric-bearing types serialize — `RunMetrics`/`RMetrics`
//! from `aura-backtest` (via `summarize_r`), `FamilySelection` from
//! `aura-analysis` — not a second hand-list that could independently drift.
//! `aura-campaign` is the one crate that already depends on the type sources
//! and `aura-research` (the vocabulary), so this pin lives here
//! (`aura-research` deliberately has no dep on either type crate —
//! `metric_vocabulary`'s own doc comment names this as an accepted residual).
use std::collections::BTreeSet;
use aura_analysis::{summarize_r, FamilySelection, RunMetrics, SelectionMode};
use aura_analysis::{FamilySelection, SelectionMode};
use aura_backtest::{summarize_r, RunMetrics};
/// The names of every field of a serialized sample whose JSON value is a
/// `Number` — i.e. a scalar metric readout, not a string/enum tag, a nested
@@ -83,7 +85,7 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
scalar_fields.extend(selection_scalars);
let vocabulary: BTreeSet<String> =
aura_research::metric_vocabulary().iter().map(|s| s.to_string()).collect();
aura_research::metric_vocabulary().iter().map(|m| m.id.to_string()).collect();
let missing_from_vocabulary: Vec<_> = scalar_fields.difference(&vocabulary).collect();
let stale_in_vocabulary: Vec<_> = vocabulary.difference(&scalar_fields).collect();
@@ -105,3 +107,31 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
top-level enumeration)"
);
}
/// #147: the rankable roster is single-sourced from the vocabulary impl, the
/// rosters nest (rankable ⊆ per-member ⊆ research vocabulary), and the legacy
/// CLI alias keeps resolving. Any drift fails here, loudly.
#[test]
fn rankable_roster_is_single_sourced_and_nested() {
use aura_engine::MetricVocabulary;
assert_eq!(
aura_campaign::RANKABLE_METRICS,
<RunMetrics as MetricVocabulary>::known(),
"campaign roster must BE the vocabulary's roster"
);
for name in aura_campaign::RANKABLE_METRICS {
assert!(
aura_campaign::PER_MEMBER_METRICS.contains(name),
"rankable metric '{name}' missing from PER_MEMBER_METRICS"
);
}
for name in aura_campaign::PER_MEMBER_METRICS {
assert!(
aura_research::metric_vocabulary().iter().any(|m| m.id == *name),
"per-member metric '{name}' missing from the research vocabulary"
);
}
// the pre-rename CLI alias must keep resolving (back-compat).
assert!(<RunMetrics as MetricVocabulary>::resolve("exposure_sign_flips").is_some());
}
+30 -10
View File
@@ -12,9 +12,6 @@ path = "src/main.rs"
[dependencies]
aura-core = { path = "../aura-core" }
aura-engine = { path = "../aura-engine" }
# aura-composites: the RiskExecutor / vol_stop composite-builders the
# r-sma harness wires (kept out of aura-engine so the engine stays domain-free).
aura-composites = { path = "../aura-composites" }
aura-registry = { path = "../aura-registry" }
aura-research = { path = "../aura-research" }
# aura-campaign: campaign-execution semantics (preflight, cell loop, stage
@@ -22,7 +19,17 @@ aura-research = { path = "../aura-research" }
# and renders its outcome (#198).
aura-campaign = { path = "../aura-campaign" }
aura-std = { path = "../aura-std" }
aura-strategy = { path = "../aura-strategy" }
aura-backtest = { path = "../aura-backtest" }
aura-vocabulary = { path = "../aura-vocabulary" }
aura-ingest = { path = "../aura-ingest" }
# aura-measurement: the C28 measurement rung's IC vocabulary + reduction
# (#295), the shell re-exposes it as the `aura measure ic` verb.
aura-measurement = { path = "../aura-measurement" }
# aura-runner: the C28 assembly position (#295) — harness assembly, input
# binding (C26), and the param<->config translators the shell wraps into
# verbs.
aura-runner = { path = "../aura-runner" }
# data-server: the local M1 archive `aura run --real` streams from. Mirrors the
# git line in crates/aura-ingest/Cargo.toml verbatim (same source of truth).
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
@@ -40,16 +47,29 @@ serde_json = { workspace = true }
# EPIPE. The standard vetted crate for signal disposition, already transitive in
# Cargo.lock; admitted under the per-case dependency policy.
libc = "0.2"
# sha2: the run's topology_hash (#158) is the SHA256 of the canonical signal
# blueprint serialization. A vetted RustCrypto crate (per-case C16 review,
# SHA256 user-settled); lives in the research-side CLI, off the frozen engine
# (invariant 8).
sha2 = "0.10"
libloading = "0.8"
toml = "0.8"
# clap: the vetted standard argument parser. Adopted (C16 per-case review) to
# replace the hand-rolled argv parser + ten duplicated help surfaces with one
# declarative source, giving scoped --help, --version, --flag=value, and
# long-option abbreviation. Research-side CLI only (invariant 8): a dev-loop
# compile tax, never a frozen-artifact tax.
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
# zip: hand-packs a tiny synthetic M1 archive (tests/common/mod.rs) in the
# exact on-disk format data-server reads — already transitive via the
# data-server dependency above; declared directly here so test code may
# `use zip::...` (#250, no new dependency actually enters the build graph).
zip = "2"
# aura-composites: the RiskExecutor / vol_stop composite-builders — production
# use moved to aura-runner (#295); the shell's own use is test-only
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
aura-composites = { path = "../aura-composites" }
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
aura-analysis = { path = "../aura-analysis" }
# sha2: production topology_hash / dylib_sha256 hashing now lives entirely in
# aura-runner (#295); the shell's own use is test-only (tests/cli_run.rs
# re-hashes a stored blueprint to check a golden), so this rides the same
# dev-only-edge idiom as `zip`/`aura-analysis` above.
sha2 = "0.10"
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
@@ -1 +0,0 @@
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
@@ -1 +0,0 @@
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
@@ -1 +0,0 @@
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
-1
View File
@@ -1 +0,0 @@
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
//! Stable stderr class markers (C14): `aura: note: <text>` for benign
//! diagnostics on a continuing run (exit code unaffected),
//! `aura: warning: <text>` for recorded faults the run survives.
//! Error lines that accompany a non-zero exit — and plain info lines
//! such as the run-record summary — keep the bare `aura:` prefix; for
//! errors, the exit-code partition is the machine contract.
macro_rules! note {
($($arg:tt)*) => {
eprintln!("aura: note: {}", format_args!($($arg)*))
};
}
macro_rules! warning {
($($arg:tt)*) => {
eprintln!("aura: warning: {}", format_args!($($arg)*))
};
}
pub(crate) use {note, warning};
/// The zero-trade note's message text (#313), pluralization-aware — the
/// milestone fieldtest tripped over "all 1 walk-forward windows"
/// (captured: `fieldtests/milestone-stderr-honesty/captured/
/// example2_singular.err`). Pure over the count so the wording is
/// unit-pinned.
fn zero_trade_note_text(n_windows: usize) -> String {
if n_windows == 1 {
"the single walk-forward window recorded zero trades".to_string()
} else {
format!("all {n_windows} walk-forward windows recorded zero trades")
}
}
/// The #313 zero-trade note, shared by both walk-forward paths (the
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
/// place. Takes the per-window trade counts; a no-op unless there is at
/// least one window and every one of them traded zero times.
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
let n_windows = window_trades.len();
if n_windows > 0 && window_trades.all(|n| n == 0) {
note!("{}", zero_trade_note_text(n_windows));
}
}
#[cfg(test)]
mod tests {
use super::zero_trade_note_text;
#[test]
/// #278 fieldtest friction: the single-window form must read as
/// grammatical English while the plural form keeps the exact phrase
/// the e2e pins grep for.
fn zero_trade_note_pluralizes() {
assert_eq!(
zero_trade_note_text(1),
"the single walk-forward window recorded zero trades"
);
assert_eq!(
zero_trade_note_text(3),
"all 3 walk-forward windows recorded zero trades"
);
}
}
+70 -30
View File
@@ -2,7 +2,7 @@
//! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op`
//! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph
//! build` / `aura graph introspect` subcommands here drive these ops through the
//! injected `aura_std::std_vocabulary`.
//! injected `aura_vocabulary::std_vocabulary`.
use std::collections::BTreeMap;
use std::path::Path;
@@ -13,10 +13,10 @@ use aura_engine::{
};
use serde::Deserialize;
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
// production code; tests still exercise it directly.
#[cfg(test)]
use aura_std::std_vocabulary;
use aura_vocabulary::std_vocabulary;
/// The wire DTO for one construction op — the document's by-identifier shape,
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
@@ -44,11 +44,19 @@ enum OpDoc {
#[serde(rename = "as")]
as_name: String,
},
Tap {
from: String,
#[serde(rename = "as")]
as_name: String,
},
Gang {
#[serde(rename = "as")]
as_name: String,
into: Vec<String>,
},
/// Declare the composite's one-line meaning (C29, #316) — the op-script
/// twin of the builder's `.doc(...)`.
Doc { text: String },
}
impl OpDoc {
@@ -61,7 +69,9 @@ impl OpDoc {
OpDoc::Feed { .. } => "feed",
OpDoc::Connect { .. } => "connect",
OpDoc::Expose { .. } => "expose",
OpDoc::Tap { .. } => "tap",
OpDoc::Gang { .. } => "gang",
OpDoc::Doc { .. } => "doc",
}
}
}
@@ -77,7 +87,9 @@ impl From<OpDoc> for Op {
OpDoc::Feed { role, into } => Op::Feed { role, into },
OpDoc::Connect { from, to } => Op::Connect { from, to },
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
OpDoc::Doc { text } => Op::Doc { text },
}
}
}
@@ -90,6 +102,7 @@ fn format_op_error(e: &OpError) -> String {
OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"),
OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"),
OpError::DuplicateTap(s) => format!("duplicate tap name {s:?}"),
OpError::DuplicateRole(s) => format!("duplicate role {s:?}"),
OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"),
OpError::UnknownRole(s) => format!("unknown role {s:?}"),
@@ -121,6 +134,7 @@ fn format_op_error(e: &OpError) -> String {
}
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::Incomplete(ce) => format!("{ce:?}"),
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
}
}
@@ -128,7 +142,7 @@ fn format_op_error(e: &OpError) -> String {
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
/// past the last op).
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
@@ -154,14 +168,14 @@ fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite,
/// Parse a JSON op-list document, replay it through the env's vocabulary, and
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let composite = composite_from_str(doc, env)?;
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
}
/// `aura graph build`: read the op-list document from stdin, build, and print the
/// blueprint to stdout — or the cause to stderr and exit non-zero.
pub fn build_cmd(env: &crate::project::Env) {
pub fn build_cmd(env: &aura_runner::project::Env) {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
@@ -184,7 +198,7 @@ pub fn build_cmd(env: &crate::project::Env) {
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
/// closed vocabulary.
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
let schema = builder.schema();
let mut out = format!("{}\n", builder.label());
@@ -202,7 +216,7 @@ pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<Strin
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize).
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let resolver = |t: &str| env.resolve(t);
let mut session = GraphSession::new("introspect", &resolver);
@@ -221,7 +235,7 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
/// group must be set; zero or more than one is the usage error (exit 2). The id
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
/// combine (one build, both ids, content id first).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
+ cmd.unwired as usize
@@ -396,7 +410,7 @@ fn gang_fault_prose(e: &CompileError) -> String {
/// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph
/// build` path (`composite_from_str`, over `OpError`) call this same helper,
/// so the tier texts cannot drift between the two error families.
fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<String> {
fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
if !type_id.contains("::") {
return None;
}
@@ -415,7 +429,7 @@ fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<Str
/// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the
/// blueprint LOAD path's entry point over `LoadError`.
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> {
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option<String> {
match e {
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
_ => None,
@@ -439,7 +453,7 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env
/// `Ok(())` means the document loaded cleanly; callers that only need the
/// validation (not the `Composite`) re-parse `doc` themselves afterward
/// (`blueprint_from_json` is cheap and infallible at that point).
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> {
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
return Err(
"this is an op-script (an op array), not a built blueprint — run \
@@ -461,7 +475,7 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Resu
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
/// each canonicalized by its own rules.
pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
match value {
@@ -480,22 +494,22 @@ pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Resul
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
/// id in the project store (the campaign-run target-addressing convention).
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
/// cannot drift apart on what counts as a store address.
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
// fields stay bare-only.
let target = target
.strip_prefix("content:")
.filter(|t| crate::campaign_run::is_content_id(t))
.filter(|t| aura_runner::axes::is_content_id(t))
.unwrap_or(target);
let path = Path::new(target);
if path.is_file() {
return std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()));
}
if crate::campaign_run::is_content_id(target) {
if aura_runner::axes::is_content_id(target) {
return match env.registry().get_blueprint(target) {
Ok(Some(json)) => Ok(json),
Ok(None) => Err(format!("no blueprint {target} in the project store")),
@@ -510,7 +524,7 @@ fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<Str
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
/// the same form `introspect --node` already uses for param kinds.
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
use std::fmt::Write as _;
let text = resolve_blueprint_text(target, env)?;
// Shape-discriminated like file-mode --content-id: an op-script (array)
@@ -527,7 +541,7 @@ fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, Strin
/// the project vocabulary, canonicalize, content-address, and store — the
/// `process register` pattern, printing the store path so the trail is
/// followable. Prose to stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
match register_blueprint(file, env) {
Ok(line) => println!("{line}"),
Err(m) => {
@@ -537,7 +551,7 @@ pub fn register_cmd(file: &Path, env: &crate::project::Env) {
}
}
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
// Shape-discriminated like file-mode --content-id (#202): either shape
@@ -597,7 +611,7 @@ mod tests {
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds");
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
}
@@ -613,14 +627,14 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"out"}
]"#;
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds");
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
}
#[test]
fn build_from_str_reports_unknown_node_at_its_op_index() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
}
@@ -635,7 +649,7 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
"names the unconnected slot by-identifier, got: {err}");
@@ -644,11 +658,11 @@ mod tests {
#[test]
fn introspect_node_lists_ports_kinds_and_params() {
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("series"), "lists the input port: {out}");
assert!(out.contains("value"), "lists the output field: {out}");
assert!(out.contains("length"), "lists the param path: {out}");
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type");
}
/// A bind whose value-kind mismatches the param reads as prose, like the connect
@@ -656,7 +670,7 @@ mod tests {
#[test]
fn build_from_str_bad_bind_kind_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
}
@@ -665,7 +679,7 @@ mod tests {
#[test]
fn build_from_str_unknown_bind_param_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
}
@@ -686,7 +700,7 @@ mod tests {
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
#[test]
fn introspect_node_shows_the_bind_form() {
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
}
@@ -697,9 +711,35 @@ mod tests {
{"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects");
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
}
/// The `tap` op-doc parses (externally tagged, `"as"`-renamed like `expose`)
/// and `aura graph build` emits a blueprint whose serialized `taps` names the
/// wire — the name-addressed authoring path the fieldtest wanted, no raw index.
#[test]
fn tap_opdoc_builds_a_blueprint_carrying_the_tap() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"tap","from":"fast.value","as":"fast_ma"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
// the tap-doc kind_label registers as "tap"
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
assert_eq!(docs[7].kind_label(), "tap");
// and the built blueprint carries the tap (an un-tapped composite emits no
// `taps` key, so its presence is the proof the op threaded through)
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds");
assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}");
assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
}
}
+643 -2649
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -234,9 +234,9 @@ mod tests {
/// against the prototype.
#[test]
fn render_html_is_self_contained_and_embeds_the_model() {
let env = crate::project::Env::std();
let env = aura_runner::project::Env::std();
let bp = aura_engine::blueprint_from_json(
include_str!("../examples/r_sma_open.json"),
include_str!("../examples/r_sma.json"),
&|t| env.resolve(t),
)
.expect("the shipped r-sma example reloads into a renderable blueprint");
+284 -7
View File
@@ -13,7 +13,7 @@ use aura_research::{
};
use aura_registry::RefFault;
use crate::project::Env;
use aura_runner::project::Env;
#[derive(clap::Args)]
pub struct ProcessCmd {
@@ -29,6 +29,8 @@ pub enum ProcessSub {
Introspect(DocIntrospectCmd),
/// Register a valid process document into the store under the runs root.
Register { file: PathBuf },
/// Print a registered process document's canonical bytes to stdout.
Show { id: String },
}
#[derive(clap::Args)]
@@ -60,6 +62,17 @@ impl DocIntrospectCmd {
}
}
/// Parse `--parallel-instruments` in domain terms: clap's built-in
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
/// zero for non-zero type") at exactly the moment a user needs guidance.
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
"must be a whole number of at least 1 — it bounds how many distinct \
instruments are resident in parallel"
.to_string()
})
}
/// Exactly one introspect mode (the graph-introspect guard idiom: usage
/// error on stderr, exit 2).
fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
@@ -79,9 +92,10 @@ fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
/// 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);
for m in aura_research::metric_vocabulary() {
let name = m.id;
let rankable = aura_campaign::RANKABLE_METRICS.contains(&name);
let gate = aura_campaign::PER_MEMBER_METRICS.contains(&name);
// The generalize applicability is the registry's own R-expectancy
// predicate — no fourth roster site (#190/#207).
let generalize = aura_registry::check_r_metric(name).is_ok();
@@ -137,6 +151,9 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
format!("pipeline[{stage}]: walk_forward {field} must be > 0")
}
DocFault::EmptyInstruments => "data.instruments is empty".into(),
DocFault::DuplicateInstrument { index, instrument } => {
format!("data.instruments[{index}]: \"{instrument}\" is listed more than once")
}
DocFault::NoWindow => "data.windows is empty".into(),
DocFault::BadWindow { index } => {
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
@@ -147,6 +164,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::BadCost { index } => {
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
}
DocFault::CostInstrumentKeys { index, missing, extra } => {
let mut parts = Vec::new();
if !missing.is_empty() {
parts.push(format!("misses campaign instrument(s) {}", missing.join(", ")));
}
if !extra.is_empty() {
parts.push(format!("names no campaign instrument(s): {}", extra.join(", ")));
}
format!("cost[{index}]: instrument map {}", parts.join("; "))
}
DocFault::NoStrategy => "strategies is empty".into(),
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
DocFault::EmptyAxis { strategy, axis } => {
@@ -155,7 +182,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
DocFault::UnknownTap { index, tap } => format!(
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
DocFault::UnknownBindingColumn { role, column } => format!(
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
@@ -164,6 +191,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::ProcessRefMustBeContentId => {
"process.ref: a process is referenced by content id in this version".into()
}
DocFault::BadDescription { subject, fault } => match fault {
aura_core::DocGateFault::Empty => format!(
"description: `{subject}`'s description is empty — omit the \
field or write a one-line meaning (C29)"
),
aura_core::DocGateFault::RestatesName => format!(
"description: `{subject}`'s description merely restates the \
document name (C29)"
),
},
}
}
@@ -184,6 +221,7 @@ pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
introspect_process(i)
}
ProcessSub::Register { file } => register_process(file, env),
ProcessSub::Show { id } => show_process(id, env),
};
if let Err(m) = result {
eprintln!("aura: {m}");
@@ -223,6 +261,15 @@ fn validate_process_file(file: &PathBuf) -> Result<(), String> {
}
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"process register needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let doc = parse_valid_process(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = process_to_json(&doc);
@@ -235,6 +282,60 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
Ok(())
}
/// Resolve `id` against a document store's full candidate content-id list —
/// mirroring the exact-first / unique-match / else-refuse shape
/// `aura_runner::reproduce::load_family` established for #298's family/run
/// handle. Called only once the caller's own exact-match lookup has already
/// missed, so a filter match here is always a strict, shorter prefix (ids
/// are fixed-length hashes; a full-length miss cannot be a prefix of
/// another). `Ok(None)`: no candidate matches — genuinely unknown, the
/// caller's existing not-found prose applies unchanged. `Err`: two or more
/// candidates share the prefix — refused by name, listing every candidate,
/// rather than guessed.
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
match matches.as_slice() {
[] => Ok(None),
[only] => Ok(Some((*only).clone())),
many => {
let listed = many.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
}
}
}
fn show_process(id: &str, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"process show needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let registry = env.registry();
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
// `print!`, not `println!`: `bytes` is the content-addressed canonical
// form the store keyed `id` on — the CLI is a transport and must not
// frame the canonical bytes with a trailing newline (#164, mirroring
// `graph_construct::build_cmd`).
print!("{bytes}");
return Ok(());
}
let candidates = registry.list_process_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(id, &candidates)? {
Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? {
Some(bytes) => {
print!("{bytes}");
Ok(())
}
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
},
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
}
}
fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
if cmd.metrics {
print_metric_roster();
@@ -278,6 +379,9 @@ fn describe_one_block(id: &str) -> Result<(), String> {
fn print_open_slots(slots: &[aura_research::OpenSlot]) {
for s in slots {
println!("open slot: {} ({})", s.path, s.hint);
for note in &s.notes {
println!(" {note}");
}
}
if slots.is_empty() {
println!("no open slots");
@@ -300,10 +404,22 @@ pub enum CampaignSub {
Register { file: PathBuf },
/// Execute a stored campaign into a realized run-set (a .json file is
/// register-then-run sugar; the canonical address is the content id).
Run { target: String },
Run {
target: String,
/// Bound on distinct instruments resident in parallel (the RAM
/// lever; 1 reproduces the sequential loop's footprint).
#[arg(
long,
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
value_parser = parse_parallel_instruments,
)]
parallel_instruments: std::num::NonZeroUsize,
},
/// List stored campaign realizations, or dump one campaign's records
/// (the bare store lines, not the run-emit wrapper).
Runs { campaign: Option<String>, run: Option<usize> },
/// Print a registered campaign document's canonical bytes to stdout.
Show { id: String },
}
/// The one authoring trap the referential tier can name outright: a ref that
@@ -317,6 +433,14 @@ fn prefix_hint(id: &str) -> &'static str {
}
}
/// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault`
/// sibling (unlike processes' `ref_fault_prose`), so this is the single
/// source both the exact-miss and prefix-resolved-but-then-missing arms
/// route through, keeping the two copies from drifting.
fn campaign_not_found_prose(id: &str) -> String {
format!("campaign {id} not found in the project store{}", prefix_hint(id))
}
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
match f {
RefFault::ProcessNotFound(id) => {
@@ -403,7 +527,18 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
Ok(())
}
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
/// via `exit_on_campaign_result`), so it is pulled out of the unified
/// `Result<(), String>` match the other four subcommands still share.
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
target,
env,
*parallel_instruments,
));
return;
}
let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => {
@@ -411,8 +546,9 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
introspect_campaign(i)
}
CampaignSub::Register { file } => register_campaign(file, env),
CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env),
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
CampaignSub::Show { id } => show_campaign(id, env),
CampaignSub::Run { .. } => unreachable!("handled above"),
};
if let Err(m) = result {
eprintln!("aura: {m}");
@@ -502,6 +638,15 @@ fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
}
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"campaign register needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let doc = parse_valid_campaign(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = campaign_to_json(&doc);
@@ -514,6 +659,36 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
Ok(())
}
fn show_campaign(id: &str, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"campaign show needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let registry = env.registry();
if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? {
// `print!`, not `println!`: see `show_process` — the CLI must not
// frame the canonical bytes with a trailing newline (#164).
print!("{bytes}");
return Ok(());
}
let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(id, &candidates)? {
Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? {
Some(bytes) => {
print!("{bytes}");
Ok(())
}
None => Err(campaign_not_found_prose(id)),
},
None => Err(campaign_not_found_prose(id)),
}
}
fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
if cmd.metrics {
print_metric_roster();
@@ -651,4 +826,106 @@ mod tests {
);
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
}
/// C29 (#316): the description-quality fault names the subject and the
/// rule (C29) for both sub-faults — an empty description and one that
/// merely restates the document name — never leaking the Debug variant
/// names of either `DocFault` or the nested `DocGateFault`.
#[test]
fn bad_description_prose_names_subject_and_rule_for_both_faults() {
let empty = doc_fault_prose(&DocFault::BadDescription {
subject: "ger40-momentum".into(),
fault: aura_core::DocGateFault::Empty,
});
assert!(
empty.contains("`ger40-momentum`'s description is empty"),
"subject and emptiness named: {empty}"
);
assert!(empty.contains("C29"), "rule cited: {empty}");
assert!(!empty.contains("BadDescription") && !empty.contains("DocGateFault"), "Debug leak: {empty}");
let restated = doc_fault_prose(&DocFault::BadDescription {
subject: "ger40-momentum".into(),
fault: aura_core::DocGateFault::RestatesName,
});
assert!(
restated.contains("`ger40-momentum`'s description merely restates the document name"),
"subject and restatement named: {restated}"
);
assert!(restated.contains("C29"), "rule cited: {restated}");
assert!(!restated.contains("BadDescription") && !restated.contains("DocGateFault"), "Debug leak: {restated}");
}
/// #260: the instrument-map key-mismatch fault is path-addressed and names
/// both directions — the campaign instruments the map misses AND the map
/// keys naming no campaign instrument — in one line, no Debug leak. The
/// extra-keys branch is otherwise unpinned (the e2e covers only missing).
#[test]
fn cost_instrument_keys_prose_names_both_directions() {
let prose = doc_fault_prose(&DocFault::CostInstrumentKeys {
index: 1,
missing: vec!["EURUSD".into()],
extra: vec!["GER40.cash".into()],
});
assert_eq!(
prose,
"cost[1]: instrument map misses campaign instrument(s) EURUSD; \
names no campaign instrument(s): GER40.cash"
);
assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}");
}
/// #300 fieldtest (df_4): outside a project (no Aura.toml → `Env::std()`),
/// show's refusal names the missing project — the honest degradation
/// `validate` and `campaign runs` already share ("no Aura.toml found up
/// from <cwd>") — never a "project store" that does not exist. Blaming the
/// id sends an author in the wrong directory chasing a registration that
/// was never the problem. The refusal itself (Err → exit 1) is correct
/// and stays.
#[test]
fn show_outside_a_project_names_the_missing_aura_toml_not_the_id() {
let env = Env::std();
for (verb, result) in [
("process show", show_process("deadbeef", &env)),
("campaign show", show_campaign("deadbeef", &env)),
] {
let msg = result.expect_err("show outside a project must refuse");
assert!(
msg.contains("no Aura.toml found up from"),
"{verb} outside a project must name the missing Aura.toml: {msg}"
);
assert!(
!msg.contains("not found in the project store"),
"{verb} outside a project must not blame the id against a \
store that does not exist: {msg}"
);
}
}
/// #302: the ambiguity arm of `resolve_id_prefix` — content ids are
/// hash-derived, not choosable, so two documents sharing a printed
/// prefix cannot be constructed honestly through an E2E fixture
/// (`show_resolves_a_unique_prefix_to_the_full_id_bytes` pins the
/// unique-match arm end-to-end instead). This unit test is the only pin
/// the ambiguous-refusal arm gets: a prefix shared by more than one
/// candidate refuses rather than guesses, and the refusal names every
/// sharing candidate so the author can pick the intended one.
#[test]
fn resolve_id_prefix_refuses_an_ambiguous_prefix_listing_candidates() {
let candidates = vec!["4e2d1111".to_string(), "4e2d2222".to_string(), "9f3a0000".to_string()];
let err = resolve_id_prefix("4e2d", &candidates).expect_err("shared prefix must refuse");
assert!(err.contains("4e2d1111"), "ambiguity refusal must list candidate 1: {err}");
assert!(err.contains("4e2d2222"), "ambiguity refusal must list candidate 2: {err}");
assert!(!err.contains("9f3a0000"), "ambiguity refusal must not list a non-matching id: {err}");
// Companion sanity for the two other arms at the same seam: a unique
// prefix resolves, and no matching prefix reports as unresolved
// rather than fabricating one.
assert_eq!(
resolve_id_prefix("9f3a", &candidates).expect("unique prefix must resolve"),
Some("9f3a0000".to_string())
);
assert_eq!(resolve_id_prefix("dead", &candidates).expect("no match is not an error"), None);
}
}
+15 -8
View File
@@ -152,6 +152,8 @@ impl Scale {
pub fn new(factor: f64) -> Self {
Self { factor, out: [Cell::from_f64(0.0)] }
}
// Replace `doc` below when renaming this sample node — it must keep
// describing the node's own behaviour, not this scaffolding step.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"__NS__::Scale",
@@ -163,6 +165,7 @@ impl Scale {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
doc: "scalar gain: emits the input times the factor param",
},
|p| Box::new(Scale::new(p[0].f64())),
)
@@ -219,7 +222,7 @@ crate is attached to its project via `[nodes]` in the project's `Aura.toml`.
const GITIGNORE_PROJECT: &str = "/runs\n";
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only)
@@ -280,7 +283,7 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> {
Ok(o) if o.status.success() => {
commit_all(&spec.target_dir, "aura new scaffold");
}
_ => eprintln!("aura: warning: git init failed (project created without a repo)"),
_ => crate::diag::warning!("git init failed (project created without a repo)"),
}
Ok(())
}
@@ -321,7 +324,7 @@ pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> {
Ok(o) if o.status.success() => {
commit_all(&spec.target_dir, "aura nodes new scaffold");
}
_ => eprintln!("aura: warning: git init failed (node crate created without a repo)"),
_ => crate::diag::warning!("git init failed (node crate created without a repo)"),
}
Ok(())
}
@@ -354,7 +357,7 @@ fn commit_all(dir: &std::path::Path, message: &str) {
.current_dir(dir)
.output();
if !matches!(add, Ok(o) if o.status.success()) {
eprintln!("aura: warning: git add failed (scaffold created without an initial commit)");
crate::diag::warning!("git add failed (scaffold created without an initial commit)");
return;
}
let commit = std::process::Command::new("git")
@@ -371,8 +374,8 @@ fn commit_all(dir: &std::path::Path, message: &str) {
.current_dir(dir)
.output();
if !matches!(commit, Ok(o) if o.status.success()) {
eprintln!(
"aura: warning: git commit failed (scaffold created without an initial commit)"
crate::diag::warning!(
"git commit failed (scaffold created without an initial commit)"
);
}
}
@@ -488,7 +491,9 @@ mod tests {
#[test]
fn scaffold_project_refuses_existing_destination() {
let tmp = std::env::temp_dir().join(format!("aura-pscaf-{}", std::process::id()));
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-pscaf");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("x")).unwrap();
let spec = project_scaffold_spec("x", &tmp).unwrap();
let e = scaffold_project(&spec).unwrap_err();
@@ -498,7 +503,9 @@ mod tests {
#[test]
fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() {
let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id()));
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-scaf");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
// existing destination
let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
+176 -91
View File
@@ -53,14 +53,16 @@ pub(crate) struct SugarInvocation<'a> {
/// by construction, so the net channel is never producible there; requesting
/// it anyway would make `campaign_run`'s executor print an unreachable "add a
/// cost block to the campaign document" remedy (#240). The exclusion reuses
/// `campaign_run`'s own tap/channel classification (`tap_channel`/
/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/
/// `TapChannel::Net`) rather than duplicating the net-tap name here.
fn persist_taps_from(trace: bool) -> Vec<String> {
if trace {
tap_vocabulary()
.iter()
.filter(|t| crate::campaign_run::tap_channel(t) != Some(crate::campaign_run::TapChannel::Net))
.map(|t| t.to_string())
.filter(|t| {
aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net)
})
.map(|t| t.id.to_string())
.collect()
} else {
vec![]
@@ -285,8 +287,8 @@ pub(crate) fn register_generated_g(
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses
/// per member (campaign_run.rs) — a bound-param axis passes this
/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member`
/// uses per member (aura-runner) — a bound-param axis passes this
/// executable-shape preflight exactly like an already-open one; an axis
/// matching neither space still fails `bind_axes`'s own named check below.
/// `probe_params` is any one grid value per axis (checks NAME
@@ -298,7 +300,7 @@ fn validate_before_register(
campaign: &CampaignDoc,
blueprint_canonical: &str,
probe_params: &[(String, Scalar)],
env: &crate::project::Env,
env: &aura_runner::project::Env,
) -> Result<(), String> {
let doc_faults = aura_research::validate_campaign(campaign);
if !doc_faults.is_empty() {
@@ -324,68 +326,78 @@ fn validate_before_register(
let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t))
.expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
let overrides = crate::campaign_run::raw_bound_overrides_of(&param_names, &raw_space, &raw_signal);
let overrides = aura_runner::axes::raw_bound_overrides_of(&param_names, &raw_space, &raw_signal);
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
let strategy_id = content_id_of(blueprint_canonical);
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
aura_runner::axes::bind_axes(&space, &strategy_id, probe_params)
.map(|_| ())
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))
}
/// Run one dissolved sweep invocation end-to-end: register the generated
/// documents, then execute through the one campaign path in sugar mode.
/// documents, then execute through the one campaign path in sugar mode. The
/// `Ok` carries the failed-cell count (#272), propagated verbatim from the
/// one campaign executor.
pub(crate) fn run_sweep_sugar(
inv: &SugarInvocation,
env: &crate::project::Env,
) -> Result<(), String> {
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_sweep(inv)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
let reg = env.registry();
let (_process_id, campaign_id) = register_generated(&reg, &generated)?;
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
crate::campaign_run::run_campaign_by_id(
&campaign_id,
env,
crate::campaign_run::RunPresentation::MemberLinesOnly,
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
)
}
/// Build the `CrossInstrument` family members from an executed generalize
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
/// the same order + shape the inline `run_generalize` built its `members` in,
/// each already carrying `manifest.instrument`. Debug-asserts one member per
/// cell: generalize's pipeline has no gate stage, so every cell nominates —
/// a cell silently missing its nominee here would be a campaign-executor
/// regression, not a legitimate generalize outcome, and the filter_map below
/// must not swallow it unnoticed.
/// each already carrying `manifest.instrument`. #272: a failed cell nominates
/// nothing, so `members.len() < outcome.cells.len()` is now a legitimate
/// outcome (the caller counts and surfaces the failed cells separately, via
/// `outcome.record.cells[].fault`) — no longer an invariant this function
/// itself enforces.
fn cross_instrument_members(
outcome: &aura_campaign::CampaignOutcome,
) -> Vec<aura_engine::RunReport> {
let members: Vec<aura_engine::RunReport> = outcome
) -> Vec<aura_backtest::RunReport> {
outcome
.cells
.iter()
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
.collect();
debug_assert_eq!(
members.len(),
outcome.cells.len(),
"generalize has no gate stage — every cell must nominate"
);
members
.collect()
}
/// Run one dissolved `generalize` invocation end-to-end: register the generated
/// documents, run through the one campaign path, then reprint today's exact
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
/// retired welded path (the committed exact-grade anchor is the gate).
/// retired welded path (the committed exact-grade anchor is the gate). The
/// `Ok` carries the failed-cell count (#272): a failed cell nominates nothing
/// (the existing no-nominee -> `missing` path already handles it), counted
/// from the recorded cells' own `fault` field rather than from the missing
/// nominee, so the count is right even when some OTHER member of the cell's
/// pipeline is what actually failed.
pub(crate) fn run_generalize_sugar(
inv: &SugarInvocation,
metric: &str,
env: &crate::project::Env,
) -> Result<(), String> {
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_generalize(inv, metric)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
let reg = env.registry();
let (_process_id, campaign_id) = register_generated_g(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
let run = crate::campaign_run::run_campaign_returning(
&campaign_id,
env,
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
)?;
// Reprint the verb's aggregate line from the recorded cross-instrument
// grade — byte-identical to the retired welded path's `generalize_json(&agg)`.
@@ -403,7 +415,8 @@ pub(crate) fn run_generalize_sugar(
.append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
.map_err(|e| e.to_string())?;
println!("{{\"family_id\":\"{family_id}\"}}");
Ok(())
let failed = run.outcome.record.cells.iter().filter(|c| c.fault.is_some()).count();
Ok(failed)
}
/// The two generated documents of one dissolved `walkforward` invocation.
@@ -414,12 +427,13 @@ pub(crate) struct GeneratedWalkforward {
}
/// Translate one `aura walkforward --real` invocation into its two generated
/// documents: a `[std::sweep(argmax), std::walk_forward]` process (the sweep
/// only enumerates the IS-refit survivor grid for the wf stage; its recorded
/// argmax selection is not part of the summary) and a campaign running the
/// invocation's axis grid over one instrument under its single risk regime.
/// The roller sizes come from `w` (ms). `inv.blueprint_canonical` is the user's
/// blueprint already stored by topology hash; its content id is the strategy ref.
/// documents: a `[std::grid, std::walk_forward]` process (the leading
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
/// stage — it executes nothing and persists no family (#256)) and a campaign
/// running the invocation's axis grid over one instrument under its single
/// risk regime. The roller sizes come from `w` (ms). `inv.blueprint_canonical`
/// is the user's blueprint already stored by topology hash; its content id is
/// the strategy ref.
pub(crate) fn translate_walkforward(
inv: &SugarInvocation,
metric: &str,
@@ -432,13 +446,7 @@ pub(crate) fn translate_walkforward(
name: "walkforward".to_string(),
description: None,
pipeline: vec![
StageBlock::Sweep {
selection: Some(SweepSelection {
metric: metric.to_string(),
select: SelectRule::Argmax,
deflate: false,
}),
},
StageBlock::Grid,
StageBlock::WalkForward {
in_sample_ms: w.in_sample_ms,
out_of_sample_ms: w.out_of_sample_ms,
@@ -494,20 +502,33 @@ pub(crate) fn register_generated_wf(
/// documents, run through the one campaign path, then reprint the per-window member
/// lines + the summary line from the recorded `WalkForward` family. The stdout
/// summary line is byte-identical to the retired welded path (the committed
/// exact-grade anchor is the gate).
/// exact-grade anchor is the gate). #272: the invocation's one cell either
/// completed (`Ok(0)`) or failed (`Ok(1)`) — a failed single cell is not an
/// error, since a completed-with-failed-cell run is the campaign path's own
/// vocabulary (exit 3 at the dispatch boundary), never an "absent bootstrap"
/// refusal the retired welded path's `.ok_or` would have raised.
pub(crate) fn run_walkforward_sugar(
inv: &SugarInvocation,
metric: &str,
w: WfWindows,
select: SelectRule,
env: &crate::project::Env,
) -> Result<(), String> {
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_walkforward(inv, metric, w, select)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
let reg = env.registry();
let (_process_id, campaign_id) = register_generated_wf(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
let run = crate::campaign_run::run_campaign_returning(
&campaign_id,
env,
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
)?;
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
crate::diag::warning!("walkforward cell failed at stage {}: {}", f.stage, f.detail);
return Ok(1);
}
// The single cell's walk_forward StageFamily.
let wf = run
@@ -528,6 +549,9 @@ pub(crate) fn run_walkforward_sugar(
summary_axes.push("stop_length".to_string());
summary_axes.push("stop_k".to_string());
}
crate::diag::note_zero_trade_windows(
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
);
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes));
// Trace persistence runs LAST (mirroring `present_campaign`'s ordering,
@@ -544,7 +568,7 @@ pub(crate) fn run_walkforward_sugar(
// always `None`. `run`/`mc --trace` stay refused by design (#224), not by
// this tail being skipped.
if let Some(trace_name) = &run.outcome.record.trace_name {
crate::campaign_run::persist_campaign_traces(
aura_runner::runner::persist_campaign_traces(
trace_name,
&run.campaign.presentation.persist_taps,
&run.outcome,
@@ -554,7 +578,7 @@ pub(crate) fn run_walkforward_sugar(
env,
)?;
}
Ok(())
Ok(0)
}
/// The two generated documents of one dissolved `mc` invocation.
@@ -565,8 +589,9 @@ pub(crate) struct GeneratedMc {
}
/// Translate one `aura mc --real` invocation into its two generated documents:
/// a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process (the
/// sweep enumerates the IS-refit survivor grid for the wf stage; the terminal
/// a `[std::grid, std::walk_forward, std::monte_carlo]` process (the leading
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
/// stage — it executes nothing and persists no family (#256); the terminal
/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps
/// `E[R]`) and a campaign running the invocation's axis grid over one instrument
/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes
@@ -587,13 +612,7 @@ pub(crate) fn translate_mc(
name: "mc".to_string(),
description: None,
pipeline: vec![
StageBlock::Sweep {
selection: Some(SweepSelection {
metric: metric.to_string(),
select: SelectRule::Argmax,
deflate: false,
}),
},
StageBlock::Grid,
StageBlock::WalkForward {
in_sample_ms: w.in_sample_ms,
out_of_sample_ms: w.out_of_sample_ms,
@@ -652,19 +671,31 @@ pub(crate) fn register_generated_mc(
/// byte-identical to the retired welded path (the committed exact-grade anchor is
/// the gate). mc prints ONLY the one summary line — no per-window member lines
/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family.
/// #272: the invocation's one cell either completed (`Ok(0)`) or failed
/// (`Ok(1)`) — a failed single cell is not an error (see `run_walkforward_sugar`'s
/// twin note).
pub(crate) fn run_mc_sugar(
inv: &SugarInvocation,
metric: &str,
w: WfWindows,
mc: McKnobs,
env: &crate::project::Env,
) -> Result<(), String> {
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_mc(inv, metric, w, mc)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
let reg = env.registry();
let (_process_id, campaign_id) = register_generated_mc(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
let run = crate::campaign_run::run_campaign_returning(
&campaign_id,
env,
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
)?;
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
crate::diag::warning!("mc cell failed at stage {}: {}", f.stage, f.detail);
return Ok(1);
}
// The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
@@ -680,7 +711,7 @@ pub(crate) fn run_mc_sugar(
})
.ok_or("mc produced no pooled-OOS bootstrap")?;
println!("{}", crate::mc_r_bootstrap_json(boot));
Ok(())
Ok(0)
}
#[cfg(test)]
@@ -895,10 +926,9 @@ mod tests {
/// that silently, before the dispatch rewire ever exercises the path live.
#[test]
fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() {
let dir = std::env::temp_dir().join(format!(
"aura-verb-sugar-generalize-registry-test-{}",
std::process::id()
));
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-verb-sugar-generalize-registry-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
@@ -934,17 +964,11 @@ mod tests {
content_id_of(&campaign_to_json(&b.campaign)),
"identical invocations must generate identical campaign ids"
);
// pipeline: sweep(argmax) then walk_forward(argmax, rolling)
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling)
assert_eq!(
a.process.pipeline,
vec![
StageBlock::Sweep {
selection: Some(SweepSelection {
metric: "sqn_normalized".to_string(),
select: SelectRule::Argmax,
deflate: false,
})
},
StageBlock::Grid,
StageBlock::WalkForward {
in_sample_ms: 7_776_000_000,
out_of_sample_ms: 2_592_000_000,
@@ -1047,10 +1071,9 @@ mod tests {
#[test]
fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() {
let dir = std::env::temp_dir().join(format!(
"aura-verb-sugar-walkforward-registry-test-{}",
std::process::id()
));
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-verb-sugar-walkforward-registry-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
let ax = wf_axes();
@@ -1084,17 +1107,11 @@ mod tests {
content_id_of(&campaign_to_json(&b.campaign)),
"identical invocations must generate identical campaign ids"
);
// pipeline: sweep(argmax) then walk_forward(argmax, rolling) then monte_carlo
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) then monte_carlo
assert_eq!(
a.process.pipeline,
vec![
StageBlock::Sweep {
selection: Some(SweepSelection {
metric: "sqn_normalized".to_string(),
select: SelectRule::Argmax,
deflate: false,
})
},
StageBlock::Grid,
StageBlock::WalkForward {
in_sample_ms: 7_776_000_000,
out_of_sample_ms: 2_592_000_000,
@@ -1140,10 +1157,9 @@ mod tests {
#[test]
fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() {
let dir = std::env::temp_dir().join(format!(
"aura-verb-sugar-mc-registry-test-{}",
std::process::id()
));
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join("aura-verb-sugar-mc-registry-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
let ax = wf_axes();
@@ -1165,4 +1181,73 @@ mod tests {
assert_eq!(stored_process, process_to_json(&generated.process));
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal `RunReport` stamped with `instrument`, distinguishable in a
/// members list by nothing but that stamp — the field
/// `cross_instrument_members` preserves verbatim from a nominating cell.
fn fake_report(instrument: &str) -> aura_backtest::RunReport {
aura_engine::RunReport {
manifest: aura_engine::RunManifest {
commit: String::new(),
params: vec![],
defaults: vec![],
window: (aura_engine::Timestamp(0), aura_engine::Timestamp(1)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: Some(instrument.to_string()),
topology_hash: None,
project: None,
},
metrics: aura_backtest::RunMetrics {
total_pips: 0.0,
max_drawdown: 0.0,
bias_sign_flips: 0,
r: None,
},
}
}
fn cell_outcome(nominee_instrument: Option<&str>) -> aura_campaign::CellOutcome {
aura_campaign::CellOutcome {
families: vec![],
selections: vec![],
nominee: nominee_instrument.map(|i| (vec![], fake_report(i))),
}
}
/// #272: `cross_instrument_members` skips a cell with no nominee (a
/// failed cell nominates nothing) rather than padding the members list or
/// aborting — the surviving cells' reports still reach the persisted
/// `CrossInstrument` family, in cell order, with the failed cell simply
/// absent (not a placeholder). Three cells (AAA nominates, BBB fails, CCC
/// nominates) must yield exactly the two survivors' reports, in order.
#[test]
fn cross_instrument_members_skips_a_failed_cell_and_keeps_survivor_order() {
let outcome = aura_campaign::CampaignOutcome {
record: aura_registry::CampaignRunRecord {
campaign: String::new(),
process: String::new(),
run: 0,
seed: 0,
cells: vec![],
generalizations: vec![],
trace_name: None,
},
run: 0,
cells: vec![
cell_outcome(Some("AAA")),
cell_outcome(None),
cell_outcome(Some("CCC")),
],
};
let members = cross_instrument_members(&outcome);
let instruments: Vec<Option<String>> =
members.iter().map(|r| r.manifest.instrument.clone()).collect();
assert_eq!(
instruments,
vec![Some("AAA".to_string()), Some("CCC".to_string())],
"the failed (no-nominee) cell is skipped, the two survivors keep their order"
);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
File diff suppressed because it is too large Load Diff
+251 -16
View File
@@ -3,14 +3,11 @@
//! and drive the `tests/fixtures/demo-project` fixture through the real
//! `aura` binary (#223).
//!
//! **Process-local serialization only.** [`project_lock`]'s `Mutex` is a
//! per-process `static`: it serializes test THREADS within one test-binary
//! process, but grants no cross-process exclusion. A process-parallel test
//! runner (e.g. `cargo-nextest`, which forks one process per test binary) —
//! or simply two concurrent `cargo test` invocations over the same working
//! tree — still race on the shared `tests/fixtures/demo-project/runs` store.
//! That boundary is a known, documented limitation (#223), not something
//! this module claims to close.
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
//! a unique tempdir per test, wired to the shared, once-built fixture crate
//! via an absolute `[nodes]` pointer (`project.rs`'s pointer-arm routing), so
//! no two tests ever race on the same `runs/` store — libtest's default
//! thread parallelism applies without a serializing lock (#250).
//!
//! Each of the three test binaries compiles this file as its own `mod
//! common`, so an item unused by one binary trips that binary's `-D
@@ -18,7 +15,8 @@
//! rather than deleting a helper just because one binary doesn't need it.
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
/// The demo-project fixture (`Aura.toml` present), built once per test-binary
/// process. Each test binary has its own `OnceLock` (this module is compiled
@@ -42,13 +40,111 @@ pub fn built_project() -> &'static PathBuf {
})
}
/// Serializes every test that touches the shared demo-project fixture store
/// (they remove/re-seed `<fixture>/runs`, so parallel test threads would race
/// on it). A poisoned lock is taken over: one failed test must not cascade
/// into unrelated lock panics.
pub fn project_lock() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
/// A per-test project directory, removed on drop (including mid-test panic).
#[allow(dead_code)]
pub struct FreshProject {
root: PathBuf,
}
impl Drop for FreshProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// Mints a unique, empty tempdir under `std::env::temp_dir()`, named
/// `aura-e2e-<pid>-<counter>` so concurrent test threads (same process) and
/// concurrent `cargo test` processes never collide.
///
/// #258 sweep note: this site keeps `std::process::id()` deliberately, unlike
/// the fixed, pid-free sandbox names the rest of #258's fix uses — it has no
/// pre-create wipe to defeat in the first place. `FreshProject::drop` (above)
/// unconditionally removes the returned root when the owning test ends (pass,
/// fail, or panic-unwind), so nothing strands across runs; the pid instead
/// disambiguates concurrent `cargo test` *processes* racing this one function
/// from dozens of call sites, which a fixed tag-keyed name cannot do without
/// plumbing a unique tag through every one of them.
fn mint_tempdir() -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let root = std::env::temp_dir().join(format!(
"aura-e2e-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&root).expect("create fresh project tempdir");
root
}
/// Writes the `Aura.toml` + `demo_signal.json` common to every fresh project:
/// an absolute `[nodes]` pointer at the shared, once-built fixture crate, and
/// — when `data_dir` is `Some` — a `[paths] data` override pointing at it
/// (relative, resolved against the project root — `root` itself — since
/// `Env::data_path` joins a relative `paths.data` onto the project root,
/// same as `runs_root()` and the `[nodes]` pointers, #254).
fn write_project_files(root: &Path, data_dir: Option<&str>) {
let fixture = built_project();
let data_line = data_dir.map(|d| format!("data = \"{d}\"\n")).unwrap_or_default();
std::fs::write(
root.join("Aura.toml"),
format!(
"[paths]\nruns = \"runs\"\n{data_line}\n[nodes]\ncrates = [\"{}\"]\n",
fixture.display()
),
)
.expect("write fresh project Aura.toml");
std::fs::copy(fixture.join("demo_signal.json"), root.join("demo_signal.json"))
.expect("copy demo_signal.json into the fresh project");
}
/// Mint a unique tempdir holding a 2-line data-level project (`Aura.toml`
/// with an absolute `[nodes]` pointer at the shared, once-built fixture
/// crate) plus a copy of `demo_signal.json`. Every test gets its own `runs/`
/// store under this root, so no two tests ever contend for the same store —
/// this is what lets `project_lock` disappear (#250). Bind the guard for the
/// whole test body: `let (dir, _g) = fresh_project();`.
#[allow(dead_code)]
pub fn fresh_project() -> (PathBuf, FreshProject) {
let root = mint_tempdir();
write_project_files(&root, None);
(root.clone(), FreshProject { root })
}
/// Like [`fresh_project`], but additionally generates a tiny synthetic M1
/// archive under `<root>/data/` (two symbols, `SYMA` spanning 2024-01..08 and
/// `SYMB` spanning 2024-03..06 — `SYMB` lies strictly inside `SYMA`) and
/// points `[paths] data` at it. For tests whose property is archive-window
/// (span) semantics, not archive size — no host tick-data mount needed, and
/// no `--real`-symbol data refusal is ever reachable, so gates on data
/// presence are dead code once a test switches to this helper (#250).
#[allow(dead_code)]
pub fn fresh_project_with_data() -> (PathBuf, FreshProject) {
let root = mint_tempdir();
write_project_files(&root, Some("data"));
let data_dir = root.join("data");
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 8));
synthetic_data::write_symbol_archive(&data_dir, "SYMB", (2024, 3), (2024, 6));
(root.clone(), FreshProject { root })
}
/// Like [`fresh_project_with_data`], but the single symbol `GAPSYM` carries a
/// DELIBERATE interior month gap: monthly files exist for 2024-01..2024-02 and
/// 2024-05..2024-06, while 2024-03..2024-04 are absent. This is the Copper
/// failure shape (#264 — files present at both ends while an interior window
/// has no data, so a first/last-bounds check passes but a campaign aborts
/// mid-run). For tests whose property is per-symbol month-gap reporting: the
/// two `write_symbol_archive` calls write disjoint contiguous segments into the
/// same `data/` dir (the second re-writes GAPSYM's identical geometry sidecar),
/// leaving March and April as a real hole in the monthly file index.
#[allow(dead_code)]
pub fn fresh_project_with_gapped_data() -> (PathBuf, FreshProject) {
let root = mint_tempdir();
write_project_files(&root, Some("data"));
let data_dir = root.join("data");
std::fs::create_dir_all(&data_dir).expect("create synthetic gapped archive dir");
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 1), (2024, 2));
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 5), (2024, 6));
(root.clone(), FreshProject { root })
}
/// A scratch filesystem entry a test writes under the git-tracked
@@ -78,3 +174,142 @@ impl Drop for ScratchGuard {
}
}
}
/// A tiny, deterministic synthetic M1 archive generator: hand-packs the exact
/// on-disk format `data-server` reads (its `loader.rs` and `records.rs`) —
/// one zip per `SYMBOL_YYYY_MM.m1` holding a single `.bin` entry of packed
/// 48-byte `RawM1Record`s, plus a `SYMBOL.meta.json` geometry sidecar — so
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
/// archive instead of streaming the 6.6 GB host archive (#250).
#[allow(dead_code)]
mod synthetic_data {
use std::io::Write;
use std::path::Path;
/// Days since the Unix epoch for a proleptic-Gregorian civil date.
/// Howard Hinnant's `days_from_civil` — dependency-free calendar math so
/// this module needs no `chrono` (data-server's own dependency, not
/// aura-cli's).
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
let y = i64::from(if m <= 2 { y - 1 } else { y });
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; // [0, 399]
let mp = (i64::from(m) + 9) % 12; // [0, 11], Mar=0 .. Feb=11
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
era * 146_097 + doe - 719_468
}
/// Unix milliseconds at `y-m-d hh:mm:00` UTC.
fn unix_ms(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> i64 {
days_from_civil(y, m, d) * 86_400_000 + (i64::from(hh) * 3600 + i64::from(mm) * 60) * 1000
}
/// 0 = Sunday .. 6 = Saturday (1970-01-01 was a Thursday, day 0).
fn weekday(y: i32, m: u32, d: u32) -> i64 {
(days_from_civil(y, m, d) % 7 + 4 + 7) % 7
}
fn days_in_month(y: i32, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
2 => 28,
_ => unreachable!("month out of range: {m}"),
}
}
/// A deterministic price at continuous minute offset `t` (minutes since
/// 2024-01-01T00:00 UTC, shared across both symbols so their overlapping
/// span carries the same underlying curve): a gentle upward trend plus a
/// 180-minute sine — short enough against a day's 480 tradeable minutes to
/// produce several SMA(3)/SMA(12) crossovers per day.
fn price_at(t_minutes: f64) -> f64 {
let hours = t_minutes / 60.0;
100.0 + 0.01 * hours + 5.0 * (2.0 * std::f64::consts::PI * t_minutes / 180.0).sin()
}
/// Packs one `RawM1Record`-shaped 48-byte little-endian record: `time`
/// (Delphi `TDateTime`, days since 1899-12-30 — the inverse of
/// `data_server::records::delphi_to_unix_ms`), `open/high/low/close`,
/// `spread` (f32), `volume` (i32).
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
const MS_PER_DAY: f64 = 86_400_000.0;
let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
let mut rec = [0u8; 48];
rec[0..8].copy_from_slice(&dt.to_le_bytes());
rec[8..16].copy_from_slice(&open.to_le_bytes());
rec[16..24].copy_from_slice(&high.to_le_bytes());
rec[24..32].copy_from_slice(&low.to_le_bytes());
rec[32..40].copy_from_slice(&close.to_le_bytes());
rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
rec
}
/// One month's worth of weekday, 08:00-16:00 UTC, 1-minute bars (~10k).
fn month_records(year: i32, month: u32) -> Vec<[u8; 48]> {
let epoch_ms = unix_ms(2024, 1, 1, 0, 0);
let mut records = Vec::new();
for day in 1..=days_in_month(year, month) {
if !(1..=5).contains(&weekday(year, month, day)) {
continue; // weekend
}
for hh in 8..16 {
for mm in 0..60 {
let ms = unix_ms(year, month, day, hh, mm);
let t = (ms - epoch_ms) as f64 / 60_000.0;
let (open, close) = (price_at(t), price_at(t + 1.0));
let high = open.max(close) + 0.2;
let low = open.min(close) - 0.2;
records.push(pack_record(ms, open, high, low, close));
}
}
}
records
}
/// Writes one `SYMBOL_YYYY_MM.m1` zip (a single `<SYMBOL>.bin` entry of
/// packed records — mirrors `data-server`'s own `create_test_m1_zip` test
/// helper) under `data_dir`.
fn write_month_zip(data_dir: &Path, symbol: &str, year: i32, month: u32) {
let path = data_dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
let mut zip = zip::ZipWriter::new(file);
zip.start_file::<String, ()>(format!("{symbol}.bin"), Default::default())
.expect("start synthetic m1 zip entry");
for rec in month_records(year, month) {
zip.write_all(&rec).expect("write synthetic m1 record");
}
zip.finish().expect("finish synthetic m1 zip");
}
/// Writes every month in `[from, to]` (inclusive, `(year, month)` pairs)
/// for `symbol`, plus its `<SYMBOL>.meta.json` geometry sidecar —
/// GER40-shaped (digits 1, pip/tick/lot sized like an index CFD) so the
/// R/stop machinery never hits "no recorded geometry".
pub fn write_symbol_archive(data_dir: &Path, symbol: &str, from: (i32, u32), to: (i32, u32)) {
let (mut y, mut m) = from;
loop {
write_month_zip(data_dir, symbol, y, m);
if (y, m) == to {
break;
}
m += 1;
if m > 12 {
m = 1;
y += 1;
}
}
std::fs::write(
data_dir.join(format!("{symbol}.meta.json")),
format!(
"{{\"schemaVersion\":2,\"symbol\":\"{symbol}\",\"digits\":1,\"pipSize\":1,\
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"{symbol}\",\"quoteAsset\":\"EUR\"}}"
),
)
.expect("write synthetic geometry sidecar");
}
}
@@ -26,6 +26,7 @@ impl Identity {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "one-input f64 pass-through",
},
|_| Box::new(Identity::new()),
)
@@ -29,6 +29,7 @@ impl Identity {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "one-input f64 pass-through",
},
|_| Box::new(Identity::new()),
)
+1
View File
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
+1
View File
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1
View File
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1
View File
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
@@ -0,0 +1 @@
# restated fixture — static project context only (C17); paths only (cycle 0102).
@@ -0,0 +1,15 @@
[package]
name = "restated-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1,83 @@
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
//! pass-through node under the `restated` namespace whose `NodeSchema` doc
//! merely restates its own type id (`DocGateFault::RestatesName`, the fault
//! arm `undescribed-project` does not exercise). The charter is satisfied
//! (prefixed id, list/resolver in sync) so the refusal under test is exactly
//! the doc gate's name-restatement branch.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through whose doc is a no-content restatement of its
/// own name.
pub struct Echo {
out: [Cell; 1],
}
impl Echo {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"restated::Echo",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
// The one deliberate violation this fixture exists for: this
// norm-equals the type id "restated::Echo" once punctuation
// and case are stripped, so it carries no meaning beyond the
// name itself.
doc: "Restated Echo",
},
|_| Box::new(Echo::new()),
)
}
}
impl Default for Echo {
fn default() -> Self {
Self::new()
}
}
impl Node for Echo {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"restated::Echo".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"restated::Echo" => Some(Echo::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["restated::Echo"]
}
aura_core::aura_project! {
namespace: "restated",
vocabulary: vocabulary,
type_ids: type_ids,
}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
@@ -0,0 +1 @@
# undescribed fixture — static project context only (C17); paths only (cycle 0102).
@@ -0,0 +1,15 @@
[package]
name = "undescribed-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1,78 @@
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
//! pass-through node under the `und` namespace whose `NodeSchema` carries the
//! empty-string alibi `doc: ""` — compile-legal (the field is present), but
//! the load gate must refuse it. The charter is satisfied (prefixed id,
//! list/resolver in sync) so the refusal under test is exactly the doc gate.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through with a deliberately empty doc.
pub struct Opaque {
out: [Cell; 1],
}
impl Opaque {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"und::Opaque",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
// The one deliberate violation this fixture exists for.
doc: "",
},
|_| Box::new(Opaque::new()),
)
}
}
impl Default for Opaque {
fn default() -> Self {
Self::new()
}
}
impl Node for Opaque {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"und::Opaque".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"und::Opaque" => Some(Opaque::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["und::Opaque"]
}
aura_core::aura_project! {
namespace: "und",
vocabulary: vocabulary,
type_ids: type_ids,
}
+289 -22
View File
@@ -54,6 +54,40 @@ const SIGNAL_DOC: &str = r#"[
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC with the C29 doc op — the register-reaching variant (a store
/// write requires a described composite; build-only tests keep SIGNAL_DOC).
const SIGNAL_DOC_DESCRIBED: &str = r#"[
{"op":"doc","text":"fast/slow SMA difference clamped into a directional bias"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC with a `doc` op whose text merely restates the op-script
/// composite's name ("graph", the literal `replay("graph", ..)` gives every
/// CLI-built op-script) -- the RestatesName arm of the C29 shape gate,
/// reached via the op-script route rather than a builder-authored blueprint.
const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
{"op":"doc","text":"Graph"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
/// never wired — a 0-cover input slot only the holistic finalize gate can
/// reject. The smallest document that is op-valid yet whole-document-invalid.
@@ -74,6 +108,21 @@ fn graph_build_emits_blueprint_for_a_valid_document() {
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
}
/// C29 (#316): the op-script `doc` op's text lands verbatim in the built
/// blueprint's `doc` field -- `graph build` is a pure translation from ops to
/// the canonical envelope, so a doc op is not consumed or reworded on the way
/// through, only threaded (the shape gate is a separate, register-time check;
/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`).
#[test]
fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(ok, "exit success");
assert!(
stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#),
"the doc op's exact text appears in the blueprint's doc field: {stdout}"
);
}
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
@@ -121,8 +170,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
28,
"the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}"
33,
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
);
}
@@ -382,7 +431,7 @@ fn graph_introspect_no_flag_is_usage_exit_2() {
/// A fresh, unique working directory for a test that persists content-addressed
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name));
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-graph-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
@@ -462,6 +511,47 @@ fn graph_register_is_idempotent() {
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
}
/// C29 register seam end-to-end (#316): a doc-less root composite refuses at
/// `aura graph register` naming the composite and the rule (exit 1, C14); a
/// doc-less named nested composite refuses naming the nested name; the
/// described shipped example registers. The gate lives at the store write
/// every register path shares — this pins its verb surface.
#[test]
fn register_refuses_undescribed_composites_end_to_end() {
let dir = temp_cwd("register-doc-gate");
// doc-less root: the shipped example minus its doc member
let text = std::fs::read_to_string(example("r_sma.json")).expect("read example");
let mut v: serde_json::Value = serde_json::from_str(&text).expect("example parses");
v["blueprint"].as_object_mut().expect("blueprint object").remove("doc");
let docless = dir.join("docless.json");
std::fs::write(&docless, serde_json::to_string(&v).unwrap()).expect("write fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", docless.to_str().unwrap()]);
assert_eq!(code, Some(1), "doc-less root refuses: {out} {err}");
assert!(
err.contains("composite `sma_signal` carries no doc"),
"stderr names the composite and the rule: {err}"
);
assert!(err.contains("C29"), "stderr cites the contract: {err}");
// doc-less named nested composite inside a described root
v["blueprint"]["doc"] = serde_json::Value::String("a described root".into());
v["blueprint"]["nodes"]
.as_array_mut()
.expect("nodes array")
.push(serde_json::json!({"composite": {"name": "inner", "nodes": []}}));
let nested = dir.join("nested.json");
std::fs::write(&nested, serde_json::to_string(&v).unwrap()).expect("write fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", nested.to_str().unwrap()]);
assert_eq!(code, Some(1), "doc-less nested refuses: {out} {err}");
assert!(
err.contains("composite `inner` carries no doc"),
"stderr names the nested composite: {err}"
);
// the described shipped example registers
let (out, err, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
assert_eq!(code, Some(0), "described example registers: {out} {err}");
assert!(out.starts_with("registered blueprint "), "prints the register line: {out}");
}
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
@@ -471,7 +561,7 @@ fn graph_register_is_idempotent() {
fn graph_params_lists_raw_axis_namespace() {
let dir = temp_cwd("params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}
@@ -512,7 +602,7 @@ fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() {
#[test]
fn graph_params_by_content_id_matches_params_by_file() {
let dir = temp_cwd("params-by-id");
let bp = example("r_sma_open.json");
let bp = fixture("r_sma_open.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
@@ -533,7 +623,7 @@ fn graph_params_by_content_id_matches_params_by_file() {
#[test]
fn graph_params_tolerates_content_prefix_on_target() {
let dir = temp_cwd("params-content-prefix");
let bp = example("r_sma_open.json");
let bp = fixture("r_sma_open.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
@@ -647,11 +737,12 @@ fn graph_register_rejects_an_unknown_node_type_with_prose() {
#[test]
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
let dir = temp_cwd("register-op-script");
// The op-script form (a JSON array of ops), written to a file.
// The op-script form (a JSON array of ops), written to a file. Register
// requires a described composite (C29), so the described twin is used here.
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
std::fs::write(&op_script, SIGNAL_DOC_DESCRIBED).expect("write op-script fixture");
// Its `graph build` envelope (a JSON object), the shape `register` already accepts.
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
@@ -669,6 +760,33 @@ fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
registered_id(&env_out),
"op-script and its built envelope register to the same content id"
);
// C29: the doc-less op-script form refuses at register like any other
// doc-less composite entering the store.
let docless_script = dir.join("docless-script.json");
std::fs::write(&docless_script, SIGNAL_DOC).expect("write op-script fixture");
let (dl_out, dl_err, dl_code) =
run_in(&dir, &["graph", "register", docless_script.to_str().unwrap()]);
assert_eq!(dl_code, Some(1), "doc-less op-script refuses: {dl_out} {dl_err}");
assert!(dl_err.contains("carries no doc"), "stderr names the rule: {dl_err}");
assert!(dl_err.contains("C29"), "stderr cites the contract: {dl_err}");
}
/// C29: an op-script's `doc` op is not a bare presence checkbox -- its text
/// goes through the same shape gate a builder-authored doc does. A doc that
/// merely restates the composite's name (here "graph", every CLI op-script's
/// literal name) refuses at register exactly like the builder-authored
/// RestatesName case already pinned at the registry-unit layer, but reached
/// end-to-end through the op-script CLI seam this time.
#[test]
fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
let dir = temp_cwd("register-op-script-restated-doc");
let restated_script = dir.join("restated-doc-script.json");
std::fs::write(&restated_script, SIGNAL_DOC_NAME_RESTATED).expect("write op-script fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
assert_eq!(code, Some(1), "name-restating doc refuses: {out} {err}");
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
assert!(err.contains("C29"), "stderr cites the contract: {err}");
}
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
@@ -744,7 +862,7 @@ fn shipped_r_sma_example_is_genuinely_closed() {
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159): the shipped open example (`examples/r_sma_open.json`) is a
/// Property (#159): the open fixture (`tests/fixtures/r_sma_open.json`) is a
/// usable sweep-axis template from its public gallery location, not just a
/// byte-identical artifact — `graph introspect --params` lists exactly the two
/// unbound SMA lengths, in lowering order, matching the raw axis namespace the
@@ -753,10 +871,10 @@ fn shipped_r_sma_example_is_genuinely_closed() {
/// shipped copy is actually introspectable/bindable from where a consumer runs
/// it.
#[test]
fn shipped_r_sma_open_example_lists_its_axis_namespace() {
fn open_r_sma_fixture_lists_its_axis_namespace() {
let dir = temp_cwd("example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "fast.length:I64\nslow.length:I64\n",
@@ -764,6 +882,31 @@ fn shipped_r_sma_open_example_lists_its_axis_namespace() {
);
}
/// Property (#248): the four `_open` blueprint twins were RELOCATED out of the
/// public gallery, not deleted — each stem is present under `tests/fixtures/`
/// (the internal sweep-axis fixture location) and absent from `examples/` (the
/// public, user-facing gallery). This is the acceptance criterion's core claim
/// ("`examples/` ships no `_open` variants") stated as a filesystem fact, so a
/// future example addition that reintroduces a twin — or a migration that moves
/// the reference but forgets the file — fails here even though every
/// `include_str!`/path-string call site already resolves.
#[test]
fn open_twins_are_relocated_from_examples_to_fixtures_not_deleted() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
for stem in ["r_sma", "r_breakout", "r_meanrev", "r_channel"] {
let gallery_path = format!("{manifest_dir}/examples/{stem}_open.json");
assert!(
!std::path::Path::new(&gallery_path).exists(),
"{stem}_open.json must not ship in the public examples/ gallery (#248)"
);
let fixture_path = format!("{manifest_dir}/tests/fixtures/{stem}_open.json");
assert!(
std::path::Path::new(&fixture_path).exists(),
"{stem}_open.json must survive relocated under tests/fixtures/ (#248)"
);
}
}
/// Property (#159 cut 2): the shipped closed example (`examples/r_breakout.json`) is
/// genuinely closed — `graph introspect --params` reports zero unbound params.
#[test]
@@ -775,14 +918,14 @@ fn shipped_r_breakout_example_is_genuinely_closed() {
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159 cut 2): the shipped open example (`examples/r_breakout_open.json`)
/// Property (#159 cut 2): the open fixture (`tests/fixtures/r_breakout_open.json`)
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
/// one knob).
#[test]
fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
fn open_r_breakout_fixture_lists_its_axis_namespace() {
let dir = temp_cwd("r-breakout-example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout_open.json")]);
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_breakout_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "channel_length:I64\n",
@@ -805,7 +948,7 @@ fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
/// member) would be caught here even if the internal builder-level
/// equivalence still holds.
#[test]
fn shipped_r_breakout_open_gang_axis_matches_the_closed_example() {
fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-breakout-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_breakout.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
@@ -815,7 +958,7 @@ fn shipped_r_breakout_open_gang_axis_matches_the_closed_example() {
let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&["sweep", &example("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
&["sweep", &fixture("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
@@ -840,13 +983,13 @@ fn shipped_r_meanrev_example_is_genuinely_closed() {
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159 cut 3): the shipped open example (`examples/r_meanrev_open.json`)
/// Property (#159 cut 3): the open fixture (`tests/fixtures/r_meanrev_open.json`)
/// lists its raw axis namespace, in lowering (node-add) order.
#[test]
fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
fn open_r_meanrev_fixture_lists_its_axis_namespace() {
let dir = temp_cwd("r-meanrev-example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev_open.json")]);
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_meanrev_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "window:I64\nband.factor:F64\n",
@@ -863,7 +1006,7 @@ fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
/// param positions could silently mis-bind the co-present un-ganged axis
/// instead (or vice-versa) even though each axis alone still resolves.
#[test]
fn shipped_r_meanrev_open_gang_plus_plain_axis_matches_the_closed_example() {
fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-meanrev-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_meanrev.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
@@ -874,7 +1017,7 @@ fn shipped_r_meanrev_open_gang_plus_plain_axis_matches_the_closed_example() {
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&[
"sweep", &example("r_meanrev_open.json"),
"sweep", &fixture("r_meanrev_open.json"),
"--axis", "r_meanrev_signal.window=3",
"--axis", "r_meanrev_signal.band.factor=2.0",
],
@@ -1132,6 +1275,26 @@ fn graph_build_reports_gang_kind_mismatch_as_prose() {
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
}
/// The `doc` op's duplicate refusal (a second meaning line in one op-script)
/// reads as prose at the binary seam, attributed to the second `doc` op's
/// index -- never the raw `DuplicateDoc` Debug variant name.
#[test]
fn graph_build_reports_duplicate_doc_fault_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"doc","text":"first"},
{"op":"doc","text":"second"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a duplicate doc fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 2 (doc): a doc op may appear at most once"),
"names the op + cause as prose: {stderr}"
);
assert!(!stderr.contains("DuplicateDoc"), "does not leak the Debug variant name: {stderr}");
}
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
/// register` on a hand-corrupted blueprint whose gangs section fails structural
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
@@ -1269,3 +1432,107 @@ fn graph_build_hints_attach_for_namespaced_ids_in_a_data_only_project() {
"the hint points at the attach verb: {stderr}"
);
}
/// Property (#284): the `tap` op-script op, driven through the real `aura
/// graph build` process (not an in-crate call to `build_from_str`), resolves
/// a name-addressed interior wire (`"fast.value"`, session node 0 / output
/// field 0) into the exact same `{node, field}` shape a raw-index-authored
/// blueprint would carry — the emitted blueprint's `taps` array names the
/// wire by resolved index. This is the process-boundary counterpart of
/// `construction.rs`'s `tap_op_resolves_and_appends` (in-crate call): it pins
/// that the CLI's serde front end (`OpDoc::Tap` → `Op::Tap`) actually reaches
/// the engine and back out to stdout across a real subprocess, the surface a
/// data-level author drives.
#[test]
fn graph_build_emits_a_tap_op_declared_wire() {
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"tap","from":"fast.value","as":"fast_ma"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], ops);
assert!(ok, "graph build: stderr: {stderr}");
assert!(
stdout.contains(r#""taps":[{"name":"fast_ma","from":{"node":0,"field":0}}]"#),
"the tap resolves to node 0 (fast), field 0 (value): {stdout}"
);
}
/// Property (#284): a blueprint authored through the name-addressed `tap` op
/// — built by the real `aura graph build` process, never hand-edited by raw
/// node index — is not merely well-formed JSON: `aura run` actually binds and
/// persists its declared tap, exactly as a raw-index-authored tap does
/// (`tap_recording.rs`'s coverage of the underlying bind/persist mechanism).
/// This is the whole point of #284 (removing the raw-index miscount class):
/// the op-authored artifact must round-trip through the full build -> run ->
/// persisted-trace pipeline, not just parse. Taps `"slow.value"` (SMA(4),
/// deliberately not the node-0 `"fast"` port the sibling build-only test
/// checks) so this test cannot pass by accident on the wrong node.
#[test]
fn tap_authored_via_op_script_runs_and_persists_the_series() {
const PRICES: [f64; 18] = [
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
];
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.5}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"tap","from":"slow.value","as":"slow_ma"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
let (blueprint_json, stderr, ok) = run(&["graph", "build"], ops);
assert!(ok, "graph build: stderr: {stderr}");
assert!(blueprint_json.contains("\"taps\""), "blueprint carries the tap: {blueprint_json}");
let dir = temp_cwd("tap-op-run-roundtrip");
let bp_path = dir.join("tapped.json");
std::fs::write(&bp_path, &blueprint_json).expect("write built blueprint");
let (_stdout, stderr2, code2) = run_in(&dir, &["run", bp_path.to_str().unwrap()]);
assert_eq!(code2, Some(0), "aura run over the op-authored tap: {stderr2}");
// `aura graph build` always names the root composite "graph" (`composite_from_str`),
// so the trace store's run-name directory is fixed.
let trace_path = dir.join("runs/traces/graph/slow_ma.json");
let trace_text = std::fs::read_to_string(&trace_path)
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
assert_eq!(trace["tap"], "slow_ma");
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
// SMA(4), silent until warm (4 samples): first emission at the 4th sample
// (ts=4), a rolling mean of the last four prices per subsequent cycle.
let expected: Vec<f64> = (3..PRICES.len())
.map(|i| PRICES[i - 3..=i].iter().sum::<f64>() / 4.0)
.collect();
let expected_ts: Vec<i64> = (4..=PRICES.len() as i64).collect();
let got_ts: Vec<i64> =
trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
assert_eq!(got_ts, expected_ts, "recorded timestamps");
let got: Vec<f64> = trace["columns"][0]
.as_array()
.expect("columns[0] array")
.iter()
.map(|v| v.as_f64().unwrap())
.collect();
assert_eq!(got.len(), expected.len(), "recorded row count");
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
}
}
+125
View File
@@ -0,0 +1,125 @@
//! #290 / C28: `aura measure ic <run> --signal <tap> --price <tap>` reduces a
//! measurement run's two recorded taps to an Information Coefficient + its
//! permutation-null significance. Exercises the WIRING and well-formedness over a
//! real causal run; the signal-vs-noise math is unit-tested (a look-ahead-engineered
//! signal is impossible in a causal run, C2).
use std::path::Path;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_aura");
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-measic-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
/// `examples/r_sma.json` turned MEASUREMENT-shaped with TWO taps (node 0 "signal",
/// node 1 "price") and no `bias` output — same closed topology, runs on the built-in
/// synthetic stream. (Two-tap authoring mirrors tests/tap_recording.rs.)
fn two_tap_blueprint_json() -> String {
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
v["blueprint"]["taps"] = serde_json::json!([
{"name": "signal", "from": {"node": 0, "field": 0}},
{"name": "price", "from": {"node": 1, "field": 0}},
]);
v["blueprint"]["output"] = serde_json::json!([]);
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
}
fn run_measurement(cwd: &Path) {
let bp = cwd.join("measurement.json");
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
let out = Command::new(BIN)
.args(["run", bp.to_str().unwrap()])
.current_dir(cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
}
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
let mut args = vec!["measure", "ic", run, "--signal", "signal", "--price", "price"];
args.extend_from_slice(extra);
Command::new(BIN).args(&args).current_dir(cwd).output().expect("spawn aura measure ic")
}
#[test]
fn measure_ic_emits_a_well_formed_report() {
let cwd = temp_cwd("wellformed");
run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &[]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
assert_eq!(r["run"], "sma_signal");
assert_eq!(r["signal_tap"], "signal");
assert_eq!(r["price_tap"], "price");
assert_eq!(r["horizon"], 1);
assert_eq!(r["permutations"], 1000);
assert_eq!(r["seed"], 0);
assert!(r["n_pairs"].as_u64().unwrap() >= 2, "expected aligned pairs, got {}", r["n_pairs"]);
let ic = r["information_coefficient"].as_f64().unwrap();
assert!(ic.is_finite() && (-1.0..=1.0).contains(&ic), "ic = {ic}");
let p = r["overfit_probability"].as_f64().unwrap();
assert!(p > 0.0 && p <= 1.0, "overfit_probability = {p}");
}
#[test]
fn measure_ic_is_deterministic() {
let cwd = temp_cwd("determinism");
run_measurement(&cwd);
let a = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
let b = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
assert!(a.status.success() && b.status.success());
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
}
#[test]
fn measure_ic_unknown_run_errors() {
let cwd = temp_cwd("unknownrun");
run_measurement(&cwd);
let out = measure_ic(&cwd, "no_such_run", &[]);
assert!(!out.status.success(), "an unknown run must exit non-zero");
}
/// `--horizon` is real plumbing from the CLI arg through
/// `information_coefficient`'s alignment window, not merely echoed into the
/// report: a horizon that exceeds the recorded price series collapses every
/// signal/forward-return pair, hitting the reduction's documented degenerate
/// floor (`n_pairs=0`, `ic=0.0`, `overfit_probability=1.0`, per main.rs's
/// `information_coefficient` doc comment) — a floor the unit tests exercise
/// only by calling the reduction in-memory, never through CLI parsing + a
/// persisted-trace round trip.
#[test]
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
let cwd = temp_cwd("oversizedhorizon");
run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &["--horizon", "100000000"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
assert_eq!(r["horizon"], 100_000_000);
assert_eq!(r["n_pairs"], 0, "an oversized horizon aligns no pairs");
assert_eq!(r["information_coefficient"], 0.0, "degenerate floor: ic = 0.0");
assert_eq!(r["overfit_probability"], 1.0, "degenerate floor: overfit_probability = 1.0");
}
#[test]
fn measure_ic_missing_tap_errors() {
let cwd = temp_cwd("missingtap");
run_measurement(&cwd);
let out = Command::new(BIN)
.args(["measure", "ic", "sma_signal", "--signal", "nope", "--price", "price"])
.current_dir(&cwd)
.output()
.expect("spawn");
assert!(!out.status.success(), "a missing tap must exit non-zero");
assert!(
String::from_utf8_lossy(&out.stderr).contains("nope"),
"the error names the missing tap: {}",
String::from_utf8_lossy(&out.stderr)
);
}
+88 -7
View File
@@ -9,7 +9,7 @@ use std::process::{Command, Output};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
mod common;
use common::{built_project, project_lock};
use common::built_project;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project")
@@ -19,6 +19,14 @@ fn badcharter_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project")
}
fn undescribed_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/undescribed-project")
}
fn restated_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/restated-project")
}
fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
@@ -98,17 +106,16 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
/// rather than shell-relative (C17).
#[test]
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
let _fixture = project_lock();
let dir = built_project();
let sub = dir.join("src");
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let open_bp =
format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let closed_bp =
format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"sweep",
&open_bp,
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
@@ -165,6 +172,78 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
}
/// C29 load seam end-to-end (#316): an extension node whose doc fails the
/// shape gate (the empty-string alibi the compiler cannot catch) refuses at
/// load with the entry and the rule named — through the real
/// `aura-cli::project::load` path (libloading + descriptor read), not merely
/// the pure `doc_gate` unit function. The described twin's load is pinned by
/// `project_run_resolves_demo_node_and_is_bit_identical`.
#[test]
fn undescribed_vocabulary_entry_refuses_end_to_end() {
let dir = undescribed_dir();
let build = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the undescribed fixture");
assert!(
build.status.success(),
"undescribed fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
"an undescribed vocabulary entry is a runtime refusal (exit 1), \
not a usage error"
);
let err = String::from_utf8_lossy(&run.stderr);
assert!(
err.contains("vocabulary entry `und::Opaque` has an empty doc"),
"stderr must name the entry and the rule: {err}"
);
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
}
/// C29 load seam end-to-end, the sibling fault arm (#316): a doc that merely
/// restates its own type id (`DocGateFault::RestatesName`) refuses at load
/// exactly like the empty-doc case above, through the same real
/// `aura-cli::project::load` path. `RestatesName`'s rendered prose was
/// previously pinned only against the pure `Display` impl in
/// `aura-runner::project`'s unit tests (never reachable at all through the
/// CLI) — a regression that reordered `doc_gate`'s two fault arms, or that
/// skipped the gate call for this arm specifically, would pass every
/// existing test and still ship a self-description hole for exactly this
/// case: an alibi doc that types-checks but says nothing.
#[test]
fn restated_name_vocabulary_entry_refuses_end_to_end() {
let dir = restated_dir();
let build = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the restated fixture");
assert!(
build.status.success(),
"restated fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
"a name-restating vocabulary entry is a runtime refusal (exit 1), \
not a usage error"
);
let err = String::from_utf8_lossy(&run.stderr);
assert!(
err.contains("vocabulary entry `restated::Echo` has a doc that merely restates its name"),
"stderr must name the entry and the rule: {err}"
);
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
}
/// Force a file's modification time to a fixed instant (explicit mtime, no
/// sleeping — mtime granularity would make a "touch then compare" race).
fn set_mtime(path: &Path, secs_since_epoch: u64) {
@@ -369,7 +448,8 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
/// "binds no node crate" hint (not the outside-project "no Aura.toml" text).
#[test]
fn data_only_project_hints_attach_for_namespaced_ids() {
let tmp = std::env::temp_dir().join(format!("aura-dataonly-hint-{}", std::process::id()));
let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-dataonly-hint");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
let bp = tmp.join("bp.json");
@@ -389,7 +469,8 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
#[test]
fn missing_artifact_refuses_with_build_hint() {
// A minimal never-built project: valid cargo metadata, no dylib on disk.
let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id()));
let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-nobuild");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("src")).unwrap();
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
+12 -1
View File
@@ -14,8 +14,19 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
.expect("run aura")
}
/// Fixed, tag-keyed, pid-free name (#258) — kept under `env::temp_dir()`
/// rather than the `CARGO_TARGET_TMPDIR` build-tree anchor used by the other
/// sandbox helpers: two of this file's tests (`new_outside_a_work_tree_*`)
/// require a base genuinely detached from any git work tree, and
/// `CARGO_TARGET_TMPDIR` resolves inside this very checkout's `target/`.
/// The checkout discriminator keeps concurrently running checkouts/worktrees
/// (which share `env::temp_dir()`) off each other's fixed names while the
/// name stays deterministic per checkout, so each run still reclaims the last.
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id()));
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
env!("CARGO_MANIFEST_DIR").hash(&mut h);
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{:08x}", h.finish() as u32));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
+116 -1
View File
@@ -4,7 +4,7 @@ use std::path::Path;
use std::process::Command;
fn temp_cwd(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("aura-nodes-{tag}-{}", std::process::id()));
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
@@ -18,6 +18,39 @@ fn aura(args: &[&str], cwd: &Path) -> std::process::Output {
.expect("spawn aura")
}
/// Property (#258): a test sandbox must not outlive its run — `temp_cwd` must
/// hand back a run-STABLE path that lives off the `env::temp_dir()` tmpfs, so
/// its own pre-create `remove_dir_all` reclaims the previous run instead of
/// stranding one `aura-nodes-*` directory per test-binary invocation.
///
/// The helper today keys the name on `std::process::id()`, so every run mints a
/// new path the wipe can never match: >50 000 leaked dirs filled a 16 GiB tmpfs
/// to 100%. The in-repo remedy is the knob-lab scaffold's (commit 84e1075, sibling
/// `project_sweep_campaign.rs`): a fixed name under `CARGO_TARGET_TMPDIR`. This
/// pins that property at one representative site — the assertion reads the path
/// value only, so it is deterministic and does not depend on any external state.
#[test]
fn temp_cwd_sandbox_does_not_leak_under_env_temp_dir() {
let sandbox = temp_cwd("leakguard");
// Read the path value, then reclaim immediately so a red run of this very
// test does not itself add to the leak it describes.
let path = sandbox.clone();
let _ = std::fs::remove_dir_all(&sandbox);
let pid = std::process::id().to_string();
assert!(
!path.to_string_lossy().contains(&pid),
"sandbox name embeds the pid ({pid}), so no later run's pre-create wipe \
can reclaim it one directory leaks per run: {}",
path.display(),
);
assert!(
!path.starts_with(std::env::temp_dir()),
"sandbox lives under env::temp_dir() — the tmpfs the >50k-dir leak filled: {}",
path.display(),
);
}
#[test]
fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() {
let cwd = temp_cwd("attach");
@@ -178,3 +211,85 @@ fn attached_crate_namespace_resolves_after_build() {
"{}", String::from_utf8_lossy(&out.stdout)
);
}
/// E2E (#278): loading an attached node crate whose `src/` is newer than its
/// built dylib emits exactly one `aura: warning: ` class-marked line naming
/// the stale dylib, and still succeeds (the run proceeds with the existing
/// dylib rather than refusing) — staleness is a continuing-run warning, never
/// a fault. A freshly built dylib (no touched source since) emits none. Real
/// `cargo build` + a real `libloading::Library::new` load, exercising
/// `aura_runner::project::load_crate`'s actual call site rather than the pure
/// `stale_warning` helper alone.
#[test]
fn attached_crate_load_warns_when_source_outruns_the_built_dylib() {
let cwd = temp_cwd("stale-warn");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
let crate_root = cwd.join("lab-nodes");
let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build");
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
// Fresh build, untouched source: no staleness warning.
let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr));
assert!(
!String::from_utf8_lossy(&fresh.stderr).contains("may be stale"),
"a freshly built dylib is not stale: {}",
String::from_utf8_lossy(&fresh.stderr)
);
// Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the
// dylib's just-completed build time) without rebuilding — the exact
// "source outran the dylib, no rebuild yet" shape `stale_warning` guards.
let lib_rs = crate_root.join("src/lib.rs");
let f = std::fs::File::open(&lib_rs).expect("open lib.rs");
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60))
.expect("bump lib.rs mtime past the dylib's build time");
let stale = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(
stale.status.success(),
"staleness is a warning, not a refusal: {}",
String::from_utf8_lossy(&stale.stderr)
);
let stderr = String::from_utf8_lossy(&stale.stderr);
assert_eq!(
stderr.matches("may be stale").count(),
1,
"exactly one staleness warning: {stderr}"
);
assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}");
assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}");
}
/// Property (#316 self-description): the scaffold's own seed node ships a
/// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`)
/// — non-empty and not a bare restatement of the type name. This exact text
/// is what a new extension author copy-pastes as the very first node of
/// their crate; if the scaffold's own doc failed the gate it would teach the
/// wrong pattern (an alibi doc) to every project built from it, and nothing
/// else would catch that — `NodeSchema.doc` is required at compile time
/// (E0063 without it) but its *shape* is never checked by the compiler.
#[test]
fn nodes_new_scaffold_doc_passes_the_doc_gate() {
let cwd = temp_cwd("doc-gate");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap();
let doc_line = lib
.lines()
.find(|l| l.trim_start().starts_with("doc:"))
.unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}"));
let doc_text = doc_line
.split_once('"')
.and_then(|(_, rest)| rest.rsplit_once('"'))
.map(|(text, _)| text)
.unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}"));
aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| {
panic!("scaffold's own doc line fails its own gate: {f:?}{doc_text:?}")
});
}
@@ -37,7 +37,7 @@ const GER40_TO_MS: &str = "1727740799999";
/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab`
/// vocabulary namespace — `Scale` is the starter node every node-crate
/// scaffold emits (#183).
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","doc":"fast/slow SMA bias scaled by an extension gain node","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
/// The minimal executable process pipeline (one sweep stage) — the campaign
/// cell's methodology reference.
@@ -63,11 +63,15 @@ fn aura_in(dir: &Path, args: &[&str]) -> Output {
/// `blueprints/scaled_open.json`. Returns the project dir. `OnceLock` so the
/// expensive scaffold+build happens once per test binary; the node crate's
/// path-dep resolves into this checkout (the baked engine root), like
/// `project_new.rs`.
/// `project_new.rs`. The base is a FIXED name under `CARGO_TARGET_TMPDIR`
/// (real disk, reclaimed by `cargo clean`), not a pid-suffixed tempdir: the
/// wipe below must hit the PREVIOUS run's copy, else every test-binary run
/// leaks the ~66 MB built scaffold — 159 of those filled the 16 GB /tmp
/// tmpfs to 100 % (#257).
fn built_scale_project() -> &'static PathBuf {
static BUILT: OnceLock<PathBuf> = OnceLock::new();
BUILT.get_or_init(|| {
let base = std::env::temp_dir().join(format!("aura-235-{}", std::process::id()));
let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-235-knob-lab");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).expect("create scaffold base");
@@ -327,7 +331,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
// Archive-gated: skip cleanly on a data-less machine (the member-data
// refusal, never a panic) — mirroring campaign_run_real_e2e_*.
if run.status.code() == Some(1)
if run.status.code() == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{
eprintln!("skip: no local GER40 data for the campaign cell");
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
//! #286 / C28 phase 3: `aura run` on a measurement-shaped blueprint (declares a
//! tap, exposes no `bias`) runs BARE — no wrap_r scaffold — emits a
//! `MeasurementReport` and persists the tapped series. A taps-only blueprint is
//! refused before this feature; this pins the new additive behaviour.
use std::path::Path;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_aura");
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-meas-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
/// The shipped `examples/r_sma.json`, turned MEASUREMENT-shaped: one declared tap
/// on node 0 ("fast", SMA(2)) field 0, and its `bias` output removed. Same closed
/// topology (`sma_signal`), so it runs on the built-in synthetic stream.
fn measurement_blueprint_json() -> String {
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
v["blueprint"]["output"] = serde_json::json!([]); // no `bias` — a measurement, not a strategy
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
}
#[test]
fn measurement_blueprint_runs_bare_and_emits_its_tap() {
let cwd = temp_cwd("runs-bare");
let bp_path = cwd.join("measurement.json");
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
// stdout is a MeasurementReport: the declared tap name, no R `metrics`, the
// measurement broker sentinel.
let report: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("stdout parses as MeasurementReport JSON");
assert_eq!(report["taps"], serde_json::json!(["fast_tap"]));
assert!(report.get("metrics").is_none(), "a measurement run has no R metrics");
assert_eq!(report["manifest"]["broker"], "measurement");
// The tapped series is persisted through the trace store.
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
let trace_text = std::fs::read_to_string(&trace_path)
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
assert_eq!(trace["tap"], "fast_tap");
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
}
@@ -0,0 +1,115 @@
//! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the
//! established binding-refusal register — never a process panic. Both cases
//! are user mistakes on the hand-authored single-run path, so each deserves a
//! diagnosis, not a stack trace.
//!
//! Two independent unrunnable shapes, each pinned as its own minimal,
//! self-contained blueprint (inlined here — no shared fixture file):
//! 1. a declared tap whose `from` wire references an out-of-range node
//! index (taps are authored by raw node index, so a miscount is the
//! expected mistake) — compiles to `CompileError::TapWireOutOfRange`;
//! 2. an exposed output that is not a `bias` stream (a measurement-only
//! blueprint) — `wrap_r` cannot weld it to the R-evaluator.
use std::path::Path;
use std::process::Command;
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the
/// test crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// A fresh, unique working directory per test (so nothing a run might emit
/// dirties the repo). Unique per test + per process.
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-refuse-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
/// Run `aura run <path>` in an isolated cwd and assert the CLEAN-REFUSAL
/// contract: exit code 1, an `aura:`-prefixed stderr message, and NO panic —
/// the property both cases share. `what` names the offending shape for the
/// failure message.
fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
let cwd = temp_cwd(tag);
let bp_path = cwd.join("blueprint.json");
std::fs::write(&bp_path, bp_json).expect("write blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
let stderr = String::from_utf8_lossy(&out.stderr);
// The defining symptom of the bug: the process panics (Rust's default
// unwind exits 101 and prints "panicked at ...") instead of refusing.
assert!(
!stderr.to_lowercase().contains("panic"),
"{what}: expected a clean refusal, got a PANIC:\n{stderr}"
);
// The clean-refusal register: `aura: <msg>` on stderr, exit 1 — exactly
// what the binding refusals in `run_signal_r` already emit.
assert!(
stderr.contains("aura:"),
"{what}: refusal must carry the `aura:` prefix:\n{stderr}"
);
assert_eq!(
out.status.code(),
Some(1),
"{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}"
);
}
/// Property: a blueprint whose declared tap wire points at an out-of-range
/// node index is refused, not crashed. Minimal trigger — a single-node closed
/// strategy (`price → Bias → "bias"`, so the R-wrap welds cleanly) carrying
/// one tap whose `from.node` (5) is out of range for a one-node graph. This
/// compiles to `CompileError::TapWireOutOfRange`, which `run_signal_r`'s
/// `compile_with_params(...).expect(...)` currently turns into a panic.
#[test]
fn run_refuses_a_tap_wire_pointing_out_of_range() {
let bp = r#"{
"format_version": 1,
"blueprint": {
"name": "bias_with_bad_tap",
"nodes": [
{"primitive": {"type": "Bias", "name": "bias",
"bound": [{"pos": 0, "name": "scale", "kind": "F64", "value": {"F64": 1.0}}]}}
],
"edges": [],
"input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}],
"output": [{"node": 0, "field": 0, "name": "bias"}],
"taps": [{"name": "bad", "from": {"node": 5, "field": 0}}]
}
}"#;
assert_clean_refusal(bp, "bad-tap-wire", "out-of-range tap wire");
}
/// Property: a blueprint whose single exposed output is not a `bias` stream
/// (a measurement-only blueprint — here `price → SMA → "measurement"`) is
/// refused, not crashed. `wrap_r` welds the nested signal by its `"bias"`
/// output port; a non-bias output makes that weld resolve to
/// `BuildError::UnknownOutPort`, which `wrap_r`'s `g.build().expect(...)`
/// currently turns into a panic. (This pins the refusal only — teaching
/// `aura run` to accept bias-less blueprints is a separate feature.)
#[test]
fn run_refuses_a_non_bias_output_blueprint() {
let bp = r#"{
"format_version": 1,
"blueprint": {
"name": "measurement_only",
"nodes": [
{"primitive": {"type": "SMA", "name": "avg",
"bound": [{"pos": 0, "name": "length", "kind": "I64", "value": {"I64": 2}}]}}
],
"edges": [],
"input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}],
"output": [{"node": 0, "field": 0, "name": "measurement"}]
}
}"#;
assert_clean_refusal(bp, "non-bias-output", "non-bias exposed output");
}
+43
View File
@@ -0,0 +1,43 @@
//! C29 coverage: every engine-shipped vocabulary entry carries a
//! gate-passing one-line meaning. Blocks, metrics, tap slots, folds, and
//! the std node vocabulary — the compile/unit seam of the self-description
//! contract.
use aura_core::doc_gate;
#[test]
fn every_shipped_vocabulary_entry_passes_the_doc_gate() {
// process + campaign blocks (BlockSchema.doc — the pre-existing model)
for b in aura_research::process_vocabulary()
.iter()
.chain(aura_research::campaign_vocabulary().iter())
{
doc_gate(b.id, b.doc)
.unwrap_or_else(|f| panic!("block {}: {:?}", b.id, f));
}
// metrics
for m in aura_research::metric_vocabulary() {
doc_gate(m.id, m.doc)
.unwrap_or_else(|f| panic!("metric {}: {:?}", m.id, f));
}
// tap slots
for t in aura_research::tap_vocabulary() {
doc_gate(t.id, t.doc)
.unwrap_or_else(|f| panic!("tap {}: {:?}", t.id, f));
}
// tap folds (the #283 registry roster already carries docs)
for (name, doc) in aura_runner::tap_plan::FoldRegistry::core().roster() {
doc_gate(name, doc)
.unwrap_or_else(|f| panic!("fold {}: {:?}", name, f));
}
// std node vocabulary: resolve each shipped type id to its schema
let env = aura_runner::project::Env::std();
for &type_id in aura_vocabulary::std_vocabulary_types() {
let builder = env
.resolve(type_id)
.unwrap_or_else(|| panic!("std type {type_id} must resolve"));
let schema = builder.schema();
doc_gate(type_id, schema.doc)
.unwrap_or_else(|f| panic!("node {type_id}: {:?}", f));
}
}
+214
View File
@@ -0,0 +1,214 @@
//! #282: a hand-authored blueprint with a declared tap, run on the single-run
//! path, records the tapped series; the same blueprint in a sweep does not
//! (`run_blueprint_member`, the sweep/reduce path, never calls `bind_tap` —
//! structurally guaranteed by Task 6 leaving it untouched, not re-asserted
//! here).
use std::path::Path;
use std::process::Command;
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the
/// test crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// A fresh, unique working directory for this process test (so the run's
/// `./runs/traces/` never dirties the repo). Unique per test + per process.
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-tap-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
/// The exact synthetic price array `r_sma_prices()` (main.rs) feeds the
/// `RunData::Synthetic` path — duplicated here (not re-exported; a private
/// CLI helper) so the tapped SMA(2) column can be pinned against an
/// independently-derived expectation, not a blind capture.
const R_SMA_PRICES: [f64; 18] = [
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
];
/// The shipped `examples/r_sma.json` blueprint, with one additional declared
/// tap on node 0 ("fast", `SMA(length=2)`) field 0 — the smallest addition
/// that names an interior producer output without touching the existing
/// topology (nodes/edges/input_roles/output all verbatim).
fn tap_blueprint_json() -> String {
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
}
/// Property: on the single-run path (`aura run <blueprint.json>` over the
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
/// per-cycle series is persisted through the trace store — the persisted
/// `fast_tap.json` carries exactly the SMA(2) values over the known synthetic
/// price stream (silent until warm at sample 2, one value per cycle after).
#[test]
fn single_run_persists_a_declared_tap_series() {
let cwd = temp_cwd("single-run-persists");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
});
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
assert_eq!(trace["tap"], "fast_tap");
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
// SMA(2), silent until warm (2 samples): first emission at the 2nd sample
// (ts=2), value = mean(prices[0], prices[1]); then a rolling mean of the
// last two prices per subsequent cycle (ts = i+1 for prices[i]).
let expected: Vec<f64> = (1..R_SMA_PRICES.len())
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
.collect();
let expected_ts: Vec<i64> = (2..=R_SMA_PRICES.len() as i64).collect();
let got_ts: Vec<i64> = trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
assert_eq!(got_ts, expected_ts, "recorded timestamps");
let got: Vec<f64> = trace["columns"][0]
.as_array()
.expect("columns[0] array")
.iter()
.map(|v| v.as_f64().unwrap())
.collect();
assert_eq!(got.len(), expected.len(), "recorded row count");
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
}
}
/// A tap-free blueprint run (the existing `run_prints_json_and_exits_zero`
/// shape) leaves no `runs/` directory at all — the tap machinery is fully
/// inert (no binds, no trace_store write) when a blueprint declares no taps,
/// matching Task 6's byte-identical guarantee.
#[test]
fn tap_free_run_writes_no_trace_store() {
let cwd = temp_cwd("tap-free-writes-nothing");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(BIN)
.args(["run", &bp])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(!cwd.join("runs").exists(), "a tap-free run must not create a trace store");
}
/// The shipped `examples/r_sma.json` blueprint with TWO declared taps that
/// share the same name (`"dup"`), on two distinct producers (node 0 "fast" and
/// node 1 "slow") — the shape `run_signal_r`'s `seen: BTreeSet` dedup guard
/// exists for (`FlatGraph::bind_tap` itself keeps no cross-call state per its
/// own doc comment, so nothing upstream of the CLI catches this).
fn duplicate_tap_blueprint_json() -> String {
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
v["blueprint"]["taps"] = serde_json::json!([
{"name": "dup", "from": {"node": 0, "field": 0}},
{"name": "dup", "from": {"node": 1, "field": 0}},
]);
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
}
/// Property: **a blueprint declaring two taps under the same name is refused
/// on the single-run path with a named error and exit code 1, before any
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
/// guard (bind_tap itself binds a single tap and keeps no cross-call state,
/// per its own doc comment) actually fires end-to-end, and the partial first
/// bind never reaches disk as a half-written trace store.
#[test]
fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
let cwd = temp_cwd("duplicate-tap-name-refused");
let bp_path = cwd.join("dup_tap_r_sma.json");
std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("dup") && stderr.contains("more than once"),
"stderr must name the offending tap and the duplicate-bind fault: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
}
/// The `aura: writing tap traces failed: …` register, pre-run arm: `runs`
/// exists as a FILE, so the trace store's directory creation
/// (`begin_run`) fails before the run — exit 1 through the register.
/// (This register was code-present but untested before #283.)
#[test]
fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
let cwd = temp_cwd("store-root-is-a-file");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
"the tap-trace register must fire: {stderr}"
);
}
/// The same register, deferred arm (#283): the run directory pre-exists
/// read-only, so `begin_run` succeeds (create_dir_all on an existing dir)
/// but the record consumer's deferred open in `initialize` fails; the run
/// COMPLETES, the failure surfaces terminally through the register, and no
/// `index.json` is written (the store's treat-as-absent crash shape).
#[cfg(unix)]
#[test]
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
use std::os::unix::fs::PermissionsExt;
let cwd = temp_cwd("run-dir-read-only");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run_dir = cwd.join("runs/traces/sma_signal");
std::fs::create_dir_all(&run_dir).expect("pre-create run dir");
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
.expect("make run dir read-only");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
// restore permissions FIRST so the next run's temp_cwd cleanup works.
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
.expect("restore run dir permissions");
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
"the tap-trace register must fire terminally: {stderr}"
);
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
}
+2
View File
@@ -14,6 +14,8 @@ publish.workspace = true
aura-core = { path = "../aura-core" }
aura-engine = { path = "../aura-engine" }
aura-std = { path = "../aura-std" }
aura-strategy = { path = "../aura-strategy" }
aura-backtest = { path = "../aura-backtest" }
[dev-dependencies]
# serde_json: the risk_executor fixture round-trips a folded RunMetrics through
+12 -4
View File
@@ -17,10 +17,11 @@
//! what C19/C23 DCE deletes), so it appears nowhere here.
use aura_core::{PrimitiveBuilder, Scalar};
use aura_engine::{Composite, GraphBuilder, NodeHandle};
use aura_std::{
cost_port, intern_port, CostSum, Delay, Ema, FixedStop, LinComb, Mul,
PositionManagement, Sizer, Sqrt, Sub, COST_FIELD_NAMES, GEOMETRY_WIDTH,
PM_FIELD_NAMES,
use aura_backtest::{PositionManagement, PM_FIELD_NAMES};
use aura_std::{Delay, Ema, LinComb, Mul, Sqrt, Sub};
use aura_strategy::{
cost_port, intern_port, CostSum, FixedStop, Sizer, VolTfStop, COST_FIELD_NAMES,
GEOMETRY_WIDTH,
};
/// The volatility stop as a composition of primitives:
@@ -75,6 +76,7 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
pub enum StopRule {
Fixed(f64),
Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, length: i64, k: f64 },
}
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
@@ -88,6 +90,12 @@ pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
Box::new(move |g| match stop {
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
StopRule::VolTf { period_minutes, length, k } => g.add(
VolTfStop::builder()
.bind("period_minutes", Scalar::i64(period_minutes))
.bind("length", Scalar::i64(length))
.bind("k", Scalar::f64(k)),
),
}),
risk_budget,
)
+1 -1
View File
@@ -5,7 +5,7 @@
use aura_composites::cost_graph;
use aura_core::Scalar;
use aura_std::{ConstantCost, VolSlippageCost};
use aura_strategy::{ConstantCost, VolSlippageCost};
/// Two heterogeneous cost nodes: ConstantCost (no extras, index 0) and
/// VolSlippageCost (one extra `volatility`, index 1). The composite exposes the 4
+45 -3
View File
@@ -9,8 +9,9 @@ use aura_core::{
ScalarKind, Timestamp,
};
use aura_composites::{risk_executor, StopRule};
use aura_engine::{summarize_r, GraphBuilder, RunMetrics, VecSource};
use aura_std::{Recorder, PM_FIELD_NAMES, PM_RECORD_KINDS};
use aura_engine::{GraphBuilder, VecSource};
use aura_backtest::{summarize_r, RunMetrics, PM_FIELD_NAMES, PM_RECORD_KINDS};
use aura_std::Recorder;
use std::sync::mpsc::channel;
// The dense-record columns this fixture reads, named in lockstep with the sibling
@@ -34,6 +35,7 @@ impl ConstLongBias {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(ConstLongBias { out: [Cell::from_f64(0.0)] }),
)
@@ -180,11 +182,51 @@ fn risk_executor_vol_stop_arm_bootstraps_and_folds() {
assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn);
}
/// Property (#262): the RiskExecutor embeds the `VolTfStop` primitive (the new
/// `StopRule::VolTf` arm) and folds to a finite RMetric, proving the arm's
/// `price -> stop_distance` port/kind/firing wiring actually bootstraps and
/// runs — not merely compiles. Timestamps are real epoch-ns minute ticks
/// (`i * 60s` in ns) so `period_minutes: 1` rolls a bucket every cycle,
/// matching the node's own bucket-rollover contract; a short k·σ stop over a
/// rising-then-falling path opens a trade and closes at least one.
#[test]
fn risk_executor_vol_tf_stop_arm_bootstraps_and_folds() {
const MINUTE_NS: i64 = 60 * 1_000_000_000;
let (tx, rx) = channel();
let mut g = GraphBuilder::new("vol_tf_harness");
let price = g.source_role("price", ScalarKind::F64);
let strat = g.add(ConstLongBias::builder());
let exec = g.add(risk_executor(
StopRule::VolTf { period_minutes: 1, length: 1, k: 2.0 },
1.0,
));
let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx));
g.feed(price, [strat.input("price"), exec.input("price")]);
g.connect(strat.output("bias"), exec.input("bias"));
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
let col: &'static str = format!("col[{i}]").leak();
g.connect(exec.output(field), rec.input(col));
}
let mut h = g
.build()
.expect("vol_tf_harness wires")
.bootstrap_with_params(vec![])
.expect("bootstraps");
let path = [100.0, 101.0, 100.0, 101.0, 103.0, 106.0, 104.0, 99.0, 95.0, 96.0];
let stream: Vec<(Timestamp, Scalar)> =
path.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64 * MINUTE_NS), Scalar::f64(p))).collect();
h.run(vec![Box::new(VecSource::new(stream))]);
let ledger: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
let m = summarize_r(&ledger, &[]);
assert!(m.n_trades >= 1, "the vol_tf-stop executor must fold at least one trade");
assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn);
}
/// Drain one `risk_executor`-variant harness over the shared rising-then-falling path,
/// folding the dense R-record. `exec` is the variant under test (bound or open); `params`
/// is the positional point the open variant needs (empty for the bound one). Shared by the
/// open-vs-bound equivalence test below so the two arms run byte-identical inputs.
fn fold_executor(exec: aura_engine::Composite, params: Vec<Scalar>) -> aura_engine::RMetrics {
fn fold_executor(exec: aura_engine::Composite, params: Vec<Scalar>) -> aura_backtest::RMetrics {
let (tx, rx) = channel();
let mut g = GraphBuilder::new("vol_open_harness");
let price = g.source_role("price", ScalarKind::F64);
+2 -2
View File
@@ -44,8 +44,8 @@ pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use node::{
zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder,
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
};
pub use scalar::{Scalar, ScalarKind, Timestamp};
pub use series_fold::SeriesFold;
+101 -3
View File
@@ -345,6 +345,36 @@ pub struct NodeSchema {
pub inputs: Vec<PortSpec>,
pub output: Vec<FieldSpec>,
pub params: Vec<ParamSpec>,
/// One-line meaning of the node — non-load-bearing metadata, shown by
/// introspection surfaces.
pub doc: &'static str,
}
/// Shape fault of a vocabulary entry's meaning line (C29 gate).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocGateFault {
Empty,
RestatesName,
}
/// Deterministic shape check for a vocabulary entry's meaning line —
/// string shape only, never content judgement (the engine must not
/// evaluate prose).
pub fn doc_gate(name: &str, doc: &str) -> Result<(), DocGateFault> {
if doc.trim().is_empty() {
return Err(DocGateFault::Empty);
}
fn norm(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
let short_name = name.rsplit("::").next().unwrap_or(name);
if norm(doc) == norm(name) || norm(doc) == norm(short_name) {
return Err(DocGateFault::RestatesName);
}
Ok(())
}
/// The universal composable dataflow unit (C8): a **producer, consumer, or both**.
@@ -379,6 +409,15 @@ pub trait Node {
"node".to_string()
}
/// Start-of-stream hook: `Harness::run` calls it once per node, in
/// topological order, before the first source value — the mirror of
/// [`Node::finalize`]. A consumer overrides it to acquire run resources
/// (e.g. the file a recording sink streams into). Infallible by
/// signature: an implementation that can fail stores its error, degrades
/// to inert, and surfaces the failure once, terminally, at `finalize`.
/// The default is a no-op, so existing nodes are unaffected.
fn initialize(&mut self) {}
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
/// order, after the source loop drains. A folding sink overrides it to flush
/// its accumulated summary; the default is a no-op, so existing nodes are
@@ -437,13 +476,14 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
assert_eq!(with.label(), "SMA");
let none = PrimitiveBuilder::new(
"Sub",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(none.label(), "Sub");
@@ -473,7 +513,7 @@ mod tests {
fn node_name_strips_namespace_prefix() {
let b = PrimitiveBuilder::new(
"demo::Identity",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| unreachable!("never built in this test"),
);
assert_eq!(b.node_name(), "identity");
@@ -505,7 +545,7 @@ mod tests {
fn primitive_builder_build_runs_the_closure() {
let f = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(f.params(), Vec::<ParamSpec>::new());
@@ -520,6 +560,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -529,6 +570,21 @@ mod tests {
);
}
#[test]
fn schema_carries_declared_doc() {
let b = PrimitiveBuilder::new(
"SMA",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![],
doc: "simple moving average",
},
|_| Box::new(Bare),
);
assert_eq!(b.schema().doc, "simple moving average");
}
/// A node whose label echoes the param vector its constructor received — lets a
/// test read back the positional vector `.build()` reconstructed after binds.
struct Probe(Vec<Cell>);
@@ -556,6 +612,7 @@ mod tests {
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
],
doc: "test-only schema",
},
|p| Box::new(Probe(p.to_vec())),
)
@@ -632,6 +689,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -650,6 +708,7 @@ mod tests {
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -665,6 +724,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -716,6 +776,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
@@ -737,6 +798,43 @@ mod tests {
})
);
}
#[test]
fn doc_gate_refuses_empty_and_whitespace() {
assert_eq!(doc_gate("EMA", ""), Err(DocGateFault::Empty));
assert_eq!(doc_gate("EMA", " \t"), Err(DocGateFault::Empty));
}
#[test]
fn doc_gate_refuses_name_restatement() {
assert_eq!(doc_gate("EMA", "EMA"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("EMA", "e_m_a"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("rolling_max", "Rolling Max"), Err(DocGateFault::RestatesName));
}
/// C29 tightening (#316 fieldtest): for a namespaced entry, the bare
/// display segment is a restatement too — a doc that adds nothing beyond
/// the name must not pass just because the name carries a `::` prefix.
/// Genuine meaning lines that merely *contain* the name stay legal.
#[test]
fn doc_gate_refuses_namespaced_short_name_restatement() {
// The most natural alibi at the extension-author seam: the node's
// own display name as its doc.
assert_eq!(doc_gate("lab316_nodes::Scale", "Scale"), Err(DocGateFault::RestatesName));
// Guard: a real meaning line stays Ok even when it contains the
// short name as a substring...
assert_eq!(
doc_gate("lab316_nodes::Scale", "scalar gain: emits the input times the factor param"),
Ok(())
);
// ...and a namespaced entry with a genuine doc is untouched.
assert_eq!(doc_gate("und::Opaque", "one-input f64 pass-through"), Ok(()));
}
#[test]
fn doc_gate_accepts_a_meaning_line() {
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
}
}
#[cfg(test)]
+26
View File
@@ -11,6 +11,32 @@ use crate::cell::Cell;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub struct Timestamp(pub i64);
impl Timestamp {
/// Nanoseconds per minute — the one scale factor `snap_to_nearest_minute`
/// rounds against.
const NS_PER_MINUTE: i64 = 60 * 1_000_000_000;
/// Snap this instant to the nearest whole-minute boundary (round-half-up,
/// ties toward the later minute) — the sub-second-provider-jitter
/// correction (#280): a bar's nominal minute can be a bucket/session
/// boundary while the raw provider stamp lands a fraction of a second
/// early or late. Consumers deriving bucket membership or wall-clock
/// minutes from `ctx.now()` snap the read here, once, rather than
/// dividing/truncating the raw jittered stamp — the recorded stream
/// itself stays byte-verbatim (record-then-replay, C11); only the
/// consumer-side read is corrected.
pub fn snap_to_nearest_minute(self) -> Timestamp {
let m = Self::NS_PER_MINUTE;
let rem = self.0.rem_euclid(m);
let floor = self.0 - rem;
if rem * 2 >= m {
Timestamp(floor + m)
} else {
Timestamp(floor)
}
}
}
/// The kind tag of a scalar / column, used for the edge-time type check (C7).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum ScalarKind {
+21 -4
View File
@@ -7,10 +7,10 @@ publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
# aura-analysis holds the pure trading-domain reductions (R-metrics, the
# position-event table, the multiple-comparison hurdle math) lifted out of
# `report` (issue #136). The engine re-exports them via `report::` so callers
# resolve the moved types through aura-engine unchanged (C18 byte-identity).
# aura-analysis holds the domain-free post-run statistics and the
# selection-provenance types the engine's `RunManifest.selection` embeds
# (#136, reduced to the domain-free half by #291). Re-exported via `report::`
# so callers resolve the types through aura-engine unchanged (C18).
aura-analysis = { path = "../aura-analysis" }
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
# it gives the run-report types a typed (de)serialization path for the run
@@ -19,8 +19,22 @@ serde = { workspace = true }
# serde_json renders RunReport JSON for both the registry (disk) and stdout
# (RunReport::to_json) — one shape, no hand-rolled writer (amended C16).
serde_json = { workspace = true }
# rayon is admitted under the amended C16 per-case dependency policy (INDEX.md):
# it backs `run_indexed`'s work-stealing pool for the sweep/campaign executor.
# It stays within C1 (parallelism across sims, never within one) — each sim
# still runs its own deterministic, synchronous event loop on one thread; rayon
# only fans the *disjoint* sims of a family out across threads, replacing
# `std::thread::scope`. No dep on a workspace precedent (chrono's pattern:
# direct versioned dep).
rayon = "1"
[dev-dependencies]
# aura-backtest is TEST-ONLY (back to its pre-#291 home, C28 phase 2 D3): the
# engine's own tests instantiate `RunReport<M>` with `M = RunMetrics`
# (SimBroker fixtures, summarize/RunMetrics literals) via aura-backtest's
# concrete aliases. The library code stays metric-agnostic — `RunReport<M>`
# names no concrete metric type — so aura-backtest is out of [dependencies].
aura-backtest = { path = "../aura-backtest" }
# aura-std is a TEST-ONLY dependency: the engine's own integration tests
# (r_sma_e2e, random_sweep_e2e, ger40_breakout) drive real standard nodes through
# a bootstrapped Harness. The engine's library code never names a concrete node — it
@@ -28,6 +42,9 @@ serde_json = { workspace = true }
# positionally, by index), so aura-std stays out of [dependencies] and the engine is
# `-> aura-core` only. The node-naming composite-builders live in `aura-composites`.
aura-std = { path = "../aura-std" }
aura-market = { path = "../aura-market" }
aura-strategy = { path = "../aura-strategy" }
aura-vocabulary = { path = "../aura-vocabulary" }
# chrono / chrono-tz build the Berlin-local-wall-clock epoch-ns timestamps the
# GER40 session-breakout E2E fixture (tests/ger40_breakout.rs) feeds the engine,
# exactly as `Session`'s own unit test does. Pinned to aura-std's versions (the
+250 -32
View File
@@ -18,7 +18,7 @@ use aura_core::{
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
};
use crate::harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
use crate::sweep::sweep;
use crate::{GridSpace, ParamRange, RandomSpace, RunReport, SweepFamily};
@@ -33,6 +33,30 @@ pub struct OutField {
pub name: String,
}
/// One interior output wire a tap names: a producer node's output field, by
/// interior local index — the output-side analogue of a role `Target`
/// (`{node, slot}`) and of `OutField`'s `{node, field}`.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TapWire {
pub node: usize,
pub field: usize,
}
/// A declared measurement point: a name paired with an interior output wire.
/// A PURE declaration — no side effect, no channel endpoint; the effectful sink
/// is constructed run-mode-aware at bind time, never in the serialized artefact.
/// The output-side twin of `Role` (`input_roles`): a `Role` names an abstract
/// input without naming a source; a `Tap` names an interior output without
/// naming a sink. The declaration `name` is a debug symbol for the topological
/// content-id (blanked by `strip_debug_symbols`, like `Role.name`/`OutField.name`);
/// the resolved flat-graph tap keeps its name load-bearing for by-name binding
/// (like `SourceSpec.role`, #275).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Tap {
pub name: String,
pub from: TapWire,
}
/// A blueprint item: a primitive node or a nested composite. Both present a declared
/// interface (typed inputs + one output) to the enclosing graph.
pub enum BlueprintNode {
@@ -105,7 +129,15 @@ fn derive_signature(c: &Composite) -> NodeSchema {
.collect();
let mut params = Vec::new();
collect_params(c.nodes(), c.gangs(), "", &mut params);
NodeSchema { inputs, output, params }
NodeSchema {
inputs,
output,
params,
// A derived composite signature is graph wiring, not a vocabulary entry:
// no seam walks this doc. The described surface is the composite's own
// `doc`, gated at register (C29, register seam).
doc: "",
}
}
/// The scalar kind of the interior input slot a composite target addresses,
@@ -174,6 +206,7 @@ pub struct Composite {
edges: Vec<Edge>,
input_roles: Vec<Role>,
output: Vec<OutField>,
taps: Vec<Tap>,
gangs: Vec<Gang>,
}
@@ -189,7 +222,7 @@ impl Composite {
input_roles: Vec<Role>,
output: Vec<OutField>,
) -> Self {
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, gangs: Vec::new() }
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, taps: Vec::new(), gangs: Vec::new() }
}
/// The authored render name (cluster title, #13). Non-load-bearing.
@@ -207,6 +240,16 @@ impl Composite {
pub fn doc(&self) -> Option<&str> {
self.doc.as_deref()
}
/// Install declared measurement taps (the output-side twin of `input_roles`).
/// Fluent, mirroring `with_doc`. Empty by default.
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
self.taps = taps;
self
}
/// The declared measurement taps (read-only). Empty for an un-tapped blueprint.
pub fn taps(&self) -> &[Tap] {
&self.taps
}
/// The interior blueprint items (read-only graph-as-data, C9).
pub fn nodes(&self) -> &[BlueprintNode] {
&self.nodes
@@ -291,7 +334,7 @@ impl Composite {
// structural validation, all pre-build (no node constructed):
let space = self.param_space();
check_param_namespace_injective(&space)?;
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output, &self.taps)?;
check_root_roles_bound(&self.input_roles)?;
if point.len() != space.len() {
@@ -310,6 +353,7 @@ impl Composite {
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
let mut flat_signatures: Vec<NodeSchema> = Vec::new();
let mut flat_edges: Vec<Edge> = Vec::new();
let mut flat_taps: Vec<FlatTap> = Vec::new();
let mut cursor = 0usize;
let lowerings = lower_items(
@@ -319,6 +363,7 @@ impl Composite {
&mut flat_nodes,
&mut flat_signatures,
&mut flat_edges,
&mut flat_taps,
)?;
for e in &self.edges {
@@ -335,10 +380,24 @@ impl Composite {
for t in &role.targets {
targets.extend(resolve_target(t, &lowerings)?);
}
flat_sources.push(SourceSpec { kind, targets });
flat_sources.push(SourceSpec::named(role.name.clone(), kind, targets));
}
Ok(FlatGraph { nodes: flat_nodes, signatures: flat_signatures, sources: flat_sources, edges: flat_edges })
// the root's own declared taps resolve through the root `lowerings`, same
// as any nested composite's — the root frame IS a composite frame, just
// the outermost one, so it hoists into the same accumulator.
for tap in &self.taps {
let (node, field) = resolve_tap_wire(&tap.from, &tap.name, &lowerings, &flat_signatures)?;
flat_taps.push(FlatTap { name: tap.name.clone(), node, field });
}
Ok(FlatGraph {
nodes: flat_nodes,
signatures: flat_signatures,
sources: flat_sources,
edges: flat_edges,
taps: flat_taps,
})
}
/// Compile from a self-describing param vector — the authoring-edge frontend
@@ -509,9 +568,10 @@ impl SweepBinder {
/// Resolve the named axes against `param_space()` into a positional grid and
/// run the disjoint sweep. `sweep` is [`SweepBinder::sweep_with_lattice`] with
/// the lattice dropped, so every existing caller is byte-unchanged.
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
pub fn sweep<M, F>(self, run_one: F) -> Result<SweepFamily<M>, BindError>
where
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
self.sweep_with_lattice(run_one).map(|(family, _lattice)| family)
}
@@ -520,9 +580,10 @@ impl SweepBinder {
/// in `param_space()` / odometer order). The lattice is what a plateau
/// neighbourhood walk needs (cycle 0077); only the engine's post-`resolve_axes`
/// grid holds it in the correct order.
pub fn sweep_with_lattice<F>(self, run_one: F) -> Result<(SweepFamily, Vec<usize>), BindError>
pub fn sweep_with_lattice<M, F>(self, run_one: F) -> Result<(SweepFamily<M>, Vec<usize>), BindError>
where
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let space = self.bp.param_space();
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
@@ -553,9 +614,10 @@ impl RandomBinder {
/// Resolve the named ranges against `param_space()` into a positional
/// `RandomSpace` and run the disjoint sweep. `count`/`seed` are `RandomSpace`'s
/// extra inputs — the only signature difference from [`SweepBinder::sweep`].
pub fn sweep<F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily, BindError>
pub fn sweep<M, F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily<M>, BindError>
where
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let space = self.bp.param_space();
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
@@ -812,6 +874,9 @@ pub enum CompileError {
DoubleWiredPort { node: usize, slot: usize },
/// A gang table failed structural validation at a minting boundary.
BadGang(GangFault),
/// A declared tap's wire references a non-existent interior node, or a field
/// beyond that producer's output arity.
TapWireOutOfRange { tap: String },
}
/// The typed detail of a `CompileError::BadGang` (one arm per `check_gangs`
@@ -860,6 +925,7 @@ pub(crate) fn validate_wiring(
edges: &[Edge],
roles: &[Role],
output: &[OutField],
taps: &[Tap],
) -> Result<(), CompileError> {
// edges: index-range + producer/consumer kind match. The kind-mismatch variant
// is the SAME one bootstrap returns today (Bootstrap(KindMismatch)), just raised
@@ -891,12 +957,20 @@ pub(crate) fn validate_wiring(
return Err(CompileError::OutputPortOutOfRange);
}
}
// taps: each declared tap's node index in range (structural leg only — deep
// field-arity is validated at resolve time by `resolve_tap_wire`, mirroring
// how the output leg above pins the node index).
for tap in taps {
if tap.from.node >= nodes.len() {
return Err(CompileError::TapWireOutOfRange { tap: tap.name.clone() });
}
}
// wiring totality: every interior input slot covered by exactly one wiring act
check_ports_connected(nodes, edges, roles)?;
// recurse into nested composites
for item in nodes {
if let BlueprintNode::Composite(c) = item {
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps())?;
}
}
Ok(())
@@ -1117,6 +1191,7 @@ fn lower_items(
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_signatures: &mut Vec<NodeSchema>,
flat_edges: &mut Vec<Edge>,
flat_taps: &mut Vec<FlatTap>,
) -> Result<Vec<ItemLowering>, CompileError> {
let mut lowerings = Vec::with_capacity(items.len());
for item in items {
@@ -1140,6 +1215,7 @@ fn lower_items(
flat_nodes,
flat_signatures,
flat_edges,
flat_taps,
)?);
}
}
@@ -1156,6 +1232,7 @@ fn inline_composite(
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_signatures: &mut Vec<NodeSchema>,
flat_edges: &mut Vec<Edge>,
flat_taps: &mut Vec<FlatTap>,
) -> Result<ItemLowering, CompileError> {
// `name` is the non-load-bearing render symbol (#13); it dissolves at inline
// (C23 — the boundary does not reach the flat graph), so it is not destructured.
@@ -1166,11 +1243,12 @@ fn inline_composite(
// `gangs` is an authoring-time gate only (checked at `with_gangs`); it has
// no runtime representation in the flat graph, same as `name`. `doc` is
// the prose twin of `name` (#125) and dissolves alongside it.
let Composite { name: _, doc: _, nodes, edges, input_roles, output, gangs: _ } = c;
let Composite { name: _, doc: _, nodes, edges, input_roles, output, taps, gangs: _ } = c;
let item_count = nodes.len();
// recursively lower interior items, then rewrite interior edges through them
let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges)?;
let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges, flat_taps)?;
for e in &edges {
for fe in rewrite_edge(e, &interior, flat_signatures)? {
flat_edges.push(fe);
@@ -1216,6 +1294,15 @@ fn inline_composite(
roles.push(flat_targets);
}
// this composite's own declared taps hoist into the flat, bottom-up
// accumulator now — a tap is terminal (no re-export through the boundary,
// unlike `output`), so it resolves through THIS level's freshly-lowered
// `interior` and is pushed once, never touched again by an enclosing level.
for tap in &taps {
let (node, field) = resolve_tap_wire(&tap.from, &tap.name, &interior, flat_signatures)?;
flat_taps.push(FlatTap { name: tap.name.clone(), node, field });
}
Ok(ItemLowering::Composite { output: out, roles })
}
@@ -1264,6 +1351,35 @@ fn resolve_target(t: &Target, lowerings: &[ItemLowering]) -> Result<Vec<Target>,
}
}
/// Resolve a declared tap's interior `{node, field}` into a flat producer
/// `(node, field)` through the lowering table — a Leaf resolves to its remapped
/// index with an output-arity check; a nested Composite resolves through its
/// `output` re-export remap. Mirrors the `OutField` resolution loop in
/// `inline_composite`. Taps do NOT re-export through the boundary (a tap is
/// terminal) — this is used both for a composite's own taps (resolved through its
/// freshly-lowered interior) and for the root's own taps (resolved through the
/// root `lowerings`).
fn resolve_tap_wire(
w: &TapWire,
name: &str,
lowerings: &[ItemLowering],
flat_signatures: &[NodeSchema],
) -> Result<(usize, usize), CompileError> {
let err = || CompileError::TapWireOutOfRange { tap: name.to_string() };
if w.node >= lowerings.len() {
return Err(err());
}
match &lowerings[w.node] {
ItemLowering::Leaf { index } => {
if w.field >= flat_signatures[*index].output.len() {
return Err(err());
}
Ok((*index, w.field))
}
ItemLowering::Composite { output, .. } => output.get(w.field).copied().ok_or_else(err),
}
}
/// The declared scalar kind of a flat node's input slot (for role kind-checking).
fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, CompileError> {
flat_signatures[t.node]
@@ -1277,9 +1393,12 @@ fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, Co
mod tests {
use super::*;
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
use crate::{f64_field, ParamRange, RandomSpace, RunManifest, VecSource};
use aura_backtest::{summarize, RunMetrics};
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
use aura_std::{Bias, Ema, LinComb, Recorder, SimBroker, Sma, Sub};
use aura_backtest::SimBroker;
use aura_std::{Ema, LinComb, Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
/// One knob fanning into two sibling open params passes the gate; the
@@ -1727,6 +1846,7 @@ mod tests {
inputs: vec![],
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
};
let to = NodeSchema {
inputs: vec![
@@ -1735,6 +1855,7 @@ mod tests {
],
output: vec![],
params: vec![],
doc: "test-only schema",
};
assert!(edge_kind_check(&from, 0, &to, 0).is_ok());
assert_eq!(
@@ -1751,7 +1872,7 @@ mod tests {
/// `fn` (Copy + Sync) so it serves both as the `sweep`/binder closure and as a
/// direct reference. Deterministic, so the same point reproduces its report
/// exactly — that is what makes two families comparable for equality.
fn run_point(point: &[Cell]) -> RunReport {
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_cells(point)
@@ -1763,6 +1884,7 @@ mod tests {
manifest: RunManifest {
commit: "test".to_string(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
@@ -1930,7 +2052,7 @@ mod tests {
.axis("sma_cross.fast.length", [2.0, 3.0]) // F64 values for the I64 slot
.axis("sma_cross.slow.length", [4])
.axis("bias.scale", [0.5])
.sweep(|_: &[Cell]| -> RunReport { panic!("axis pre-validation must reject before running") });
.sweep(|_: &[Cell]| -> RunReport<RunMetrics> { panic!("axis pre-validation must reject before running") });
assert_eq!(
result,
Err(BindError::KindMismatch {
@@ -2157,7 +2279,7 @@ mod tests {
fn pass1() -> BlueprintNode {
PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
)
.into()
@@ -2165,7 +2287,12 @@ mod tests {
fn join2() -> BlueprintNode {
PrimitiveBuilder::new(
"Join2",
NodeSchema { inputs: vec![f64_any(), f64_any()], output: out_v(), params: vec![] },
NodeSchema {
inputs: vec![f64_any(), f64_any()],
output: out_v(),
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Join2 { out: [Cell::from_f64(0.0)] }),
)
.into()
@@ -2173,7 +2300,7 @@ mod tests {
fn sink_f64() -> BlueprintNode {
PrimitiveBuilder::new(
"SinkF64",
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(SinkF64),
)
.into()
@@ -2185,6 +2312,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(SinkI64),
)
@@ -2364,6 +2492,8 @@ mod tests {
sources[0].targets,
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
);
// the bound root role's name survives lowering as the source binding key (#275)
assert_eq!(sources[0].role.as_deref(), Some("src"));
}
#[test]
@@ -2735,6 +2865,7 @@ mod tests {
inputs: vec![f64_any()],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
@@ -2755,14 +2886,11 @@ mod tests {
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
sources: vec![SourceSpec::raw(ScalarKind::F64, vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 },
],
}],
])],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -2771,6 +2899,7 @@ mod tests {
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
Edge { from: 3, to: 6, slot: 0, from_field: 0 },
],
taps: Vec::new(),
})
.expect("valid hand-wired DAG");
(h, rx_eq, rx_ex)
@@ -3016,7 +3145,8 @@ mod tests {
#[test]
fn param_space_reflects_only_open_knobs() {
use aura_std::{Bias, Sma};
use aura_strategy::Bias;
use aura_std::Sma;
// "sma2_entry": Sma "bias" with length bound to 2 (a structural constant)
// plus Bias "exp" whose `scale` stays open. The bound knob must be
// absent from param_space; only the open one remains.
@@ -3042,7 +3172,8 @@ mod tests {
/// order — the bound value becomes a default an axis may override.
#[test]
fn reopen_returns_a_bound_knob_to_param_space() {
use aura_std::{Bias, Sma};
use aura_strategy::Bias;
use aura_std::Sma;
let strat = Composite::new(
"sma2_entry",
vec![
@@ -3063,7 +3194,8 @@ mod tests {
/// error, not a silent no-op — open axes pass through without reopen.
#[test]
fn reopen_unknown_or_open_path_errors() {
use aura_std::{Bias, Sma};
use aura_strategy::Bias;
use aura_std::Sma;
let strat = Composite::new(
"sma2_entry",
vec![
@@ -3084,7 +3216,8 @@ mod tests {
/// carries the bound value (the default the CLI renders in --list-axes).
#[test]
fn bound_param_space_is_path_qualified_with_values() {
use aura_std::{Bias, Sma};
use aura_strategy::Bias;
use aura_std::Sma;
let strat = Composite::new(
"sma2_entry",
vec![
@@ -3338,6 +3471,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::I64 }],
params: vec![],
doc: "test-only schema",
},
|_| panic!("build must not run when validation fails pre-build"),
);
@@ -3440,7 +3574,7 @@ mod tests {
// cannot take .named).
let paramless_sma = PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
)
.named("sma");
@@ -3571,4 +3705,88 @@ mod tests {
let bn: BlueprintNode = c.into();
assert!(matches!(bn, BlueprintNode::Composite(_)));
}
#[test]
fn composite_carries_declared_taps() {
// A Composite installs and exposes a taps declaration via with_taps/taps(),
// mirroring with_doc/doc(); a bare Composite has no taps.
let bare = Composite::new("m", vec![], vec![], vec![], vec![]);
assert!(bare.taps().is_empty());
let tapped = Composite::new("m", vec![], vec![], vec![], vec![])
.with_taps(vec![Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]);
assert_eq!(tapped.taps(), &[Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]);
}
/// A tap on a root leaf producer resolves to that producer's flat (node, field) —
/// the property `compile` must give a declared tap a load-bearing flat identity.
#[test]
fn compile_resolves_a_root_tap_to_flat_indices() {
let bp = Composite::new(
"m",
vec![Sub::builder().into()],
vec![],
vec![Role {
name: "a".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![],
)
.with_taps(vec![Tap { name: "d".into(), from: TapWire { node: 0, field: 0 } }]);
let flat = bp.compile_with_params(&[]).expect("compiles");
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "d".into(), node: 0, field: 0 }]);
}
/// A tap wire naming a field beyond its producer's output arity is rejected —
/// `compile` must not silently resolve a tap into a bogus flat index.
#[test]
fn compile_rejects_a_tap_wire_out_of_output_arity() {
let bp = Composite::new(
"m",
vec![Sub::builder().into()],
vec![],
vec![Role {
name: "a".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![],
)
.with_taps(vec![Tap { name: "bad".into(), from: TapWire { node: 0, field: 9 } }]); // Sub has 1 output field
assert!(matches!(bp.compile_with_params(&[]), Err(CompileError::TapWireOutOfRange { .. })));
}
/// A tap declared on a NESTED composite hoists to the root `FlatGraph.taps`
/// with its wire remapped through the inline offset — the case the CLI
/// wrapper needs (a tap named deep in an authored blueprint must still surface
/// as a single flat, by-name-bindable measurement point at the root).
#[test]
fn compile_hoists_an_interior_composite_tap_to_the_root() {
let inner = Composite::new(
"inner",
vec![Sub::builder().into()],
vec![],
vec![Role {
name: "x".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
source: None,
}],
vec![OutField { node: 0, field: 0, name: "o".into() }],
)
.with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]);
let root = Composite::new(
"root",
vec![BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "a".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
let flat = root.compile_with_params(&[]).expect("compiles");
// the inner Sub lowered to flat node 0; the hoisted tap points at it
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "inner_d".into(), node: 0, field: 0 }]);
}
}
+70 -16
View File
@@ -46,6 +46,8 @@ pub struct CompositeData {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output: Vec<OutField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub taps: Vec<crate::blueprint::Tap>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gangs: Vec<Gang>,
}
@@ -94,6 +96,7 @@ fn project(c: &Composite) -> CompositeData {
edges: c.edges().to_vec(),
input_roles: c.input_roles().to_vec(),
output: c.output().to_vec(),
taps: c.taps().to_vec(),
gangs,
}
}
@@ -135,10 +138,11 @@ pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
/// The identity-canonical form (#171): the canonical document with every
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
/// bound-param names, role names, output re-export names. Everything load-bearing
/// survives: type ids, node order, edges, role targets/order, output pairs/order,
/// and bound positions/kinds/values (param openness stays identity-bearing: a
/// bound slot is textually present, an open one absent). Research-side comparison
/// bound-param names, role names, output re-export names, tap names. Everything
/// load-bearing survives: type ids, node order, edges, role targets/order, output
/// pairs/order, tap interior wires, and bound positions/kinds/values (param
/// openness stays identity-bearing: a bound slot is textually present, an open
/// one absent). Research-side comparison
/// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
@@ -167,6 +171,9 @@ fn strip_debug_symbols(b: &mut CompositeData) {
for out in &mut b.output {
out.name = String::new(); // (node, field) + order survive
}
for tap in &mut b.taps {
tap.name = String::new(); // interior wire (node, field) survives
}
for gang in &mut b.gangs {
gang.name = String::new();
for m in &mut gang.members {
@@ -227,6 +234,7 @@ fn reconstruct(
});
}
let composite = Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
.with_taps(d.taps.clone())
.with_gangs(d.gangs.clone())
.map_err(LoadError::Gang)?;
Ok(match &d.doc {
@@ -236,7 +244,7 @@ fn reconstruct(
}
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
/// through the injected `resolve` (e.g. `aura_std::std_vocabulary`). The version
/// through the injected `resolve` (e.g. `aura_vocabulary::std_vocabulary`). The version
/// envelope is checked before the payload is reconstructed.
pub fn blueprint_from_json(
data: &str,
@@ -259,7 +267,8 @@ mod tests {
use crate::harness::{Edge, Target};
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_std::{Bias, Recorder, Sma, Sub};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
// Open param point for the sink-free signal (fast.length is bound): [slow.length, bias.scale].
@@ -295,7 +304,7 @@ mod tests {
fn serialized_blueprint_runs_bit_identical_to_rust_built() {
let rust_built = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&rust_built).expect("serializes");
let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
let loaded = blueprint_from_json(&json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
let trace_rust = run_recording(rust_built);
let trace_loaded = run_recording(loaded);
@@ -308,7 +317,7 @@ mod tests {
fn serializer_and_loader_are_inverse() {
let original = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&original).expect("serializes");
let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
let loaded = blueprint_from_json(&json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
// serialize -> load -> serialize is byte-stable (canonical round-trip identity)
assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes"));
}
@@ -331,7 +340,7 @@ mod tests {
vec![OutField { node: 0, field: 0, name: "bias".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
let loaded = blueprint_from_json(&json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes"));
// the nested composite survived as a composite, not flattened
assert!(json.contains(r#"{"composite":{"name":"sma_cross""#), "nested composite preserved");
@@ -362,7 +371,7 @@ mod tests {
assert_ne!(plain_json, doced_json, "the doc is canonical-byte-bearing");
assert!(doced_json.contains("why this graph"), "doc serialized: {doced_json}");
assert!(!plain_json.contains("\"doc\""), "absent doc emits no key: {plain_json}");
let loaded = blueprint_from_json(&doced_json, &|t| aura_std::std_vocabulary(t)).expect("loads");
let loaded = blueprint_from_json(&doced_json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
assert_eq!(loaded.doc(), Some("why this graph: the spread detects trend turns"));
assert_eq!(doced_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
assert_eq!(
@@ -372,20 +381,65 @@ mod tests {
);
}
/// #282: a composite's declared taps round-trip through project→reconstruct
/// byte-stably (mirroring `doc_round_trips_canonically_and_is_identity_blind`),
/// and an un-tapped composite's canonical bytes carry no `taps` key at all
/// (skip_serializing_if empty) — the golden-pinned fixture's bytes stay
/// untouched by this additive field.
#[test]
fn taps_round_trip_canonically_and_absent_is_byte_stable() {
use crate::blueprint::{Tap, TapWire};
let plain = crate::test_fixtures::sink_free_sma_cross_signal();
let tapped = crate::test_fixtures::sink_free_sma_cross_signal()
.with_taps(vec![Tap { name: "d".into(), from: TapWire { node: 0, field: 0 } }]);
let plain_json = blueprint_to_json(&plain).expect("serializes");
let tapped_json = blueprint_to_json(&tapped).expect("serializes");
assert_ne!(plain_json, tapped_json, "taps are canonical-byte-bearing");
assert!(!plain_json.contains("\"taps\""), "absent taps emit no key: {plain_json}");
let loaded = blueprint_from_json(&tapped_json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
assert_eq!(loaded.taps(), tapped.taps());
assert_eq!(tapped_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
}
/// #282: a tap's `name` is a debug symbol like `Role.name`/`OutField.name` —
/// two blueprints differing only in tap name share identity JSON (name
/// blanked, interior wire survives) while their canonical JSON differs,
/// mirroring `renamed_role_and_output_share_identity_json_not_canonical_json`.
#[test]
fn renamed_tap_shares_identity_json_not_canonical_json() {
use crate::blueprint::{Tap, TapWire};
fn probe(tap_name: &str) -> Composite {
crate::test_fixtures::sink_free_sma_cross_signal()
.with_taps(vec![Tap { name: tap_name.into(), from: TapWire { node: 0, field: 0 } }])
}
let a = probe("p_long");
let b = probe("p_short");
assert_ne!(
blueprint_to_json(&a).expect("serializes"),
blueprint_to_json(&b).expect("serializes"),
"canonical bytes keep tap names apart"
);
assert_eq!(
blueprint_identity_json(&a).expect("identity-serializes"),
blueprint_identity_json(&b).expect("identity-serializes"),
"identity form is tap-name-blind"
);
}
#[test]
fn unknown_node_type_fails_named() {
// a valid envelope naming a type outside the vocabulary -> clean, named error
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"NoSuchNode"}}]}}"#;
// `.err().unwrap()` (not `.unwrap_err()`): the Ok type `Composite` holds a
// build closure and is not `Debug`, which `unwrap_err`'s bound would require.
let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).err().unwrap();
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
}
#[test]
fn unsupported_version_fails_named() {
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).err().unwrap();
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
}
@@ -413,7 +467,7 @@ mod tests {
assert_ne!(with_unknown, canonical, "probe must actually inject unknown keys");
// tolerated, not refused
let loaded = blueprint_from_json(&with_unknown, &|t| aura_std::std_vocabulary(t))
let loaded = blueprint_from_json(&with_unknown, &|t| aura_vocabulary::std_vocabulary(t))
.expect("an unknown optional field is tolerated, not refused");
// the unknown keys did not leak into the graph: re-serialization equals the
@@ -421,7 +475,7 @@ mod tests {
assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical);
// and the loaded graph runs bit-identically to the plain canonical one (C1).
let plain = blueprint_from_json(&canonical, &|t| aura_std::std_vocabulary(t)).expect("loads");
let plain = blueprint_from_json(&canonical, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
assert_eq!(
run_recording(loaded),
run_recording(plain),
@@ -602,9 +656,9 @@ mod tests {
}
// The resolver used by the gang-serde tests below (mirrors the inline
// `&|t| aura_std::std_vocabulary(t)` closure the rest of this module uses).
// `&|t| aura_vocabulary::std_vocabulary(t)` closure the rest of this module uses).
fn fixture_resolver() -> impl Fn(&str) -> Option<PrimitiveBuilder> {
|t: &str| aura_std::std_vocabulary(t)
|t: &str| aura_vocabulary::std_vocabulary(t)
}
// Shared topology: two open SMAs feeding a `Sub`. `ganged_fixture_named`
+24 -3
View File
@@ -10,7 +10,9 @@
use aura_core::{NodeSchema, ScalarKind};
use crate::blueprint::{BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role};
use crate::blueprint::{
BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role, Tap, TapWire,
};
use crate::harness::{Edge, Target};
/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
@@ -92,6 +94,7 @@ pub struct GraphBuilder {
edges: Vec<(OutPort, InPort)>,
roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
out: Vec<(String, OutPort)>,
taps: Vec<(String, OutPort)>,
gangs: Vec<(String, Vec<ParamRef>)>,
}
@@ -106,6 +109,7 @@ impl GraphBuilder {
edges: Vec::new(),
roles: Vec::new(),
out: Vec::new(),
taps: Vec::new(),
gangs: Vec::new(),
}
}
@@ -149,6 +153,15 @@ impl GraphBuilder {
self.out.push((name.to_string(), from));
}
/// Declare a measurement tap on a producer's output field under `name`
/// (resolved at build) — the output-side twin of [`GraphBuilder::expose`]: a
/// recorded observation point (a `Composite.taps` entry, #284/C27), not a
/// boundary output. The builder-surface counterpart of the op-script `tap` op,
/// so a Rust-authored graph reaches taps the same way an op-script does (C24).
pub fn tap(&mut self, from: OutPort, name: &str) {
self.taps.push((name.to_string(), from));
}
/// Fuse two or more sibling params into one public knob (resolved at build).
pub fn gang(&mut self, name: &str, members: impl IntoIterator<Item = ParamRef>) {
self.gangs.push((name.to_string(), members.into_iter().collect()));
@@ -185,6 +198,12 @@ impl GraphBuilder {
output.push(OutField { node: from.node, field, name: name.clone() });
}
let mut taps = Vec::with_capacity(self.taps.len());
for (name, from) in &self.taps {
let field = self.resolve_field(from)?;
taps.push(Tap { name: name.clone(), from: TapWire { node: from.node, field } });
}
let mut gangs = Vec::with_capacity(self.gangs.len());
for (name, refs) in &self.gangs {
let mut members = Vec::with_capacity(refs.len());
@@ -230,7 +249,7 @@ impl GraphBuilder {
let kind = kind.unwrap_or(ScalarKind::F64);
gangs.push(Gang { name: name.clone(), kind, members });
}
let mut composite = Composite::new(self.name, self.nodes, edges, roles, output);
let mut composite = Composite::new(self.name, self.nodes, edges, roles, output).with_taps(taps);
if let Some(doc) = self.doc {
composite = composite.with_doc(doc);
}
@@ -325,7 +344,9 @@ mod tests {
use crate::test_fixtures::sma_cross as hand_sma_cross;
use crate::{Composite, OutField, Role, Target};
use aura_core::{Firing, ScalarKind};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use aura_backtest::SimBroker;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
/// The `sma_cross` sub-composite authored through the builder.
+256 -12
View File
@@ -14,7 +14,7 @@ use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use crate::blueprint::{
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring,
BlueprintNode, Composite, Gang, GangMember, OutField, Role,
BlueprintNode, Composite, Gang, GangMember, OutField, Role, Tap, TapWire,
};
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
use crate::harness::{Edge, Target};
@@ -37,8 +37,15 @@ pub enum Op {
Connect { from: String, to: String },
/// Re-export `"node.field"` at the boundary under `as_name`.
Expose { from: String, as_name: String },
/// Declare a measurement tap on `"node.field"` under `as_name` (#284) — the
/// output-side twin of `Expose` (a `Tap`, not an `OutField`).
Tap { from: String, as_name: String },
/// Fuse two or more sibling params into one public knob (#61).
Gang { as_name: String, into: Vec<String> },
/// Declare the composite's one-line meaning (C29, #316) — the op-script
/// twin of the builder's `.doc(...)`. At most one per script; a second
/// is a fault (a declarative script carries no last-wins ambiguity).
Doc { text: String },
}
/// A per-op construction fault, by-identifier so the cause names the op.
@@ -51,6 +58,11 @@ pub enum OpError {
/// distinct fault class from an interior node-id clash, so callers can tell the
/// two apart from the error value alone.
DuplicateOutput(String),
/// A tap-name collision (the separate `tap_names` namespace) — two taps named
/// alike would collide as recorded series. A distinct fault class from an
/// interior id (`DuplicateIdentifier`) or a boundary-output clash
/// (`DuplicateOutput`).
DuplicateTap(String),
DuplicateRole(String),
UnknownIdentifier(String),
UnknownRole(String),
@@ -84,6 +96,8 @@ pub enum OpError {
/// A holistic finalize fault (totality / injectivity / unbound root role),
/// wrapping the unchanged engine gate's `CompileError`.
Incomplete(CompileError),
/// A second `doc` op — the meaning line is declared at most once.
DuplicateDoc,
}
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
@@ -101,8 +115,11 @@ pub struct GraphSession<'v> {
role_names: HashMap<String, usize>,
out: Vec<OutField>,
out_names: HashSet<String>,
taps: Vec<Tap>,
tap_names: HashSet<String>,
coverage: HashMap<(usize, usize), usize>,
gangs: Vec<Gang>,
doc: Option<String>,
}
impl<'v> GraphSession<'v> {
@@ -121,8 +138,11 @@ impl<'v> GraphSession<'v> {
role_names: HashMap::new(),
out: Vec::new(),
out_names: HashSet::new(),
taps: Vec::new(),
tap_names: HashSet::new(),
coverage: HashMap::new(),
gangs: Vec::new(),
doc: None,
}
}
@@ -136,7 +156,15 @@ impl<'v> GraphSession<'v> {
Op::Feed { role, into } => self.feed(role, into),
Op::Connect { from, to } => self.connect(from, to),
Op::Expose { from, as_name } => self.expose(from, as_name),
Op::Tap { from, as_name } => self.tap(from, as_name),
Op::Gang { as_name, into } => self.gang(as_name, into),
Op::Doc { text } => {
if self.doc.is_some() {
return Err(OpError::DuplicateDoc);
}
self.doc = Some(text);
Ok(())
}
}
}
@@ -304,6 +332,25 @@ impl<'v> GraphSession<'v> {
Ok(())
}
/// Declare a measurement tap on a resolved interior output wire — the
/// output-side twin of `expose`. Node resolution (`node_index` →
/// `UnknownIdentifier`), the output-field resolver, and the name-dedup are the
/// same as `expose`'s; only the appended item differs: a `Tap` on the separate
/// `taps`/`tap_names` namespace (dedup → `DuplicateTap`), not an `OutField`.
fn tap(&mut self, from: String, as_name: String) -> Result<(), OpError> {
let (from_node, from_field_name) = Self::split_port(&from)?;
let fi = self.node_index(&from_node)?;
let field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e {
PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() },
PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() },
})?;
if !self.tap_names.insert(as_name.clone()) {
return Err(OpError::DuplicateTap(as_name));
}
self.taps.push(Tap { name: as_name, from: TapWire { node: fi, field } });
Ok(())
}
/// Eager arm of the gang gate: resolve every member against the CURRENT
/// (post-bind) open param lists — a member bound at `Add` has left the open
/// schema and correctly fails as `UnknownParam`, mirroring `try_bind` — enforce
@@ -392,14 +439,16 @@ impl<'v> GraphSession<'v> {
.into_iter()
.map(|(name, source, targets)| Role { name, targets, source })
.collect();
// No doc is attached here: the op-script vocabulary deliberately has no
// doc-carrying surface yet (#125 scope cut) — an op-built composite
// carries no authored rationale until that vocabulary grows a slot.
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
// The doc op (C29, #316) threads through `Composite::with_doc`.
let mut c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
.with_taps(self.taps)
.with_gangs(self.gangs)
.map_err(OpError::Incomplete)?;
if let Some(t) = self.doc {
c = c.with_doc(t);
}
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(|e| match e {
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps()).map_err(|e| match e {
CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort {
node: self.ids[node].clone(),
slot: self.schemas[node].inputs[slot].name.clone(),
@@ -439,7 +488,7 @@ pub fn replay(
mod tests {
use super::{GraphSession, Op, OpError};
use aura_core::{BindOpError, Scalar, ScalarKind};
use aura_std::std_vocabulary;
use aura_vocabulary::std_vocabulary;
fn session(name: &str) -> GraphSession<'static> {
GraphSession::new(name, &std_vocabulary)
@@ -639,6 +688,77 @@ mod tests {
);
}
/// `tap` names an interior output wire by `"node.field"` (the output-side twin
/// of `expose`), appending a resolved `Tap` to the composite — the
/// name-addressed wire resolves to the same interior `{node, field}` a
/// raw-index tap would carry (`fast` is session node 0, its `value` field 0).
#[test]
fn tap_op_resolves_and_appends() {
use crate::blueprint::{Tap, TapWire};
let mut s = scaffold();
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }).unwrap();
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
s.apply(Op::Expose { from: "sub.value".into(), as_name: "bias".into() }).unwrap();
assert_eq!(s.apply(Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() }), Ok(()));
let c = s.finish().expect("finishes");
assert_eq!(c.taps(), &[Tap { name: "fast_ma".into(), from: TapWire { node: 0, field: 0 } }]);
}
/// `tap` reuses `expose`'s resolvers: a bad output field maps to the
/// by-identifier `UnknownOutPort`; an unknown source node to `UnknownIdentifier`
/// (via `node_index`), never the nonexistent `UnknownNode`.
#[test]
fn tap_op_bad_out_port_and_ghost_node_rejected() {
let mut s = scaffold();
assert_eq!(
s.apply(Op::Tap { from: "fast.nope".into(), as_name: "t".into() }),
Err(OpError::UnknownOutPort { node: "fast".into(), name: "nope".into() })
);
assert_eq!(
s.apply(Op::Tap { from: "ghost.value".into(), as_name: "t".into() }),
Err(OpError::UnknownIdentifier("ghost".into()))
);
}
/// A second tap under the same boundary name is a `DuplicateTap` — the twin of
/// `expose`'s `DuplicateOutput`, over the separate `tap_names` namespace.
#[test]
fn tap_op_duplicate_name_rejected() {
let mut s = scaffold();
assert_eq!(s.apply(Op::Tap { from: "fast.value".into(), as_name: "t".into() }), Ok(()));
assert_eq!(
s.apply(Op::Tap { from: "slow.value".into(), as_name: "t".into() }),
Err(OpError::DuplicateTap("t".into()))
);
}
/// `finish()` threads op-declared taps into `Composite.taps`, and the #282
/// compile path hoists them into `FlatGraph.taps` — proving the `.with_taps`
/// thread this op adds reaches the flat graph (guards the latent drop `finish`
/// had: it built `.with_gangs` but never `.with_taps`). `fast` lowers to flat
/// node 0, its `value` field 0.
#[test]
fn tap_op_threads_into_flat_graph() {
use crate::harness::FlatTap;
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
];
let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
.expect("replays")
.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)])
.expect("compiles");
assert_eq!(flat.taps, vec![FlatTap { name: "fast_ma".into(), node: 0, field: 0 }]);
}
/// The gang op fuses two sibling params into one public knob; the replayed
/// composite's `param_space()` carries exactly the gang address.
#[test]
@@ -659,6 +779,51 @@ mod tests {
assert_eq!(names, ["length"]);
}
/// #261 — the Frankfurt `Session` preset is reachable from a blueprint. The
/// property this pins: a data-only op-script (a blueprint) that names the
/// preset's closed-roster type-id resolves it through `std_vocabulary`,
/// carries its one scalar `period_minutes` knob and the `bars_since_open:
/// i64` session-index output, wires its `trigger` input through the ordinary
/// edge convention, and builds to a finished `Composite`. Reachability is the
/// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned
/// in `aura-std/src/session.rs` and are not re-pinned here. Today the preset
/// id is absent from the roster (the base `Session` builder takes structural
/// construction args, so the zero-arg macro cannot host it), so the lookup
/// returns `None` and the op-script's `Add` would fail `UnknownNodeType`.
#[test]
fn session_frankfurt_preset_is_reachable_from_a_blueprint() {
// (a) the preset resolves through the closed roster as the Session preset
// — its one scalar knob and its i64 session-index output, not some
// other node parked under the id.
let preset = std_vocabulary("SessionFrankfurt")
.expect("the Frankfurt Session preset is in the std vocabulary roster");
assert_eq!(preset.label(), "SessionFrankfurt");
let schema = preset.schema();
assert_eq!(schema.params.len(), 1, "exactly one scalar knob");
assert_eq!(schema.params[0].name, "period_minutes");
assert_eq!(schema.params[0].kind, ScalarKind::I64);
assert_eq!(schema.inputs.len(), 1);
assert_eq!(schema.inputs[0].name, "trigger");
assert_eq!(schema.output[0].name, "bars_since_open");
assert_eq!(schema.output[0].kind, ScalarKind::I64);
// (b) an op-script naming that id builds: a bound trigger source feeds the
// preset's `trigger`, its `period_minutes` knob binds, and the i64 bar
// index exposes — a data-only blueprint reaching the session stream.
let ops = vec![
Op::Source { role: "trigger".into(), kind: ScalarKind::F64 },
Op::Add {
type_id: "SessionFrankfurt".into(),
as_name: Some("session".into()),
bind: vec![("period_minutes".into(), Scalar::i64(15))],
},
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
];
super::replay("session_blueprint", ops, &std_vocabulary)
.expect("the preset resolves, wires and builds through an op-script");
}
/// Eager refusals: unknown member param (a member bound at `Add` has left the
/// open schema), kind mismatch across members, a doubly-ganged member, a
/// single-member gang, and an unknown node identifier.
@@ -717,6 +882,32 @@ mod tests {
);
}
/// C29 (#316): the doc op sets the composite's meaning line — the
/// op-script twin of the builder's .doc(...).
#[test]
fn doc_op_sets_the_composite_doc() {
let mut s = scaffold();
s.apply(Op::Doc { text: "a described op-built composite".into() }).unwrap();
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] })
.unwrap();
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
let c = s.finish().expect("finishes");
assert_eq!(c.doc(), Some("a described op-built composite"));
}
/// A second doc op is a fault, not a silent overwrite.
#[test]
fn duplicate_doc_op_is_refused() {
let mut s = scaffold();
s.apply(Op::Doc { text: "first".into() }).unwrap();
assert_eq!(
s.apply(Op::Doc { text: "second".into() }),
Err(OpError::DuplicateDoc)
);
}
#[test]
fn finish_reports_incomplete_on_unwired_slot() {
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm
@@ -800,7 +991,7 @@ mod tests {
let finalize = ops.len();
// `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures
// and is not `Debug`.
let (idx, _err) = super::replay("g", ops, &aura_std::std_vocabulary)
let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
.err()
.expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)");
// An eager realisation attributes the fault to the closing connect op; a
@@ -827,7 +1018,7 @@ mod tests {
Op::Expose { from: "s.value".into(), as_name: "out".into() },
];
assert!(
super::replay("g", ops, &aura_std::std_vocabulary).is_err(),
super::replay("g", ops, &aura_vocabulary::std_vocabulary).is_err(),
"a self-loop op-list must be rejected (DAG invariant 5 / C9)"
);
}
@@ -841,7 +1032,7 @@ mod tests {
];
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
// not `Debug` (it holds build closures), which `unwrap_err` would require.
let err = super::replay("g", ops, &aura_std::std_vocabulary).err().unwrap();
let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary).err().unwrap();
assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into())));
}
@@ -855,7 +1046,8 @@ mod tests {
fn replay_built_signal_compiles_identically_to_graphbuilder() {
use crate::GraphBuilder;
use aura_core::Scalar;
use aura_std::{Bias, Sma, Sub};
use aura_std::{Sma, Sub};
use aura_strategy::Bias;
// param space of the flat root: fast.length(i64), slow.length(i64),
// bias.scale(f64) — same vector applied to both sides, so it cancels.
@@ -874,7 +1066,7 @@ mod tests {
Op::Connect { from: "sub.value".into(), to: "bias.signal".into() },
Op::Expose { from: "bias.bias".into(), as_name: "bias".into() },
];
let replay_flat = super::replay("root", ops, &aura_std::std_vocabulary)
let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary)
.expect("replay resolves")
.compile_with_params(&params)
.expect("replay compiles");
@@ -947,4 +1139,56 @@ mod tests {
assert_eq!(replay_flat.edges, built_flat.edges);
assert_eq!(replay_flat.sources, built_flat.sources);
}
/// C24 lockstep extended to taps (#284): the op-script's `Op::Tap` replay and
/// the `GraphBuilder::tap` twin serialize to byte-identical blueprint JSON and
/// compile to byte-identical `FlatGraph.taps` — the tap op is the by-identifier
/// face of the same construction semantics `GraphBuilder::build` runs, not a
/// second one. Guards the builder/op-script tap symmetry (both surfaces can
/// declare a tap, and they resolve it identically).
#[test]
fn tap_replay_matches_builder() {
use crate::blueprint_serde::blueprint_to_json;
use crate::GraphBuilder;
use aura_core::Scalar;
use aura_std::{Sma, Sub};
// (a) op-script: an SMA crossover with a tap on the raw spread (sub.value).
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
Op::Tap { from: "sub.value".into(), as_name: "spread".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
];
let replayed = super::replay("g", ops, &std_vocabulary).expect("replays");
// (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...).
let mut g = GraphBuilder::new("g");
let fast = g.add(Sma::builder().named("fast"));
let slow = g.add(Sma::builder().named("slow"));
let sub = g.add(Sub::builder().named("sub"));
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [fast.input("series"), slow.input("series")]);
g.connect(fast.output("value"), sub.input("lhs"));
g.connect(slow.output("value"), sub.input("rhs"));
g.tap(sub.output("value"), "spread");
g.expose(sub.output("value"), "bias");
let built = g.build().expect("root resolves");
assert_eq!(
blueprint_to_json(&replayed).expect("replay serializes"),
blueprint_to_json(&built).expect("builder serializes"),
);
let params = [Scalar::i64(2), Scalar::i64(4)];
let replay_flat = replayed.compile_with_params(&params).expect("replay compiles");
let built_flat = built.compile_with_params(&params).expect("builder compiles");
assert_eq!(replay_flat.taps, built_flat.taps);
assert_eq!(replay_flat.edges, built_flat.edges);
}
}
+4 -1
View File
@@ -321,7 +321,8 @@ mod tests {
use aura_core::{
Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
};
use aura_std::{Bias, Recorder, Sma, Sub};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
/// A bare node whose only purpose is to back a `PrimitiveBuilder` in a test.
@@ -347,6 +348,7 @@ mod tests {
],
output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
@@ -461,6 +463,7 @@ mod tests {
FieldSpec { name: "lo".into(), kind: ScalarKind::I64 },
],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
File diff suppressed because it is too large Load Diff
+30 -32
View File
@@ -13,28 +13,27 @@
//! - [`BootstrapError`] — wiring faults caught once, at bootstrap (kind
//! mismatch, bad index, directed cycle).
//!
//! Delivered in cycle 0009 — the run report surface:
//! Delivered in cycle 0009, generic since #292 — the run-record surface:
//!
//! - [`RunMetrics`] / [`RunManifest`] / [`RunReport`] — the `(manifest, metrics)`
//! pair C18 mandates per run, with [`RunReport::to_json`] for the structured
//! C14 face;
//! - [`summarize`] — the post-run pure reduction over a run's recorded
//! pip-equity + exposure streams into [`RunMetrics`]; [`f64_field`] bridges a
//! recording sink's `Vec<Scalar>` rows to it.
//! - [`RunReport`] / [`RunManifest`] — the `(manifest, metrics)` pair C18
//! mandates per run. [`RunReport`] is generic over its metric payload `M`:
//! the engine names no concrete metric type; the backtest layer
//! (`aura-backtest`) instantiates it and hosts the pip/R reductions. C14 face
//! via [`RunReport::to_json`]; [`f64_field`] bridges a recording sink's
//! `Vec<Scalar>` rows into the `(ts, f64)` streams a metric reduction folds.
//!
//! Orchestration families of the atomic sim unit
//! (`(topology + params + data-window + seed)`) ship along three of C12's axes: the
//! **grid** axis via [`sweep`] over a [`GridSpace`] into a [`SweepFamily`], the
//! **window** axis via [`walk_forward`] over a [`WindowRoller`] into a
//! [`WalkForwardResult`] (C12 axis 3: rolling in-sample/out-of-sample splits, the
//! in-sample optimize closure-supplied), and
//! the **seed** axis via [`monte_carlo`] over a seed set into an [`McFamily`]
//! (C12 axis 4: Monte-Carlo *is* a sweep over seeds; both drive one shared
//! disjoint-parallel executor). The **seed** input itself ships too:
//! [`SyntheticSpec`] is a seeded `Source` producer (`Fn(u64) -> impl Source`,
//! C12 seed-as-input) whose stream is fully seed-determined, making
//! [`RunManifest`]'s seed a live captured input; the atomic unit's `-> metrics`
//! reduction ships via [`summarize`].
//! (`(topology + params + data-window + seed)`) ship along C12's axes: the
//! **grid** axis via [`sweep`] over a [`GridSpace`] into a [`SweepFamily`], and
//! the **window** axis via [`walk_forward`] over a [`WindowRoller`] into a
//! [`WalkForwardResult`] (C12 axis 3: rolling in-sample/out-of-sample splits,
//! the in-sample optimize closure-supplied). The **seed** axis (C12 axis 4:
//! Monte-Carlo *is* a sweep over seeds) is assembled one rung out in
//! `aura-backtest`, over the same shared disjoint-parallel executor this crate
//! provides. The **seed** input itself ships here: [`SyntheticSpec`] is a
//! seeded `Source` producer (`Fn(u64) -> impl Source`, C12 seed-as-input) whose
//! stream is fully seed-determined, making [`RunManifest`]'s seed a live
//! captured input.
//!
//! Still to come (subsequent cycles): the broker-independent position-event
//! output and downstream broker nodes (C10), the random param-sweep
@@ -48,14 +47,13 @@ mod builder;
mod construction;
mod graph_model;
mod harness;
mod mc;
mod report;
mod sweep;
mod walkforward;
pub use blueprint::{
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder,
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
};
pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
@@ -65,23 +63,23 @@ pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHan
pub use construction::{replay, GraphSession, Op, OpError};
pub use graph_model::model_to_json;
pub use harness::{
window_of, BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SplitMix64,
SyntheticSpec, Target, VecSource,
window_of, bind_sources, BootstrapError, Edge, FlatGraph, FlatTap, Harness, Source, SourceBindError,
SourceSpec, SplitMix64, SyntheticSpec, TapBindError, Target, VecSource,
};
pub use report::{
derive_position_events, expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
r_metrics_from_rs, summarize, summarize_r, ColumnarTrace, FamilySelection, JoinedRow,
PositionAction, PositionEvent, ProjectProvenance, RMetrics, RunManifest, RunMetrics,
expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
ColumnarTrace, FamilySelection, JoinedRow,
MeasurementReport, ProjectProvenance, RunManifest,
RunReport, SelectionMode,
};
pub use sweep::{
sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily,
SweepPoint,
};
pub use mc::{
monte_carlo, r_bootstrap, resample_block, McAggregate, McDraw, McFamily, MetricStats,
RBootstrap,
run_indexed, sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError,
SweepFamily, SweepPoint,
};
// Foundation-grade aura-analysis items, re-exported so ladder rungs without a
// direct aura-analysis edge (e.g. aura-backtest) reach them through the engine
// (#291; the metric-vocabulary seam rides the same path, #147).
pub use aura_analysis::{one_sided_p_laplace, resample_block, MetricStats, MetricVocabulary};
pub use walkforward::{
param_stability, walk_forward, RollMode, WalkForwardError, WalkForwardResult,
WindowBounds, WindowOutcome, WindowRoller, WindowRun,
+55 -221
View File
@@ -8,19 +8,17 @@
//! cycle 0029) — the same encoder the run registry uses, so a record's stdout
//! and on-disk shapes coincide.
use aura_core::{Scalar, ScalarKind, SeriesFold, Timestamp};
use aura_core::{Scalar, ScalarKind, Timestamp};
use std::collections::HashMap;
// The pure trading-domain reductions (R-metrics, the position-event table, the
// multiple-comparison hurdle math) live in `aura-analysis` (issue #136); they are
// re-imported here so `report::`'s namespace — and therefore `aura-engine`'s
// `lib.rs` re-export and every `crate::report::X` reference — resolves unchanged
// (C18 byte-identity). `summarize` (below) bridges trace columns into these types
// at the engine boundary, so it stays here.
pub use aura_analysis::{
derive_position_events, expected_max_of_normals, inv_norm_cdf, r_metrics_from_rs, summarize_r,
FamilySelection, PositionAction, PositionEvent, RMetrics, RunMetrics, SelectionMode,
};
// The backtest reductions (R-metrics, the position-event table, `summarize`)
// moved to `aura-backtest::metrics` (C28 phase 2, #291/#292 — the engine's
// production edge to aura-backtest is cut, D4); the domain-free hurdle math +
// selection provenance stays re-imported here from `aura-analysis` so
// `report::`'s namespace — and therefore `aura-engine`'s `lib.rs` re-export and
// every `crate::report::X` reference — resolves unchanged for those names
// (C18 byte-identity).
pub use aura_analysis::{expected_max_of_normals, inv_norm_cdf, FamilySelection, SelectionMode};
/// The reproducible run descriptor (C18). **Caller-supplied**: the engine
/// cannot introspect a git commit, an RNG seed, or a broker label — the World
@@ -34,6 +32,16 @@ pub struct RunManifest {
/// self-describing [`Scalar`], so the param's kind (an `i64` length vs an
/// `f64` scale) survives into the record instead of collapsing to `f64`.
pub params: Vec<(String, Scalar)>,
/// The bound params this run did NOT vary — the wrap-prefixed
/// `bound_param_space()` of the signal, EXCLUDING any bound param an axis
/// reopened (those flow through `params` instead, #246). Complements
/// `params` ("what varied") with "what was held": the two are disjoint by
/// construction. Empty for every pre-#249 record. One-directional serde
/// widening (C14/C18), identical idiom to `selection`/`instrument`/
/// `topology_hash` — except a `Vec`, not an `Option`, so it always
/// serializes (mirroring `params`), never skipped when empty.
#[serde(default)]
pub defaults: Vec<(String, Scalar)>,
/// The data-window: inclusive `(from, to)` epoch-ns bounds (C12).
pub window: (Timestamp, Timestamp),
/// The RNG seed (C12 seed-as-input). `0` for a seed-free synthetic run.
@@ -80,16 +88,18 @@ pub struct ProjectProvenance {
pub commit: Option<String>,
}
/// A run's full structured result: the descriptor plus the metrics it
/// reproduces. The durable run record of C18 ("stores manifests + metrics,
/// re-derives full results on demand").
/// A run's full structured result: the descriptor plus the metric payload `M`
/// it reproduces. Metric-agnostic (C28): the engine never names a concrete
/// metric type; the backtest layer instantiates `M` (see `aura-backtest`). The
/// durable run record of C18 ("stores manifests + metrics, re-derives full
/// results on demand").
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RunReport {
pub struct RunReport<M> {
pub manifest: RunManifest,
pub metrics: RunMetrics,
pub metrics: M,
}
impl RunReport {
impl<M: serde::Serialize> RunReport<M> {
/// Render the canonical, machine-readable JSON (C14) via serde — the same
/// encoder the run registry uses on disk, so a record's stdout shape and its
/// `runs.jsonl` shape are byte-identical. `params` is an array of
@@ -101,35 +111,26 @@ impl RunReport {
}
}
/// Reduce a run's recorded pip-equity + exposure streams into summary metrics.
/// Pure — identical inputs yield identical metrics (C1/C12). Timestamps are
/// carried in the input to match exactly what a sink records; the reduction
/// itself is value-only (it does not read the timestamps).
pub fn summarize(
equity: &[(Timestamp, f64)],
exposure: &[(Timestamp, f64)],
) -> RunMetrics {
// total_pips + max_drawdown fold over equity; sign_flips folds over exposure.
// SeriesFold is the single arithmetic source of truth (shared with the
// SeriesReducer sink), so this is byte-identical to the prior inline loops.
let mut eqf = SeriesFold::new();
for &(_, v) in equity {
eqf.fold(v);
}
let mut exf = SeriesFold::new();
for &(_, v) in exposure {
exf.fold(v);
}
RunMetrics {
total_pips: eqf.last,
max_drawdown: eqf.max_drawdown,
bias_sign_flips: exf.sign_flips,
r: None,
/// A measurement run's stdout/record shape (C28 phase 3): the run descriptor plus
/// the declared tap names, mirroring the persisted `index.json`. A measurement run
/// has no broker → no equity/exposure → NO R `metrics` (the difference from
/// [`RunReport`]). The tapped series themselves persist through the trace store.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MeasurementReport {
pub manifest: RunManifest,
pub taps: Vec<String>,
}
impl MeasurementReport {
/// Canonical machine-readable JSON via serde, same encoder as [`RunReport`].
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("a finite MeasurementReport always serializes")
}
}
/// Bridge a recording sink's recorded `(ts, row)` stream to [`summarize`]:
/// extract one `f64` field of each row into `(ts, f64)` samples. Panics if a
/// Bridge a recording sink's recorded `(ts, row)` stream to a metric reduction
/// (e.g. `aura-backtest`'s `summarize`): extract one `f64` field of each row
/// into `(ts, f64)` samples. Panics if a
/// row has no such field or the field is not an `f64` scalar — a wiring bug (a
/// sink's declared kinds are fixed at bootstrap, so a correctly-wired
/// equity/exposure sink always yields `f64` at field 0), surfaced like the
@@ -283,10 +284,7 @@ fn scalar_to_f64(s: Scalar) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edge, FlatGraph, Harness, SourceSpec, Target, VecSource};
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
use aura_backtest::RunMetrics;
#[test]
fn runmanifest_without_selection_field_deserialises_to_none() {
@@ -298,7 +296,7 @@ mod tests {
#[test]
fn runmanifest_without_selection_serialises_without_the_key() {
let m = RunManifest {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None,
project: None,
@@ -317,7 +315,7 @@ mod tests {
#[test]
fn runmanifest_instrument_round_trips_and_omits_when_none() {
let with = RunManifest {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: Some("GER40".into()),
topology_hash: None,
project: None,
@@ -328,7 +326,7 @@ mod tests {
assert_eq!(back.instrument, Some("GER40".to_string()));
let without = RunManifest {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None,
project: None,
@@ -370,7 +368,7 @@ mod tests {
commit: Some("deadbeef".into()),
};
let m = RunManifest {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None,
project: Some(p.clone()),
@@ -393,7 +391,7 @@ mod tests {
neighbourhood_score: None, n_neighbours: None,
};
let m = RunManifest {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: Some(sel.clone()), instrument: None,
topology_hash: None,
project: None,
@@ -441,173 +439,6 @@ mod tests {
assert_eq!(sel.n_neighbours, None);
}
/// The declared signature of a `Recorder` over one f64 column (the sink shape
/// the two-sink harness uses).
fn f64_recorder_sig() -> NodeSchema {
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
}
}
/// Build an f64 source stream from (timestamp, value) points (mirrors the
/// harness.rs test helper; the e2e test needs its own copy — the harness
/// test module's is private to that module).
fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect()
}
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
#[allow(clippy::type_complexity)]
fn build_two_sink_harness() -> (
Harness,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Bias::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker
],
}],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
})
.expect("valid signal-quality DAG");
(h, rx_eq, rx_ex)
}
fn run_once() -> RunReport {
let (mut h, rx_eq, rx_ex) = build_two_sink_harness();
h.run(vec![Box::new(VecSource::new(f64_stream(&[
(1, 1.0000),
(2, 1.0010),
(3, 1.0025),
(4, 1.0020),
(5, 1.0040),
])))]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: "test-commit".to_string(),
params: vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
window: (Timestamp(1), Timestamp(5)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics,
}
}
#[test]
fn report_is_deterministic_end_to_end() {
let r1 = run_once();
let r2 = run_once();
// a run actually emitted metrics over a non-empty pip curve
assert!(r1.metrics.total_pips.is_finite());
// same manifest -> same metrics (C1/C12): two runs are bit-identical
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
.enumerate()
.map(|(i, &v)| (Timestamp(i as i64 + 1), v))
.collect()
}
#[test]
fn summarize_total_pips_is_last_cumulative_value() {
let equity = samples(&[0.0, 5.0, 4.0, 12.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.total_pips, 12.0);
}
#[test]
fn summarize_is_zero_on_empty_streams() {
let m = summarize(&[], &[]);
assert_eq!(m.total_pips, 0.0);
assert_eq!(m.max_drawdown, 0.0);
assert_eq!(m.bias_sign_flips, 0);
}
#[test]
fn summarize_max_drawdown_is_worst_peak_to_trough() {
// peak 10 then trough 5 (drop 5), recovers to 8; worst drop is 5,
// not the final drop (10 -> 8 = 2).
let equity = samples(&[0.0, 10.0, 5.0, 8.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 5.0);
}
#[test]
fn summarize_max_drawdown_zero_on_monotonic_curve() {
let equity = samples(&[0.0, 1.0, 2.0, 3.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 0.0);
}
#[test]
fn summarize_sign_flips_counts_signum_changes() {
// signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3.
let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 3);
}
#[test]
fn summarize_sign_flips_zero_on_constant_sign() {
let exposure = samples(&[0.2, 0.5, 1.0, 0.7]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 0);
}
#[test]
fn f64_field_projects_the_named_field() {
let rows = vec![
@@ -637,6 +468,7 @@ mod tests {
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(1.0)),
],
defaults: vec![],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
@@ -654,7 +486,7 @@ mod tests {
};
assert_eq!(
report.to_json(),
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["bias_scale",{"F64":1.0}]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"bias_sign_flips":1}}"#,
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["bias_scale",{"F64":1.0}]],"defaults":[],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"bias_sign_flips":1}}"#,
);
}
@@ -669,6 +501,7 @@ mod tests {
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(1.0)),
],
defaults: vec![],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
@@ -693,6 +526,7 @@ mod tests {
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(1.0)),
],
defaults: vec![],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
@@ -706,7 +540,7 @@ mod tests {
let json = serde_json::to_string(&report).expect("serialize RunReport");
// window is a 2-element [from, to] array (Timestamp newtype is transparent)
assert!(json.contains("\"window\":[1,6]"), "window shape: {json}");
let back: RunReport = serde_json::from_str(&json).expect("deserialize RunReport");
let back: RunReport<RunMetrics> = serde_json::from_str(&json).expect("deserialize RunReport");
assert_eq!(back, report);
}
+155 -77
View File
@@ -12,7 +12,6 @@
use aura_core::{zip_params, Cell, ParamSpec, Scalar, ScalarKind};
use crate::RunReport;
use crate::harness::SplitMix64;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A validated cartesian grid over a blueprint's param-space: one discrete
/// value-list per param slot, in `param_space()` order. Enumerates a family of
@@ -345,20 +344,20 @@ impl Space for ListSpace {
/// and `report` carries the run's `(manifest, metrics)` — the unit the run
/// registry indexes (C18).
#[derive(Clone, Debug, PartialEq)]
pub struct SweepPoint {
pub struct SweepPoint<M> {
pub params: Vec<Cell>,
pub report: RunReport,
pub report: RunReport<M>,
}
/// The ordered result family of a sweep — one `SweepPoint` per grid point, in
/// enumeration (odometer) order, independent of thread completion order.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily {
pub struct SweepFamily<M> {
pub space: Vec<ParamSpec>,
pub points: Vec<SweepPoint>,
pub points: Vec<SweepPoint<M>>,
}
impl SweepFamily {
impl<M> SweepFamily<M> {
/// The i-th point's params paired with their names — a derived view over the
/// carried param-space (reuses [`zip_params`]); no new per-point state.
pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> {
@@ -366,77 +365,21 @@ impl SweepFamily {
}
}
/// Run `run_one` over every point the `space` produces (`Space::points` —
/// `GridSpace`'s cartesian product or `RandomSpace`'s seeded draws), disjointly
/// in parallel (C1), and collect the family in enumeration order. `run_one`
/// builds + bootstraps + runs +
/// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs
/// are lock-free. Parallelism is `available_parallelism()` workers — `std` only,
/// via `std::thread::scope`.
pub fn sweep<S, F>(space: &S, run_one: F) -> SweepFamily
/// The single assembly path behind both [`sweep`] and the thread-count-explicit
/// test wrapper: run `run_one` over each already-enumerated `point` via
/// `run_indexed` (on whichever pool is ambient when called — the process-wide
/// global pool, or a locally `install`ed one) and collect the family in
/// enumeration order. Kept as one source of truth so the two callers can never
/// drift on assembly logic (grid points, `RandomSpace` draws) while their pool
/// selection differs.
fn assemble_sweep<M, F>(space: Vec<ParamSpec>, points: Vec<Vec<Cell>>, run_one: &F) -> SweepFamily<M>
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
sweep_with_threads(space, nthreads, run_one)
}
/// Run `n` disjoint jobs in parallel and collect their results in **job-index
/// order** — the shared disjoint-parallel core both `sweep` (over grid points)
/// and `monte_carlo` (over seeds) drive (C1: order is the input order, not the
/// completion order). Workers pull job indices from a shared atomic cursor
/// (work-stealing load-balances uneven per-job cost); each tags its result with
/// the index, and the results are sorted on that index after the scope joins.
/// Only the cursor is shared; the results side is lock-free. `nthreads` is
/// clamped to `[1, n.max(1)]` (a 0-job call yields an empty vec; a 0 thread
/// count coerces to 1).
pub(crate) fn run_indexed<T, F>(n: usize, nthreads: usize, run_one: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync,
{
let nthreads = nthreads.clamp(1, n.max(1));
let cursor = AtomicUsize::new(0);
let mut results: Vec<(usize, T)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..nthreads)
.map(|_| {
scope.spawn(|| {
let mut local: Vec<(usize, T)> = Vec::new();
loop {
let i = cursor.fetch_add(1, Ordering::Relaxed);
if i >= n {
break;
}
local.push((i, run_one(i)));
}
local
})
})
.collect();
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
results.sort_by_key(|&(i, _)| i);
results.into_iter().map(|(_, t)| t).collect()
}
/// The thread-count-explicit core of [`sweep`]. Module-private: the public
/// `sweep` derives the count, while the tests drive it at 1 and at N to pin
/// determinism under concurrency (C1). A thin adapter over [`run_indexed`]: it
/// enumerates the `space`'s param-space points (`Space::points`), runs each
/// disjointly, and zips the reports back onto their points in enumeration
/// (odometer) order.
fn sweep_with_threads<S, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
{
let points = space.points();
let reports = run_indexed(points.len(), nthreads, |i| run_one(&points[i]));
let reports = run_indexed(points.len(), |i| run_one(&points[i]));
SweepFamily {
space: space.param_specs().to_vec(),
space,
points: points
.into_iter()
.zip(reports)
@@ -445,11 +388,82 @@ where
}
}
/// Run `run_one` over every point the `space` produces (`Space::points` —
/// `GridSpace`'s cartesian product or `RandomSpace`'s seeded draws), disjointly
/// in parallel (C1), and collect the family in enumeration order. `run_one`
/// builds + bootstraps + runs + summarizes one point; it shares nothing
/// mutable, so it is `Sync` and the runs are lock-free. Parallelism is the
/// shared rayon pool (`run_indexed`): a nested sweep / walk-forward shares its
/// workers rather than each spawning its own `available_parallelism()`
/// OS-thread batch.
pub fn sweep<S, M, F>(space: &S, run_one: F) -> SweepFamily<M>
where
S: Space,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let points = space.points();
assemble_sweep(space.param_specs().to_vec(), points, &run_one)
}
/// Run `n` disjoint jobs in parallel and collect their results in **job-index
/// order** — the shared disjoint-parallel core that `sweep` (over grid points),
/// `monte_carlo` (over seeds), and `walk_forward` (over window bounds) drive
/// (C1: order is the input order, not the completion order). Jobs run on the
/// ambient rayon pool: the process-wide global pool at top level, or — inside a
/// `ThreadPool::install` scope (the `*_with_threads` test wrappers) — that local
/// pool. Nested `run_indexed` calls (a `walk_forward` window running an inner
/// `sweep`) share the same workers via work-stealing, so total live workers
/// never exceed the pool size — no per-call OS-thread batch, no multiplicative
/// oversubscription. `IndexedParallelIterator::collect` restores enumeration
/// order regardless of which worker ran which job; a 0-job call yields an empty
/// vec. `run_one` is shared by reference across workers (`Sync`), never moved.
// `run_one` is captured by reference in the `map` closure below (needs only
// `F: Sync`, not `F: Send`): the seemingly-redundant closure is load-bearing —
// passing `run_one` directly would move it into `Map` and require `F: Send`, a
// bound the `_with_threads` wrappers' `install`-borrowed callers don't carry.
#[allow(clippy::redundant_closure)]
pub fn run_indexed<T, F>(n: usize, run_one: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync,
{
use rayon::prelude::*;
(0..n).into_par_iter().map(|i| run_one(i)).collect()
}
/// The thread-count-explicit wrapper of [`sweep`]. Module-private: the public
/// `sweep` runs on the ambient pool, while the tests drive this at 1 and at N
/// to pin determinism under concurrency (C1). The point enumeration runs
/// outside the pool; `assemble_sweep` (run_indexed + family assembly) runs
/// inside a local rayon pool of `nthreads` workers via `ThreadPool::install`,
/// sharing the exact assembly path `sweep` uses — no second copy to drift. A
/// 1-worker pool runs the jobs inline (no deadlock) — the sequential-equivalent
/// reference. Bounds match `sweep` (`F: Sync`): the install closure only
/// *borrows* `run_one` and moves the already-owned `space`/`points`, so no
/// `Send` bound on `F` is needed.
#[cfg(test)]
fn sweep_with_threads<S, M, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily<M>
where
S: Space,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let points = space.points();
let param_specs = space.param_specs().to_vec();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(nthreads)
.build()
.expect("rayon thread pool");
pool.install(|| assemble_sweep(param_specs, points, &run_one))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
use crate::{f64_field, summarize, RunManifest, VecSource};
use crate::{f64_field, RunManifest, VecSource};
use aura_backtest::{summarize, RunMetrics};
use aura_core::{Cell, ParamSpec, Scalar, ScalarKind, Timestamp};
fn i64_space(n: usize) -> Vec<ParamSpec> {
@@ -710,7 +724,7 @@ mod tests {
/// AND a direct reference for the "sweep == N independent runs" comparison.
/// The manifest is a minimal fixed fixture — the metrics are the run's, and
/// determinism makes `run_point` reproduce a point's report exactly.
fn run_point(point: &[Cell]) -> RunReport {
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_cells(point)
@@ -722,6 +736,7 @@ mod tests {
manifest: RunManifest {
commit: "test".to_string(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
@@ -802,6 +817,68 @@ mod tests {
assert_eq!(one, sweep(&grid, run_point));
}
#[test]
fn nested_run_indexed_shares_the_pool() {
use std::sync::atomic::{AtomicUsize, Ordering};
// A shared gauge: live leaf jobs on entry, max seen. Under one shared pool,
// nested run_indexed (outer jobs each spawning an inner run_indexed) must
// never exceed the pool size — the pre-rayon per-call scope would have run
// POOL*POOL threads here. The sleep lets concurrency actually build; the
// assertion is an upper bound, so the test is not timing-flaky.
const POOL: usize = 4;
let live = AtomicUsize::new(0);
let max = AtomicUsize::new(0);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(POOL)
.build()
.expect("rayon thread pool");
pool.install(|| {
run_indexed(POOL * 2, |_outer| {
run_indexed(POOL * 2, |_inner| {
let now = live.fetch_add(1, Ordering::SeqCst) + 1;
max.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(2));
live.fetch_sub(1, Ordering::SeqCst);
0u8
})
});
});
let peak = max.load(Ordering::SeqCst);
assert!(
peak <= POOL,
"nested run_indexed peaked at {peak} live workers, exceeding the {POOL}-worker pool"
);
}
#[test]
#[ignore = "benchmark: cargo test -p aura-engine bench_nested_pool -- --ignored --nocapture"]
fn bench_nested_pool_walltime() {
use std::time::Instant;
// Synthetic nested workload: an outer fan-out whose each job runs an inner
// fan-out of CPU-bound leaves — the walk_forward(windows) -> sweep(members)
// shape, without any archive. Measures wall time under the shared pool; run
// before/after the rayon swap to see the oversubscription collapse. Not a
// committed baseline (that is #251).
fn busy(rounds: u64) -> u64 {
(0..rounds).fold(0u64, |a, x| a.wrapping_add(x.wrapping_mul(x).rotate_left(7)))
}
const OUTER: usize = 48;
const INNER: usize = 12;
let start = Instant::now();
let checksum: u64 = run_indexed(OUTER, |_w| {
run_indexed(INNER, |i| busy(4_000_000 + i as u64))
.into_iter()
.fold(0u64, u64::wrapping_add)
})
.into_iter()
.fold(0u64, u64::wrapping_add);
let elapsed = start.elapsed();
println!(
"nested run_indexed ({OUTER}x{INNER} CPU-bound leaves): {elapsed:?} \
(checksum {checksum})"
);
}
#[test]
fn distinct_points_produce_distinct_metrics() {
let family = sweep(&sma_cross_grid(), run_point);
@@ -872,11 +949,12 @@ mod tests {
/// A fake report whose manifest `commit` encodes the input cells — enough to
/// prove the sweep closure ran on exactly the given point, without a harness
/// run. A free `fn` (Sync) so it serves directly as the `sweep` closure.
fn tagged_report(point: &[Cell]) -> RunReport {
fn tagged_report(point: &[Cell]) -> RunReport<RunMetrics> {
RunReport {
manifest: RunManifest {
commit: point.iter().map(|c| c.i64().to_string()).collect::<Vec<_>>().join(","),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
+3 -1
View File
@@ -6,7 +6,9 @@
use crate::{BlueprintNode, Composite, Edge, OutField, Role, Target};
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
use aura_backtest::SimBroker;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
/// Seven synthetic F64 ticks driving the harness; deterministic input fixture.

Some files were not shown because too many files have changed in this diff Show More