Compare commits

184 Commits

Author SHA1 Message Date
claude 77ad0465cb fix(aura-research, aura-cli, aura-runner): milestone-fieldtest bugs — the unwired ramp hole + three diagnostics
RED-first fixes for the four M42 A/B findings. campaign introspect
--unwired now drills into a present-but-empty presentation section
exactly as it drills into data, listing persist_taps/emit as open slots
— the miss caused three of the cold start's five dead ends (the process
sibling has no analogous nested-required shape, verified). The override
panic catch wraps known non-diagnostic payloads (capacity overflow) in
domain prose while passing real constructor messages through
byte-identical; a kind-mismatched override renders as prose naming
path, expected and got kinds with a write-this example instead of the
ParamKindMismatch debug struct; chart's unknown-handle refusal names
the current trace surface (exec --tap / presentation.persist_taps)
instead of the retired --trace flag.

closes #319
2026-07-26 00:37:28 +02:00
claude e6b60bf680 fieldtest: milestone-42 A/B — promise holds, cold start 9x slower to first family
Milestone-scope consumer run on the release binary at 2f1bace, three
scenarios: cold start (help-only) reaches a first single run in 5
commands and a first selecting 4-cell family in 28 with 5 dead ends;
the five retired verbs refuse without re-routing; the full retired
research scope (grid, walk-forward, monte-carlo bootstrap, generalize)
runs document-first over two years of GER40+USDJPY in 2.4s with
bit-identical reproduce (21/21). A/B against the sugar-era cycle-300
transcript: comparable to first run, ~9x slower to first family,
strictly better artifact — the dominant cost is an introspect --unwired
bug on present-but-empty presentation (fixed in-cycle, follow-up
commit) plus the missing pre-run coverage view (evidence on the
tracker). 4 bugs / 6 friction / 3 spec-gap / 4 working, routed on the
tracker.

refs #319
2026-07-26 00:21:47 +02:00
claude 2f1baceec7 fix(aura-cli): exec --override refuses out-of-domain values and the wrapped form
RED-first fixes for the two field-test bugs on the new flag's blueprint
leg, both formerly exit-101 panics. An out-of-domain override value now
renders the node constructor's own message as a prose refusal, exit 1 —
the single-run bootstrap rides the same catch mechanism the campaign
path's #272 per-cell containment uses (a campaign-leg pin confirms the
identical value was already contained there, exit 3, unchanged). The
retired wrapped axis form refuses before the params zip with a
did-you-mean naming the raw node.param namespace and the discovery verb,
mirroring the campaign leg's existing prose family.

refs #319
2026-07-26 00:01:20 +02:00
claude 3aa63833f1 fieldtest: 319-sugar-retirement — 4 examples, 2 bugs / 4 friction / 2 spec-gap / 4 working
Source-blind consumer run over the retired-sugar surface on the release
binary at 1476990: the blueprint smoke loop (op-script -> graph build ->
exec --tap/--override) runs and charts; twelve exec target-class probes
and the --override refusal family behave as documented on both legs; a
retired-verb consumer reaches a running 4-member real-data sweep family
through the document ramp; the campaign walk-forward zero-trade note
fires and stays silent correctly. The two bugs are exit-101 panics on
--override's blueprint leg (out-of-domain value; the retired wrapped
axis form) — fixed in-cycle RED-first in the follow-up commit. Friction
and spec-gap findings are bundled on the tracker; the trace-dir
overwrite evidence landed on the standing collision issue.

refs #319
2026-07-25 23:48:51 +02:00
claude 1476990cfd audit: #319 cycle close — architect drift resolved, bench green
Architect review over 9cfe296..dc5f174 (drift_found -> all five items
fixed in this close): C24's family-machinery sentence re-amended to
current truth (synthetic walks are reproduce-only inputs since the
builder retirement; superseded text to the sidecar); the ported
generalize test's doc no longer promises the retired CrossInstrument
family handle and its stale section header is gone;
FamilyKind::CrossInstrument carries its C18 dead-machinery/read-back
note in code; live comments in campaign_run, bench surfaces, and
graph_construct name exec instead of the retired verbs; the five
inlined topology_hash copies in the bin test module consolidated into
one test helper. What holds, per the architect: C24<->code gated-intake
lockstep exact, C12's single-hash claim true in code, exec dispatch
matches C14's amended text.

Bench (regression gate) exit 0, all five fingerprints OK:
engine_throughput bars_per_s 14014049 (+0.1%), ingest_throughput
12935441 (-0.5%), campaign_sweep wall 1.407s (+0.9%), campaign_heavy
wall 5.688s (+2.1%), cli_fixed_cost run_ms 3.6 (+6.6%, spawn-floor
jitter under parallel-agent load, report-only). No baseline moved.
Also rides: the reviewer re-check's two comment nits (classify-once
wording, SIGPIPE example verb).

refs #319
2026-07-25 23:32:18 +02:00
claude dc5f1742f4 fix(aura-cli, aura-runner): review findings — duplicate --override refusal, live discovery prose, neutral JSON refusal
In-cycle fixes for the independent review of the #319 package, RED-first
for both Importants. A repeated --override path now refuses at the shared
lexer (exit 2, path named) instead of panicking exit 101 through
reopen_all — which also un-misattributes the campaign leg's collision
prose for duplicates. override_paths' refusal points at aura graph
introspect --params (the retired --list-axes reference swept from every
live comment, one explicitly-historical mention remains). exec classifies
file targets through a Result-bearing peek: unparseable JSON refuses
neutrally before any leg is chosen. RunPresentation (single-variant)
removed; run_campaign_returning's contract note names its one deliberate
usage-class exit; C14's routing sentence and C20's family-builder
paragraph amended to current truth (superseded text to the history
sidecars); bench error strings and deletion blank-line runs tidied.

refs #319
2026-07-25 23:21:07 +02:00
claude 9eb6d6b4f6 docs(readme, guides, glossary, ledger), bench: the surviving surface everywhere
Slice 8, closing the #319 retirement's prose and bench debt. Bench argv
rides exec (fixed_cost single-run probe, campaign surfaces, and the seed
helper that still called the retired sweep at runtime — caught by the
bench run itself); all five fingerprints unchanged against the committed
baselines, confirming record-line parity through the retirement. Live
docs (README, authoring guide, project layout, glossary) describe only
exec + campaign documents + graph introspect --params; three glossary
entries explicitly mark their verb retired. Ledger amendments per the C29
discipline — C25's executor-verb-set re-settled (exec + the --override
residue), C14's dual grammar reduced to the one dispatch, C24's
document-built runs/families + gated-intake route list (lockstep with the
code comment), C12's override clause and identity anchor repointed to the
runner-layer hash computation, C18/C22/C27/C01 mention rewrites —
superseded sentences moved verbatim to the history sidecars (c25's
created). Stale dual-grammar doc comment on is_blueprint_file rewritten.

refs #319
2026-07-25 22:45:24 +02:00
claude 06d7e0f30a refactor(aura-runner, aura-cli): retire the family builders and the CLI topology_hash duplicate
Slice 7 of the #319 retirement. aura-runner's blueprint_sweep_family /
blueprint_walkforward_family / blueprint_mc_family lost their only
production callers with the quintet and are gone (family.rs 889 -> 199
lines) together with their zero-caller helpers; reproduce's live inputs
(DataSource, showcase/walkforward sources) stay. Coverage was ported
first: member-computation identity + family-shared topology_hash now pin
through a campaign-document twin in tests/exec.rs, reproduce's multi-member
and MC-seed-label branches re-mint their fixtures directly. The CLI-side
topology_hash helper goes with them — its keep-rationale was disproven
(the record line's hash is computed in the runner's member machinery;
minuted on the issue) — and c28_layering's file list drops the stale
verb_sugar entry. The synthetic per-seed MC family remains an intentional
retirement casualty, recorded in the test dispositions.

refs #319
2026-07-25 22:14:24 +02:00
claude db8f947441 feat(aura-cli)!: retire the research-verb sugar — run/sweep/walkforward/mc/generalize removed
Slice 6 of the #319 retirement, the destructive half. The five flag verbs,
their five Args structs and dispatch arms, the whole argv->document
translator (verb_sugar.rs, 1257 lines) and 33 quintet-only helpers are
gone; retired verbs now refuse at the clap layer as unknown commands.
main.rs shrinks 4445 -> 2764 lines. The gated-intake route list narrows to
the canonical layer + exec's blueprint leg; exec's own refusal prose no
longer names dead verbs; test seeding recipes ride graph register.

cli_run.rs walked the full disposition table: 94 tests retired with their
dead flag surface (each with a named green twin where the property
survives), 75 ported — exec ports keep assertions byte-identical, campaign
ports inherit their golden grades unchanged (walkforward's 90/30/30 real
roller and the archive window clipping the sugar applied silently are now
explicit document fields — the equivalence held exactly, one reconstructed
walkforward pooled expectancy_r within 1e-12, cause documented inline).
Surface deltas recorded by the ports: per-cell refusals are #272 contained
faults (exit 3 + warning) rather than hard exit 1; the synthetic per-seed
mc family and generalize's merged cross-instrument family record were
sugar-only constructs and retire with it (the campaign record's
generalizations[] carries the data).

refs #319
2026-07-25 21:36:58 +02:00
claude 24782caaec feat(aura-cli): surviving-surface prose — scaffold template, concepts help, sweep-vehicle tests
Slice 5 of the #319 retirement. The scaffolder's project CLAUDE.md and the
CLI concepts help now teach only the surviving surface (exec over both
document classes, --override, graph introspect --params for discovery;
traces via exec --tap / presentation.persist_taps). Sweep vehicles moved
onto campaign documents driven by exec (project_new's starter quickstarts,
cli_broken_pipe — whose bare-sweep EPIPE probe was latently vacuous and now
streams a real 4-member campaign, project_sweep_campaign's real-data leg,
graph_construct's gang-axis parity pair as paired one-cell campaigns);
pure discovery sites became graph introspect --params, and the line-identity
test kept its literal --params expectations minus the sweep half. Help pins
rewritten; the scaffold template's in-src pin follows the new bytes.

refs #319
2026-07-25 19:34:07 +02:00
claude 5dc8e03249 test(aura-cli): re-target the run-vehicle argv sites onto exec
Slice 4 of the #319 retirement: 34 mechanical argv swaps across six test
files (tap_recording 12, project_load 13, run_measurement 3, measure_ic 1,
run_refuses_unrunnable_blueprint 1, graph_construct 4 run-halves) — every
site drove run as a synthetic vehicle with at most --tap, so no one-cell
campaign conversions were needed; assertions untouched. graph_construct's
sweep sites and the remaining vehicle files stay for the next slice.

refs #319
2026-07-25 19:01:29 +02:00
claude 696d7fe59a feat(aura-cli): exec --override on the campaign leg + zero-trade note migration
Slice 3 of the #319 retirement. The campaign leg's --override injects a
single-value axis over the named bound param into every strategy entry —
after intrinsic validation, before the referential gate, so an unknown
path speaks validate_campaign_refs' existing did-you-mean prose and a
kind mismatch its existing fault; collision with a document-declared axis
refuses (an override overrides a bound value, never an axis, exit 2).
The stored bytes and the recorded campaign_id stay those of the original
document; the audit record of an override is the raw member manifests.
The walk-forward zero-trade aura: note: diagnostic now has a campaign-
path producer call (present_campaign per-cell), so it survives the
quintet's removal at today's strength.

refs #319
2026-07-25 18:56:56 +02:00
claude da19e27b6a feat(aura-cli): exec --override (the #246 residue) + the measurement leg
Slice 2 of the #319 retirement. The blueprint leg now carries both run
shapes: a bias signal via run_signal_r, a no-bias measurement blueprint
(>=1 declared tap) via run_measurement — record lines byte-identical to
run's. --override NODE.PARAM=VALUE (repeatable) is the one deliberate
sugar residue: validated through the shared override_paths prose, the
closed-guard runs on the reopened probe (space == override set), values
bind in reopened param_space order so the manifest records them raw under
params ("what varied"). A measurement blueprint refuses --override
explicitly (its space is not wrap_r-wrapped) rather than dropping it
silently.

refs #319
2026-07-25 18:41:14 +02:00
claude 78b80ec0fd feat(aura-cli): exec — the one executor verb, campaign legs + blueprint single run
The #319 retirement's constructive half, slice 1: a new top-level
`exec <target>` executes a campaign document (file, register-then-run, or
64-hex content id — byte-behaviour of the retired `campaign run`, which no
longer parses) or a fully-bound signal blueprint (synthetic single run,
record line byte-identical to `aura run`'s, root-name gate migrated).
Flag discipline is per-leg: --parallel-instruments on campaign targets,
--tap on blueprint targets (refused on campaign targets with prose).
File routing peeks the document's top-level "kind" key: is_blueprint_file
alone cannot tell two .json classes apart.

--override (the #246 residue) and the measurement leg follow in later
slices; run/sweep/walkforward/mc/generalize stay alive until the removal
slice.

refs #319
2026-07-25 18:29:16 +02:00
claude 9cfe2965c0 feat(aura-cli, aura-runner): JSON data namespace — info verb, NDJSON list, coverage retired
Reshape the aura data namespace around one principle: every verb emits
JSON, no format flag (the headless-agent posture #264 named).

- aura data info <symbol> (new): one flat JSON object — symbol plus the
  six neutral geometry fields (digits, pipSize, tickSize, lotSize,
  baseAsset, quoteAsset) from DataServer::symbol_meta when the
  <SYMBOL>.meta.json sidecar yields geometry; a symbol is known via bar
  files OR sidecar (a sidecar-only symbol still reports its geometry);
  an unknown symbol refuses with prose, exit 1. description stays out
  until the neutral reader exposes it (data-server#4).
- aura data list: NDJSON — one JSON string per line; an empty archive
  emits zero stdout lines, with the human affordance moved to a stderr
  note in the #278 class vocabulary.
- aura data coverage: retired — #272's per-cell fault isolation subsumed
  the prophylactic pre-run check; aura-runner's data_coverage_report and
  its presentation helpers go with it, while interior_gap_months stays
  as the campaign path's single gap walk (#295).

The three derived design decisions (description omitted, broader
refusal prose, empty-archive stderr notice) are minuted on the issue.

closes #273
2026-07-25 12:25:45 +02:00
claude 32eb5a6a9e fieldtest: blueprint-name — 4 examples, 1 bug / 0 friction / 1 spec-gap / 4 working
Source-blind consumer run over the four cycle axes on the release
binary at 4ff85b9: the authoring loop lands the name in bytes, store
document, and traces/<name>/ (with the documented default collision
observable on the unnamed twin); the refusal battery is uniform and
op-indexed across the op route and the file intakes; a named blueprint
defaults its use-splice instance identifier as documented, two unnamed
splices collide, two named ones coexist; help/guide/glossary agree on
eleven ops and the render-name vs registry-label split. Finding B1
(sweep --list-axes ungated, mangled listing at exit 0) is fixed
RED-first in the preceding commit; SG1 (prefix variance in the refusal
prose) is ratified there in C24.

refs #331
2026-07-25 05:15:01 +02:00
claude 564a767974 fix(aura-cli): sweep --list-axes joins the gated intake class
Fieldtest finding B1: the one remaining authored-envelope FILE route,
sweep --list-axes, skipped the root-name gate — a hand-edited
"name":"../escape" listed mangled, non-bindable wrapped axis names
at exit 0 while every sibling intake refused. The discovery surface now
gates before any axis line prints (exit 1, shared prose), restoring
both the #331 every-authored-intake claim and #328's discovery-surface
identity. RED-first: the e2e refusal test was written and observed
failing (exit 0, axis lines printed) before the fix.

Re-enumeration of every read_to_string-fed envelope load confirms the
class is now closed; C24 additionally ratifies the fieldtest's SG1 —
the refusal core sentence is byte-uniform across sites while the
leading context prefix varies by site convention.

refs #331, #328
2026-07-25 05:14:50 +02:00
claude 4ff85b94e5 audit: #331 cycle close — every authored intake gates the root name, cli_fixed_cost re-pinned
Architect drift review (drift_found) resolved fix-path on all three
items. [high] The sweep/walkforward/mc family builders and
validate_and_register_axes read authored envelope files and
put_blueprint'd them ungated — falsifying the C29 exemption's premise
that the store is populated through gated intakes; all four sites now
gate the root name before the family/axis build (placing the gate
after the build produced a misleading unknown-axis error instead of
the gate refusal — RED-verified), with an e2e pin on the synthetic
sweep route. The decision was gate-the-routes, not weaken-the-prose:
the exemption's premise is the invariant, not a wording choice.
[medium] C24 now states the gate rule as the class it is (the op
intake plus every CLI intake reading an authored envelope from a
file; store read-back exempt, C29). [low] C23 reconciles
names-as-debug-symbols with the render name's one operational role
(trace-directory key — never compilation, identity, or execution
semantics).

Ratify: aura-bench cli_fixed_cost baseline re-pinned
(run_line_fnv 6bb0d796f760d140 -> 9bdbc3acf7b2926a). The moved metric
was caused by the #328 tidy (4474814), which switched single-run
manifest defaults from wrapped to raw axis names after that cycle's
bench rerun had already passed; bbac29d and this cycle's HEAD produce
fingerprint-identical record lines (verified with both binaries on
one scratch project), so this cycle only inherits the stale pin. All
five surfaces fingerprint OK after the re-pin.

refs #331, #328
2026-07-25 04:55:12 +02:00
claude 10570b75f8 docs(guide, ledger, glossary): the eleventh op — naming a blueprint from the script
The authoring guide's ops table grows the name row (ten -> eleven) plus
a paragraph on what the graph default costs a multi-strategy project
(indistinguishable store entries, one shared trace directory, colliding
use-splice instance defaults). C24's op-script grammar records the
eleventh verb and the two-seam shape gate with its C29 read-back
exemption; the glossary's blueprint-label entry now separates the
render name (op-settable, keys trace runs and instance defaults) from
the registry label (register --name, explicit, orthogonal).

refs #331
2026-07-25 04:33:25 +02:00
claude a851af993a feat(aura-engine, aura-cli): the blueprint name op — name_gate on every authored intake
An eleventh op-script op {"op":"name","name":"<n>"} sets the composite
render name (engine Op::Name + CLI OpDoc mirror); omitting it keeps the
default "graph" byte-identically. The default leaked everywhere one
research project has more than one strategy: every store entry named
graph, every default tap recording sharing traces/graph/, and two
use-splices of unnamed blueprints colliding on the default instance
identifier — the authored name dissolves all three through existing
mechanics (no downstream site edited).

Mechanics: at-most-once per script (doc-op precedent), position-free
(read only at finish). A shared deterministic name_gate (aura-engine:
non-empty, no path separators, not . or ..) guards every seam where a
name is born from authored data, because the name keys a trace
directory unsanitized: the op intake (GraphSession::set_name) and all
four CLI fresh-file envelope intakes (register, introspect
--content-id FILE, the bare graph FILE viewer, aura run FILE — the run
route reached begin_run(signal.name()) ungated, and introspect
--params FILE rode the same parse). Store read-back (reproduce, use
resolution, introspect/params by content id) stays deliberately
ungated — C29: registered artifacts are never retroactively
invalidated — pinned by a test that plants a bad-root-name blueprint
via the registry API and asserts introspect --params still answers.
Refusal prose is single-sourced (name_gate_fault_prose) so every seam
reads identically, op-indexed on the op route.

Identity semantics: the authored name hashes into the content id (a
named document is a different document) and never into the identity id
(names are stripped as debug symbols, C23) — pinned by a twin test.
The previously untested use-splice instance-name default
(construction.rs) got its ratifying pin before the collision claim
leans on it. The registry label (register --name) stays orthogonal.

Review rounds caught and fixed: the envelope gate initially fired on
store read-back (C29 violation at the introspect surface), and the
run/params fresh-file routes were unenumerated intake seams — closed
with the call-site classification now recorded in the wrapper docs.

Verification: cargo build/test/clippy -D warnings all green (99 test
targets, 0 failures, independently re-run); the new run-route test was
RED-verified by hand-removing the gate.

closes #331
refs #328, #311

Spec: blueprint-name-op (fork minutes on #331)
2026-07-25 04:33:18 +02:00
claude bbac29db2d docs(guide): campaign-validate transcript shows the real content-id shape
Ratifies the fieldtest spec-gap: the transcript abbreviated the
strategy reference to a 4-hex placeholder while the binary prints the
full 64-hex content id — the quoted line now carries the real id from
the committed cycle-328 fixture, keeping the transcript byte-honest.

refs #328
2026-07-25 02:10:16 +02:00
claude 5e32f3ccdf fieldtest: axis-namespace — 4 examples, 0 bugs / 0 friction / 1 spec-gap / 4 working
Source-blind consumer fieldtest against the release binary at 4474814:
discovery-surface identity (--params vs --list-axes byte-identical,
raw + default= on bound), the document-first loop with two
bound-override axes (validate + run e2e, manifest raw), the translation
seams (all three verbs refuse wrapped identically exit 2; campaign
validate did-you-mean exit 1), and manifests raw in params AND defaults
on all routes with reproduce 4/4 bit-identical. One spec-gap: the guide
transcript abbreviated the strategy content-id (ratified in the
follow-up doc commit). Not exercisable source-blind: replay of a
pre-#328 wrapped-name on-disk family (noted in TRANSCRIPT).

refs #328
2026-07-25 02:10:16 +02:00
claude 4474814fa7 audit: #328 cycle close — manifests record raw on every route, walkforward intake refuses wrapped
Architect drift review (cycle a3785a6..b3b7115) found the cycle's raw
switch incomplete on two high items; both fixed forward here rather
than minuted as residue, since #319 builds on this namespace:

- The real/campaign executor still minted WRAPPED manifest.params and
  manifest.defaults (AC2 overclaim in the feat commit body). The mint
  seams now reshape to raw on every route (member.rs raw_bound_defaults,
  runner.rs manifest_space mirror of the family.rs pattern); the fix
  surfaced a latent regression in persist_campaign_traces, which keyed
  point_from_params by exact wrapped name and is now translation-aware
  (same recipe as reproduce_family_in). Old on-disk families stay
  replayable (C29; reproduce reads both shapes).
- The walkforward --axis intake silently accepted wrapped names — the
  silent alias fork 2 rejected. It now runs the same
  refuse_wrapped_synthetic_axes preflight as the plain synthetic sweep
  (new e2e: aura_walkforward_synthetic_blueprint_refuses_a_wrapped_form
  _axis_name); family builders translate raw names onto wrapped
  SweepBinder slots on all three entry points. C24's "retired from the
  surface" claim is thereby made true instead of softened.
- Low items: stale graph_construct docstring updated; new
  graph_params_and_sweep_list_axes_are_line_identical pins the two
  discovery surfaces against format drift.
- Bench infra: aura-bench's campaign_sweep surface seeded wrapped axis
  literals and was correctly refused by the new intake — seeds converted
  to raw; full bench rerun: all 5 surfaces fingerprint OK, deltas within
  load noise (report-only, no baseline update needed).

Library-unit tests calling family builders directly keep wrapped input
(valid: translation is idempotent on exact wrapped names; intake-level
refusal is the user contract). Suite + clippy verified green post-fix.

refs #328
2026-07-25 01:58:50 +02:00
claude b3b7115825 docs(ledger, glossary, guide): the one-namespace axis prose
The C24 axis-discovery paragraph loses its obsolete rationale (names
prefixed by the wrapping, discovery pinned to the sweep verb) in favour
of the one-raw-namespace statement; C18 records the ratified #246
bound-override coincidence contract (param_space OR bound_param_space,
no schema flag) as the document-side absorption #328 demanded; the
glossary sweep and use entries, the authoring-guide worked block, and
the README axis examples move to raw names. Refusal transcripts quote
the byte-actual binary output (review caught an invented aura: error:
prefix; the campaign-validate transcript shows the real fault_block
shape). Frozen corpora (fieldtests/, *.history.md) keep their
historical wrapped prose.

refs #328
2026-07-25 01:33:18 +02:00
claude 3e1e7e21da feat(aura-runner, aura-cli, aura-registry): one raw axis namespace end to end
Reconciles the sweep-axis namespace (closes #328): the raw form
<node>.<param> (splice paths keep their interior path) is now the only
user-facing axis name — printed by --list-axes, accepted by --axis on
BOTH sweep routes, recorded in the synthetic run manifest, discovered
by graph introspect --params (bound params included, default= lexicon),
and validated by campaign documents. The wrapped <blueprint>.<...> form
is retired with translation refusals on both seams.

Mechanics:
- classify_axis_intake (aura-runner axes.rs): explicit raw-first
  acceptance predicate shared by both --axis intake routes. Deliberately
  not built on raw_matches_wrapped, whose equality branch would have
  accepted the retired form silently (skeptic finding, minuted on #328).
  Wrapped hit -> translation refusal naming the raw candidate (shared
  prose builder wrapped_axis_refusal, one literal for both routes).
- The synthetic route resolution moves to the raw frame the real route
  (bind_axes) already uses: override_paths/wrapped_bound_overrides_of
  match via raw_matches_wrapped (also removing a latent slicing panic),
  blueprint_sweep_family translates raw names onto wrapped SweepBinder
  slots, manifest.params records raw keys (name-only reshaping, zip is
  positional). The two sweep routes now share one convention.
- reproduce translates recorded manifest names raw-or-wrapped onto the
  wrapped space: old registered artifacts stay replayable (C29), new
  raw manifests round-trip.
- introspect --params gains the bound default= lines via the same
  render_value the --list-axes bound pass uses - the two discovery
  surfaces are byte-identical by construction.
- AxisNotInParamSpace carries raw_candidate; ref_fault_prose renders
  the did-you-mean iff present. Sweep-terminal bind errors
  (MissingKnob/KindMismatch) render the raw knob name (render seam
  only; acceptance criterion: no user-facing surface prints a wrapped
  axis name).
- Downstream consequence fixes: scaffold quickstart example spoke
  wrapped; ~90 wrapped test literals converted to raw across six test
  files (deliberately-bogus refusal names and the untouched internal
  walkforward wrapped route kept, with comments).

Fork decisions and the skeptic correction are minuted on #328
(comments 2026-07-24/25). Grounding-check PASS first attempt (12
assumptions ratified). Review (opus): translation ambiguity cleared
(uniform single prefix per blueprint), reproduce both-shapes traced;
repair pass added the synthetic-route refusal e2e + the shared prose
builder. Verified: cargo test --workspace green (99 targets, 0
failures), clippy -D warnings clean.

refs #246, refs #319, refs #331
2026-07-25 01:33:18 +02:00
claude a3785a6ec6 docs(guide): Session trigger cadence sets emission density, not the index
Ratifies the m37 milestone-fieldtest concern: the guide recommended a
once-per-bar trigger without saying whether bars_since_open depends on
it. It does not — the index is clock-derived (ctx.now()), so a denser
trigger stream (raw m1 price, as the shipped corpus wires it) emits
the same per-bar index more often. The once-per-bar wiring is a
convention for one-emission-per-bar, not a correctness requirement.
2026-07-24 23:43:53 +02:00
claude 697d81dd22 fieldtest: m37 data-authorability-boundary — 3 scenarios, 0 bugs / 2 friction / 1 spec-gap / 4 working
Milestone-close gate for milestone 37 (data-authorability boundary,
#310/#317/#271): source-blind consumer fieldtest against the release
binary at eb2b0a1, scenarios derived top-down from the milestone
promise rather than per-cycle axes.

- m37_1: NY Session authored via args + fold-selected taps, run e2e
  over US500 (default and --tap plans; summaries correct, unlisted
  tap inert).
- m37_2: London anchor registered by name, spliced via use into a
  consumer with open SMA lengths, swept over GER40, reproduced 2/2
  bit-identical (v2 propagates through the splice).
- m37_3a-e: five-way boundary-refusal battery; all exit 1 with
  actionable prose, escalation path named for namespaced types.

Gate verdict: green — all three boundary mechanisms deliver the
promise; genuinely-new logic is turned away toward Rust. Findings
routed: fold-summary read-back absorbed into #309, cross-run tap-file
lifecycle evidence onto #311, bare-refusal escalation-pointer parity
onto #341 (refs #309, refs #311, refs #341).
2026-07-24 23:43:53 +02:00
claude eb2b0a132c docs(guide, glossary): pin each ArgKind's lexical canonical form
Fieldtest spec-gap ratified: Count is a plain positive decimal with no
sign and no leading zeros ('03' refuses) — and the per-kind asymmetry
against TimeOfDay's required zero-pad is deliberate, pinned with its
rationale: canonical is each domain's conventional notation,
fixed-width for wall-clock times, minimal for numbers.

refs #271
2026-07-24 22:05:34 +02:00
claude 14c43474ab fieldtest: construction-args — 4 examples, 0 bugs / 1 friction / 2 spec-gap / 4 working
Source-blind consumer run over the #271 surface: a NY first-bar-
momentum Session authored purely as data and run e2e over --real
US500; a LinComb{arity:3} blend binding the arity-unlocked weights
with the Session period swept (4 members, reproduced 4/4
bit-identically); eight strict-form refusal probes (all exit 1,
actionable prose); an args composite registered and use-spliced into
a consumer (v2 propagates, ids stable, C29 doc gate holds).

Findings routed: Count lexical form + asymmetry ratified into
glossary/guide (follow-up commit); introspect dead-end on arg-bearing
ports and the missing CostSum wiring path absorbed into #341.

refs #271
2026-07-24 22:05:34 +02:00
claude 6ca359ae00 audit: #271 cycle close — C24 reconciled with its own args paragraphs
Architect drift review (fable gate) on 26b3d68..HEAD, full diff read.
What holds: invariant 4 stays clean (ArgKind a separate closed enum at
every surface), minuted scope held (aura-research and SessionFrankfurt
untouched, no matrix surfacing), C18/C19 discipline held (args-free
bytes golden-pinned at v1, args id-bearing, no pending builder reaches
a graph on op or load path).

Fixed here (high/medium items): C24's out-of-the-round-trippable-set
paragraph still listed LinComb/CostSum/Session — now records their
#271 entry, with SimBroker as the remaining (scalar-typed, narrower)
exclusion; the pre-ship-dormancy paragraph's 'first ship freezes v1'
sentence stales against the shipped data-driven v2 writer — the
dormancy is recorded as ended by #271, the first exercised Tier-2 bump.

Routed (low items): #341 — refuse serializing a still-pending builder
on the Rust path (silent unloadable v1 artifact); version-refusal
prose prints only the ceiling.

Regression: aura-bench all five surfaces fingerprint OK, deltas within
noise (max +2.3% wall at loadavg 2.7, report-only) — the hot path is
untouched by design (bootstrap-only channel). Full workspace suite
independently re-run by the orchestrator: test-exit 0. Spec and plan
swept per lifecycle.

refs #271, refs #341
2026-07-24 21:48:38 +02:00
claude bc8fb46110 docs(ledger, glossary, guide): construction args across the surfaces
C24 gains the add-op args clause and the data-driven format-version
paragraph (tier-2 discipline with id-stability); C15's current state
records Session as args-authorable; the glossary defines construction
arg and arg kind (and de-stales the session-node entry to aura-market);
the authoring guide fixes the moved roster path, extends the
Session-anchoring section with the args path, and teaches the add-op
args row; the README op-list sentence notes args on arg-bearing types.

refs #271
2026-07-24 21:43:13 +02:00
claude a8b1ba45c5 feat(aura-engine, aura-vocabulary, aura-cli): the args channel end to end
The data plane reaches non-scalar structural config (closes #271, the
final Data-authorability-boundary item). Op::Add gains args
(string pairs, applied via try_args after resolve and before the bind
loop — no pending builder ever enters a graph; an arg-bearing type
without args refuses as MissingArg). OpError::BadArg carries the
precise ArgOpError; the CLI renders it as prose naming the arg, its
kind, and the kind's closed hint (exit 1, #175 content-fault class).

Serde: PrimitiveData.args (skip-if-empty — args-free documents stay
byte-identical, golden-pinned, so every existing content id is stable
per C18) with a data-driven format version: the writer emits 1 for
args-free documents and 2 when any primitive recursively carries args;
the loader accepts 1..=2. The old v2-refusal pin flips deliberately to
v3 (named contract change). Identity JSON keeps args — they are
structural, and the Rust configured() path and the args op-script
bridge to the same identity id (pinned).

Roster: Session / LinComb / CostSum enter (33 -> 36, both count pins;
the LinComb-rejection assertion flips to the resolved side). introspect
--node shows arg rows (name, kind, hint) plus the pending note;
OP_REFERENCE's add row teaches the args form. Registry comment
de-staled (LinComb is now roster-reachable).

Sequencing note: this commit lands op seam and roster together so no
intermediate tree state exposes a pending builder.
2026-07-24 21:43:02 +02:00
claude e84ad6d0d2 feat(aura-market, aura-std, aura-strategy): arg-bearing builders go zero-arg
Session, LinComb, and CostSum — the three builders the roster scope doc
excludes — re-shape onto the args seam (refs #271): builder() is now
zero-arg and returns a pending recipe (Session: tz + open; LinComb:
arity; CostSum: n_costs), the full signature forms in make, and
configured(...) is the Rust-path convenience producing the identical
recipe (twin-pinned). Session's period_minutes becomes a real ParamSpec
bound by configured instead of a baked struct field — same behaviour,
now sweepable when left open. Session::new and SessionFrankfurt are
untouched.

All builder(n)-form call sites move mechanically to configured(n):
aura-runner member.rs (wrap_r), aura-composites (vol_stop/cost_graph),
aura-engine blueprint.rs + e2e tests, aura-ingest breakout example,
and the crates' own tests. aura-std drops its unused chrono/chrono-tz
deps (stale since the b39fd63 session move).

The roster is deliberately untouched here: rostering before the op
seam lands would expose unconfigured pending builders to add_node.
2026-07-24 21:42:50 +02:00
claude f449cb06f2 feat(aura-core): typed construction-arg seam on PrimitiveBuilder
A second, closed construction channel beside the scalar bind seam
(refs #271): ArgKind (Tz / TimeOfDay / Count — deliberately NOT
ScalarKind, which stays invariant 4's streamed set), ArgSpec, ArgValue,
ConstructionArg (verbatim strict-form strings, the id-bearing twin of
BoundParam), ArgOpError, and an ArgsState on PrimitiveBuilder:
pending(name, doc, specs, make) declares an arg-bearing recipe that is
introspectable but not constructible; try_args validates strictly
(exact IANA name, zero-padded HH:MM, plain positive decimal — the
accepted form IS the canonical form, no normalization layer), then
runs make and returns the configured builder. build() on a pending
builder panics — the bind panic-contract twin; the data path always
goes through try_args.

chrono-tz enters aura-core for the typed Tz value (already a workspace
dependency of four crates; typed values at the seam beat a
validated-string re-parse split across two coupled sites).

One test per ArgOpError variant plus strict-form refusal pins.
2026-07-24 21:42:39 +02:00
claude 26b3d689df docs: open patterns run when the harness can bind them; README's tenth op; C29 backstop
Fieldtest truth pass (composites-use, B1/S1/S2/S3):

- The "running an open pattern standalone refuses" sentence was wrong — and
  it was this cycle's own ratification prose, not code. Reality (ratified
  as-is): the harness binds input roles to archive columns BY NAME (C26), so
  an open pattern whose roles match the data runs standalone as a matter of
  design; what refuses, by name, is a role the harness cannot bind, or the
  run surface's other gates (bias/tap, free knobs). The authoring guide's
  input row now states exactly that; C24's input row ("only compile/
  bootstrap require it bound") and the glossary's "requires them bound"
  were already accurate and stand.
- README enumerated nine op kinds — the tenth (use) added (S2).
- The use-seam C29 doc gate is stated as what it is: a backstop — the
  register verb already gates both input forms, so it fires only for store
  content written before C29 or through the raw in-crate path (S3,
  ratified).

B1's fieldtest classification was bug->debug; overridden to ratify+document:
the observed behaviour is C26-coherent and useful (quick-testing a pattern
against real data), the defect was the sentence. Minuted on #317.

refs #317, refs #339
2026-07-24 20:09:41 +02:00
claude cb3330ceb5 fieldtest: composites-use — 4 examples, 1 bug / 1 friction / 3 spec-gap / 3 working
Source-blind per-cycle fieldtest of the #317 surface (register->label->
discover->use loop; ref forms + refusal UX; open patterns; sweep axes through
a spliced instance). Corpus under fieldtests/composites-use/ with TRANSCRIPT.md.

Working: the full authoring loop (W1), ref-form variety incl. ambiguous/
unknown-prefix and unknown-label refusals (W2), sweep --list-axes and a real
sweep through graph.<instance>.<node>.<param> plus splice-time bind (W3).

Findings, routed at close:
- B1 (reclassified: docs untruth, behaviour ratified): an open pattern whose
  input role names an archive column RUNS standalone — the harness binds
  roles by name, which is C26's design; the "running standalone refuses"
  claim was the wrong sentence, fixed in the follow-up docs commit and
  minuted on #317.
- S1: the run path's actual refusal trio (bias/tap gate, free knobs, role-
  column) vs the docs' bootstrap-gate story -> same docs commit + #339 note.
- F1: gang-on-instance-member refusal does not name the unsupported-rule ->
  evidence added to #339 item 1.
- S2: README still enumerated nine ops -> fixed in the follow-up commit.
- S3: the use-seam C29 gate is unreachable through today's CLI writes
  (register gates both input forms) -> ratified as a backstop, one line in
  C24.
- Orthogonal pre-existing observation (NOT #317): aura run --params did not
  close free knobs in the fieldtester's hands -> #340.

refs #317
2026-07-24 20:09:11 +02:00
claude 4a30222fdc audit: #317 cycle close — residues homed, gang-refusal claim pinned
Architect (fable gate): drift_found, minor, no contract violation. What
holds: invariant 9 / engine-store split (Op::Use resolves only through the
injected closure; the label sidecar is the C24 §Enforcement-shift-permitted
store-layer discovery, the splice inlines so the marketplace Forbids clause
is untouched); the C1 twin oracle; the open-pattern split consistent on both
cadences across C24, guide, module docs, and the flipped pin.

Drift resolutions:
- [medium] #317 minute 4621 over-claimed "gangable" -> correcting comment
  4629 posted; all prose surfaces already state "sweepable, not gangable".
- [medium] four accepted residues had no tracker home -> collective issue
  #339 (instance-param ganging, gang-knob binding at splice, open-run
  refusal rendering, --unwired on use-bearing documents) per the
  consolidation practice.
- [low] the documented "gang refuses an instance member path" claim was
  unpinned -> pinned here: gang_of_a_spliced_instance_member_path_refuses
  (this commit's one code change).
- [low] --unwired on use-bearing documents -> #339 item 4.

Regression: aura-bench report-only, all five surfaces fingerprint OK —
engine_throughput -0.2% bars_per_s, ingest +0.0%, campaign_sweep +0.1% cpu /
+0.5% rss / -0.3% wall, campaign_heavy +1.2% cpu / -8.8% rss / -0.9% wall,
cli_fixed_cost +1.2% help / -1.8% run. No baseline moved, nothing to ratify.

Erratum (batch-2 report): the harvest-sweep fieldtest spec consumed this
cycle; removed with the plan/spec sweep at this close.

refs #317
2026-07-24 19:51:57 +02:00
claude 2c2c2fdef6 docs(ledger, glossary, guide): the ten-verb grammar, the use seam, open patterns
C24: the op-script grammar section is rewritten to the ten verbs — repairing a
pre-existing drift on the way (it read "The eight verbs" and omitted the doc
op shipped with #316) — with the new use row and the open-pattern reading of
input roles; a "Registered-blueprint splice" paragraph records the resolution
split (all store I/O CLI-side at DTO conversion; the engine's second injected
closure mirrors vocab, the §"Enforcement shift" permission extended to
registered-blueprint references); the construction-gates paragraph moves
root-role boundness from the holistic finalize list to compile/bootstrap only
(#317 comment 4627).

C29: the register-seam wording drops "named" — the code gates EVERY nested
composite, recon-confirmed pre-existing prose/code mismatch — and the use seam
joins the seam list (build-time gate on fetched store content; no retroactive
invalidation of existing reads).

Glossary: use (op) and blueprint label entries. Authoring guide: ten ops
(the stale "nine ops" heading + the input row's obsolete finalize-refusal
claim corrected), the use row, and the register --name / discovery paragraph.

Prose truth per review: instance params are sweepable, NOT gangable (the gang
op refuses a composite instance's member path) — stated as such everywhere;
the gangability of spliced instances is recorded residue, not a claim.

refs #317
2026-07-24 19:45:27 +02:00
claude 623d304b7f feat(aura-cli): use op on the data plane — register --name, resolution echo, discovery
The op-script gains its tenth verb end-to-end: OpDoc::Use carries a tagged ref
({"content_id": id-or-unique-prefix} | {"name": label}); DTO conversion resolves
it (label sidecar / #302 prefix semantics / verbatim id), fetches, deserializes
eagerly (a vocab/parse failure fails fast naming the id prefix — review
finding, no swallowed .ok()), re-walks the C29 doc gate over the fetched
composite (a doc-less store entry cannot enter a NEW composition; existing
reads stay valid — no retroactive invalidation), echoes every resolution as
the existing benign marker (aura: note: use "<instance>": <ref> -> <id>,
stderr; stdout stays clean payload), and hands the session a pure cached
lookup closure.

graph register <file> --name <label> records the label and echoes repoints
(label "x" -> <new> (was <old>)); graph introspect --registered lists
label / 12-char id prefix / root doc line — the discovery surface for what
use can reference. Refusals are by-identifier, name their rule, and enumerate
the label set (empty store reads as prose); all op-list content faults are
runtime exit 1 per the pinned #175 attribution, argv misuse stays exit 2.

Ten new/extended e2e tests cover label round-trip + repoint, echo discipline,
unknown-label enumeration, the C29 refusal shape, end-to-end sweep axes
through a spliced instance (graph.<instance>.<node>.<param>), the open-pattern
build flow, and the standalone-run bootstrap refusal (asserts the existing
Debug-form fault; an index->name rendering at the run boundary is recorded
residue, not silently claimed). OP_REFERENCE and its help pin move 9 -> 10.

Empirical note for the record: aura run's bias arm re-roots the loaded signal
via wrap_r, so the standalone-run refusal for an open pattern is exercised on
the bare-tap measurement path; the bias path surfaces openness as a role-
binding refusal instead (pre-existing shape, untouched).

closes #317
2026-07-24 19:45:10 +02:00
claude 9e26be60f3 feat(aura-engine): Op::Use splices a registered blueprint; open patterns build
The construction session gains the use op: Op::Use { ref_id, name, bind }
resolves through a second injected closure (subgraph: &dyn Fn(&str) ->
Option<Composite>, mirroring vocab's closed-lookup posture — the engine never
touches the store) and pushes the fetched composite as BlueprintNode::Composite,
renamed to the instance identifier (names are identity-blind debug symbols),
with path-qualified binds applied at splice time. Port resolution, kind checks,
the eager cycle gate, and the holistic finish gates all walk the existing
Composite arm — the session storage already carried it; the
"sessions only ever add primitives" unreachable! is retired by construction.

Open patterns (ratified mid-cycle, #317 comment 4627): finish() drops the
root-role gate; compile/bootstrap keep it. An op-script's explicit input roles
are a pattern's formal parameters — buildable, registerable, splicable; running
one standalone still refuses at bootstrap. The pinned finish-refusal test flips
to assert the new split (finish OK, compile refuses). No shipped fixture used
the input op; no corpus behaviour moves.

Gang integrity at the use seam (review finding): binding a ganged member's raw
path refused via the new BindOpError::AlreadyGanged { param, gang } (aura-core)
instead of silently de-fusing the gang — membership keyed on the same
(node, original_pos) coordinate collect_params/check_gangs use, per nesting
level. The gang's public knob stays unbindable at the seam (recorded residue).

Correctness oracle: use_op_splices_byte_identical_to_the_rust_built_twin pins
blueprint_to_json byte-equality against the GraphBuilder twin AND a
bit-identical recorded run (C1). Seven further use_op_* tests cover the fault
surfaces; open_input_pattern_finishes_registers_shaped_and_splices covers the
open-pattern round trip build -> serialize -> reload -> splice -> run.

No blueprint-format change: the serialized form already nests composites
recursively; the golden byte pin stays untouched.

refs #317
2026-07-24 19:44:51 +02:00
claude 7392075aa6 feat(aura-registry): blueprint name labels — append-only sidecar, latest-wins
A registered blueprint gains a legible, re-pointable handle: `put_blueprint_label`
appends {"name","content_id"} lines to blueprint_names.jsonl (behind the one
registry write mutex, #276), `resolve_blueprint_label` reads latest-wins and
skips dangling entries (self-repair posture, #191), `blueprint_labels` returns
the deduped, name-sorted discovery set. Label shape is gated deterministically
(BadLabel: nonempty after trim, no whitespace/control chars — C29-style shape
gate, never content judgement). Re-labelling repoints; old targets stay
reachable by content id (append-only, nothing invalidated).

Also: gate_composite_docs goes pub and list_blueprint_ids lands — the CLI's
use-seam C29 walk and #302-style prefix resolution reuse the existing
mechanisms instead of duplicating them (review-sanctioned deviation from the
plan's file list).

Design: #317 (fork decisions minuted as issue comments 4621/4627 — the
name channel is the store/CLI layer C24 §"Enforcement shift" explicitly
permits; the engine never resolves labels).

refs #317
2026-07-24 19:44:34 +02:00
claude 05e9a00afc fieldtest: harvest-sweep — 3 examples, 1 bug / 1 friction / 6 working
Source-blind against the release binary at 120d116, one example per
axis: hs_1_discovery (help -> --folds roster -> --tap spread=mean
without opening source or JSON; fold row at the last warm ts as
documented), hs_2_typo_key (three closed-set refusals — typo'd op key,
undeclared tap, unknown fold — each naming token + valid set, exit 1),
hs_3_three_taps (skipped-tap note fires exactly per skipped tap under
an explicit plan, silent under record-all and bind-all).

Findings routed: the op-key refusal cites the wrong line (points at
the next element; recovery still carried by the key name) -> #336;
declared-tap names have no positive discovery surface (the deferred
#333 view half, evidence now in hand) -> #337. All seven sweep
closures verified working from the outside.

refs #336, refs #337
2026-07-24 16:46:38 +02:00
claude 120d116982 audit: harvest-sweep cycle close — C29 forward-pointers lifted to shipped state
Architect drift review (range 7cc3ce0..HEAD): holds confirmed — C27
unbound-inert intact (the #334 note is C14's promised benign class,
structurally dead under record-all); #326's exit-1 matches C14's
class-1 wording and the pinned #175 attribution family; C29 discipline
moved with the FoldSchema retirement (registry roster doc-gated +
prose pinned against the executable rules); commit bodies substantiate
against the diff.

Drift resolved: [medium] C29's closing forward-pointers still described
#315 rendering as pending, #267 as upcoming, and a fold introspection
surface as blocked on #310 — all shipped/retired; updated to the shipped
state including the #332 registry-roster form. [low] the runner-side
eprintln register family grew by the note and the roster-carrying
refusal — tracked forward on #297 (scope comment there), consistent
with the C14 tension already routed in the #310 audit.

Regression check (aura-bench, report-only): all five fingerprints OK.
engine_throughput 14002097 bars/s (-0.0%), ingest 12982397 (-0.1%),
campaign_sweep wall 1.402s (+0.5%), campaign_heavy wall 5.588s (+0.3%,
peak_rss +7.7%), cli_fixed_cost help 1.7ms (+11.4% NOTICE) — the NOTICE
and rss delta measured at loadavg 8.9 with a parallel build running;
sub-2ms jitter, not ratified as a real move (no baseline change;
harness is report-only).

Erratum to 8dbca82's body: the blast-radius corpus is 24 committed
*.ops.json files, not 21 (all verified building; the count was
understated).

refs #297
2026-07-24 16:37:50 +02:00
claude 938397295d refactor(aura-std, aura-runner): retire the orphaned FoldSchema table
Review finding on the #332 fix: rendering --folds from the fold-registry
roster removed fold_vocabulary()/FoldSchema's last production consumer,
leaving a second, unsurfaced source of fold docs whose wording had
already diverged from the registry. The table and its two tests are
gone; the discipline they carried moves onto the surviving surface: the
registry tests now doc-gate every roster entry (C29 entry seam) and pin
the bind/output prose inside each doc line against the entry's
executable binds_at/output rules, so the roster cannot drift from what
the wiring enforces.

refs #332
2026-07-24 16:32:59 +02:00
claude 98342246f6 docs(ledger, glossary): ratify the fold summary-row timestamp semantics
Fieldtest spec_gap (#335): the timestamp a fold's one summary row
carries was undocumented. Ratified as-is (derived decision, minuted on
the issue): the row is emitted at finalize and stamped with the instant
of the last contributing (warm) value (TapFold::last_ts) — first alone
pins the first contributing instant, and min/max deliberately do not
carry the extremum's instant (a whole-window row privileges no interior
instant, and the extremum ts would cost extra state for no consumer).

The issue's initial finalize-ts reading (and the first draft of this
ratification) was refuted in review against tap_fold.rs: a tap that
goes cold before run end stamps the last WARM eval's instant, not the
run-end instant — observationally identical only while the tap stays
warm to the end, which is what the fieldtest fixtures did. Correction
minuted on the issue.

Recorded in C27's Current state and the glossary tap-plan paragraph;
the fold-registry roster doc lines state it on the surface itself
(sibling commit).

closes #335
2026-07-24 16:29:50 +02:00
claude fa7453dd9f feat(aura-runner): skipped-tap note; undeclared-tap refusal enumerates declared taps
Two fold-selector fieldtest findings on the --tap seam, RED-first.

Skipped-tap note (#334): an explicit --tap plan replaces the record-all
default entirely (C27: unbound is inert) but gave no runtime signal that
unlisted declared taps went unbound — a forgotten tap surfaced only as a
missing trace file. bind_tap_plan's unbound arm now emits the C14 benign
note (aura: note: declared tap "<name>" unbound this run) per skipped
tap, exit unaffected. No new carrier: the arm is only reachable when
plan.default_named is None (an explicit plan) — under record-all it is
structurally dead, so the default emits nothing. Emitted runner-side
beside the existing eprintln registers; the runner->CLI print migration
is #297.

Undeclared-tap refusal (#333): --tap on a tap the blueprint does not
declare was refused without naming the valid taps, forcing the author
back into the blueprint JSON. TapPlanError::UnknownTap now carries the
declared-tap roster and Display appends it, mirroring UnknownLabel's
fold-roster idiom (C29: a refusal names the closed set it checks
against). Decision minuted on #333 (refusal roster over a new introspect
view).

Rider (#335): the fold roster doc lines now state the summary-row
timestamp — the accumulator folds (count/sum/mean/min/max) land one row
at the last warm (contributing) value's ts, and last gains the same
at-its-own-timestamp wording first already carried. The ratification
record lands in the sibling ledger/glossary commit.

closes #334
closes #333
2026-07-24 16:29:50 +02:00
claude 8dbca82756 fix(aura-cli): --folds renders the registry roster; op-lists refuse unknown keys
Two fold-selector/op-script surface-honesty bugs from the 2026-07-24
fieldtests, both RED-first.

--folds (#332): graph introspect --folds rendered the aura-std FoldKind
table — capitalized ids (Mean) and no record entry — while aura run
--tap resolves labels against the fold-registry roster (lowercase,
record included). The discovery surface the --tap help points at thus
misdescribed the accepted input twice. It now renders
FoldRegistry::core().roster() (label + doc per entry); the pinned
FoldKind-rendering test was rewritten to pin the roster form (8 rows,
no capitalized id leaks, record present).

Unknown op-list keys (#326): a typo'd key in an op-list element
("params" for "bind") was silently dropped and the wrong graph built
with zero signal, contradicting the closed-vocabulary posture (C25).
OpDoc now carries serde deny_unknown_fields; the refusal names the
offending key. Exit class is 1, not the issue's suggested 2: op-lists
arrive on stdin, and the pinned #175 attribution principle classes
stdin-content faults as runtime — the new refusal fires from that same
deserialize. Blast radius verified: all 21 committed *.ops.json under
fieldtests/ still build cleanly.

closes #332
closes #326
2026-07-24 16:12:12 +02:00
claude 73ad87b08a fix(aura-research): process.ref hint states the tagged { content_id } shape
campaign introspect (--block std::process_ref and --unwired) described
the slot as "content id of a process document" — a plausible bare-string
reading the validator rejects; the required {"content_id":"..."} tagging
appeared in no introspection output (binary-only M1 fieldtest finding).

The SlotKind::ContentRef label and the open-slot hint now lead with the
tagged { content_id } shape. Deliberately narrower than the issue's
suggestion of mirroring std::strategy's { content_id } | { identity_id }
form: validate_campaign refuses an identity_id process ref
(DocFault::ProcessRefMustBeContentId) and a pinned test forbids
advertising it, so the hint states the one accepted key only (C29
honesty; correction minuted on the issue). RED-first: the extended
process_ref_describe_advertises_content_id_only assertion failed against
the old label before the fix.

closes #329
2026-07-24 16:02:45 +02:00
claude 7943b123ae fix(aura-strategy): charge-basis-specific cost-node one-liners
The three cost nodes (ConstantCost, CarryCost, VolSlippageCost) shared a
byte-identical generic doc line, so `aura graph introspect --vocabulary`
could not tell them apart — C29's per-entry meaning was formally met but
not entry-specific (binary-only M1 fieldtest finding).

cost_node_builder now takes the factor-specific one-line doc as a
parameter (the shared string is gone from the single schema home); each
builder states its own charge basis. RED-first:
cost_node_descriptions_are_pairwise_distinct failed against the shared
string before the fix.

closes #330
2026-07-24 16:02:45 +02:00
claude 7cc3ce0d9e fieldtest: fold-selector — 2 examples, 0 bugs / 5 friction / 1 spec-gap
Per-cycle fieldtest of the #310 --tap selector, source-blind against the
release binary (e482f0e). Spine holds: fold selection end-to-end
byte-exact incl. the C1 pin and kind-aware folds/refusals on a Bool tap;
parse refusals precise at exit 2. Findings routed to the tracker:
introspect --folds renders the aura-std FoldKind table instead of the
registry roster (capitalized ids, record missing); no public surface
enumerates a blueprint's declared taps; an explicit --tap plan drops
unlisted taps without the C14 'aura: note:' skipped-tap notice; fold
summary-row timestamp semantics undocumented and mixed. Exit-class
tension already tracked on #297 (audit).

refs #310
2026-07-24 15:22:24 +02:00
claude e482f0ec35 audit: #310 cycle close — run usage names --tap; C14 class tension routed to #297
Architect (cycle 9124275..1baee77) holds: the C27 boundary is preserved
and ledger-anchored (selection = run-mode authority, fold growth stays
Rust/role 2; validation solely in the shared bind_tap_plan seam before
store I/O; unlisted taps inert); C1 byte-identity pinned for the
explicit all-record plan vs the record-all default; ledger discipline
kept (superseded C27 sentence verbatim in the new history sidecar); the
C25 projection tension was explicitly adjudicated in C27's Current
state, deferral #312/#327 minuted.

Drift resolution:
- [medium] C14 exit-class: --tap-content refusals (unknown label /
  undeclared tap) exit 1 through the assembly crate's runner-side
  registers while C14 assigns argv/blueprint-content faults to class 2.
  Pre-existing register family (#283 duplicate-tap, #286 exposes-
  neither). RESOLVED AS TRACKED-FORWARD to #297 (RunnerError
  propagation is where the CLI regains class authority; evidence
  comment with concrete sites posted there). A CLI-side shadow
  pre-validation was rejected: it duplicates the bind_tap_plan seam
  and can drift.
- [low] FIXED here: the three hand-rolled `aura run` usage strings and
  the loaded-blueprint-grammar comment now name --tap.
- [low] ADJUDICATED equivalent-as-delivered: the fold-mean expectation
  is derived from the pinned R_SMA_PRICES const rather than a second
  record run — an independent derivation of the same series; the
  spec's intent (fold row equals the series mean) is pinned.

Regression: aura-bench report-only, exit 0, all five surfaces within
tolerance, fingerprints OK (engine 13.78M bars/s Δ-1.6%, ingest 13.13M
Δ+1.0%, campaign_sweep rss 93.9MB Δ+1.9%, campaign_heavy rss 106.9MB
Δ+5.4%, cli run 3.6ms Δ-0.3%; loadavg-5.3 warning noted, report-only).
No baseline update.

Cycle spec + plan + the spent m1 fieldtest spec removed from the
working tree (git-ignored files, shell rm per lifecycle).

refs #310, refs #297
2026-07-24 15:11:27 +02:00
claude 1baee774bb feat(aura-cli): --tap TAP=FOLD fold-subscription selector on aura run
Decide #310's data-authorability fork as (b), split by run-mode
authority: selecting a Named fold subscription is data-reachable on the
one-shot run path; adding a fold stays a Rust entry (role 2, C25).
`aura run` gains a repeatable --tap TAP=FOLD selector feeding the
existing TapPlan seam: no flag keeps the record-all default; any flag
replaces the plan entirely (unlisted taps stay unbound — C27 inert,
non-error). Validation stays in bind_tap_plan (roster-enumerating label
refusal, typed UndeclaredTap, both before store I/O); parse-level
refusals (malformed pair, duplicate tap) exit 2 via the verb's usage
convention.

Rejected alternatives (minuted on #310 with the skeptic-pass
rationale): an op-script/blueprint carrier (C27 forbids recording
policy in the serialized fragment); ratifying Rust-only (softens a
designed-in promise with effort as the only rationale). The
campaign/document carrier and the persist_taps/declared-tap namespace
reconciliation are deferred to the Measurement-reachable milestone
(#312/#327, minuted on #312).

Ledger: C27 Current state records the boundary; the superseded sentence
moved verbatim to the new c27-declared-taps.history.md sidecar; the
glossary tap-plan paragraph updated, fixing the pre-existing inaccuracy
that named `aura measure` a record-all-passing verb (it is the post-hoc
IC analysis and constructs no tap plan).

Verification: 5 new unit tests (parse branches), 5 new binary-level
selector tests incl. a byte-identity pin of an explicit all-record plan
vs the record-all default (C1), 1 measurement-arm test; full workspace
suite green; clippy -D warnings clean. Independent opus diff review:
approved, no Important/Minor findings (nits: inert-arm generic refusal,
a=b=c falling to the roster refusal — accepted as-is).

closes #310
2026-07-24 15:03:39 +02:00
claude 9124275bf3 fieldtest: m1-self-description — 5 examples, 1 bug / 3 friction / 3 spec-gap
Binary-only milestone fieldtest for milestone 36 (self-description): a
fieldtester agent bootstrapped from the release binary (d26f0c8) alone —
no engine sources, no repo docs, no bootstrap card — and drove five
end-to-end scenarios: cold bootstrap -> author -> validate (green,
synthetic + real EURUSD, 3944 trades), execution semantics +
latch/edge-pulse idiom (green), vocabulary breadth (33 nodes / 7 folds /
17 metrics / 6+6 blocks, every entry with a meaning), document ramp from
a bare {} (green, 3 members), trace/measurement path (dead-ends,
tracked).

Verdict: the bootstrap card (#267, closed as superseded) is empirically
redundant for the bootstrap->author->validate spine. Two seams keep the
promise short of end-to-end, both tracked outside M1: the declared-tap
drop on the registration path (#327, the missing mechanism of #312/M6)
and the sugar<->document axis namespace (#328, prerequisite of the #319
retirement). Friction routed: #329 #330 #331; evidence comments on #312
and #324. Milestone 36 closed with this adjudication; the fieldtest spec
remains the usual git-ignored working file.

refs #267, refs #312, refs #319
2026-07-24 11:06:22 +02:00
claude d26f0c84a4 fix(cli): qualify the desugaring claims to the --real path; loud vocabulary-resolve invariant
Independent post-cycle review (opus, fresh context) on the branch diff
returned one Minor and one Nit; both adjudicated against the source and
fixed:

- Minor: the concepts paragraph and the sweep/walkforward/mc long helps
  claimed the document desugaring unconditionally, but only the --real
  branches route through verb_sugar — the synthetic branches execute
  in-process and register no documents. The claims now carry the --real
  qualifier (and the paragraph names the in-process synthetic path).
  generalize keeps the unconditional wording: its only mode is --real
  (usage requires it), so the claim is true as stated.
- Nit: the vocabulary listing's `None` fallback would have silently printed
  a bare, C29-incomplete row if a rostered type id ever failed to resolve.
  Replaced with a loud expect — an invariant breach should not degrade into
  exactly the surface this cycle exists to remove.

refs #315
2026-07-24 10:19:50 +02:00
claude 162bf849ce fix(cli): the concepts paragraph no longer claims run writes documents
The shipped wording lifted #319's loose "quintet is pure translation"
phrasing into "the research verbs (run, sweep, ...) are sugar — each writes
registered process/campaign documents". Source check (dispatch_run): only
the four verb_sugar bridges (sweep, walkforward, mc, generalize) generate
and register documents; `run` is a direct single backtest, exactly why its
own long help already avoided the desugaring claim. The paragraph now names
the four document-bridged verbs and places run as their single-backtest
sibling — consistent with `run --help`.

Found by re-auditing the shipped prose claims against the source after the
owner asked how I know no error was made (skills-less-cycle experiment,
refs #315): prose claims in help text are exactly the class the green test
suite cannot semantically verify.
2026-07-24 10:10:12 +02:00
claude 829a1984e6 feat(cli, std, research): self-describing surfaces — two-layer help, per-entry meanings, op reference
The binary is the only always-present teacher (C29); the 2026-07-22 external
field test showed the help teaching nothing but the verb inventory. This
cycle makes every closed vocabulary the binary ships listable with a one-line
meaning, and opens the help with the concepts the field agent had to discover
forensically.

#315 — help + introspection:
- `aura --help` opens with the two-layer concepts paragraph (research verbs
  as sugar over registered process/campaign documents; the directly
  authorable data plane; bias-as-target-position + protective-stop execution
  model in R; how traces come to exist).
- Each document-bridged sugar verb's long help names the process shape it
  desugars to (sweep -> std::sweep; walkforward -> [std::grid,
  std::walk_forward]; mc -> + std::monte_carlo; generalize -> [std::sweep
  (selection), std::generalize]). `run` is not verb_sugar-bridged, so its
  help points at the canonical document-first form instead of claiming a
  desugaring it does not perform.
- `graph introspect --vocabulary` lists each node type with its schema doc
  (bare names taught nothing); `--node <T>`'s head line carries the meaning.
- `graph introspect --folds` (new): the tap-fold vocabulary with bind rule,
  output kind, and meaning — it existed only as source comments. The rows
  live in aura-std (`fold_vocabulary`), unit-pinned against
  `fold_binds_at`/`fold_output_kind` so the prose cannot drift from the
  executable rules, and doc_gate-checked (C29 entry seam). The discovery
  surface is graph introspect because folds bind at graph-declared taps
  (C27); the document layer still refuses a folds slot pending #310.
- `process introspect --metrics` lines carry each metric's meaning (the doc
  column existed since #316 but was never printed).
- `campaign introspect --block std::presentation` lists the four persisted
  taps' meanings under the persist_taps slot.
- The scaffolded project CLAUDE.md teaches the execution semantics (bias as
  held target, protective stop, R) and the document data plane.

#323 — C29 authoring surfaces:
- `introspect --unwired` enumerates the optional `description` envelope slot
  for both document kinds (absent-only, like risk/cost); the campaign lib
  fixture gains a description, and the risk/cost-bound e2e binds description
  too so "no open slots" keeps meaning complete.
- `graph build --help` carries the op-list reference: all nine op kinds with
  fields and a worked element each (OP_REFERENCE lives beside OpDoc so a new
  variant is one screen from its help line).
- The authoring guide's S0 worked literal is byte-pinned to the scaffold's
  LIB_RS template (rendered with the guide's my_lab namespace); the scaffold
  e2es compile that template for real, so the pin is a transitive compile
  guard. The template gains the guide's C29 comment, the guide gains the
  template's rename note — one worked example, two surfaces, zero drift.

Deliberately out of scope (ratified #319, 2026-07-24): no doc-strings for
the quintet's 48 flag fields — the sugar surface retires; only the pointer
lines above bridge toward the document layer.

Verification: full workspace suite green, clippy -D warnings clean,
aura-bench exit 0 with all fingerprints OK (help_ms +1.6% — the longer help
text, in tolerance), cargo doc warning count unchanged (7 pre-existing).
9 new e2e pins in tests/help_self_description.rs; guide + CLAUDE.md pins in
scaffold unit tests; fold-vocabulary rules + doc_gate unit-pinned in
aura-std.

closes #315
closes #323
2026-07-24 10:02:38 +02:00
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
555 changed files with 42889 additions and 16612 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
+165 -5
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,22 +153,28 @@ dependencies = [
name = "aura-cli"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-campaign",
"aura-composites",
"aura-core",
"aura-engine",
"aura-ingest",
"aura-market",
"aura-measurement",
"aura-registry",
"aura-research",
"aura-runner",
"aura-std",
"aura-strategy",
"aura-vocabulary",
"chrono-tz",
"clap",
"data-server",
"libc",
"libloading",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
@@ -145,9 +182,11 @@ dependencies = [
name = "aura-composites"
version = "0.1.0"
dependencies = [
"aura-backtest",
"aura-core",
"aura-engine",
"aura-std",
"aura-strategy",
"serde_json",
]
@@ -155,6 +194,7 @@ dependencies = [
name = "aura-core"
version = "0.1.0"
dependencies = [
"chrono-tz",
"serde",
"serde_json",
]
@@ -164,10 +204,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",
]
@@ -176,23 +221,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",
]
@@ -207,13 +279,56 @@ 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",
"chrono",
"chrono-tz",
"aura-strategy",
]
[[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]]
@@ -404,6 +519,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"
@@ -479,6 +613,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"
@@ -803,6 +943,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"
+7
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]
+35 -31
View File
@@ -31,47 +31,46 @@ Invoke it as `aura <command> …` (examples below use the plain name).
- **Blueprint** — a serialized signal graph as data: a param-generic
`price → bias` node graph. A blueprint file (`blueprint.json`) is the unit a
downstream consumer loads and drives verbs over.
downstream consumer loads and executes or varies.
- **Open vs. closed** — a blueprint with unbound (free) numeric knobs is *open*;
one with every knob bound is *closed*. Different verbs want different states
(see each verb below).
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
in a content-addressed store and can be listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
one with every knob bound is *closed*. A plain `exec` wants closed; naming a
knob as a campaign axis (or an `exec --override`) is what reopens it.
- **Family** — the set of runs a campaign document's process pipeline produces
over a blueprint (a sweep grid, a Monte-Carlo seed set, a walk-forward window
sequence). Families are persisted in a content-addressed store and can be
listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
bound with a comma-separated value list. A **gang** fuses several sibling
knobs into one axis (one value drives all members).
## Running & orchestrating a loaded blueprint
## Executing a document
These verbs all take the **blueprint file as their first positional argument**.
Most drive a family of runs over it; `graph` renders its structure so a mis-wire
is visible before a run. `walkforward`, `mc`, and `generalize` share
`sweep`'s generic grammar — the blueprint positional plus `--real` and a
repeatable `--axis <name>=<csv>` (`generalize` takes `--real <SYM1,SYM2,…>`, at
least two instruments, with a single value per axis) — see `--help`.
`aura exec <target>` (#319) is the one executor verb: `<target>` is either a
loaded **blueprint** file (a single synthetic run) or a **campaign document**
(a `.json` file or its registered 64-hex content id — instruments × windows ×
strategy × param axes × process, see `docs/authoring-guide.md` §3). `graph`
renders a blueprint's structure so a mis-wire is visible before a run.
| Command | Purpose |
|---|---|
| `aura run <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or use `sweep`. |
| `aura exec <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or vary it as a campaign axis. |
| `aura exec <bp.json> --override <node.param>=<value>` | Reopen one **bound** param for this single execution only (the value is recorded raw in the manifest); repeatable. |
| `aura exec <bp.json> --tap <name>=<fold>` | Subscribe a declared tap to a fold (`record`/`count`/`sum`/`mean`/`min`/`max`/`first`/`last`) for this run; repeatable. |
| `aura graph <bp.json>` | **Render** the blueprint's structure as an interactive HTML DAG so a mis-wire is visible before a run. Omit the file to render the built-in sample; a named-but-unreadable file is a usage error. |
| `aura sweep <bp.json> --list-axes` | **Discover** the blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`. Run this first to learn the axis names. |
| `aura sweep <bp.json> --axis <name>=<csv> [--axis …]` | Run a **grid family** over the named axes and persist it. |
| `aura mc <bp.json> --seeds <n>` | Run a **synthetic Monte-Carlo family** of `n` seeded realizations. Wants a **closed** blueprint (the inverse of sweep). |
| `aura mc <bp.json> --real <sym> --axis <name>=<csv> [--axis …]` | Run a **Monte-Carlo R-bootstrap campaign** over recorded data — driven through the same generated-campaign pipeline as `sweep`; `--block-len`/`--resamples`/`--seed` tune the bootstrap. |
| `aura walkforward <bp.json> --axis <name>=<csv> [--axis …]` | Run an **in-sample-refit walk-forward family** — rolling optimize + out-of-sample test across windows, stitched into one verdict + parameter stability. `--select` chooses the per-window objective. |
| `aura graph introspect --params <bp.json\|id>` | **Discover** a blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`, bound params trailing `default=<value>`. Run this first to learn the axis names for a campaign document. |
| `aura exec <campaign.json\|id> [--parallel-instruments <n>]` | Execute a **campaign document** — a grid family, gates, walk-forward, Monte-Carlo, and cross-instrument generalization per its process document, one cell per (strategy, instrument, window). |
| `aura runs families` | List every persisted family (id, kind, member count). |
| `aura runs family <id> [rank <metric>]` | List one family's members, optionally ranked best-first by an R metric (e.g. `sqn_normalized`, `expectancy_r`). |
| `aura reproduce <family-id>` | Re-derive every member of a persisted family from the content-addressed store and check it is bit-identical (the C18/C1 determinism guarantee). |
**Important contract — every open knob is mandatory.** On `sweep` and
`walkforward`, the knobs enumerated by `--list-axes` are *all* required: you must
supply an `--axis` for each open knob. There is no default value — pin a knob you
don't want to vary with a single-value axis (`--axis name=4`). Omitting one is a
clean error naming the missing knob, not a silent default.
**Important contract — every open knob is mandatory on a campaign axis.** A
campaign document's `strategies[].axes` must name every open knob
`graph introspect --params` lists: there is no default value — pin a knob you
don't want to vary with a single-value axis (`{"kind":"I64","values":[4]}`).
Omitting one is a clean error naming the missing knob, not a silent default.
Use `aura <cmd> --help` for the full data-window (`--real`/`--from`/`--to`),
naming (`--name`), and selection (`--select`) flags.
Use `aura exec --help` for the exact flag grammar, `docs/authoring-guide.md`
for the campaign document shape (data window, naming, presentation/emit).
## Authoring & introspecting topology
@@ -111,15 +110,20 @@ Each element is one op, tagged by `"op"`. Node params are bound with the **typed
```
Piping this document into `aura graph build` emits a `blueprint.json` you can
then feed to `run` / `sweep` / `mc` / `walkforward` above:
then feed to `exec` above:
```sh
aura graph build < crossover.ops.json > crossover.bp.json
aura sweep crossover.bp.json --list-axes
aura graph introspect --params crossover.bp.json
```
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`, `doc`, and `use` (`tap` declares a recorded measurement point on an
interior wire; `doc` declares the composite's one-line meaning, required at
register — C29; `use` splices a registered blueprint in as a nested composite,
by content id or label; `add` additionally takes an `args` object for
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
non-scalar construction consumed before `bind`; 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`
@@ -351,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.487171,
"run_ms": 3.376192
},
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "10570b7",
"date": "2026-07-25"
}
@@ -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");
}
}
+444
View File
@@ -0,0 +1,444 @@
//! 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 `graph register` (#319: the
/// surviving registration surface) 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, &["graph", "register", file, "--name", name])?;
if code != Some(0) {
return Err(format!("seed register failed ({code:?}): {out}"));
}
blueprint_stems(dir)
.into_iter()
.find(|s| !before.contains(s))
.ok_or_else(|| "seed register 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, &["exec", &doc], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!(
"aura exec 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 exec whose fingerprint is the
//! FNV-1a hash of the exec'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 exec` 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, &["exec", "bench_sma_a.json"], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!("aura exec 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 exec 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 exec` 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 exec` 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"
+532 -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.
@@ -557,7 +889,7 @@ fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> Run
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,
@@ -627,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
@@ -678,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 {
@@ -757,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)
@@ -767,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");
@@ -789,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()
}
+300 -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
@@ -365,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(),
}),
},
}
@@ -731,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]
@@ -871,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,
@@ -885,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"))
@@ -1047,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]
@@ -1163,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
+419 -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]
}
@@ -84,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),
}),
},
}
@@ -126,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> {
@@ -202,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,
@@ -219,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"))
@@ -231,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
@@ -294,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
@@ -331,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
@@ -350,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");
@@ -367,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
@@ -400,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]
@@ -435,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);
@@ -449,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!(
@@ -470,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
@@ -483,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));
@@ -496,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");
@@ -505,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)
@@ -513,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];
@@ -541,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];
@@ -564,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);
@@ -623,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");
@@ -644,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))
@@ -677,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
@@ -696,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
@@ -706,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));
@@ -754,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
@@ -772,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()));
@@ -791,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.
@@ -818,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
@@ -836,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 { .. }));
}
@@ -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;
@@ -64,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]
@@ -88,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.
@@ -125,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());
}
+32 -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,13 +47,6 @@ 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
@@ -60,3 +60,25 @@ clap = { version = "4", features = ["derive"] }
# 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-market: the shell's own use is test-only (#271's identity-bridge test,
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
# `Session::configured` as the Rust-authored twin of the args op-script path)
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
aura-market = { path = "../aura-market" }
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
# `Session::configured` — test-only, pinned to the same version every other
# crate's chrono-tz entry uses.
chrono-tz = { version = "0.10", default-features = false }
# 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 -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 -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 -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"}]}}
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 (#319 Task 5: migrated onto the campaign
/// walk-forward leg in `campaign_run.rs`, the surviving executor path) 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"
);
}
}
File diff suppressed because it is too large Load Diff
+1036 -4349
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -234,7 +234,7 @@ 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.json"),
&|t| env.resolve(t),
+314 -13
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.
pub(crate) 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();
@@ -93,10 +107,12 @@ fn print_metric_roster() {
(true, false) => "rankable",
(false, false) => "annotation",
};
// C29 (#315): the roster carries each metric's one-line meaning, not
// only where it may be used.
if generalize {
println!("{name:<24} {base} | generalize");
println!("{name:<24} {base} | generalize{}", m.doc);
} else {
println!("{name:<24} {base}");
println!("{name:<24} {base}{}", m.doc);
}
}
}
@@ -137,6 +153,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 +166,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 +184,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 +193,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 +223,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 +263,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 +284,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.
pub(crate) 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();
@@ -271,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> {
for s in schema.slots {
let req = if s.required { "required" } else { "optional" };
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
// C29 (#315): the tap slot's closed value vocabulary carries its
// per-entry meanings, indented under the slot line.
if matches!(s.kind, aura_research::SlotKind::TapKinds) {
for t in aura_research::tap_vocabulary() {
println!(" {:<14} {}", t.id, t.doc);
}
}
}
Ok(())
}
@@ -278,6 +388,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");
@@ -298,12 +411,11 @@ pub enum CampaignSub {
Introspect(DocIntrospectCmd),
/// Register a valid campaign document into the store under the runs root.
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 },
/// 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 +429,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) => {
@@ -329,8 +449,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
RefFault::StrategyUnloadable { id, error } => {
format!("strategy {id} cannot be loaded: {error}")
}
RefFault::AxisNotInParamSpace { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
if let Some(candidate) = raw_candidate {
msg.push_str(&format!(
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
));
}
msg
}
RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
@@ -411,8 +537,8 @@ 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),
};
if let Err(m) = result {
eprintln!("aura: {m}");
@@ -502,6 +628,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 +649,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();
@@ -577,6 +742,7 @@ mod tests {
ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
}),
ref_fault_prose(&RefFault::AxisKindMismatch {
strategy: "9f3a".into(),
@@ -597,6 +763,39 @@ mod tests {
}
}
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
/// and verbatim, exactly when `raw_candidate` is present — the document-
/// side refusal seam's mirror of the sweep `--axis` intake translation
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
/// `axis names are raw node.param paths` clause pinned there.
#[test]
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "graph.fast.length".into(),
raw_candidate: Some("fast.length".into()),
});
assert_eq!(
msg,
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
axis names are raw node.param paths — did you mean \"fast.length\"?"
);
}
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
/// leading segment yields no param-space hit, or the axis has no leading
/// segment to strip at all) renders today's prose unchanged — no
/// speculative suggestion.
#[test]
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
});
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
}
#[test]
/// #194 (the prefix trap): a doc ref that carries the display `content:`
/// prefix (a copy-paste from register/introspect output) is bare-only in
@@ -651,4 +850,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);
}
}
+72 -17
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,9 @@ impl Scale {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
// C29: every vocabulary entry ships a one-line meaning — the
// doc field is required (E0063 without it) and gated at load.
doc: "scalar gain: emits the input times the factor param",
},
|p| Box::new(Scale::new(p[0].f64())),
)
@@ -219,22 +224,32 @@ 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)
This directory is an aura project: blueprints + research documents over the
std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
- Run: `aura run blueprints/signal.json` (the starter is closed — all
params bound; bound values are defaults — any `--axis` may override them
(#246))
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
- Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream
— the starter is closed; all params bound; bound values are defaults —
`--override NODE.PARAM=VALUE` may override one for this run (#246))
- Campaign: `aura exec <campaign.json>` executes a registered campaign
document (file or content id) — the multi-cell/axis surface
- Axes: `aura graph introspect --params blueprints/signal.json` lists the
open + bound-overridable axes, RAW `<node>.<param>` names (#328)
- Native nodes: when the project needs its first project-specific node,
`aura nodes new <name>` scaffolds a node crate beside this project and
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
- Topology is data (`blueprints/*.json`); results land in `runs/`.
- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
continuously-tracked target position; a protective stop defines the risk
unit R, and quality metrics are R-based. Entry signals become held state
via the signal-side latch/edge-pulse idiom (see
`aura graph introspect --vocabulary`).
- Data plane: process/campaign documents are authored directly with
`aura process` / `aura campaign`, growing one from a bare `{}` via
`aura campaign introspect --unwired`.
"#;
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
@@ -280,7 +295,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 +336,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 +369,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 +386,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)"
);
}
}
@@ -445,11 +460,47 @@ mod tests {
}
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("demo-lab"));
// The CLAUDE.md sweep quickstart targets the closed starter itself
// with the rendered blueprint-name axis prefix (#246: a bound param
// is a default an axis overrides).
// The CLAUDE.md quickstart targets the closed starter itself through
// the surviving surface (#319): exec for both document classes, the
// #246 override residue, and raw-namespace axis discovery (#328).
assert!(claude.contains("blueprints/signal.json"));
assert!(claude.contains("demo_lab_signal.fast.length"));
assert!(claude.contains("--override NODE.PARAM=VALUE"));
assert!(claude.contains("graph introspect --params"));
}
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
/// (bias as held target position, protective stop, R) and the document
/// data plane — the two things the 2026-07-22 field agent had to discover
/// forensically.
#[test]
fn project_claude_md_teaches_execution_semantics_and_the_data_plane() {
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("target position"), "names the held-target model: {claude}");
assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}");
assert!(claude.contains("aura process"), "points at the document layer: {claude}");
assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}");
}
/// #323: the authoring guide's S0 worked literal is byte-identical to the
/// scaffold's `LIB_RS` template (rendered with the guide's `my_lab`
/// namespace). The scaffold e2e tests compile that template for real, so
/// this equality pin is a transitive compile guard — the guide literal
/// can no longer drift silently from the schema (it broke against the
/// required `NodeSchema.doc` once).
#[test]
fn guide_worked_example_matches_the_scaffold_template() {
let guide = include_str!("../../../docs/authoring-guide.md");
let anchor = "### Worked example: `Scale`";
let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..];
let start = after.find("```rust\n").expect("worked example is a rust fence") + 8;
let fence = &after[start..];
let fence = &fence[..fence.find("\n```").expect("fence closes") + 1];
let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..];
let expected = body.replace("__NS__", "my_lab");
assert_eq!(
fence, expected,
"docs/authoring-guide.md S0 literal drifted from the compiled scaffold template"
);
}
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
@@ -488,7 +539,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 +551,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();
File diff suppressed because it is too large Load Diff
+113 -26
View File
@@ -5,47 +5,136 @@
//! handled cleanly, never a panic.
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
mod common;
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
/// 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 so the spawned `aura sweep` writes its
/// `./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 _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, out.status.code())
}
fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(&p, text).expect("write doc");
p
}
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Seed one blueprint into the built demo project's store via `aura graph
/// register` and return its content id — copied verbatim from
/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep`
/// side-effect seeding this used to ride played no role in the returned
/// content id, a pure function of the loaded blueprint's canonical bytes;
/// `name` labels the registered id, mirroring the old per-call sweep family
/// name).
fn seed_blueprint(dir: &Path, name: &str) -> String {
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
assert_eq!(code, Some(0), "seed register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Register `doc` as a process document in the project store; returns its
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
write_doc(dir, file, doc);
let (out, code) = run_code_in(dir, &["process", "register", file]);
assert_eq!(code, Some(0), "process register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` —
/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same
/// shape, which yields exactly four family members (the four-line stream
/// this test's property needs).
fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "broken-pipe",
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = window.0,
to = window.1,
)
}
/// Property: a family-emitting command whose stdout reader closes early exits on
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken
/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON
/// lines via `println!`; when the reader closes stdout after the first line, the
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at
/// startup, so that write returns EPIPE and `println!` panics rather than the
/// process terminating quietly — exactly the behaviour a CLI must not have. This
/// is the contract: any aura subcommand can be piped into `| head` / `| less` /
/// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
/// when the reader closes stdout after the first line, the next write hits a
/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
/// write returns EPIPE and `println!` panics rather than the process
/// terminating quietly — exactly the behaviour a CLI must not have. This is
/// the contract: any aura subcommand can be piped into `| head` / `| less` /
/// a UI pane that goes away, and it must finish quietly, not panic.
///
/// Autonomous: constructs its own early-closing reader inline (no `head`, no
/// shell, no fixture files) by spawning the binary with a piped stdout and
/// dropping the read handle after a single short read, which closes the pipe's
/// read end and makes the writer's next write hit EPIPE.
#[test]
fn family_emitting_command_survives_early_reader_close() {
let cwd = temp_cwd("broken-pipe-sweep");
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir),
ScratchPath::File(dir.join("broken-pipe.process.json")),
ScratchPath::File(dir.join("broken-pipe.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "broken-pipe-seed");
let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC);
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
write_doc(&dir, "broken-pipe.campaign.json", &doc);
let mut child = Command::new(BIN)
.arg("sweep")
.current_dir(&cwd)
.args(["exec", "broken-pipe.campaign.json"])
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura sweep");
.expect("spawn aura exec");
// Read just enough to guarantee the writer is mid-stream, then drop the read
// handle. Dropping it closes the read end of the pipe; the next `println!`
@@ -63,9 +152,7 @@ fn family_emitting_command_survives_early_reader_close() {
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
let status = child.wait().expect("reap aura sweep");
let _ = std::fs::remove_dir_all(&cwd);
let status = child.wait().expect("reap aura exec");
// The defect: stderr carries the broken-pipe panic.
assert!(
File diff suppressed because it is too large Load Diff
+39 -8
View File
@@ -1,7 +1,9 @@
//! Shared fixture helpers for the `aura-cli` end-to-end test binaries
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`), all of which build
//! and drive the `tests/fixtures/demo-project` fixture through the real
//! `aura` binary (#223).
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and —
//! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`,
//! `graph_construct.rs`), all of which build and drive the
//! `tests/fixtures/demo-project` fixture through the real `aura` binary
//! (#223).
//!
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
//! a unique tempdir per test, wired to the shared, once-built fixture crate
@@ -9,10 +11,10 @@
//! 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
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below
//! rather than deleting a helper just because one binary doesn't need it.
//! Each consuming test binary compiles this file as its own `mod common`, so
//! an item unused by one binary trips that binary's `-D warnings` `dead_code`
//! lint; see the `#[allow(dead_code)]` markers below rather than deleting a
//! helper just because one binary doesn't need it.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
@@ -55,6 +57,15 @@ impl Drop for FreshProject {
/// 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!(
@@ -118,6 +129,26 @@ pub fn fresh_project_with_data() -> (PathBuf, FreshProject) {
(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
/// demo-project fixture root (only `runs/` there is fixture-gitignored),
/// removed on drop — including during a mid-test panic — so a failed
@@ -153,7 +184,7 @@ impl Drop for ScratchGuard {
/// 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 {
pub mod synthetic_data {
use std::io::Write;
use std::path::Path;
File diff suppressed because it is too large Load Diff
@@ -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 -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"}},{"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"}]}]}}
{"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 -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"}},{"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"}]}]}}
{"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 -1
View File
@@ -1 +1 @@
{"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"}]}]}}
{"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 -1
View File
@@ -1 +1 @@
{"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"}]}}
{"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,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,242 @@
//! End-to-end pins for the self-description surfaces (#315, #323, #319): the
//! help opens with the two-layer concept, `exec` names both document classes
//! it executes, every introspection roster carries per-entry meanings, and
//! `graph build --help` carries the op-list reference. Driven over the
//! built binary — the zero-setup surface a reader without repo access gets.
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// Run `aura <args>` (no stdin); return (stdout, stderr, success).
fn run(args: &[&str]) -> (String, String, bool) {
let out = Command::new(BIN).args(args).output().expect("spawn aura");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
/// #315: `aura --help` opens with the concepts paragraph — the two layers,
/// the bias-as-target-position + protective-stop execution model, and how
/// traces come to exist. Each pin sits on one authored line of the explicit
/// `long_about` string, so clap's wrapping cannot split it.
#[test]
fn top_level_help_opens_with_the_two_layer_concept() {
let (out, err, ok) = run(&["--help"]);
assert!(ok, "aura --help failed: {err}");
assert!(
out.contains("the one executor over both document classes"),
"names exec + the document classes: {out}"
);
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
assert!(out.contains("introspect --unwired"), "names the bare-{{}} ramp: {out}");
assert!(out.contains("aura chart"), "names the trace consumers: {out}");
}
/// #319: `exec --help` names both document classes it accepts — the sugar
/// verbs' retirement leaves `exec` as the one executor over a campaign
/// document (file or content id) or a signal blueprint (single run).
#[test]
fn exec_help_names_both_document_classes() {
let (out, err, ok) = run(&["exec", "--help"]);
assert!(ok, "aura exec --help failed: {err}");
assert!(out.contains("campaign"), "names the campaign document class: {out}");
assert!(out.contains("blueprint"), "names the blueprint document class: {out}");
assert!(out.contains("single run"), "names the blueprint leg's single-run shape: {out}");
}
/// #323: `graph build --help` carries the op-list reference — the op kinds
/// and fields are learnable from the binary, not only from serde refusals.
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
/// joins the roster (ten -> eleven).
#[test]
fn graph_build_help_carries_the_op_reference() {
let (out, err, ok) = run(&["graph", "build", "--help"]);
assert!(ok, "aura graph build --help failed: {err}");
for op in [
r#"{"op":"source""#,
r#"{"op":"input""#,
r#"{"op":"add""#,
r#"{"op":"feed""#,
r#"{"op":"connect""#,
r#"{"op":"expose""#,
r#"{"op":"tap""#,
r#"{"op":"gang""#,
r#"{"op":"doc""#,
r#"{"op":"use""#,
r#"{"op":"name""#,
] {
assert!(out.contains(op), "op reference carries {op}: {out}");
}
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
}
/// #317: `graph introspect --registered` lists every registry label — row
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
/// as prose, not a blank/garbled listing.
#[test]
fn graph_introspect_registered_lists_labels() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
// Empty store: prose, not a blank listing, exit 0.
let out_empty = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out_empty.status.success(), "empty store still exits 0");
assert_eq!(
String::from_utf8_lossy(&out_empty.stdout),
"no labels registered\n",
"the empty-store form reads as prose"
);
// Register + label a described open pattern, then list it.
let pattern = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let bp_path = dir.join("pattern.ops.json");
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
let built = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
child.wait_with_output()
})
.expect("build the pattern");
assert!(built.status.success(), "the pattern builds");
let envelope = dir.join("pattern.bp.json");
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
let reg = Command::new(BIN)
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
.current_dir(&dir)
.output()
.expect("spawn aura register");
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(&reg.stderr));
let out = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out.status.success(), "listing succeeds");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
let fields: Vec<&str> = row.split_whitespace().collect();
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
}
/// #315: the node vocabulary listing carries each type's one-line meaning —
/// no more bare names that teach nothing.
#[test]
fn graph_introspect_vocabulary_carries_meanings() {
let (out, err, ok) = run(&["graph", "introspect", "--vocabulary"]);
assert!(ok, "vocabulary listing failed: {err}");
let sma = out
.lines()
.find(|l| l.split_whitespace().next() == Some("SMA"))
.unwrap_or_else(|| panic!("no SMA line: {out}"));
assert!(sma.contains("moving average"), "SMA line carries its meaning: {sma}");
for l in out.lines().filter(|l| !l.trim().is_empty()) {
assert!(l.split_whitespace().count() >= 2, "bare name without meaning: {l}");
}
}
/// #315: `--node <T>`'s head line carries the type's meaning beside its label.
#[test]
fn graph_introspect_node_head_line_carries_the_meaning() {
let (out, err, ok) = run(&["graph", "introspect", "--node", "SMA"]);
assert!(ok, "node introspect failed: {err}");
let head = out.lines().next().unwrap_or("");
assert!(head.contains("moving average"), "head line carries the meaning: {out}");
}
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
/// `FoldKind` table — lowercase, parseable labels, including the `record`
/// entry that has no `FoldKind` counterpart. Previously this surface printed
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
/// text's own discovery surface described input `--tap` refused.
#[test]
fn graph_introspect_folds_lists_the_fold_registry_roster() {
let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
assert!(ok, "folds listing failed: {err}");
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
assert!(
!out.split_whitespace().any(|w| w == "Mean"),
"no capitalized FoldKind id leaks through: {out}"
);
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
assert!(record.contains("series"), "record row meaning: {record}");
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
}
/// #315: the metric roster carries each metric's one-line meaning beside its
/// applicability tags.
#[test]
fn process_introspect_metrics_carries_meanings() {
let (out, err, ok) = run(&["process", "introspect", "--metrics"]);
assert!(ok, "metric roster failed: {err}");
let er = out
.lines()
.find(|l| l.split_whitespace().next() == Some("expectancy_r"))
.unwrap_or_else(|| panic!("no expectancy_r line: {out}"));
assert!(er.contains("mean realized R"), "meaning text on the roster line: {er}");
for l in out.lines().filter(|l| !l.trim().is_empty()) {
assert!(l.contains(""), "roster line without meaning: {l}");
}
}
/// #315: the persisted-tap vocabulary's meanings are readable where the taps
/// are advertised — under the `std::presentation` block's slot listing.
#[test]
fn campaign_introspect_block_presentation_lists_tap_meanings() {
let (out, err, ok) = run(&["campaign", "introspect", "--block", "std::presentation"]);
assert!(ok, "presentation describe failed: {err}");
assert!(out.contains("cumulative pip equity"), "equity tap meaning: {out}");
assert!(out.contains("after the cost model"), "net_r_equity tap meaning: {out}");
}
/// #323: `introspect --unwired` enumerates the optional C29 `description`
/// slot for both document kinds — the authoring side of the gate is
/// advertised where every other slot is.
#[test]
fn introspect_unwired_lists_the_description_slot() {
let dir = std::env::temp_dir().join(format!("aura-selfdesc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bare = dir.join("bare.json");
std::fs::write(&bare, "{}").unwrap();
for family in ["process", "campaign"] {
let (out, err, ok) = run(&[family, "introspect", "--unwired", bare.to_str().unwrap()]);
assert!(ok, "{family} --unwired failed: {err}");
assert!(
out.contains("open slot: description"),
"{family} --unwired lists the description slot: {out}"
);
assert!(out.contains("C29-gated"), "{family} hint names the gate: {out}");
}
let _ = std::fs::remove_dir_all(&dir);
}
+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(["exec", 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)
);
}
+171 -29
View File
@@ -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)
@@ -30,13 +38,13 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
#[test]
fn project_run_resolves_demo_node_and_is_bit_identical() {
let dir = built_project();
let a = aura(&["run", "demo_signal.json"], dir);
let a = aura(&["exec", "demo_signal.json"], dir);
assert!(
a.status.success(),
"run failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "demo_signal.json"], dir);
let b = aura(&["exec", "demo_signal.json"], dir);
assert!(b.status.success());
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
}
@@ -44,7 +52,7 @@ fn project_run_resolves_demo_node_and_is_bit_identical() {
#[test]
fn project_run_manifest_carries_provenance() {
let dir = built_project();
let out = aura(&["run", "demo_signal.json"], dir);
let out = aura(&["exec", "demo_signal.json"], dir);
assert!(out.status.success());
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("run report is JSON");
@@ -75,7 +83,7 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
let bp = fixture_dir().join("demo_signal.json");
let out = aura(&["run", bp.to_str().unwrap()], cwd);
let out = aura(&["exec", bp.to_str().unwrap()], cwd);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
@@ -96,31 +104,91 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
/// subdirectory. This is the property that makes `[paths]` project-relative
/// rather than shell-relative (C17).
///
/// #319 vehicle note: the synthetic in-process family builder this test used
/// to drive via bare `sweep --axis` (no `--real`) retired with the quintet —
/// no surviving CLI surface produces a family record without touching a real
/// archive (`exec`'s campaign leg always resolves against `DataServer`), so
/// the vehicle is now a one-cell campaign over the `fresh_project_with_data`
/// synthetic `SYMA` archive (mirrors `exec.rs`'s `seed_blueprint` +
/// `campaign_doc_json_for` recipe), executed from a project subdirectory.
#[test]
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
let dir = built_project();
let sub = dir.join("src");
let (dir, _fixture) = common::fresh_project_with_data();
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).expect("create invocation subdirectory");
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let closed_bp =
format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let reg_out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["graph", "register", &closed_bp, "--name", "proj-anchor-seed"])
.current_dir(&dir)
.output()
.expect("spawn aura graph register");
assert!(reg_out.status.success(), "register failed: {}", String::from_utf8_lossy(&reg_out.stderr));
let reg_text = String::from_utf8_lossy(&reg_out.stdout);
let bp_id = reg_text
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.to_string();
let process_doc = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
std::fs::write(dir.join("anchor.process.json"), process_doc).expect("write process doc");
let proc_out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "register", "anchor.process.json"])
.current_dir(&dir)
.output()
.expect("spawn aura process register");
assert!(proc_out.status.success(), "process register failed: {}", String::from_utf8_lossy(&proc_out.stderr));
let proc_text = String::from_utf8_lossy(&proc_out.stdout);
let proc_id = proc_text
.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string();
// The established `SYMA` window (2024-03..06, fully inside its 2024-01..08
// span) other #319 vehicle campaigns use.
let campaign_doc = format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "run-seam",
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#
);
std::fs::write(dir.join("anchor.campaign.json"), &campaign_doc).expect("write campaign doc");
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"proj-anchor",
])
.args(["exec", "../anchor.campaign.json"])
.current_dir(&sub)
.output()
.expect("spawn aura sweep");
.expect("spawn aura exec");
assert!(
out.status.success(),
"sweep failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
@@ -154,7 +222,7 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
"badcharter fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
let run = aura(&["exec", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
@@ -164,6 +232,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(&["exec", "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(&["exec", "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) {
@@ -207,7 +347,7 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
set_mtime(&dylib, DYLIB_MTIME);
set_mtime(&src, SOURCE_MTIME);
let out = aura(&["run", "demo_signal.json"], dir);
let out = aura(&["exec", "demo_signal.json"], dir);
// Restore sane mtimes before asserting, so a failed assertion does not
// leave the shared fixture wedged "stale" (or with a future mtime that
@@ -271,7 +411,7 @@ fn nested_nodes_dir() -> PathBuf {
fn data_only_project_runs_and_stamps_commit_only_provenance() {
let dir = dataonly_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "signal.json"])
.args(["exec", "signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -307,7 +447,7 @@ fn data_only_project_runs_and_stamps_commit_only_provenance() {
fn multi_crate_pointer_refuses_end_to_end() {
let dir = multicrate_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "x.json"])
.args(["exec", "x.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -347,7 +487,7 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
String::from_utf8_lossy(&build.stderr)
);
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "pointer_signal.json"])
.args(["exec", "pointer_signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -368,13 +508,14 @@ 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");
std::fs::write(&bp, r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"nosuch::Node"}}],"edges":[],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}],"source":"F64"}],"output":[{"node":0,"field":0,"name":"bias"}]}}"#).unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", bp.to_str().unwrap()])
.args(["exec", bp.to_str().unwrap()])
.current_dir(&tmp)
.output()
.expect("spawn aura");
@@ -388,7 +529,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();
@@ -397,7 +539,7 @@ fn missing_artifact_refuses_with_build_hint() {
"[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
)
.unwrap();
let out = aura(&["run", "x.json"], &tmp);
let out = aura(&["exec", "x.json"], &tmp);
assert_eq!(out.status.code(), Some(1), "runtime refusal");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cargo build"), "build hint present: {err}");
+142 -40
View File
@@ -1,11 +1,12 @@
//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction
//! — plus the refusal contract. Scaffolded projects live in per-test temp
//! dirs.
//! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the
//! refusal contract. Scaffolded projects live in per-test temp dirs.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
mod common;
fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
@@ -14,8 +15,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
@@ -50,17 +62,17 @@ fn init_and_commit(dir: &Path) {
///
/// Documented deviation from the task text: the plan's wording asks this
/// headline to also assert that the run "writes a manifest under `x/runs/`".
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the
/// trace store — it only prints the `RunReport` to stdout; only
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241
/// split this task's file list does not touch (giving `run` a store write
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new`
/// and this test file). There is thus no on-disk manifest for THIS verb to
/// assert against; the assertion below proves that absence directly instead
/// of only asserting it in prose, and the persisted-store property the plan
/// wanted is covered by `data_only_project_sweeps_without_any_build` below,
/// which does assert `proj.join("runs").exists()`.
/// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r`
/// (main.rs): a single-blueprint `aura exec` never touches the trace store —
/// it only prints the `RunReport` to stdout; only exec's campaign leg
/// persists (`env.trace_store()`), a pre-#241 split this task's file list
/// does not touch (giving the blueprint leg a store write would widen scope
/// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test
/// file). There is thus no on-disk manifest for THIS leg to assert against;
/// the assertion below proves that absence directly instead of only
/// asserting it in prose, and the persisted-store property the plan wanted
/// is covered by `data_only_project_execs_a_campaign_without_any_build`
/// below, which does assert `proj.join("runs").exists()`.
#[test]
fn new_scaffolds_and_runs_the_data_only_loop() {
let base = tmp("loop");
@@ -91,13 +103,13 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml");
assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs");
let a = aura(&["run", "blueprints/signal.json"], &proj);
let a = aura(&["exec", "blueprints/signal.json"], &proj);
assert!(
a.status.success(),
"run failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "blueprints/signal.json"], &proj);
let b = aura(&["exec", "blueprints/signal.json"], &proj);
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
let p = &v["manifest"]["project"];
@@ -105,50 +117,140 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
assert!(
!proj.join("runs").exists(),
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \
the task text's \"writes a manifest under x/runs/\" does not hold for this verb"
"a plain single-blueprint `aura exec` must not persist a trace store (only exec's \
campaign leg does) — the task text's \"writes a manifest under x/runs/\" does not \
hold for this leg"
);
let _ = std::fs::remove_dir_all(&base);
}
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's
/// gate) with zero build step — using the scaffolded (closed) starter
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises.
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Writes a tiny, self-contained January-2024 `SYMA` archive into
/// `<proj>/data` and points the scaffolded `Aura.toml`'s `[paths]` table at
/// it. #319: `exec`'s campaign leg (the surviving surface for what `aura
/// sweep --axis` used to do inline) needs a registered instrument, unlike a
/// plain blueprint run — this keeps the scaffolder's "zero host setup"
/// headline intact (no real archive mount) by minting the archive straight
/// into the scaffold instead.
fn seed_synthetic_archive(proj: &Path) {
let data_dir = proj.join("data");
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
common::synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 1));
let toml_path = proj.join("Aura.toml");
let toml = std::fs::read_to_string(&toml_path).expect("read scaffolded Aura.toml");
let toml = toml.replacen("runs = \"runs\"\n", "runs = \"runs\"\ndata = \"data\"\n", 1);
std::fs::write(&toml_path, toml).expect("point Aura.toml at the synthetic archive");
}
/// The id extracted from a `registered <kind> <id> (<path>)` line (#194: bare
/// id, no `content:` prefix — tolerant of the pre-#194 prefix all the same).
fn registered_id(stdout: &str, kind: &str) -> String {
let needle = format!("registered {kind} ");
stdout
.lines()
.find(|l| l.starts_with(&needle))
.unwrap_or_else(|| panic!("no \"{needle}\" line: {stdout}"))
.trim_start_matches(&needle)
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, one-axis campaign document over `bp_id`/`proc_id` — the
/// surviving surface for what `aura sweep --axis` used to do inline over the
/// scaffolded starter's bound `fast.length` (#246: a bound param is a default
/// an axis overrides). Windowed fully inside `seed_synthetic_archive`'s
/// January-2024 `SYMA` archive.
fn one_axis_campaign_doc(bp_id: &str, proc_id: &str, axis_name: &str, axis_values: &str) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "quickstart",
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745599999 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "{axis_name}": {{ "kind": "I64", "values": [{axis_values}] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 1,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#
)
}
/// #241 headline: a fresh data-only project executes a small campaign — #319's
/// surviving surface for the retired `aura sweep` — with zero build step,
/// using the scaffolded (closed) starter blueprint and the exact axis the
/// scaffolded CLAUDE.md advertises. Registering the blueprint straight (`aura
/// graph register`, not a sweep side-effect) and seeding a tiny synthetic
/// archive (`seed_synthetic_archive`) keeps the loop free of any host data
/// mount, matching the pre-retirement headline.
///
/// #246: the closed starter IS the sweep target — a bound param is a default
/// an axis overrides.
/// #246: the closed starter IS the campaign axis target — a bound param is a
/// default an axis overrides.
#[test]
fn data_only_project_sweeps_without_any_build() {
fn data_only_project_execs_a_campaign_without_any_build() {
let base = tmp("sweep");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
&proj,
);
seed_synthetic_archive(&proj);
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let bp_id = registered_id(&String::from_utf8_lossy(&reg.stdout), "blueprint");
std::fs::write(proj.join("quickstart.process.json"), SWEEP_ONLY_PROCESS_DOC)
.expect("write process doc");
let preg = aura(&["process", "register", "quickstart.process.json"], &proj);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4");
std::fs::write(proj.join("quickstart.campaign.json"), &doc).expect("write campaign doc");
let out = aura(&["exec", "quickstart.campaign.json"], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(proj.join("runs").exists(), "store must land inside the project");
let _ = std::fs::remove_dir_all(&base);
}
/// Property (#246): the scaffold's sweep quickstart genuinely re-opens the
/// Property (#246): the scaffold's campaign quickstart genuinely re-opens the
/// bound `fast.length` param — the persisted family has exactly one member
/// per axis value, not a single collapsed member. A silent regression where
/// the bound-param override is dropped (member always built from the
/// blueprint's own default) would still exit 0 and still create a `runs`
/// store, so `data_only_project_sweeps_without_any_build` above cannot catch
/// it; only the member count can.
/// store, so `data_only_project_execs_a_campaign_without_any_build` above
/// cannot catch it; only the member count can.
#[test]
fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
fn data_only_project_campaign_over_the_starter_opens_one_member_per_axis_value() {
let base = tmp("sweep-members");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
&proj,
);
seed_synthetic_archive(&proj);
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let bp_id = registered_id(&String::from_utf8_lossy(&reg.stdout), "blueprint");
std::fs::write(proj.join("members.process.json"), SWEEP_ONLY_PROCESS_DOC)
.expect("write process doc");
let preg = aura(&["process", "register", "members.process.json"], &proj);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4, 8");
std::fs::write(proj.join("members.campaign.json"), &doc).expect("write campaign doc");
let out = aura(&["exec", "members.campaign.json"], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let fams = aura(&["runs", "families"], &proj);
assert!(fams.status.success(), "runs families exit: {:?}", fams.status);
@@ -290,10 +392,10 @@ fn new_outside_a_work_tree_leaves_a_resolvable_head() {
// ...so the first quickstart run stamps a commit into the manifest, not
// an empty provenance block.
let run = aura(&["run", "blueprints/signal.json"], &proj);
let run = aura(&["exec", "blueprints/signal.json"], &proj);
assert!(
run.status.success(),
"run failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&run.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
+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:?}")
});
}
+83 -45
View File
@@ -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.
@@ -149,72 +149,110 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>))
}
/// Acceptance box 1 (#235): a project-authored node with an OPEN param is
/// discoverable as a sweep axis, sweeps over real data into a persisted family,
/// and that family reproduces bit-identically — the defining research loop of a
/// genuine external project ("sweep my OWN node's knob"), proven end to end.
/// discoverable as an axis, execs over real data (a campaign — #319: the
/// surviving surface for what `aura sweep --axis` used to do inline) into a
/// persisted family, and that family reproduces bit-identically — the
/// defining research loop of a genuine external project ("vary my OWN node's
/// knob"), proven end to end.
#[test]
fn project_node_open_param_sweeps_and_reproduces() {
let (dir, _g) = fresh_project();
// (a) `--list-axes` discovers the PROJECT node's own open knob, followed by
// the fixture's three BOUND params as `default=`-lines (#246: every bound
// param is an equally re-openable `--axis`, listed regardless of how many
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
// no data, so the discoverability half runs on every host. The wrapped
// name is `<blueprint>.<node>.<param>`.
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
// (a) `graph introspect --params` discovers the PROJECT node's own open
// knob, followed by the fixture's three BOUND params as `default=`-lines
// (#246: every bound param is an equally re-openable axis, listed
// regardless of how many knobs happen to be open alongside it). NOT
// gated: enumerating axes needs no data, so the discoverability half runs
// on every host. The name is RAW `<node>.<param>` (#328: the blueprint
// name stays out of axis paths).
let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]);
assert!(
axes.status.success(),
"--list-axes stderr: {}",
"graph introspect --params stderr: {}",
String::from_utf8_lossy(&axes.stderr)
);
assert_eq!(
String::from_utf8_lossy(&axes.stdout),
"scaled_signal.gain.factor:F64\n\
scaled_signal.fast.length:I64 default=2\n\
scaled_signal.slow.length:I64 default=4\n\
scaled_signal.bias.scale:F64 default=0.5\n",
"gain.factor:F64\n\
fast.length:I64 default=2\n\
slow.length:I64 default=4\n\
bias.scale:F64 default=0.5\n",
"the project node's own OPEN param is the one open axis, alongside the \
fixture's bound defaults; stderr: {}",
String::from_utf8_lossy(&axes.stderr)
);
// The real-data sweep/reproduce legs are archive-gated.
// The real-data campaign/reproduce legs are archive-gated.
if !local_data_present() {
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
return;
}
// (b) sweep the project node's knob over real GER40 data -> two members.
let sweep = aura_in(
dir,
&[
"sweep",
"blueprints/scaled_open.json",
"--real",
"GER40",
"--from",
GER40_FROM_MS,
"--to",
GER40_TO_MS,
"--axis",
"scaled_signal.gain.factor=0.5,1.0",
"--name",
"knob",
],
// (b) exec a campaign over the project node's knob, real GER40 data ->
// two members. Register the blueprint straight (not a sweep side-effect)
// and a minimal sweep-only process, then reference both by content id.
let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let reg_out = String::from_utf8_lossy(&reg.stdout).into_owned();
let bp_id = reg_out
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("blueprint id")
.to_string();
std::fs::write(dir.join("knob-reproduce.process.json"), SWEEP_ONLY_PROCESS)
.expect("write process doc");
let preg = aura_in(dir, &["process", "register", "knob-reproduce.process.json"]);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let preg_out = String::from_utf8_lossy(&preg.stdout).into_owned();
let proc_id = preg_out
.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("process id")
.to_string();
let campaign = format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "knob-reproduce",
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
"axes": {{ "gain.factor": {{ "kind": "F64", "values": [0.5, 1.0] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = GER40_FROM_MS,
to = GER40_TO_MS,
bp = bp_id,
proc = proc_id,
);
std::fs::write(dir.join("knob-reproduce.campaign.json"), &campaign).expect("write campaign doc");
let exec = aura_in(dir, &["exec", "knob-reproduce.campaign.json"]);
assert!(
sweep.status.success(),
"sweep stderr: {}",
String::from_utf8_lossy(&sweep.stderr)
exec.status.success(),
"exec stderr: {}",
String::from_utf8_lossy(&exec.stderr)
);
let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().collect();
let stdout = String::from_utf8_lossy(&exec.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().filter(|l| l.starts_with(r#"{"family_id":"#)).collect();
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
// Each member's manifest carries the swept PROJECT-node param binding under
// its wrapped name — the load-bearing proof that it is MY node's knob that
// varied across the family, not some incidental std axis.
// Each member's manifest carries the swept PROJECT-node param binding
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
// real/campaign route's own `manifest.params` mints raw too now) — the
// load-bearing proof that it is MY node's knob that varied across the
// family, not some incidental std axis.
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
assert_eq!(
@@ -227,7 +265,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
.expect("manifest.params is an array");
let bound = params
.iter()
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
.find(|p| p[0].as_str() == Some("gain.factor"))
.and_then(|p| p[1]["F64"].as_f64());
assert_eq!(
bound,
@@ -322,7 +360,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
);
std::fs::write(dir.join("knob.campaign.json"), &campaign).expect("write campaign doc");
let run = aura_in(dir, &["campaign", "run", "knob.campaign.json"]);
let run = aura_in(dir, &["exec", "knob.campaign.json"]);
let out = format!(
"{}{}",
String::from_utf8_lossy(&run.stdout),
@@ -331,7 +369,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
+140
View File
@@ -0,0 +1,140 @@
//! #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(["exec", 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"]));
}
/// #310: the measurement arm honours the `--tap` selector — a `last`
/// fold lands one summary row instead of the full series.
#[test]
fn measurement_run_honours_the_tap_selector() {
let cwd = temp_cwd("selector-last");
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(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
let got = rows[0].as_f64().expect("f64 row");
let expected = (1.0097_f64 + 1.0092) / 2.0;
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
}
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
/// normalizes there) instead of refusing at the same root-name choke point
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
/// property that actually matters — the escaped directory is never created.
#[test]
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
let cwd = temp_cwd("bad-root-name");
let mut v: serde_json::Value =
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
v["blueprint"]["name"] = serde_json::json!("../x");
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
let bp_path = cwd.join("bad-root-name.json");
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(
out.status.code(),
Some(2),
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(r#"blueprint name "../x" is invalid"#),
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
);
assert!(
out.stdout.is_empty(),
"a refused run must not print a report: {:?}",
String::from_utf8_lossy(&out.stdout)
);
// The property that actually matters: the escaped write target (one level
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
// was never created — the gate fires before `begin_run` ever touches disk.
assert!(
!cwd.join("runs/x").exists(),
"the escaped trace directory must never be created"
);
assert!(
!cwd.join("runs/traces").exists(),
"no trace directory of any shape is written on a refused run"
);
}
@@ -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(["exec", 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));
}
}
+422
View File
@@ -0,0 +1,422 @@
//! #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(["exec", 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(["exec", &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")
}
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
/// plan and the record-all default can be compared file-by-file (#310).
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": "fast_tap", "from": {"node": 0, "field": 0}},
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
]);
serde_json::to_string(&v).expect("re-serialize two-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(["exec", 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(["exec", 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(["exec", 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");
}
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
/// fold instead of the record-all default — the trace store holds ONE
/// summary row whose value is the mean of the full SMA(2) series, and
/// `index.json` lists exactly the subscribed tap.
#[test]
fn run_tap_selector_persists_a_fold_summary_row() {
let cwd = temp_cwd("tap-selector-mean");
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(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
assert_eq!(trace["tap"], "fast_tap");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
.collect();
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
let got = rows[0].as_f64().expect("f64 row");
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
}
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
/// record-all default — the selector replaces the default without
/// changing what `record` itself writes (C1 determinism across runs).
#[test]
fn run_explicit_record_plan_matches_the_record_all_default() {
let cwd_a = temp_cwd("record-default");
let cwd_b = temp_cwd("record-explicit");
for cwd in [&cwd_a, &cwd_b] {
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
.expect("write two-tap blueprint");
}
let run = |cwd: &std::path::Path, extra: &[&str]| {
let mut args = vec!["exec", "two_tap.json"];
args.extend_from_slice(extra);
let out = Command::new(BIN)
.args(&args)
.current_dir(cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
};
run(&cwd_a, &[]);
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
for file in ["fast_tap.json", "slow_tap.json"] {
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
}
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
};
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
}
/// #310: an unknown fold label is refused through the registry's
/// roster-enumerating refusal, before any store write.
#[test]
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
let cwd = temp_cwd("tap-selector-unknown-label");
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(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unknown fold label must refuse");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("medain") && stderr.contains("mean"),
"refusal must name the label and enumerate the roster: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #310/#333: a selection naming a tap the blueprint does not declare is
/// refused typed (UndeclaredTap), before any store write, and the refusal
/// enumerates the blueprint's declared taps (the same way the unknown-fold
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
/// both `fast_tap` and `slow_tap` must be named so the author does not have
/// to reopen the blueprint JSON.
#[test]
fn run_tap_selector_refuses_an_undeclared_tap() {
let cwd = temp_cwd("tap-selector-undeclared");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an undeclared tap must refuse");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
assert!(
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
"refusal must enumerate the blueprint's declared taps: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
/// gets no such note; `slow_tap` (left unlisted) does.
#[test]
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
let cwd = temp_cwd("tap-selector-unbound-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
"stderr must note the unbound declared tap: {stderr}"
);
assert!(
!stderr.contains("\"fast_tap\" unbound"),
"the subscribed tap must not be noted as unbound: {stderr}"
);
}
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
/// unbound — nothing is skipped, so the note must never appear.
#[test]
fn run_with_no_tap_flag_emits_no_unbound_note() {
let cwd = temp_cwd("tap-selector-default-no-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", 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 stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("unbound this run"),
"the record-all default must never note a skipped tap: {stderr}"
);
}
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
/// before anything runs.
#[test]
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
let cwd = temp_cwd("tap-selector-usage");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
for args in [
vec!["--tap", "fast_tapmean"],
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
] {
let mut full = vec!["exec", bp_path.to_str().expect("utf-8 path")];
full.extend(args);
let out = Command::new(BIN)
.args(&full)
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
assert!(!cwd.join("runs").exists());
}
}
+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
+15 -7
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:
@@ -51,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
let scale = g.add(match knobs {
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::builder(1).named("stop_k"),
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::configured(1).named("stop_k"),
});
g.feed(price, [delay.input("series"), sub.input("lhs")]);
g.connect(delay.output("value"), sub.input("rhs"));
@@ -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,
)
@@ -166,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
let entry = g.input_role("entry_price");
let stop = g.input_role("stop_price");
let agg = g.add(CostSum::builder(n));
let agg = g.add(CostSum::configured(n));
// Per-role geometry fan targets, collected across all nodes, fed once per role.
let mut closed_t = Vec::with_capacity(n);
+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);
+4
View File
@@ -7,6 +7,10 @@ publish.workspace = true
[dependencies]
serde = { workspace = true }
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
# already a workspace dependency of four other crates (see docs/specs/
# construction-args.md § Dependency note).
chrono-tz = { version = "0.10", default-features = false }
[dev-dependencies]
serde_json = { workspace = true }
+2 -1
View File
@@ -44,7 +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,
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder,
};
pub use scalar::{Scalar, ScalarKind, Timestamp};
+469 -5
View File
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
instance_name: Option<String>,
schema: NodeSchema,
bound: Vec<BoundParam>,
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
schema: NodeSchema,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
}
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
/// not yet constructible. `schema` carries only the doc line (empty
/// inputs/output/params — the real signature forms inside `make`, once
/// `try_args` has parsed values to hand it); `build` on a pending builder
/// panics (the `bind` panic-contract twin) — the data path always goes
/// through `try_args` first, so no pending builder ever reaches `build`.
pub fn pending(
name: &'static str,
doc: &'static str,
specs: &'static [ArgSpec],
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
) -> Self {
Self {
name,
instance_name: None,
schema: NodeSchema { doc, ..Default::default() },
bound: Vec::new(),
args: ArgsState::Pending { specs, make },
build: Box::new(move |_| {
panic!(
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
call try_args first"
)
}),
}
}
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
/// configured builder (`args: Configured{..}`) with this builder's
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
/// (uniform op application, #157's try_bind precedent); `Plain` +
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
/// first unknown arg name, then a duplicate, then the first spec-order
/// missing arg, then per-kind parse failures (`BadValue`).
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
let (specs, make) = match &self.args {
ArgsState::Plain | ArgsState::Configured { .. } => {
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
}
ArgsState::Pending { specs, make } => (*specs, *make),
};
for (name, _) in raw {
if !specs.iter().any(|s| s.name == name.as_str()) {
return Err(ArgOpError::UnknownArg(name.clone()));
}
}
for i in 0..raw.len() {
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
}
}
for spec in specs {
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
return Err(ArgOpError::MissingArg(spec.name.to_string()));
}
}
let mut values = Vec::with_capacity(specs.len());
let mut accepted = Vec::with_capacity(specs.len());
for spec in specs {
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
arg: spec.name.to_string(),
kind: spec.kind,
got: raw_val.clone(),
})?;
values.push((spec.name.to_string(), value));
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
}
let mut built = make(&values);
built.instance_name = self.instance_name;
built.args = ArgsState::Configured { values: accepted };
Ok(built)
}
/// The declared construction-arg specs (a view into the pending recipe);
/// empty unless this builder `is_pending()`.
pub fn arg_specs(&self) -> &[ArgSpec] {
match &self.args {
ArgsState::Pending { specs, .. } => specs,
_ => &[],
}
}
/// The accepted, verbatim construction-arg pairs (the serialize surface);
/// empty unless this builder was configured via `try_args`.
pub fn construction_args(&self) -> &[ConstructionArg] {
match &self.args {
ArgsState::Configured { values } => values,
_ => &[],
}
}
/// True for an arg-bearing recipe not yet configured via `try_args`.
pub fn is_pending(&self) -> bool {
matches!(self.args, ArgsState::Pending { .. })
}
/// Set this node instance's explicit name. Must be non-empty: the name forms
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
/// panic contract (and its pinned messages); `try_bind` reports the same three
/// conditions as values.
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
/// follow-up) does, refusing a ganged member's raw path at the use seam
/// before it silently de-fuses the gang.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BindOpError {
/// No still-open param has this name.
@@ -333,6 +438,141 @@ pub enum BindOpError {
AmbiguousParam(String),
/// The value's kind does not equal the param's declared kind.
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
/// `param` (the full qualified path) is a member of the gang named
/// `gang` — binding it directly would freeze that one member while the
/// gang's public knob keeps driving its siblings. Bind the gang's own
/// public knob instead (accepted residue: unbindable at the use seam,
/// #317).
AlreadyGanged { param: String, gang: String },
}
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
/// are bootstrap metadata, never streamed, so the two closedness axes stay
/// separate types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArgKind {
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
Tz,
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
TimeOfDay,
/// A plain positive decimal count (>= 1, no leading zeros).
Count,
}
impl ArgKind {
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
/// the canonical form (spec §Closedness) — there is no normalization
/// layer, so a near-miss string refuses rather than being silently
/// rewritten (content ids stay input-variance-free by refusal).
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
// no information of its own — `try_args` is the only caller and always maps
// it to `ArgOpError::BadValue`, which is where the real detail lives.
#[allow(clippy::result_unit_err)]
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
match self {
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
ArgKind::TimeOfDay => {
let bytes = raw.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return Err(());
}
let (h, m) = (&raw[0..2], &raw[3..5]);
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
let hour: u32 = h.parse().map_err(|_| ())?;
let minute: u32 = m.parse().map_err(|_| ())?;
if hour > 23 || minute > 59 {
return Err(());
}
Ok(ArgValue::TimeOfDay { hour, minute })
}
ArgKind::Count => {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
if raw.len() > 1 && raw.starts_with('0') {
return Err(()); // leading zero: not the canonical form
}
let n: usize = raw.parse().map_err(|_| ())?;
if n == 0 {
return Err(()); // Count is >= 1
}
Ok(ArgValue::Count(n))
}
}
}
/// The static, per-kind hint line (introspection surface, C29) — a closed
/// table keyed only by `ArgKind`, never per-entry freetext.
pub fn hint(&self) -> &'static str {
match self {
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
ArgKind::TimeOfDay => "local wall-clock HH:MM",
ArgKind::Count => "positive integer count",
}
}
}
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ArgSpec {
pub name: &'static str,
pub kind: ArgKind,
}
/// A parsed, validated construction-arg value (in-memory only; documents carry
/// the canonical string form in [`ConstructionArg`]).
#[derive(Clone, Debug, PartialEq)]
pub enum ArgValue {
Tz(chrono_tz::Tz),
TimeOfDay { hour: u32, minute: u32 },
Count(usize),
}
/// A consumed construction arg — the id-bearing, serialized twin of
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
/// (never re-normalized — see `ArgKind::parse`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConstructionArg {
pub name: String,
pub value: String,
}
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
/// for the data-level construction surface.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArgOpError {
/// Args were given, but this type declares none (a `Plain` builder).
NotArgBearing,
/// A given arg name is not among the declared `ArgSpec`s.
UnknownArg(String),
/// The same arg name was given more than once.
DuplicateArg(String),
/// A declared arg has no value in the given set.
MissingArg(String),
/// A given value failed its declared kind's strict-form parse.
BadValue { arg: String, kind: ArgKind, got: String },
}
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
enum ArgsState {
/// No construction args declared (every existing node, unaffected).
Plain,
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
/// parsed value set into the real, configured builder.
Pending {
specs: &'static [ArgSpec],
#[allow(clippy::type_complexity)]
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
},
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
/// pairs — the render/serialize surface (`construction_args`).
Configured { values: Vec<ConstructionArg> },
}
/// A node's declared interface: its inputs (in order) and its output record — an
@@ -345,6 +585,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 +649,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 +716,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 +753,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 +785,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 +800,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 +810,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 +852,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 +929,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 +948,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 +964,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 +1016,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 +1038,169 @@ 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(()));
}
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
const DEMO_ARG_SPECS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// A minimal `make`: the fixture does not need `values` to shape a real
/// schema (that's `Session::make`'s job, Task 2) — it only proves
/// `try_args` reaches `make` with parsed values and carries the result
/// forward as `Configured`.
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let _ = values;
PrimitiveBuilder::new(
"Demo",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|_| Box::new(Bare),
)
}
fn demo_pending() -> PrimitiveBuilder {
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
}
#[test]
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
assert_eq!(
plain.try_args(&[("x".into(), "1".into())]).err(),
Some(ArgOpError::NotArgBearing),
);
}
#[test]
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
let plain = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|_| Box::new(Bare),
);
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
assert_eq!(after.schema().doc, "plain doc");
assert!(after.construction_args().is_empty());
}
#[test]
fn try_args_unknown_arg_names_the_first_unknown() {
assert_eq!(
demo_pending()
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
.err(),
Some(ArgOpError::UnknownArg("nope".into())),
);
}
#[test]
fn try_args_duplicate_arg_names_the_repeated_name() {
assert_eq!(
demo_pending()
.try_args(&[
("tz".into(), "Europe/Berlin".into()),
("tz".into(), "Europe/Berlin".into()),
("open".into(), "09:30".into()),
])
.err(),
Some(ArgOpError::DuplicateArg("tz".into())),
);
}
#[test]
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
assert_eq!(
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
Some(ArgOpError::MissingArg("open".into())),
);
}
#[test]
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
assert_eq!(
demo_pending()
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
.err(),
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
);
}
/// Strict form IS the canonical form (spec §Closedness): no normalization
/// layer, so a near-miss string refuses rather than being rewritten.
#[test]
fn arg_kind_parse_strict_form_refusals_and_accepts() {
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
assert!(ArgKind::Count.parse("2").is_ok());
}
#[test]
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
let configured = demo_pending()
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
.expect("a full valid set configures");
assert!(!configured.is_pending());
assert_eq!(
configured.construction_args(),
&[
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
ConstructionArg { name: "open".into(), value: "09:30".into() },
],
);
}
#[test]
fn pending_arg_specs_are_visible_before_configuration() {
let pending = demo_pending();
assert!(pending.is_pending());
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
assert!(pending.construction_args().is_empty());
}
#[test]
#[should_panic(expected = "unconfigured")]
fn pending_build_panics() {
let _ = demo_pending().build(&[]);
}
}
#[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
+422 -40
View File
@@ -15,10 +15,11 @@
//! C23) and no external dependency (C16).
use aura_core::{
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
BindOpError, 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 +34,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 +130,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,
@@ -162,6 +195,38 @@ pub struct GangMember {
pub name: String,
}
/// Deterministic shape gate for authored composite render names (#331) —
/// shape only, never content judgement (C29 discipline): non-empty, no path
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
/// exists because the name keys a trace directory on disk unsanitized
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
/// blueprint-envelope root-name intake — never at store read-back (C29:
/// registered artifacts are never retroactively invalidated), and never on
/// the Rust builder API (`GraphBuilder::new`, role-2 native authoring).
pub fn name_gate(name: &str) -> Result<(), NameGateFault> {
if name.is_empty() {
return Err(NameGateFault::Empty);
}
if name.contains('/') || name.contains('\\') {
return Err(NameGateFault::ContainsSeparator);
}
if name == "." || name == ".." {
return Err(NameGateFault::DotSegment);
}
Ok(())
}
/// The [`name_gate`] shape violation — WHY the name was refused; the caller
/// already holds the offending name (WHAT) to build a message from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameGateFault {
Empty,
ContainsSeparator,
DotSegment,
}
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
/// interior edges (local indices), input roles (role `r` fans into the interior
@@ -174,6 +239,7 @@ pub struct Composite {
edges: Vec<Edge>,
input_roles: Vec<Role>,
output: Vec<OutField>,
taps: Vec<Tap>,
gangs: Vec<Gang>,
}
@@ -189,7 +255,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 +273,47 @@ impl Composite {
pub fn doc(&self) -> Option<&str> {
self.doc.as_deref()
}
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
/// interior — nodes, edges, roles, output — is untouched; only `name()`
/// changes, so a fetched, registered subgraph renders (and path-prefixes
/// its `param_space()`, via `collect_params`'s composite arm) under the
/// caller-chosen instance identifier instead of its own authored name.
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
self.name = name.into();
self
}
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
/// same underlying mechanic every bind goes through
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
/// nested composite frames by node/composite-name prefix — the segmentation
/// `reopen_in` (#246) also walks, but binding an open param instead of
/// unbinding a bound one. A path matching no node at any level is
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
/// param lives there); a path that reaches a primitive but whose leaf
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
/// own fault to carry the full qualified path in place of the bare leaf
/// name, so every fault out of this walk names the same thing: the path
/// the caller wrote. A path landing on a GANGED member's own param is
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
/// it directly would silently de-fuse the gang (the member frozen while
/// the gang's public knob keeps driving its siblings); the gang's own
/// public knob stays unbindable at the use seam (accepted residue). On
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
/// posture `reopen` uses.
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
Ok(self)
}
/// 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 +398,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 +417,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 +427,7 @@ impl Composite {
&mut flat_nodes,
&mut flat_signatures,
&mut flat_edges,
&mut flat_taps,
)?;
for e in &self.edges {
@@ -335,10 +444,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
@@ -451,7 +574,8 @@ pub enum ReopenError {
/// One entry of the aggregated BOUND param surface (#246): the path-qualified
/// twin of [`ParamSpec`] carrying the bound value — the default a sweep axis
/// may override, and what `--list-axes` renders as `default=`.
/// may override, and what `aura graph introspect --params` renders as
/// `default=`.
#[derive(Debug, Clone, PartialEq)]
pub struct BoundSpec {
pub name: String,
@@ -509,9 +633,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 +645,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 +679,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 +939,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 +990,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 +1022,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(())
@@ -1023,6 +1162,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
hits
}
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
/// `full_path` is the whole original path (named in every fault this
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
/// its (node, original-pos) is checked against every gang member; a hit
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
/// the member out from under its gang's public knob (review finding,
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
/// `BlueprintNode`s by value rather than mutating through a reference.
fn bind_path_in(
items: &mut Vec<BlueprintNode>,
gangs: &[Gang],
full_path: &str,
rest: &str,
value: Scalar,
) -> Result<(), BindOpError> {
// The prefix this frame has already consumed off `full_path` (composite
// names and up) — used to qualify a gang's public name the same way
// `collect_params` does, so the refusal below names the SAME address
// `param_space()` would show for that gang.
let prefix = &full_path[..full_path.len() - rest.len()];
for i in 0..items.len() {
let child_rest = match &items[i] {
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
};
let Some(child_rest) = child_rest else { continue };
let child_rest = child_rest.to_string();
// Gang guard (#317 follow-up review finding): a raw path landing on a
// GANGED member's own param must not silently freeze it while the
// gang's public knob keeps driving its siblings — refuse by
// identifier before ever reaching `try_bind`. The gang's own public
// knob staying unbindable at the use seam is accepted residue.
if let BlueprintNode::Primitive(b) = &items[i]
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
{
return Err(BindOpError::AlreadyGanged {
param: full_path.to_string(),
gang: format!("{prefix}{}", g.name),
});
}
let item = items.remove(i);
return match item {
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
Ok(b2) => {
items.insert(i, BlueprintNode::Primitive(b2));
Ok(())
}
Err(e) => Err(qualify_bind_error(e, full_path)),
},
BlueprintNode::Composite(mut c) => {
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
items.insert(i, BlueprintNode::Composite(c));
r
}
};
}
// no node at any level matched `rest`'s leading segment: no open param
// lives at this path.
Err(BindOpError::UnknownParam(full_path.to_string()))
}
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
/// visibility past the one primitive it was called on).
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
match e {
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
BindOpError::KindMismatch { expected, got, .. } => {
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
}
// `try_bind` never raises this one (it has no gang awareness) — the
// gang guard above constructs it directly, already fully qualified.
// Kept here only so this match stays total over `BindOpError`.
BindOpError::AlreadyGanged { gang, .. } => {
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
}
}
}
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
@@ -1117,6 +1342,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 +1366,7 @@ fn lower_items(
flat_nodes,
flat_signatures,
flat_edges,
flat_taps,
)?);
}
}
@@ -1156,6 +1383,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 +1394,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 +1445,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 +1502,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,11 +1544,27 @@ 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;
/// `name_gate` (#331) unit table: an ordinary name passes; an empty
/// name, one containing either path separator, and the two dot segments
/// each fault with their own fault variant.
#[test]
fn name_gate_accepts_ordinary_names_and_refuses_shape_violations() {
assert_eq!(name_gate("ny_momentum"), Ok(()));
assert_eq!(name_gate(""), Err(NameGateFault::Empty));
assert_eq!(name_gate("a/b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("a\\b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("."), Err(NameGateFault::DotSegment));
assert_eq!(name_gate(".."), Err(NameGateFault::DotSegment));
}
/// One knob fanning into two sibling open params passes the gate; the
/// value carries the gang table.
#[test]
@@ -1584,7 +1867,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -1623,7 +1906,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -1727,6 +2010,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 +2019,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 +2036,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)
@@ -1931,7 +2216,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 {
@@ -2158,7 +2443,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()
@@ -2166,7 +2451,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()
@@ -2174,7 +2464,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()
@@ -2186,6 +2476,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),
)
@@ -2365,6 +2656,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]
@@ -2736,6 +3029,7 @@ mod tests {
inputs: vec![f64_any()],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
@@ -2756,14 +3050,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 },
@@ -2772,6 +3063,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)
@@ -2987,7 +3279,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
@@ -3017,7 +3309,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.
@@ -3043,7 +3336,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![
@@ -3064,7 +3358,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![
@@ -3082,10 +3377,12 @@ mod tests {
}
/// #246: bound_param_space mirrors param_space's path qualification and
/// carries the bound value (the default the CLI renders in --list-axes).
/// carries the bound value (the default the CLI renders in
/// `aura graph introspect --params`).
#[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![
@@ -3191,7 +3488,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
// fan fast_slow's output into both LinComb terms so every interior slot
// is wired (the totality check, cycle 0040); param order is unaffected.
vec![
@@ -3251,7 +3548,7 @@ mod tests {
use aura_std::{LinComb, Sma};
let bp = Composite::new(
"root",
vec![Sma::builder().into(), LinComb::builder(2).into()],
vec![Sma::builder().into(), LinComb::configured(2).into()],
vec![],
vec![],
vec![], // output
@@ -3339,6 +3636,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"),
);
@@ -3441,7 +3739,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");
@@ -3572,4 +3870,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 }]);
}
}
+289 -31
View File
@@ -7,18 +7,29 @@
//! closure and the declared schema are re-derived on load from the injected
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
//! carries no node logic (C17) and references a closed vocabulary (C24).
//!
//! Construction args (#271) are structural, id-bearing data — like `bound`
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
//! `PrimitiveData.args` untouched.
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder};
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
/// The format version the loader understands. Bumped only by a load-bearing
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
/// discipline activates at the first external ship — which consciously
/// freezes v1 (gangs included).
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
/// The CEILING format version this build's loader understands (and the
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
/// 2026-07-10): while the project is unshipped every document lives in-repo
/// and reader/writer change atomically, so the Tier-2 bump discipline
/// activates at the first external ship.
///
/// #271 (data-driven version): the writer no longer emits a single fixed
/// version. A document emits `1` when args-free (byte-identical to every
/// pre-#271 document — content ids stable, C18) and `2` the moment any
/// primitive, at any nesting depth, carries construction `args` — the
/// must-understand signal a pre-#271 loader needs. The loader accepts the
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
/// Top-level envelope: the version is read before the payload is interpreted.
#[derive(serde::Serialize, serde::Deserialize)]
@@ -46,6 +57,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>,
}
@@ -59,17 +72,33 @@ pub enum NodeData {
}
/// A primitive node as data: its compiled-in type identity, optional instance
/// name, and bound params. The schema + build closure are re-derived on load.
/// name, its accepted construction args (#271), and bound params. The schema
/// + build closure are re-derived on load.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PrimitiveData {
#[serde(rename = "type")]
pub type_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The consumed `(name, value)` construction-arg pairs (#271) — the
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
/// (empty) for every args-free primitive, so a document with no args
/// anywhere stays byte-identical to its pre-#271 form.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound: Vec<BoundParam>,
}
/// One serialized construction-arg pair (#271) — the wire twin of
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
/// string, stored verbatim (never re-normalized).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ArgData {
pub name: String,
pub value: String,
}
/// Serializer failure (typed, named).
#[derive(Debug)]
pub enum SerializeError {
@@ -94,6 +123,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,
}
}
@@ -108,9 +138,19 @@ fn project_node(n: &BlueprintNode) -> NodeData {
// construction once a multi-param node enters the vocabulary.
let mut bound = b.bound_params().to_vec();
bound.sort_by_key(|bp| bp.pos);
// Construction args (#271) are declared in a fixed `ArgSpec` order
// (not player-chosen like binds), so no re-canonicalization is
// needed — `construction_args()` already returns them in that
// deterministic order.
let args: Vec<ArgData> = b
.construction_args()
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
NodeData::Primitive(PrimitiveData {
type_id: b.label(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}
@@ -118,8 +158,32 @@ fn project_node(n: &BlueprintNode) -> NodeData {
}
}
/// Any primitive, at any nesting depth, carries construction args (#271) —
/// the must-understand signal for the data-driven version (spec §Data-driven
/// format version).
fn has_args(b: &CompositeData) -> bool {
b.nodes.iter().any(|n| match n {
NodeData::Primitive(p) => !p.args.is_empty(),
NodeData::Composite(c) => has_args(c),
})
}
/// The version THIS document must declare (#271): `1` for an args-free
/// document (byte-identical to every pre-#271 document, C18) or `2` the
/// moment any primitive anywhere carries args — never the fixed
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
fn document_version(b: &CompositeData) -> u32 {
if has_args(b) {
2
} else {
1
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
let blueprint = project(c);
let format_version = document_version(&blueprint);
BlueprintDoc { format_version, blueprint }
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
@@ -135,10 +199,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 +232,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 {
@@ -193,11 +261,16 @@ pub enum LoadError {
/// addition needs finer granularity than a version bump.
UnsupportedVersion { found: u32, supported: u32 },
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
/// vocabulary — unknown node, or a sink node not in #155's set).
UnknownNodeType(String),
/// The document's gangs section fails structural validation against its
/// own nodes (a hand-edited or corrupted document).
Gang(crate::blueprint::CompileError),
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
/// an arg-bearing type serialized/hand-written with an empty `args` —
/// a document naming an arg-bearing type without args must not load.
BadArg(aura_core::ArgOpError),
}
fn reconstruct(
@@ -213,6 +286,15 @@ fn reconstruct(
if let Some(n) = &p.name {
b = b.named(n);
}
// Apply construction args (#271) BEFORE bound params — the
// `try_args` seam. An arg-bearing type with no `args` in the
// document refuses HERE as `MissingArg` (no pending builder
// ever reaches `bind`/enters the built graph); a `Plain` type
// has empty `p.args`, so this is `Ok(self)` unchanged for
// every pre-#271 document.
let args: Vec<(String, String)> =
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
b = b.try_args(&args).map_err(LoadError::BadArg)?;
// Re-apply bound params BY NAME. The effective param vector is
// order-independent (each `bind` computes its slot against the
// shrunk schema); ascending original `pos` is the canonical order.
@@ -227,6 +309,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,14 +319,16 @@ 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,
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
return Err(LoadError::UnsupportedVersion {
found: doc.format_version,
supported: BLUEPRINT_FORMAT_VERSION,
@@ -258,8 +343,10 @@ mod tests {
use crate::blueprint::{Composite, GangMember, OutField, Role};
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_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
use aura_market::Session;
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 +382,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 +395,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 +418,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 +449,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,21 +459,69 @@ 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"));
}
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
/// used to be refused (v1 was the only understood version); now that v2 loads
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
#[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();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
}
#[test]
@@ -413,7 +548,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 +556,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 +737,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`
@@ -759,4 +894,127 @@ mod tests {
"the gang itself is identity-bearing"
);
}
// ---- construction args (#271) --------------------------------------
// A single-node composite around an arg-configured `Session` (arity-free —
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
Composite::new(
"sess",
vec![Session::configured(9, 30, tz, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
)
}
/// #271 acceptance (engine layer): an args-bearing composite serializes its
/// construction-arg pairs, declares `format_version: 2` (the data-driven
/// must-understand tier, spec §Data-driven format version), and round-trips
/// byte-stably through load -> re-serialize.
#[test]
fn args_round_trip_preserves_pairs_and_version_2() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
let json = blueprint_to_json(&c).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
assert!(
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
"carries the verbatim accepted arg pairs: {json}"
);
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
json,
"args round-trip byte-stably"
);
}
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
/// here under the #271-specific test name the plan names.
#[test]
fn args_free_document_serializes_byte_identical_and_version_1() {
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&signal).expect("serializes");
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"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}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
}
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
/// outer composite still trips the outer document's version to 2 — the
/// version walk recurses through `NodeData::Composite`, not just the
/// top-level node list.
#[test]
fn nested_spliced_args_composite_is_version_2() {
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
let outer = Composite::new(
"outer",
vec![crate::blueprint::BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
}
/// #271 (identity is arg-bearing): construction args are structural,
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
/// two builds differing ONLY in one accepted arg value never share an
/// identity JSON, while renaming the composite (a pure debug symbol)
/// still does not affect it.
#[test]
fn identity_json_retains_args() {
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
assert_ne!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&paris).expect("identity-serializes"),
"a differing construction-arg value survives the identity projection"
);
// renaming the composite (a debug symbol) does not affect identity.
let renamed = Composite::new(
"sess_renamed",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
assert_eq!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&renamed).expect("identity-serializes"),
"the composite's own debug name does not affect identity"
);
}
/// #271 (refuse, don't guess): a hand-written document naming an
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
/// must never silently enter the built graph.
#[test]
fn loading_arg_bearing_type_without_args_refuses() {
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
assert!(
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
}
+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.
File diff suppressed because it is too large Load Diff
+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
+36 -36
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,40 +47,40 @@ 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,
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
Tap, TapWire,
};
pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
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,
@@ -90,7 +89,8 @@ pub use walkforward::{
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
// Firing / Timestamp) so a graph builder has one import surface, not two.
pub use aura_core::{
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
Timestamp,
};
#[cfg(test)]
+36 -216
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
@@ -90,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
@@ -111,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
@@ -293,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() {
@@ -451,174 +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)),
],
defaults: vec![],
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![
@@ -720,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);
}
+153 -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)
@@ -803,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);
@@ -873,7 +949,7 @@ 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(","),
+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.
+74 -46
View File
@@ -120,23 +120,23 @@ impl Iterator for WindowRoller {
/// What a per-window run yields: the params chosen on the in-sample window, the
/// recorded OOS pip-equity segment (for stitching), and the OOS [`RunReport`] (the
/// per-window C18 record). Self-describing, analog to
/// [`SweepPoint`](crate::SweepPoint)/[`McDraw`](crate::McDraw).
/// [`SweepPoint`](crate::SweepPoint) (and the backtest layer's `McDraw`).
#[derive(Clone, Debug, PartialEq)]
pub struct WindowRun {
pub struct WindowRun<M> {
/// The chosen params as the tag-free coordinate [`Cell`]s the inner sweep
/// produced (C7: the kind lives once on [`WalkForwardResult::space`], not at the
/// value). The named/typed view is `zip_params(&result.space, &chosen_params)` —
/// the same idiom as [`SweepFamily::named_params`](crate::SweepFamily).
pub chosen_params: Vec<Cell>,
pub oos_equity: Vec<(Timestamp, f64)>,
pub oos_report: RunReport,
pub oos_report: RunReport<M>,
}
/// One completed window: its bounds + the run the closure produced, in roll order.
#[derive(Clone, Debug, PartialEq)]
pub struct WindowOutcome {
pub struct WindowOutcome<M> {
pub bounds: WindowBounds,
pub run: WindowRun,
pub run: WindowRun<M>,
}
/// The result family of a walk-forward. Analog to [`SweepFamily`](crate::SweepFamily)
@@ -146,7 +146,7 @@ pub struct WindowOutcome {
/// points and `optimize`/`rank_by` are computed on demand). Non-redundant by
/// construction.
#[derive(Clone, Debug, PartialEq)]
pub struct WalkForwardResult {
pub struct WalkForwardResult<M> {
/// The param-space schema (kinds + slot names), once for the whole family —
/// the kinds the per-window [`WindowRun::chosen_params`] cells are read against
/// (C7). Identical for every window (same blueprint). Mirrors
@@ -154,7 +154,7 @@ pub struct WalkForwardResult {
pub space: Vec<ParamSpec>,
/// Per-window outcomes in roll order (window 0 first), independent of thread
/// completion (C1).
pub windows: Vec<WindowOutcome>,
pub windows: Vec<WindowOutcome<M>>,
/// The stitched continuous OOS pip-equity curve: each window's OOS segment
/// offset by the running sum of all prior segments' final cumulative values,
/// so equity carries forward across the IS/OOS boundaries (continuous, not a
@@ -165,7 +165,7 @@ pub struct WalkForwardResult {
pub stitched_oos_equity: Vec<(Timestamp, f64)>,
}
impl WalkForwardResult {
impl<M> WalkForwardResult<M> {
/// The chosen params of the `window`-th outcome paired with their names — a
/// derived view over the carried param-space (reuses [`zip_params`]); the
/// walk-forward mirror of [`SweepFamily::named_params`](crate::SweepFamily).
@@ -174,41 +174,22 @@ impl WalkForwardResult {
}
}
/// Roll the windows, run `run_window` on each disjointly in parallel (C1, via the
/// shared [`run_indexed`](crate::sweep) core), then stitch the OOS equity into one
/// continuous curve. The varying dimension is the *data window* (C12 axis 3).
/// Eager-agnostic (#71): `run_window` receives only [`WindowBounds`]; its data
/// comes from a bounds-keyed producer in the closure body, never a materialized
/// stream `Vec` in this API. Precondition: the roller yields `>= 1` window
/// (`WindowRoller::new` rejects an empty roll). Param stability is a separate
/// on-demand reduction ([`param_stability`]), never computed here.
pub fn walk_forward<F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult
where
F: Fn(WindowBounds) -> WindowRun + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
walk_forward_with_threads(roller, space, nthreads, run_window)
}
/// The thread-count-explicit core of [`walk_forward`]. Module-private: the public
/// `walk_forward` 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 collects the roller's bounds (tiny — four
/// timestamps each, no tick data), runs each window disjointly, zips the runs back
/// onto their bounds in roll order, and stitches the OOS segments.
fn walk_forward_with_threads<F>(
roller: WindowRoller,
/// The single assembly path behind both [`walk_forward`] and the thread-count-
/// explicit test wrapper: run `run_window` over each already-rolled `bounds` via
/// `run_indexed` (on whichever pool is ambient when called), stitch the OOS
/// equity, and assemble the result. One source of truth so the two callers can
/// never drift on assembly logic while their pool selection differs.
fn assemble_walk_forward<M, F>(
space: Vec<ParamSpec>,
nthreads: usize,
run_window: F,
) -> WalkForwardResult
bounds: Vec<WindowBounds>,
run_window: &F,
) -> WalkForwardResult<M>
where
F: Fn(WindowBounds) -> WindowRun + Sync,
M: Send,
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
{
let bounds: Vec<WindowBounds> = roller.collect();
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
let runs = run_indexed(bounds.len(), nthreads, |i| run_window(bounds[i]));
let windows: Vec<WindowOutcome> = bounds
let runs = run_indexed(bounds.len(), |i| run_window(bounds[i]));
let windows: Vec<WindowOutcome<M>> = bounds
.into_iter()
.zip(runs)
.map(|(bounds, run)| WindowOutcome { bounds, run })
@@ -221,11 +202,57 @@ where
WalkForwardResult { space, windows, stitched_oos_equity }
}
/// Roll the windows, run `run_window` on each disjointly in parallel (C1, via the
/// shared [`run_indexed`](crate::sweep) core), then stitch the OOS equity into one
/// continuous curve. The varying dimension is the *data window* (C12 axis 3).
/// Eager-agnostic (#71): `run_window` receives only [`WindowBounds`]; its data
/// comes from a bounds-keyed producer in the closure body, never a materialized
/// stream `Vec` in this API. Precondition: the roller yields `>= 1` window
/// (`WindowRoller::new` rejects an empty roll). Param stability is a separate
/// on-demand reduction ([`param_stability`]), never computed here.
pub fn walk_forward<M, F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult<M>
where
M: Send,
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
{
let bounds: Vec<WindowBounds> = roller.collect();
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
assemble_walk_forward(space, bounds, &run_window)
}
/// The thread-count-explicit wrapper of [`walk_forward`]. Module-private: the
/// public `walk_forward` runs on the ambient pool; the tests drive this at 1
/// and at N to pin determinism (C1). The roll enumeration runs outside the
/// pool; `assemble_walk_forward` (run_indexed + stitch + result assembly) runs
/// inside a local rayon pool of `nthreads` workers, sharing the exact assembly
/// path `walk_forward` uses — no second copy to drift. Bounds match
/// `walk_forward` (`F: Sync`): the install closure only borrows `run_window`,
/// never moves it.
#[cfg(test)]
fn walk_forward_with_threads<M, F>(
roller: WindowRoller,
space: Vec<ParamSpec>,
nthreads: usize,
run_window: F,
) -> WalkForwardResult<M>
where
M: Send,
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
{
let bounds: Vec<WindowBounds> = roller.collect();
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(nthreads)
.build()
.expect("rayon thread pool");
pool.install(|| assemble_walk_forward(space, bounds, &run_window))
}
/// Fold the per-window OOS equity segments into one continuous curve: each
/// segment's samples are offset by the running sum of all prior segments' final
/// cumulative values, so equity carries forward across windows (no per-window
/// reset). An empty segment adds `0.0` to the offset and no points.
fn stitch(windows: &[WindowOutcome]) -> Vec<(Timestamp, f64)> {
fn stitch<M>(windows: &[WindowOutcome<M>]) -> Vec<(Timestamp, f64)> {
let mut out = Vec::new();
let mut offset = 0.0_f64;
for w in windows {
@@ -251,7 +278,7 @@ fn stitch(windows: &[WindowOutcome]) -> Vec<(Timestamp, f64)> {
/// value, a `bool` slot to `0.0`/`1.0` (so `mean` is the fraction of windows that
/// chose `true`). A `timestamp` slot is structurally impossible (C20: a timestamp
/// is a structural axis, never a numeric knob).
pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
pub fn param_stability<M>(result: &WalkForwardResult<M>) -> Vec<MetricStats> {
if result.windows.is_empty() {
return Vec::new();
}
@@ -281,9 +308,10 @@ pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{summarize, RunManifest};
use crate::RunManifest;
use aura_backtest::{summarize, RunMetrics};
fn dummy_report() -> RunReport {
fn dummy_report() -> RunReport<RunMetrics> {
RunReport {
manifest: RunManifest {
commit: "t".to_string(),
@@ -312,7 +340,7 @@ mod tests {
/// A WindowOutcome with explicit OOS equity + chosen params (zero bounds — the
/// stitch/stability reductions ignore bounds).
fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> WindowOutcome {
fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> WindowOutcome<RunMetrics> {
WindowOutcome {
bounds: WindowBounds {
is: (Timestamp(0), Timestamp(0)),
@@ -325,7 +353,7 @@ mod tests {
/// A deterministic per-window run keyed by the OOS start, so distinct windows
/// differ. No real harness — these tests pin the orchestration (roll order,
/// stitch, stability); mc/sweep cover the harness path. Free `fn` => `Sync`.
fn run_window_fixture(w: WindowBounds) -> WindowRun {
fn run_window_fixture(w: WindowBounds) -> WindowRun<RunMetrics> {
let Timestamp(k) = w.oos.0;
WindowRun {
chosen_params: vec![Cell::from_i64(k % 3), Cell::from_f64(k as f64 * 0.5)],

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