Compare commits

...

51 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

New shape, per the ailang precedent:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #295
2026-07-21 05:20:27 +02:00
242 changed files with 17890 additions and 7575 deletions
+10
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
Generated
+37 -2
View File
@@ -160,19 +160,19 @@ dependencies = [
"aura-core",
"aura-engine",
"aura-ingest",
"aura-measurement",
"aura-registry",
"aura-research",
"aura-runner",
"aura-std",
"aura-strategy",
"aura-vocabulary",
"clap",
"data-server",
"libc",
"libloading",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
@@ -239,6 +239,17 @@ dependencies = [
"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"
@@ -265,6 +276,30 @@ 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"
+2
View File
@@ -13,6 +13,7 @@ members = [
"crates/aura-std",
"crates/aura-vocabulary",
"crates/aura-market",
"crates/aura-measurement",
"crates/aura-strategy",
"crates/aura-backtest",
"crates/aura-engine",
@@ -22,6 +23,7 @@ members = [
"crates/aura-ingest",
"crates/aura-registry",
"crates/aura-research",
"crates/aura-runner",
"crates/aura-campaign",
"crates/aura-bench",
]
+3 -2
View File
@@ -119,8 +119,9 @@ aura sweep crossover.bp.json --list-axes
```
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
and `gang` (`tap` declares a recorded measurement point on an interior wire —
see the authoring guide). See
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
wire; `doc` declares the composite's one-line meaning, required at register —
C29; see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics.
+5
View File
@@ -9,6 +9,7 @@
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
@@ -23,6 +24,10 @@ 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
+1
View File
@@ -1214,6 +1214,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
@@ -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");
}
}
+1
View File
@@ -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)),
)
@@ -1,9 +1,9 @@
{
"surface": "campaign_heavy",
"metrics": {
"cpu_percent": 2116.0,
"peak_rss_mb": 98.25,
"wall_s": 5.604210818
"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,
@@ -12,6 +12,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -1,9 +1,9 @@
{
"surface": "campaign_sweep",
"metrics": {
"cpu_percent": 2170.0,
"peak_rss_mb": 98.21875,
"wall_s": 1.418092216
"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,
@@ -12,6 +12,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -1,16 +1,16 @@
{
"surface": "cli_fixed_cost",
"metrics": {
"help_ms": 1.5094509999999999,
"run_ms": 3.679743
"help_ms": 1.5046899999999999,
"run_ms": 3.63305
},
"fingerprint": "run_line_fnv=792dc4434f8e3dee",
"fingerprint": "run_line_fnv=6bb0d796f760d140",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "13d8500",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -1,8 +1,8 @@
{
"surface": "engine_throughput",
"metrics": {
"bars_per_s": 13591474.080269944,
"wall_s": 0.735755367
"bars_per_s": 14005588.039151624,
"wall_s": 0.714000724
},
"fingerprint": "last=0.417351 max_dd=8.274577 flips=265308 n=10000000",
"reps": 3,
@@ -11,6 +11,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -1,8 +1,8 @@
{
"surface": "ingest_throughput",
"metrics": {
"bars_per_s": 12912289.748572402,
"wall_s": 0.388838858
"bars_per_s": 13000457.745164338,
"wall_s": 0.386201786
},
"fingerprint": "n=251040 xor=0019885debd3bd5e",
"reps": 3,
@@ -11,6 +11,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
+2 -2
View File
@@ -67,8 +67,8 @@ pub const HEAVY_PROCESS_DOC_QUICK: &str = r#"{
/// `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","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","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"}]}}"#;
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)
+13
View File
@@ -830,6 +830,19 @@ fn panic_message(payload: Box<dyn std::any::Any + Send>) -> 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
+6 -1
View File
@@ -13,7 +13,8 @@
mod exec;
pub use exec::{
execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut,
catch_member_panic, execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily,
StageSelectionOut,
};
use std::collections::BTreeMap;
@@ -61,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,
@@ -85,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();
@@ -128,7 +128,7 @@ fn rankable_roster_is_single_sourced_and_nested() {
}
for name in aura_campaign::PER_MEMBER_METRICS {
assert!(
aura_research::metric_vocabulary().contains(name),
aura_research::metric_vocabulary().iter().any(|m| m.id == *name),
"per-member metric '{name}' missing from the research vocabulary"
);
}
+20 -11
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
@@ -24,9 +21,15 @@ aura-campaign = { path = "../aura-campaign" }
aura-std = { path = "../aura-std" }
aura-strategy = { path = "../aura-strategy" }
aura-backtest = { path = "../aura-backtest" }
aura-analysis = { path = "../aura-analysis" }
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" }
@@ -44,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
@@ -64,3 +60,16 @@ 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-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, shared by both walk-forward paths (the
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
/// place. Takes the per-window trade counts; a no-op unless there is at
/// least one window and every one of them traded zero times.
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
let n_windows = window_trades.len();
if n_windows > 0 && window_trades.all(|n| n == 0) {
note!("{}", zero_trade_note_text(n_windows));
}
}
#[cfg(test)]
mod tests {
use super::zero_trade_note_text;
#[test]
/// #278 fieldtest friction: the single-window form must read as
/// grammatical English while the plural form keeps the exact phrase
/// the e2e pins grep for.
fn zero_trade_note_pluralizes() {
assert_eq!(
zero_trade_note_text(1),
"the single walk-forward window recorded zero trades"
);
assert_eq!(
zero_trade_note_text(3),
"all 3 walk-forward windows recorded zero trades"
);
}
}
+35 -29
View File
@@ -13,7 +13,7 @@ use aura_engine::{
};
use serde::Deserialize;
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
// production code; tests still exercise it directly.
#[cfg(test)]
use aura_vocabulary::std_vocabulary;
@@ -54,6 +54,9 @@ enum OpDoc {
as_name: String,
into: Vec<String>,
},
/// Declare the composite's one-line meaning (C29, #316) — the op-script
/// twin of the builder's `.doc(...)`.
Doc { text: String },
}
impl OpDoc {
@@ -68,6 +71,7 @@ impl OpDoc {
OpDoc::Expose { .. } => "expose",
OpDoc::Tap { .. } => "tap",
OpDoc::Gang { .. } => "gang",
OpDoc::Doc { .. } => "doc",
}
}
}
@@ -85,6 +89,7 @@ impl From<OpDoc> for Op {
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
OpDoc::Doc { text } => Op::Doc { text },
}
}
}
@@ -129,6 +134,7 @@ fn format_op_error(e: &OpError) -> String {
}
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::Incomplete(ce) => format!("{ce:?}"),
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
}
}
@@ -136,7 +142,7 @@ fn format_op_error(e: &OpError) -> String {
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
/// past the last op).
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
@@ -162,14 +168,14 @@ fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite,
/// Parse a JSON op-list document, replay it through the env's vocabulary, and
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let composite = composite_from_str(doc, env)?;
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
}
/// `aura graph build`: read the op-list document from stdin, build, and print the
/// blueprint to stdout — or the cause to stderr and exit non-zero.
pub fn build_cmd(env: &crate::project::Env) {
pub fn build_cmd(env: &aura_runner::project::Env) {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
@@ -192,7 +198,7 @@ pub fn build_cmd(env: &crate::project::Env) {
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
/// closed vocabulary.
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
let schema = builder.schema();
let mut out = format!("{}\n", builder.label());
@@ -210,7 +216,7 @@ pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<Strin
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize).
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let resolver = |t: &str| env.resolve(t);
let mut session = GraphSession::new("introspect", &resolver);
@@ -229,7 +235,7 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
/// group must be set; zero or more than one is the usage error (exit 2). The id
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
/// combine (one build, both ids, content id first).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
+ cmd.unwired as usize
@@ -404,7 +410,7 @@ fn gang_fault_prose(e: &CompileError) -> String {
/// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph
/// build` path (`composite_from_str`, over `OpError`) call this same helper,
/// so the tier texts cannot drift between the two error families.
fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<String> {
fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
if !type_id.contains("::") {
return None;
}
@@ -423,7 +429,7 @@ fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<Str
/// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the
/// blueprint LOAD path's entry point over `LoadError`.
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> {
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option<String> {
match e {
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
_ => None,
@@ -447,7 +453,7 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env
/// `Ok(())` means the document loaded cleanly; callers that only need the
/// validation (not the `Composite`) re-parse `doc` themselves afterward
/// (`blueprint_from_json` is cheap and infallible at that point).
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> {
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
return Err(
"this is an op-script (an op array), not a built blueprint — run \
@@ -469,7 +475,7 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Resu
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
/// each canonicalized by its own rules.
pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
match value {
@@ -488,22 +494,22 @@ pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Resul
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
/// id in the project store (the campaign-run target-addressing convention).
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
/// cannot drift apart on what counts as a store address.
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
// fields stay bare-only.
let target = target
.strip_prefix("content:")
.filter(|t| crate::campaign_run::is_content_id(t))
.filter(|t| aura_runner::axes::is_content_id(t))
.unwrap_or(target);
let path = Path::new(target);
if path.is_file() {
return std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()));
}
if crate::campaign_run::is_content_id(target) {
if aura_runner::axes::is_content_id(target) {
return match env.registry().get_blueprint(target) {
Ok(Some(json)) => Ok(json),
Ok(None) => Err(format!("no blueprint {target} in the project store")),
@@ -518,7 +524,7 @@ fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<Str
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
/// the same form `introspect --node` already uses for param kinds.
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
use std::fmt::Write as _;
let text = resolve_blueprint_text(target, env)?;
// Shape-discriminated like file-mode --content-id: an op-script (array)
@@ -535,7 +541,7 @@ fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, Strin
/// the project vocabulary, canonicalize, content-address, and store — the
/// `process register` pattern, printing the store path so the trail is
/// followable. Prose to stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
match register_blueprint(file, env) {
Ok(line) => println!("{line}"),
Err(m) => {
@@ -545,7 +551,7 @@ pub fn register_cmd(file: &Path, env: &crate::project::Env) {
}
}
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
// Shape-discriminated like file-mode --content-id (#202): either shape
@@ -605,7 +611,7 @@ mod tests {
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds");
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
}
@@ -621,14 +627,14 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"out"}
]"#;
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds");
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
}
#[test]
fn build_from_str_reports_unknown_node_at_its_op_index() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
}
@@ -643,7 +649,7 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
"names the unconnected slot by-identifier, got: {err}");
@@ -652,11 +658,11 @@ mod tests {
#[test]
fn introspect_node_lists_ports_kinds_and_params() {
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("series"), "lists the input port: {out}");
assert!(out.contains("value"), "lists the output field: {out}");
assert!(out.contains("length"), "lists the param path: {out}");
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type");
}
/// A bind whose value-kind mismatches the param reads as prose, like the connect
@@ -664,7 +670,7 @@ mod tests {
#[test]
fn build_from_str_bad_bind_kind_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
}
@@ -673,7 +679,7 @@ mod tests {
#[test]
fn build_from_str_unknown_bind_param_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
}
@@ -694,7 +700,7 @@ mod tests {
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
#[test]
fn introspect_node_shows_the_bind_form() {
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
}
@@ -705,7 +711,7 @@ mod tests {
{"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects");
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
@@ -732,7 +738,7 @@ mod tests {
assert_eq!(docs[7].kind_label(), "tap");
// and the built blueprint carries the tap (an un-tapped composite emits no
// `taps` key, so its presence is the proof the op threaded through)
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("tap op-doc builds");
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds");
assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}");
assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
}
+259 -3375
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),
+215 -5
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)]
@@ -90,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();
@@ -179,7 +182,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
DocFault::UnknownTap { index, tap } => format!(
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
DocFault::UnknownBindingColumn { role, column } => format!(
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
@@ -188,6 +191,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::ProcessRefMustBeContentId => {
"process.ref: a process is referenced by content id in this version".into()
}
DocFault::BadDescription { subject, fault } => match fault {
aura_core::DocGateFault::Empty => format!(
"description: `{subject}`'s description is empty — omit the \
field or write a one-line meaning (C29)"
),
aura_core::DocGateFault::RestatesName => format!(
"description: `{subject}`'s description merely restates the \
document name (C29)"
),
},
}
}
@@ -208,6 +221,7 @@ pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
introspect_process(i)
}
ProcessSub::Register { file } => register_process(file, env),
ProcessSub::Show { id } => show_process(id, env),
};
if let Err(m) = result {
eprintln!("aura: {m}");
@@ -247,6 +261,15 @@ fn validate_process_file(file: &PathBuf) -> Result<(), String> {
}
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"process register needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let doc = parse_valid_process(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = process_to_json(&doc);
@@ -259,6 +282,60 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
Ok(())
}
/// Resolve `id` against a document store's full candidate content-id list —
/// mirroring the exact-first / unique-match / else-refuse shape
/// `aura_runner::reproduce::load_family` established for #298's family/run
/// handle. Called only once the caller's own exact-match lookup has already
/// missed, so a filter match here is always a strict, shorter prefix (ids
/// are fixed-length hashes; a full-length miss cannot be a prefix of
/// another). `Ok(None)`: no candidate matches — genuinely unknown, the
/// caller's existing not-found prose applies unchanged. `Err`: two or more
/// candidates share the prefix — refused by name, listing every candidate,
/// rather than guessed.
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
match matches.as_slice() {
[] => Ok(None),
[only] => Ok(Some((*only).clone())),
many => {
let listed = many.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
}
}
}
fn show_process(id: &str, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"process show needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let registry = env.registry();
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
// `print!`, not `println!`: `bytes` is the content-addressed canonical
// form the store keyed `id` on — the CLI is a transport and must not
// frame the canonical bytes with a trailing newline (#164, mirroring
// `graph_construct::build_cmd`).
print!("{bytes}");
return Ok(());
}
let candidates = registry.list_process_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(id, &candidates)? {
Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? {
Some(bytes) => {
print!("{bytes}");
Ok(())
}
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
},
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
}
}
fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
if cmd.metrics {
print_metric_roster();
@@ -341,6 +418,8 @@ pub enum CampaignSub {
/// 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
@@ -354,6 +433,14 @@ fn prefix_hint(id: &str) -> &'static str {
}
}
/// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault`
/// sibling (unlike processes' `ref_fault_prose`), so this is the single
/// source both the exact-miss and prefix-resolved-but-then-missing arms
/// route through, keeping the two copies from drifting.
fn campaign_not_found_prose(id: &str) -> String {
format!("campaign {id} not found in the project store{}", prefix_hint(id))
}
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
match f {
RefFault::ProcessNotFound(id) => {
@@ -460,6 +547,7 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
}
CampaignSub::Register { file } => register_campaign(file, env),
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
CampaignSub::Show { id } => show_campaign(id, env),
CampaignSub::Run { .. } => unreachable!("handled above"),
};
if let Err(m) = result {
@@ -550,6 +638,15 @@ fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
}
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"campaign register needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let doc = parse_valid_campaign(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = campaign_to_json(&doc);
@@ -562,6 +659,36 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
Ok(())
}
fn show_campaign(id: &str, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"campaign show needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let registry = env.registry();
if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? {
// `print!`, not `println!`: see `show_process` — the CLI must not
// frame the canonical bytes with a trailing newline (#164).
print!("{bytes}");
return Ok(());
}
let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(id, &candidates)? {
Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? {
Some(bytes) => {
print!("{bytes}");
Ok(())
}
None => Err(campaign_not_found_prose(id)),
},
None => Err(campaign_not_found_prose(id)),
}
}
fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
if cmd.metrics {
print_metric_roster();
@@ -700,6 +827,35 @@ 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
@@ -718,4 +874,58 @@ mod tests {
);
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);
}
}
+9 -6
View File
@@ -152,6 +152,8 @@ impl Scale {
pub fn new(factor: f64) -> Self {
Self { factor, out: [Cell::from_f64(0.0)] }
}
// Replace `doc` below when renaming this sample node — it must keep
// describing the node's own behaviour, not this scaffolding step.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"__NS__::Scale",
@@ -163,6 +165,7 @@ impl Scale {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
doc: "scalar gain: emits the input times the factor param",
},
|p| Box::new(Scale::new(p[0].f64())),
)
@@ -219,7 +222,7 @@ crate is attached to its project via `[nodes]` in the project's `Aura.toml`.
const GITIGNORE_PROJECT: &str = "/runs\n";
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only)
@@ -280,7 +283,7 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> {
Ok(o) if o.status.success() => {
commit_all(&spec.target_dir, "aura new scaffold");
}
_ => eprintln!("aura: warning: git init failed (project created without a repo)"),
_ => crate::diag::warning!("git init failed (project created without a repo)"),
}
Ok(())
}
@@ -321,7 +324,7 @@ pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> {
Ok(o) if o.status.success() => {
commit_all(&spec.target_dir, "aura nodes new scaffold");
}
_ => eprintln!("aura: warning: git init failed (node crate created without a repo)"),
_ => crate::diag::warning!("git init failed (node crate created without a repo)"),
}
Ok(())
}
@@ -354,7 +357,7 @@ fn commit_all(dir: &std::path::Path, message: &str) {
.current_dir(dir)
.output();
if !matches!(add, Ok(o) if o.status.success()) {
eprintln!("aura: warning: git add failed (scaffold created without an initial commit)");
crate::diag::warning!("git add failed (scaffold created without an initial commit)");
return;
}
let commit = std::process::Command::new("git")
@@ -371,8 +374,8 @@ fn commit_all(dir: &std::path::Path, message: &str) {
.current_dir(dir)
.output();
if !matches!(commit, Ok(o) if o.status.success()) {
eprintln!(
"aura: warning: git commit failed (scaffold created without an initial commit)"
crate::diag::warning!(
"git commit failed (scaffold created without an initial commit)"
);
}
}
+20 -15
View File
@@ -53,14 +53,16 @@ pub(crate) struct SugarInvocation<'a> {
/// by construction, so the net channel is never producible there; requesting
/// it anyway would make `campaign_run`'s executor print an unreachable "add a
/// cost block to the campaign document" remedy (#240). The exclusion reuses
/// `campaign_run`'s own tap/channel classification (`tap_channel`/
/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/
/// `TapChannel::Net`) rather than duplicating the net-tap name here.
fn persist_taps_from(trace: bool) -> Vec<String> {
if trace {
tap_vocabulary()
.iter()
.filter(|t| crate::campaign_run::tap_channel(t) != Some(crate::campaign_run::TapChannel::Net))
.map(|t| t.to_string())
.filter(|t| {
aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net)
})
.map(|t| t.id.to_string())
.collect()
} else {
vec![]
@@ -285,8 +287,8 @@ pub(crate) fn register_generated_g(
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses
/// per member (campaign_run.rs) — a bound-param axis passes this
/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member`
/// uses per member (aura-runner) — a bound-param axis passes this
/// executable-shape preflight exactly like an already-open one; an axis
/// matching neither space still fails `bind_axes`'s own named check below.
/// `probe_params` is any one grid value per axis (checks NAME
@@ -298,7 +300,7 @@ fn validate_before_register(
campaign: &CampaignDoc,
blueprint_canonical: &str,
probe_params: &[(String, Scalar)],
env: &crate::project::Env,
env: &aura_runner::project::Env,
) -> Result<(), String> {
let doc_faults = aura_research::validate_campaign(campaign);
if !doc_faults.is_empty() {
@@ -324,10 +326,10 @@ fn validate_before_register(
let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t))
.expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
let overrides = crate::campaign_run::raw_bound_overrides_of(&param_names, &raw_space, &raw_signal);
let overrides = aura_runner::axes::raw_bound_overrides_of(&param_names, &raw_space, &raw_signal);
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
let strategy_id = content_id_of(blueprint_canonical);
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
aura_runner::axes::bind_axes(&space, &strategy_id, probe_params)
.map(|_| ())
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))
}
@@ -338,7 +340,7 @@ fn validate_before_register(
/// one campaign executor.
pub(crate) fn run_sweep_sugar(
inv: &SugarInvocation,
env: &crate::project::Env,
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_sweep(inv)?;
let probe_params = probe_params_from(inv.axes);
@@ -384,7 +386,7 @@ fn cross_instrument_members(
pub(crate) fn run_generalize_sugar(
inv: &SugarInvocation,
metric: &str,
env: &crate::project::Env,
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_generalize(inv, metric)?;
let probe_params = probe_params_from(inv.axes);
@@ -510,7 +512,7 @@ pub(crate) fn run_walkforward_sugar(
metric: &str,
w: WfWindows,
select: SelectRule,
env: &crate::project::Env,
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_walkforward(inv, metric, w, select)?;
let probe_params = probe_params_from(inv.axes);
@@ -524,7 +526,7 @@ pub(crate) fn run_walkforward_sugar(
)?;
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
eprintln!("aura: walkforward cell failed at stage {}: {}", f.stage, f.detail);
crate::diag::warning!("walkforward cell failed at stage {}: {}", f.stage, f.detail);
return Ok(1);
}
@@ -547,6 +549,9 @@ pub(crate) fn run_walkforward_sugar(
summary_axes.push("stop_length".to_string());
summary_axes.push("stop_k".to_string());
}
crate::diag::note_zero_trade_windows(
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
);
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes));
// Trace persistence runs LAST (mirroring `present_campaign`'s ordering,
@@ -563,7 +568,7 @@ pub(crate) fn run_walkforward_sugar(
// always `None`. `run`/`mc --trace` stay refused by design (#224), not by
// this tail being skipped.
if let Some(trace_name) = &run.outcome.record.trace_name {
crate::campaign_run::persist_campaign_traces(
aura_runner::runner::persist_campaign_traces(
trace_name,
&run.campaign.presentation.persist_taps,
&run.outcome,
@@ -674,7 +679,7 @@ pub(crate) fn run_mc_sugar(
metric: &str,
w: WfWindows,
mc: McKnobs,
env: &crate::project::Env,
env: &aura_runner::project::Env,
) -> Result<usize, String> {
let generated = translate_mc(inv, metric, w, mc)?;
let probe_params = probe_params_from(inv.axes);
@@ -688,7 +693,7 @@ pub(crate) fn run_mc_sugar(
)?;
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
eprintln!("aura: mc cell failed at stage {}: {}", f.stage, f.detail);
crate::diag::warning!("mc cell failed at stage {}: {}", f.stage, f.detail);
return Ok(1);
}
+735 -3
View File
@@ -2182,7 +2182,7 @@ const HL_RANGE_CLOSED_BLUEPRINT: &str = r#"{
"output": [{"node":0,"field":0,"name":"bias"}]
}
}"#;
const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_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":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_signal","doc":"high/low 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":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
/// Property (#231 task 4 quality follow-up): `aura run` over a multi-column
/// blueprint with NO `--real` (the synthetic default) refuses through the
@@ -3220,7 +3220,7 @@ fn walkforward_dissolved_refuses_a_multi_value_stop() {
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}");
assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}");
}
/// #215: --select plateau is no longer refused on the dissolved path — it now
@@ -3647,6 +3647,96 @@ fn walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2() {
);
}
/// #227: bare `--select plateau` is the documented default aggregation
/// (`plateau:mean`), not a distinct spelling. Same dedup-pin idiom as
/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`:
/// running the same grid-sweep blueprint in the same store first with
/// `--select plateau`, then with `--select plateau:mean`, must produce
/// byte-identical campaign documents — content-addressed storage collapses
/// them onto EXACTLY ONE stored campaign — while each invocation still
/// records its own campaign-run line. Gated on the shared GER40 archive;
/// skips cleanly on a data refusal.
#[test]
fn walkforward_bare_plateau_generates_the_same_campaign_as_plateau_mean() {
const FROM_MS: &str = "1735689600000";
const TO_MS: &str = "1747353600000";
let (cwd, _g) = fresh_project();
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
// Bare `plateau`: today this is an unknown-token usage refusal (exit 2);
// the feature makes it an alias for `plateau:mean`.
let bare = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"walkforward", &fixture, "--real", "GER40",
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
"--stop-length", "14", "--stop-k", "2.0",
"--select", "plateau",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
.output()
.expect("spawn aura");
if bare.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&bare.stderr);
assert!(
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
"exit 1 must be a data refusal, got: {stderr}"
);
eprintln!("skip: no local GER40 data");
return;
}
assert_eq!(
bare.status.code(),
Some(0),
"bare --select plateau must no longer be refused (it aliases plateau:mean): {}",
String::from_utf8_lossy(&bare.stderr)
);
// Explicit `plateau:mean`: names the very aggregation the bare spelling
// resolves to. Same blueprint, same axes, same window, same stop.
let explicit = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"walkforward", &fixture, "--real", "GER40",
"--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20",
"--stop-length", "14", "--stop-k", "2.0",
"--select", "plateau:mean",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
.output()
.expect("spawn aura");
assert_eq!(
explicit.status.code(),
Some(0),
"the explicit --select plateau:mean walkforward must succeed: {}",
String::from_utf8_lossy(&explicit.stderr)
);
// Byte-identity: bare `plateau` IS `plateau:mean`, so both invocations
// generate the same campaign document and content-addressed storage
// collapses them onto one content id — exactly one campaign document in
// the store, but two independent campaign-run lines.
let count = |sub: &str| {
std::fs::read_dir(cwd.join("runs").join(sub))
.map(|d| d.count())
.unwrap_or(0)
};
assert_eq!(
count("campaigns"),
1,
"bare plateau binds the SAME aggregation as plateau:mean, so the two campaign \
documents are byte-identical and dedup to one content id"
);
let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
.expect("campaign_runs.jsonl exists after two sugar runs");
assert_eq!(
runs_log.lines().count(),
2,
"each invocation still records its own campaign run (a run is an event, a document \
is content)"
);
}
/// Property: `aura generalize` is now thin sugar over the one campaign path — a
/// successful run durably auto-registers exactly one generated process document,
/// one generated campaign document (carrying the `--name` handle and the stop as
@@ -4692,6 +4782,234 @@ fn aura_walkforward_over_a_blueprint_persists_a_family() {
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (#313, Path 2 — synthetic): a walk-forward whose every window records
/// zero trades emits exactly one `aura: note: all N walk-forward windows
/// recorded zero trades` on stderr; exit stays 0 and the summary JSON is
/// unchanged (a null result is a valid research result, #198). Equal
/// fast/slow lengths make the SMA difference constantly zero, so the bias
/// never leaves 0 and no window trades.
#[test]
fn aura_walkforward_all_zero_trade_windows_emit_one_note() {
let cwd = temp_cwd("blueprint-walkforward-zero-trade-note");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=8",
"--axis", "sma_signal.slow.length=8", "--name", "wfzero"])
.current_dir(&cwd)
.output()
.expect("spawn aura walkforward (degenerate)");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert_eq!(out.status.code(), Some(0), "exit stays 0: {stderr}");
assert!(stdout.contains("\"walkforward\""), "summary line unchanged: {stdout}");
assert_eq!(
stderr.matches("walk-forward windows recorded zero trades").count(),
1,
"exactly one zero-trade note: {stderr}"
);
assert!(
stderr.contains("aura: note: all "),
"the note carries the class marker: {stderr}"
);
assert!(
!stderr.contains("aura: warning: "),
"a benign degenerate run emits no warning lines: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (#313 negative twin): a walk-forward with at least one traded window
/// emits no zero-trade note.
#[test]
fn aura_walkforward_with_trades_emits_no_zero_trade_note() {
let cwd = temp_cwd("blueprint-walkforward-traded-no-note");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3",
"--axis", "sma_signal.slow.length=4,6", "--name", "wftrades"])
.current_dir(&cwd)
.output()
.expect("spawn aura walkforward (trading)");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(out.status.code(), Some(0), "exit 0: {stderr}");
assert!(
!stderr.contains("recorded zero trades"),
"a traded run emits no zero-trade note: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (#278 decision: same fault class, same verb, same surface => same
/// treatment): a member fault on the SYNTHETIC walk-forward path is CONTAINED
/// exactly like the real/campaign path contains it (#272) — it surfaces as an
/// `aura: warning: ` class-marked stderr line naming the member fault and the
/// process takes the deliberate failed-cells exit 3 (C14 partition,
/// `exit_on_campaign_result` precedent). It never escapes as an uncaught Rust
/// panic: no exit 101, no `panicked at`, no internal `crates/` source path on
/// the consumer's stderr.
#[test]
fn aura_walkforward_synthetic_member_panic_is_contained() {
let cwd = temp_cwd("blueprint-walkforward-member-panic-contained");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
// length=0 poisons the member: `Sma::new` asserts "SMA length must be >= 1"
// during member compile — the same fault class the real path records as
// "a member panicked: …" and survives.
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=0", "--name", "wfpanic"])
.current_dir(&cwd)
.output()
.expect("spawn aura walkforward (poison member)");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")),
"the member fault surfaces as one class-marked warning naming the fault: {stderr}"
);
assert!(
!stderr.contains("panicked at"),
"no raw panic report reaches the consumer: {stderr}"
);
assert!(
!stderr.contains("crates/"),
"no internal source path leaks to the consumer: {stderr}"
);
assert_eq!(
out.status.code(),
Some(3),
"a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (#278 decision, sweep twin): the synthetic `aura sweep` shares the
/// same bare-member seam (`run_blueprint_member` inside the family builders'
/// sweep closures, no #272 fault boundary), so the same poison must be
/// contained the same way — warning + exit 3, no panic escape.
#[test]
fn aura_sweep_synthetic_member_panic_is_contained() {
let cwd = temp_cwd("blueprint-sweep-member-panic-contained");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["sweep", &bp, "--axis", "sma_signal.fast.length=0", "--name", "swpanic"])
.current_dir(&cwd)
.output()
.expect("spawn aura sweep (poison member)");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")),
"the member fault surfaces as one class-marked warning naming the fault: {stderr}"
);
assert!(
!stderr.contains("panicked at") && !stderr.contains("crates/"),
"no raw panic report or internal source path leaks: {stderr}"
);
assert_eq!(
out.status.code(),
Some(3),
"a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (#278 decision, mc twin): the synthetic `aura mc` shares the same
/// bare-member seam as its sweep/walkforward siblings (`run_blueprint_member`
/// inside `blueprint_mc_family`'s per-seed draw closure, no #272 fault
/// boundary), so the same member-compile fault class must be contained the
/// same way — one `aura: warning: ` line naming the fault, no raw panic text
/// or internal `crates/` path on stderr, deliberate exit 3 (C14), never 101.
/// mc takes only CLOSED blueprints, so the poison is BOUND (`length=0` on the
/// SMA add-op) rather than a poison axis: the test builds its minimal 4-op
/// poison blueprint inline via `aura graph build`. Two seeds so the parallel
/// draws join before the lowest-seed fault resolves (the shipped sweep
/// contract's thread-order-independent shape, C1).
#[test]
fn aura_mc_synthetic_member_panic_is_contained() {
let cwd = temp_cwd("blueprint-mc-member-panic-contained");
// Minimal poison: each op is load-bearing (source: the close role; add+bind:
// the length=0 that trips `Sma::new`'s "SMA length must be >= 1" assert at
// member compile; feed: wiring so build validation passes; expose: the bias
// contract). Bound length keeps the blueprint CLOSED, as `aura mc` requires.
let ops = r#"[
{"op":"source","role":"close","kind":"F64"},
{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":0}}},
{"op":"feed","role":"close","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"bias"}
]"#;
let ops_path = cwd.join("poison.ops.json");
std::fs::write(&ops_path, ops).expect("write poison op-list");
let bp_path = cwd.join("poison.bp.json");
let build = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["graph", "build"])
.stdin(std::fs::File::open(&ops_path).expect("open poison op-list"))
.current_dir(&cwd)
.output()
.expect("spawn aura graph build (poison blueprint)");
assert_eq!(
build.status.code(),
Some(0),
"poison blueprint builds (the fault is a member-COMPILE fault, not a build refusal): {}",
String::from_utf8_lossy(&build.stderr)
);
std::fs::write(&bp_path, &build.stdout).expect("write poison blueprint");
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["mc", bp_path.to_str().expect("utf-8 path"), "--seeds", "2"])
.current_dir(&cwd)
.output()
.expect("spawn aura mc (poison member)");
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")),
"the member fault surfaces as one class-marked warning naming the fault: {stderr}"
);
assert!(
!stderr.contains("panicked at") && !stderr.contains("crates/"),
"no raw panic report or internal source path leaks: {stderr}"
);
assert_eq!(
out.status.code(),
Some(3),
"a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (#313, Path 1 — the real/sugar walk-forward): the degenerate
/// equal-length config over the shared GER40 archive emits the same
/// zero-trade note. Gated on the archive; skips cleanly on a data refusal.
#[test]
fn walkforward_real_all_zero_trade_windows_emit_the_note() {
const FROM_MS: &str = "1735689600000";
const TO_MS: &str = "1767225599000";
let (cwd, _g) = fresh_project();
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"walkforward", &fixture, "--real", "GER40",
"--axis", "sma_signal.fast.length=5", "--axis", "sma_signal.slow.length=5",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
.output()
.expect("spawn aura walkforward (real degenerate)");
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
"exit 1 must be a data refusal, got: {stderr}"
);
eprintln!("skip: no local GER40 data");
return;
}
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(
stderr.matches("walk-forward windows recorded zero trades").count(),
1,
"exactly one zero-trade note on the real path: {stderr}"
);
assert!(stderr.contains("aura: note: all "), "class marker: {stderr}");
}
/// E2E (#173): a CLOSED blueprint with no --axis (nothing to re-fit) is a usage
/// error (exit 2), naming that >= 1 --axis is required.
#[test]
@@ -5665,7 +5983,7 @@ fn mc_dissolved_refuses_a_multi_value_stop() {
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}");
assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}");
}
/// Property: an `--axis` name that is not among the loaded blueprint's sweepable
@@ -6286,3 +6604,417 @@ fn data_list_enumerates_archive_symbols_one_per_line_sorted() {
// scoping an instrument matrix.
assert_eq!(stdout, "SYMA\nSYMB\n", "sorted symbol enumeration: {stdout:?}");
}
/// Property (#300): `aura campaign show <CID>` prints the registered
/// document's exact canonical bytes to stdout — round-tripping them back
/// through `aura campaign register` re-derives the SAME content id (canonical
/// bytes are content-id-identical), and the store stays deduplicated at one
/// document. Gated on the shared GER40 archive; skips cleanly on a data
/// refusal (same guard shape as
/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`).
#[test]
fn campaign_show_round_trips_the_registered_document() {
const FROM_MS: &str = "1735689600000";
const TO_MS: &str = "1767225599000";
let (cwd, _g) = fresh_project();
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"walkforward", &fixture, "--real", "GER40",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--select", "plateau:mean",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
.output()
.expect("spawn aura");
if run.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&run.stderr);
assert!(
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
"exit 1 must be a data refusal, got: {stderr}"
);
eprintln!("skip: no local GER40 data");
return;
}
assert_eq!(
run.status.code(),
Some(0),
"walkforward must succeed: {}",
String::from_utf8_lossy(&run.stderr)
);
let campaigns_dir = cwd.join("runs").join("campaigns");
let cid = std::fs::read_dir(&campaigns_dir)
.expect("campaigns dir exists")
.next()
.expect("exactly one campaign document")
.expect("readable dir entry")
.path()
.file_stem()
.expect("campaign filename has a stem")
.to_string_lossy()
.into_owned();
let show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["campaign", "show", &cid])
.current_dir(&cwd)
.output()
.expect("spawn aura campaign show");
assert_eq!(
show.status.code(),
Some(0),
"campaign show must succeed: {}",
String::from_utf8_lossy(&show.stderr)
);
// Directly pin the #164 no-framing invariant: `show` must print the
// stored canonical bytes byte-for-byte, with no added trailing newline
// (a `println!` regression in `show_campaign` would still pass the
// re-register check below via `campaign_to_json` -> `content_id_of`
// stripping it back out before hashing, so this comparison is the only
// thing that would catch it).
let stored_bytes = std::fs::read(campaigns_dir.join(format!("{cid}.json"))).expect("read stored campaign document");
assert_eq!(
show.stdout, stored_bytes,
"campaign show must print the stored canonical bytes byte-for-byte, no framing"
);
let roundtrip_path = cwd.join("roundtrip.campaign.json");
std::fs::write(&roundtrip_path, &show.stdout).expect("write roundtrip campaign doc");
let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["campaign", "register", roundtrip_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura campaign register");
assert_eq!(
register.status.code(),
Some(0),
"re-registering the shown document must succeed: {}",
String::from_utf8_lossy(&register.stderr)
);
let register_out = String::from_utf8_lossy(&register.stdout).into_owned();
assert!(
register_out.contains(&format!("registered campaign {cid}")),
"canonical bytes must re-register content-id-identically to {cid}: {register_out}"
);
let count = std::fs::read_dir(&campaigns_dir).expect("campaigns dir exists").count();
assert_eq!(count, 1, "the shown bytes dedup onto the same one stored document");
}
/// Property (#300): identical to `campaign_show_round_trips_the_registered_document`
/// but over the generated process document — `aura process show <PID>` prints
/// canonical bytes that re-register content-id-identically through `aura
/// process register`, with the store staying deduplicated at one document.
#[test]
fn process_show_round_trips_the_registered_document() {
const FROM_MS: &str = "1735689600000";
const TO_MS: &str = "1767225599000";
let (cwd, _g) = fresh_project();
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"walkforward", &fixture, "--real", "GER40",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--select", "plateau:mean",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
.output()
.expect("spawn aura");
if run.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&run.stderr);
assert!(
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
"exit 1 must be a data refusal, got: {stderr}"
);
eprintln!("skip: no local GER40 data");
return;
}
assert_eq!(
run.status.code(),
Some(0),
"walkforward must succeed: {}",
String::from_utf8_lossy(&run.stderr)
);
let processes_dir = cwd.join("runs").join("processes");
let pid = std::fs::read_dir(&processes_dir)
.expect("processes dir exists")
.next()
.expect("exactly one process document")
.expect("readable dir entry")
.path()
.file_stem()
.expect("process filename has a stem")
.to_string_lossy()
.into_owned();
let show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "show", &pid])
.current_dir(&cwd)
.output()
.expect("spawn aura process show");
assert_eq!(
show.status.code(),
Some(0),
"process show must succeed: {}",
String::from_utf8_lossy(&show.stderr)
);
// Directly pin the #164 no-framing invariant (see the sibling campaign
// test's comment): `show` must print the stored canonical bytes
// byte-for-byte, with no added trailing newline.
let stored_bytes = std::fs::read(processes_dir.join(format!("{pid}.json"))).expect("read stored process document");
assert_eq!(
show.stdout, stored_bytes,
"process show must print the stored canonical bytes byte-for-byte, no framing"
);
let roundtrip_path = cwd.join("roundtrip.process.json");
std::fs::write(&roundtrip_path, &show.stdout).expect("write roundtrip process doc");
let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "register", roundtrip_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura process register");
assert_eq!(
register.status.code(),
Some(0),
"re-registering the shown document must succeed: {}",
String::from_utf8_lossy(&register.stderr)
);
let register_out = String::from_utf8_lossy(&register.stdout).into_owned();
assert!(
register_out.contains(&format!("registered process {pid}")),
"canonical bytes must re-register content-id-identically to {pid}: {register_out}"
);
let count = std::fs::read_dir(&processes_dir).expect("processes dir exists").count();
assert_eq!(count, 1, "the shown bytes dedup onto the same one stored document");
}
/// Property (#300): `show` over an unknown id refuses with the same
/// project-store-not-found prose the referential tier already uses for a
/// missing `process.ref`/`strategy.ref` (`ref_fault_prose`'s
/// `ProcessNotFound`/campaign-equivalent hint shape) — no project run needed,
/// so this test needs no archive gate.
#[test]
fn show_refuses_an_unknown_id_with_the_store_hint() {
let (cwd, _g) = fresh_project();
let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["campaign", "show", "deadbeef"])
.current_dir(&cwd)
.output()
.expect("spawn aura campaign show");
assert_eq!(campaign_show.status.code(), Some(1), "unknown campaign id is a runtime refusal");
let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr);
assert!(
campaign_stderr.contains("campaign deadbeef not found in the project store"),
"campaign show stderr: {campaign_stderr}"
);
let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "show", "deadbeef"])
.current_dir(&cwd)
.output()
.expect("spawn aura process show");
assert_eq!(process_show.status.code(), Some(1), "unknown process id is a runtime refusal");
let process_stderr = String::from_utf8_lossy(&process_show.stderr);
assert!(
process_stderr.contains("process deadbeef not found in the project store"),
"process show stderr: {process_stderr}"
);
}
/// Property (#300): `show`'s not-found prose reuses the existing `prefix_hint`
/// authoring-trap detector (#194) — an id pasted WITH the display-only
/// `content:` prefix (the form `ref_fault_prose` already prints elsewhere for
/// an unresolved `process.ref`/`strategy.ref`) still gets the "drop the
/// 'content:' prefix" hint through the new `show` code path, not a bare
/// not-found. This is the one authoring mistake the store can name outright,
/// and it must survive `show` reusing `ref_fault_prose`'s helper rather than
/// re-deriving its own prose.
#[test]
fn show_hints_the_content_prefix_authoring_mistake() {
let (cwd, _g) = fresh_project();
let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["campaign", "show", "content:deadbeef"])
.current_dir(&cwd)
.output()
.expect("spawn aura campaign show");
assert_eq!(campaign_show.status.code(), Some(1), "unresolvable id is a runtime refusal");
let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr);
assert!(
campaign_stderr.contains("drop the 'content:' prefix"),
"campaign show must hint the content: authoring mistake: {campaign_stderr}"
);
let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "show", "content:deadbeef"])
.current_dir(&cwd)
.output()
.expect("spawn aura process show");
assert_eq!(process_show.status.code(), Some(1), "unresolvable id is a runtime refusal");
let process_stderr = String::from_utf8_lossy(&process_show.stderr);
assert!(
process_stderr.contains("drop the 'content:' prefix"),
"process show must hint the content: authoring mistake: {process_stderr}"
);
}
/// Property (#300): `show` is scoped to its own document-kind store — a
/// content id that IS a real, registered document in one store (here: a
/// minimal sweep-only process) does not resolve through the OTHER verb
/// (`campaign show`). `show_process`/`show_campaign` are two near-identical
/// functions written in the same change (both `registry.get_*(id) -> Option
/// -> print! the bytes or format! a not-found`); this pins that they read
/// from their own store method and neither accidentally shares the other's
/// namespace or falls back to a generic content-blob lookup.
#[test]
fn show_is_scoped_to_its_own_document_store() {
let (cwd, _g) = fresh_project();
const SWEEP_ONLY_PROCESS: &str = r#"{"format_version":1,"kind":"process","name":"knob-sweep-only","pipeline":[{"block":"std::sweep","metric":"sqn_normalized","select":"argmax"}]}"#;
std::fs::write(cwd.join("knob.process.json"), SWEEP_ONLY_PROCESS).expect("write process doc");
let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "register", "knob.process.json"])
.current_dir(&cwd)
.output()
.expect("spawn aura process register");
assert_eq!(
register.status.code(),
Some(0),
"process register must succeed: {}",
String::from_utf8_lossy(&register.stderr)
);
let register_out = String::from_utf8_lossy(&register.stdout).into_owned();
let pid = register_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 process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "show", &pid])
.current_dir(&cwd)
.output()
.expect("spawn aura process show");
assert_eq!(
process_show.status.code(),
Some(0),
"process show must find the just-registered process: {}",
String::from_utf8_lossy(&process_show.stderr)
);
let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["campaign", "show", &pid])
.current_dir(&cwd)
.output()
.expect("spawn aura campaign show");
assert_eq!(
campaign_show.status.code(),
Some(1),
"a process id must NOT resolve through campaign show: {}",
String::from_utf8_lossy(&campaign_show.stdout)
);
let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr);
assert!(
campaign_stderr.contains(&format!("campaign {pid} not found in the project store")),
"campaign show stderr: {campaign_stderr}"
);
}
/// Property (#302): `show` mirrors the prefix resolution `reproduce` gained
/// with #298 — a unique prefix of a registered content id, in the 8-hex form
/// every surface prints (family ids, run records), resolves to the registered
/// document, and `show` prints EXACTLY the bytes the full-id invocation
/// prints (the #164 byte-discipline: the stored canonical bytes, no framing).
/// One document per store makes any prefix of its id unique by construction.
/// Deliberately NOT pinned here: the ambiguous-prefix refusal (listing the
/// candidate ids) — content ids are hash-derived, not choosable, so two
/// documents sharing a printed prefix cannot be constructed honestly at this
/// layer; that arm belongs beside the resolution seam, where candidate id
/// lists are plain strings.
#[test]
fn show_resolves_a_unique_prefix_to_the_full_id_bytes() {
let (cwd, _g) = fresh_project();
fn aura(cwd: &Path, args: &[&str]) -> std::process::Output {
Command::new(BIN).args(args).current_dir(cwd).output().expect("spawn aura")
}
const PREFIX_PROBE_PROCESS: &str = r#"{"format_version":1,"kind":"process","name":"prefix-probe","pipeline":[{"block":"std::sweep","metric":"sqn_normalized","select":"argmax"}]}"#;
const PREFIX_PROBE_CAMPAIGN: &str = r#"{
"format_version": 1,
"kind": "campaign",
"name": "prefix-probe",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": { "content_id": "9f3a" },
"axes": { "fast": { "kind": "I64", "values": [8] } } } ],
"process": { "ref": { "content_id": "4e2d" } },
"seed": 1,
"presentation": { "persist_taps": [], "emit": ["family_table"] }
}"#;
for (verb, file, doc) in [
("process", "probe.process.json", PREFIX_PROBE_PROCESS),
("campaign", "probe.campaign.json", PREFIX_PROBE_CAMPAIGN),
] {
std::fs::write(cwd.join(file), doc).expect("write document");
let register = aura(&cwd, &[verb, "register", file]);
assert_eq!(
register.status.code(),
Some(0),
"{verb} register must succeed: {}",
String::from_utf8_lossy(&register.stderr)
);
let register_out = String::from_utf8_lossy(&register.stdout).into_owned();
let marker = format!("registered {verb} ");
let id = register_out
.lines()
.find(|l| l.starts_with(&marker))
.expect("register line")
.trim_start_matches(marker.as_str())
.split(' ')
.next()
.expect("content id")
.to_string();
assert!(id.len() > 8, "content id longer than the printed 8-hex prefix form: {id}");
let full = aura(&cwd, &[verb, "show", &id]);
assert_eq!(
full.status.code(),
Some(0),
"{verb} show <full id> must succeed: {}",
String::from_utf8_lossy(&full.stderr)
);
assert!(!full.stdout.is_empty(), "{verb} show <full id> prints the document bytes");
// The headline: the 8-hex prefix — unique in this one-document store —
// resolves, and the output is byte-identical to the full-id output.
let prefix = &id[..8];
let short = aura(&cwd, &[verb, "show", prefix]);
assert_eq!(
short.status.code(),
Some(0),
"{verb} show {prefix} (unique prefix of {id}) must resolve: {}",
String::from_utf8_lossy(&short.stderr)
);
assert_eq!(
short.stdout, full.stdout,
"{verb} show via unique prefix must print the identical document bytes"
);
}
}
@@ -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,
}
+141 -3
View File
@@ -54,6 +54,40 @@ const SIGNAL_DOC: &str = r#"[
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC with the C29 doc op — the register-reaching variant (a store
/// write requires a described composite; build-only tests keep SIGNAL_DOC).
const SIGNAL_DOC_DESCRIBED: &str = r#"[
{"op":"doc","text":"fast/slow SMA difference clamped into a directional bias"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC with a `doc` op whose text merely restates the op-script
/// composite's name ("graph", the literal `replay("graph", ..)` gives every
/// CLI-built op-script) -- the RestatesName arm of the C29 shape gate,
/// reached via the op-script route rather than a builder-authored blueprint.
const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
{"op":"doc","text":"Graph"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
/// never wired — a 0-cover input slot only the holistic finalize gate can
/// reject. The smallest document that is op-valid yet whole-document-invalid.
@@ -74,6 +108,21 @@ fn graph_build_emits_blueprint_for_a_valid_document() {
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
}
/// C29 (#316): the op-script `doc` op's text lands verbatim in the built
/// blueprint's `doc` field -- `graph build` is a pure translation from ops to
/// the canonical envelope, so a doc op is not consumed or reworded on the way
/// through, only threaded (the shape gate is a separate, register-time check;
/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`).
#[test]
fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(ok, "exit success");
assert!(
stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#),
"the doc op's exact text appears in the blueprint's doc field: {stdout}"
);
}
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
@@ -462,6 +511,47 @@ fn graph_register_is_idempotent() {
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
}
/// C29 register seam end-to-end (#316): a doc-less root composite refuses at
/// `aura graph register` naming the composite and the rule (exit 1, C14); a
/// doc-less named nested composite refuses naming the nested name; the
/// described shipped example registers. The gate lives at the store write
/// every register path shares — this pins its verb surface.
#[test]
fn register_refuses_undescribed_composites_end_to_end() {
let dir = temp_cwd("register-doc-gate");
// doc-less root: the shipped example minus its doc member
let text = std::fs::read_to_string(example("r_sma.json")).expect("read example");
let mut v: serde_json::Value = serde_json::from_str(&text).expect("example parses");
v["blueprint"].as_object_mut().expect("blueprint object").remove("doc");
let docless = dir.join("docless.json");
std::fs::write(&docless, serde_json::to_string(&v).unwrap()).expect("write fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", docless.to_str().unwrap()]);
assert_eq!(code, Some(1), "doc-less root refuses: {out} {err}");
assert!(
err.contains("composite `sma_signal` carries no doc"),
"stderr names the composite and the rule: {err}"
);
assert!(err.contains("C29"), "stderr cites the contract: {err}");
// doc-less named nested composite inside a described root
v["blueprint"]["doc"] = serde_json::Value::String("a described root".into());
v["blueprint"]["nodes"]
.as_array_mut()
.expect("nodes array")
.push(serde_json::json!({"composite": {"name": "inner", "nodes": []}}));
let nested = dir.join("nested.json");
std::fs::write(&nested, serde_json::to_string(&v).unwrap()).expect("write fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", nested.to_str().unwrap()]);
assert_eq!(code, Some(1), "doc-less nested refuses: {out} {err}");
assert!(
err.contains("composite `inner` carries no doc"),
"stderr names the nested composite: {err}"
);
// the described shipped example registers
let (out, err, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
assert_eq!(code, Some(0), "described example registers: {out} {err}");
assert!(out.starts_with("registered blueprint "), "prints the register line: {out}");
}
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
@@ -647,11 +737,12 @@ fn graph_register_rejects_an_unknown_node_type_with_prose() {
#[test]
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
let dir = temp_cwd("register-op-script");
// The op-script form (a JSON array of ops), written to a file.
// The op-script form (a JSON array of ops), written to a file. Register
// requires a described composite (C29), so the described twin is used here.
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
std::fs::write(&op_script, SIGNAL_DOC_DESCRIBED).expect("write op-script fixture");
// Its `graph build` envelope (a JSON object), the shape `register` already accepts.
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
@@ -669,6 +760,33 @@ fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
registered_id(&env_out),
"op-script and its built envelope register to the same content id"
);
// C29: the doc-less op-script form refuses at register like any other
// doc-less composite entering the store.
let docless_script = dir.join("docless-script.json");
std::fs::write(&docless_script, SIGNAL_DOC).expect("write op-script fixture");
let (dl_out, dl_err, dl_code) =
run_in(&dir, &["graph", "register", docless_script.to_str().unwrap()]);
assert_eq!(dl_code, Some(1), "doc-less op-script refuses: {dl_out} {dl_err}");
assert!(dl_err.contains("carries no doc"), "stderr names the rule: {dl_err}");
assert!(dl_err.contains("C29"), "stderr cites the contract: {dl_err}");
}
/// C29: an op-script's `doc` op is not a bare presence checkbox -- its text
/// goes through the same shape gate a builder-authored doc does. A doc that
/// merely restates the composite's name (here "graph", every CLI op-script's
/// literal name) refuses at register exactly like the builder-authored
/// RestatesName case already pinned at the registry-unit layer, but reached
/// end-to-end through the op-script CLI seam this time.
#[test]
fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
let dir = temp_cwd("register-op-script-restated-doc");
let restated_script = dir.join("restated-doc-script.json");
std::fs::write(&restated_script, SIGNAL_DOC_NAME_RESTATED).expect("write op-script fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
assert_eq!(code, Some(1), "name-restating doc refuses: {out} {err}");
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
assert!(err.contains("C29"), "stderr cites the contract: {err}");
}
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
@@ -1157,6 +1275,26 @@ fn graph_build_reports_gang_kind_mismatch_as_prose() {
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
}
/// The `doc` op's duplicate refusal (a second meaning line in one op-script)
/// reads as prose at the binary seam, attributed to the second `doc` op's
/// index -- never the raw `DuplicateDoc` Debug variant name.
#[test]
fn graph_build_reports_duplicate_doc_fault_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"doc","text":"first"},
{"op":"doc","text":"second"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a duplicate doc fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 2 (doc): a doc op may appear at most once"),
"names the op + cause as prose: {stderr}"
);
assert!(!stderr.contains("DuplicateDoc"), "does not leak the Debug variant name: {stderr}");
}
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
/// register` on a hand-corrupted blueprint whose gangs section fails structural
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
+80
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)
@@ -164,6 +172,78 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
}
/// C29 load seam end-to-end (#316): an extension node whose doc fails the
/// shape gate (the empty-string alibi the compiler cannot catch) refuses at
/// load with the entry and the rule named — through the real
/// `aura-cli::project::load` path (libloading + descriptor read), not merely
/// the pure `doc_gate` unit function. The described twin's load is pinned by
/// `project_run_resolves_demo_node_and_is_bit_identical`.
#[test]
fn undescribed_vocabulary_entry_refuses_end_to_end() {
let dir = undescribed_dir();
let build = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the undescribed fixture");
assert!(
build.status.success(),
"undescribed fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
"an undescribed vocabulary entry is a runtime refusal (exit 1), \
not a usage error"
);
let err = String::from_utf8_lossy(&run.stderr);
assert!(
err.contains("vocabulary entry `und::Opaque` has an empty doc"),
"stderr must name the entry and the rule: {err}"
);
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
}
/// C29 load seam end-to-end, the sibling fault arm (#316): a doc that merely
/// restates its own type id (`DocGateFault::RestatesName`) refuses at load
/// exactly like the empty-doc case above, through the same real
/// `aura-cli::project::load` path. `RestatesName`'s rendered prose was
/// previously pinned only against the pure `Display` impl in
/// `aura-runner::project`'s unit tests (never reachable at all through the
/// CLI) — a regression that reordered `doc_gate`'s two fault arms, or that
/// skipped the gate call for this arm specifically, would pass every
/// existing test and still ship a self-description hole for exactly this
/// case: an alibi doc that types-checks but says nothing.
#[test]
fn restated_name_vocabulary_entry_refuses_end_to_end() {
let dir = restated_dir();
let build = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the restated fixture");
assert!(
build.status.success(),
"restated fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
"a name-restating vocabulary entry is a runtime refusal (exit 1), \
not a usage error"
);
let err = String::from_utf8_lossy(&run.stderr);
assert!(
err.contains("vocabulary entry `restated::Echo` has a doc that merely restates its name"),
"stderr must name the entry and the rule: {err}"
);
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
}
/// Force a file's modification time to a fixed instant (explicit mtime, no
/// sleeping — mtime granularity would make a "touch then compare" race).
fn set_mtime(path: &Path, secs_since_epoch: u64) {
+82
View File
@@ -211,3 +211,85 @@ fn attached_crate_namespace_resolves_after_build() {
"{}", String::from_utf8_lossy(&out.stdout)
);
}
/// E2E (#278): loading an attached node crate whose `src/` is newer than its
/// built dylib emits exactly one `aura: warning: ` class-marked line naming
/// the stale dylib, and still succeeds (the run proceeds with the existing
/// dylib rather than refusing) — staleness is a continuing-run warning, never
/// a fault. A freshly built dylib (no touched source since) emits none. Real
/// `cargo build` + a real `libloading::Library::new` load, exercising
/// `aura_runner::project::load_crate`'s actual call site rather than the pure
/// `stale_warning` helper alone.
#[test]
fn attached_crate_load_warns_when_source_outruns_the_built_dylib() {
let cwd = temp_cwd("stale-warn");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
let crate_root = cwd.join("lab-nodes");
let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build");
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
// Fresh build, untouched source: no staleness warning.
let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr));
assert!(
!String::from_utf8_lossy(&fresh.stderr).contains("may be stale"),
"a freshly built dylib is not stale: {}",
String::from_utf8_lossy(&fresh.stderr)
);
// Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the
// dylib's just-completed build time) without rebuilding — the exact
// "source outran the dylib, no rebuild yet" shape `stale_warning` guards.
let lib_rs = crate_root.join("src/lib.rs");
let f = std::fs::File::open(&lib_rs).expect("open lib.rs");
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60))
.expect("bump lib.rs mtime past the dylib's build time");
let stale = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(
stale.status.success(),
"staleness is a warning, not a refusal: {}",
String::from_utf8_lossy(&stale.stderr)
);
let stderr = String::from_utf8_lossy(&stale.stderr);
assert_eq!(
stderr.matches("may be stale").count(),
1,
"exactly one staleness warning: {stderr}"
);
assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}");
assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}");
}
/// Property (#316 self-description): the scaffold's own seed node ships a
/// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`)
/// — non-empty and not a bare restatement of the type name. This exact text
/// is what a new extension author copy-pastes as the very first node of
/// their crate; if the scaffold's own doc failed the gate it would teach the
/// wrong pattern (an alibi doc) to every project built from it, and nothing
/// else would catch that — `NodeSchema.doc` is required at compile time
/// (E0063 without it) but its *shape* is never checked by the compiler.
#[test]
fn nodes_new_scaffold_doc_passes_the_doc_gate() {
let cwd = temp_cwd("doc-gate");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap();
let doc_line = lib
.lines()
.find(|l| l.trim_start().starts_with("doc:"))
.unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}"));
let doc_text = doc_line
.split_once('"')
.and_then(|(_, rest)| rest.rsplit_once('"'))
.map(|(text, _)| text)
.unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}"));
aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| {
panic!("scaffold's own doc line fails its own gate: {f:?}{doc_text:?}")
});
}
@@ -37,7 +37,7 @@ const GER40_TO_MS: &str = "1727740799999";
/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab`
/// vocabulary namespace — `Scale` is the starter node every node-crate
/// scaffold emits (#183).
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","doc":"fast/slow SMA bias scaled by an extension gain node","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
/// The minimal executable process pipeline (one sweep stage) — the campaign
/// cell's methodology reference.
+112 -7
View File
@@ -282,7 +282,8 @@ fn process_introspect_metrics_plus_another_mode_is_usage_exit_2() {
#[test]
fn process_register_stores_content_addressed_under_runs_root() {
let dir = temp_cwd("process-register");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
write_doc(&dir, "p.process.json", PROCESS_DOC);
let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
@@ -596,6 +597,42 @@ fn campaign_validate_outside_project_skips_referential_tier() {
assert!(out.contains("referential checks skipped (no Aura.toml found up from "));
}
/// #305: `register` outside a project (no Aura.toml up from the cwd) refuses
/// up front — exit 1, prose naming the invoked verb and the missing Aura.toml
/// (the honest-degradation guard family `show`, `campaign runs`, and the
/// dissolved run verbs already share) — and leaves NO store. Today the missing
/// `env.provenance()` guard lets `Env::registry()`'s relative `runs/` fallback
/// silently materialise `./runs/` in the innocent cwd and register the
/// document into a store no project owns, exit 0.
#[test]
fn register_outside_a_project_refuses_and_leaves_no_store() {
for (verb, file, doc) in [
("process", "p.process.json", PROCESS_DOC),
("campaign", "c.campaign.json", CAMPAIGN_DOC),
] {
let dir = temp_cwd(&format!("{verb}-register-outside-project"));
write_doc(&dir, file, doc);
let (out, code) = run_code_in(&dir, &[verb, "register", file]);
assert_eq!(
code,
Some(1),
"{verb} register outside a project must refuse; stdout/stderr: {out}"
);
assert!(
out.contains(&format!("{verb} register needs a project")),
"{verb} register's refusal must name the invoked verb: {out}"
);
assert!(
out.contains("no Aura.toml found up from"),
"{verb} register's refusal must name the missing Aura.toml: {out}"
);
assert!(
!dir.join("runs").exists(),
"{verb} register refused outside a project must leave no ./runs/ store"
);
}
}
#[test]
fn campaign_validate_refuses_empty_axis_prose_exit_1() {
let dir = temp_cwd("campaign-validate-empty-axis");
@@ -1031,7 +1068,8 @@ fn campaign_introspect_content_id_prints_hash() {
#[test]
fn campaign_register_stores_content_addressed_under_runs_root() {
let dir = temp_cwd("campaign-register");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
@@ -1166,7 +1204,8 @@ fn wrong_kind_refusal_names_the_kind_key() {
/// have already made.
#[test]
fn campaign_register_refuses_invalid_document_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
write_doc(&dir, "bad.campaign.json", &bad);
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad.campaign.json"]);
@@ -1187,7 +1226,8 @@ fn campaign_register_refuses_invalid_document_and_writes_nothing() {
/// would silently persist an unenforceable risk regime.
#[test]
fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid-risk");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
let bad = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 0, \"k\": 2.0 } } ],",
@@ -1214,7 +1254,8 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
/// precedent).
#[test]
fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid-cost");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
let bad = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": -2.0 } } ],",
@@ -1411,6 +1452,19 @@ const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// A gate no realized sweep member can ever satisfy (`n_trades ge 999999`) —
/// deterministically empties `survivor_ordinals` regardless of the fixture's
/// actual trade counts, driving the #278 zero-survivor NOTE marker.
const GATE_EMPTIES_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "gate-empties",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 999999.0 } ] }
]
}"#;
/// The full annotated shape for the gated e2e: sweep -> gate -> walk_forward
/// -> monte_carlo. The gate (`n_trades ge 0`) passes every member, so
/// walk-forward always has survivors; the mc annotator bootstraps the pooled
@@ -1937,6 +1991,57 @@ fn campaign_run_synthetic_e2e_parallel_instruments_contains_a_real_per_cell_faul
assert_eq!(cells[1]["instrument"].as_str(), Some("SYMB"), "doc order preserved: {line}");
assert!(cells[0]["fault"].is_null(), "SYMA has data in January, its cell succeeds: {line}");
assert!(!cells[1]["fault"].is_null(), "SYMB has no January data, its cell is faulted: {line}");
assert!(
out.contains("aura: warning: cell ("),
"the failed-cell line carries the warning class marker (#278): {out}"
);
}
/// #278 sibling of the warning-class pin above: a gate that empties a cell's
/// survivors (never a fault, #198 decision 8) emits the NOTE class marker,
/// not the bare `aura:` prefix nor `warning:`. `GATE_EMPTIES_PROCESS_DOC`'s
/// impossible threshold guarantees zero survivors regardless of the
/// fixture's actual sweep-member trade counts. Second property pinned on the
/// same invocation: the run's terminal "recorded" summary line — previously
/// printed with NO `aura:` prefix at all — now carries it too, so every
/// stderr line this command emits (per-cell class markers AND the run-level
/// summary) shares the one `aura:`-rooted vocabulary; a regression that
/// reverted just this one un-tagged `eprintln!` back to a bare line would
/// not be caught by the note-marker assertion above.
#[test]
fn campaign_run_synthetic_e2e_gate_emptied_cell_carries_the_note_marker() {
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.clone()),
ScratchPath::File(dir.join("gateempty.process.json")),
ScratchPath::File(dir.join("gateempty.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "campaign-run-gateempty-seed");
let proc_id =
register_process_doc(&dir, "gateempty.process.json", GATE_EMPTIES_PROCESS_DOC);
let doc = campaign_doc_json_for(
"SYMA",
&bp_id,
&proc_id,
(1704844800000, 1705708800000),
"",
"",
);
write_doc(&dir, "gateempty.campaign.json", &doc);
let (out, code) =
run_code_in(&dir, &["campaign", "run", "gateempty.campaign.json"]);
assert_eq!(code, Some(0), "a zero-survivor cell is a valid result, not a fault: {out}");
assert!(
out.contains("aura: note: cell "),
"the gate-emptied cell line carries the note class marker (#278): {out}"
);
assert!(
out.contains("aura: campaign run "),
"the run-level recorded summary line also carries the aura: prefix (#278): {out}"
);
}
/// The v2 boundary, campaign side: a generalize-bearing process is an
@@ -3642,7 +3747,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
assert!(
out.contains(
"aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
"aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
),
"the unproducible tap gets a named, one-time note with the remedy: {out}"
);
@@ -3745,7 +3850,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
assert_eq!(code_gross, Some(0), "cost-less baseline run: {out_gross}");
assert!(
out_gross.contains(
"aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
"aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
),
"the cost-less run skips the net tap with the remedy: {out_gross}"
);
+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));
}
}
+62
View File
@@ -150,3 +150,65 @@ fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
);
assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
}
/// The `aura: writing tap traces failed: …` register, pre-run arm: `runs`
/// exists as a FILE, so the trace store's directory creation
/// (`begin_run`) fails before the run — exit 1 through the register.
/// (This register was code-present but untested before #283.)
#[test]
fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
let cwd = temp_cwd("store-root-is-a-file");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
"the tap-trace register must fire: {stderr}"
);
}
/// The same register, deferred arm (#283): the run directory pre-exists
/// read-only, so `begin_run` succeeds (create_dir_all on an existing dir)
/// but the record consumer's deferred open in `initialize` fails; the run
/// COMPLETES, the failure surfaces terminally through the register, and no
/// `index.json` is written (the store's treat-as-absent crash shape).
#[cfg(unix)]
#[test]
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
use std::os::unix::fs::PermissionsExt;
let cwd = temp_cwd("run-dir-read-only");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run_dir = cwd.join("runs/traces/sma_signal");
std::fs::create_dir_all(&run_dir).expect("pre-create run dir");
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
.expect("make run dir read-only");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
// restore permissions FIRST so the next run's temp_cwd cleanup works.
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
.expect("restore run dir permissions");
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
"the tap-trace register must fire terminally: {stderr}"
);
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
}
@@ -35,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)] }),
)
+2 -2
View File
@@ -44,8 +44,8 @@ pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use node::{
zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder,
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
};
pub use scalar::{Scalar, ScalarKind, Timestamp};
pub use series_fold::SeriesFold;
+101 -3
View File
@@ -345,6 +345,36 @@ pub struct NodeSchema {
pub inputs: Vec<PortSpec>,
pub output: Vec<FieldSpec>,
pub params: Vec<ParamSpec>,
/// One-line meaning of the node — non-load-bearing metadata, shown by
/// introspection surfaces.
pub doc: &'static str,
}
/// Shape fault of a vocabulary entry's meaning line (C29 gate).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocGateFault {
Empty,
RestatesName,
}
/// Deterministic shape check for a vocabulary entry's meaning line —
/// string shape only, never content judgement (the engine must not
/// evaluate prose).
pub fn doc_gate(name: &str, doc: &str) -> Result<(), DocGateFault> {
if doc.trim().is_empty() {
return Err(DocGateFault::Empty);
}
fn norm(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
let short_name = name.rsplit("::").next().unwrap_or(name);
if norm(doc) == norm(name) || norm(doc) == norm(short_name) {
return Err(DocGateFault::RestatesName);
}
Ok(())
}
/// The universal composable dataflow unit (C8): a **producer, consumer, or both**.
@@ -379,6 +409,15 @@ pub trait Node {
"node".to_string()
}
/// Start-of-stream hook: `Harness::run` calls it once per node, in
/// topological order, before the first source value — the mirror of
/// [`Node::finalize`]. A consumer overrides it to acquire run resources
/// (e.g. the file a recording sink streams into). Infallible by
/// signature: an implementation that can fail stores its error, degrades
/// to inert, and surfaces the failure once, terminally, at `finalize`.
/// The default is a no-op, so existing nodes are unaffected.
fn initialize(&mut self) {}
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
/// order, after the source loop drains. A folding sink overrides it to flush
/// its accumulated summary; the default is a no-op, so existing nodes are
@@ -437,13 +476,14 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
assert_eq!(with.label(), "SMA");
let none = PrimitiveBuilder::new(
"Sub",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(none.label(), "Sub");
@@ -473,7 +513,7 @@ mod tests {
fn node_name_strips_namespace_prefix() {
let b = PrimitiveBuilder::new(
"demo::Identity",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| unreachable!("never built in this test"),
);
assert_eq!(b.node_name(), "identity");
@@ -505,7 +545,7 @@ mod tests {
fn primitive_builder_build_runs_the_closure() {
let f = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(f.params(), Vec::<ParamSpec>::new());
@@ -520,6 +560,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -529,6 +570,21 @@ mod tests {
);
}
#[test]
fn schema_carries_declared_doc() {
let b = PrimitiveBuilder::new(
"SMA",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![],
doc: "simple moving average",
},
|_| Box::new(Bare),
);
assert_eq!(b.schema().doc, "simple moving average");
}
/// A node whose label echoes the param vector its constructor received — lets a
/// test read back the positional vector `.build()` reconstructed after binds.
struct Probe(Vec<Cell>);
@@ -556,6 +612,7 @@ mod tests {
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
],
doc: "test-only schema",
},
|p| Box::new(Probe(p.to_vec())),
)
@@ -632,6 +689,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -650,6 +708,7 @@ mod tests {
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -665,6 +724,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -716,6 +776,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
@@ -737,6 +798,43 @@ mod tests {
})
);
}
#[test]
fn doc_gate_refuses_empty_and_whitespace() {
assert_eq!(doc_gate("EMA", ""), Err(DocGateFault::Empty));
assert_eq!(doc_gate("EMA", " \t"), Err(DocGateFault::Empty));
}
#[test]
fn doc_gate_refuses_name_restatement() {
assert_eq!(doc_gate("EMA", "EMA"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("EMA", "e_m_a"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("rolling_max", "Rolling Max"), Err(DocGateFault::RestatesName));
}
/// C29 tightening (#316 fieldtest): for a namespaced entry, the bare
/// display segment is a restatement too — a doc that adds nothing beyond
/// the name must not pass just because the name carries a `::` prefix.
/// Genuine meaning lines that merely *contain* the name stay legal.
#[test]
fn doc_gate_refuses_namespaced_short_name_restatement() {
// The most natural alibi at the extension-author seam: the node's
// own display name as its doc.
assert_eq!(doc_gate("lab316_nodes::Scale", "Scale"), Err(DocGateFault::RestatesName));
// Guard: a real meaning line stays Ok even when it contains the
// short name as a substring...
assert_eq!(
doc_gate("lab316_nodes::Scale", "scalar gain: emits the input times the factor param"),
Ok(())
);
// ...and a namespaced entry with a genuine doc is untouched.
assert_eq!(doc_gate("und::Opaque", "one-input f64 pass-through"), Ok(()));
}
#[test]
fn doc_gate_accepts_a_meaning_line() {
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
}
}
#[cfg(test)]
+23 -5
View File
@@ -129,7 +129,15 @@ fn derive_signature(c: &Composite) -> NodeSchema {
.collect();
let mut params = Vec::new();
collect_params(c.nodes(), c.gangs(), "", &mut params);
NodeSchema { inputs, output, params }
NodeSchema {
inputs,
output,
params,
// A derived composite signature is graph wiring, not a vocabulary entry:
// no seam walks this doc. The described surface is the composite's own
// `doc`, gated at register (C29, register seam).
doc: "",
}
}
/// The scalar kind of the interior input slot a composite target addresses,
@@ -1838,6 +1846,7 @@ mod tests {
inputs: vec![],
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
};
let to = NodeSchema {
inputs: vec![
@@ -1846,6 +1855,7 @@ mod tests {
],
output: vec![],
params: vec![],
doc: "test-only schema",
};
assert!(edge_kind_check(&from, 0, &to, 0).is_ok());
assert_eq!(
@@ -2269,7 +2279,7 @@ mod tests {
fn pass1() -> BlueprintNode {
PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
)
.into()
@@ -2277,7 +2287,12 @@ mod tests {
fn join2() -> BlueprintNode {
PrimitiveBuilder::new(
"Join2",
NodeSchema { inputs: vec![f64_any(), f64_any()], output: out_v(), params: vec![] },
NodeSchema {
inputs: vec![f64_any(), f64_any()],
output: out_v(),
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Join2 { out: [Cell::from_f64(0.0)] }),
)
.into()
@@ -2285,7 +2300,7 @@ mod tests {
fn sink_f64() -> BlueprintNode {
PrimitiveBuilder::new(
"SinkF64",
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(SinkF64),
)
.into()
@@ -2297,6 +2312,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(SinkI64),
)
@@ -2849,6 +2865,7 @@ mod tests {
inputs: vec![f64_any()],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
@@ -3454,6 +3471,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::I64 }],
params: vec![],
doc: "test-only schema",
},
|_| panic!("build must not run when validation fails pre-build"),
);
@@ -3556,7 +3574,7 @@ mod tests {
// cannot take .named).
let paramless_sma = PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
)
.named("sma");
+46 -4
View File
@@ -42,6 +42,10 @@ pub enum Op {
Tap { from: String, as_name: String },
/// Fuse two or more sibling params into one public knob (#61).
Gang { as_name: String, into: Vec<String> },
/// Declare the composite's one-line meaning (C29, #316) — the op-script
/// twin of the builder's `.doc(...)`. At most one per script; a second
/// is a fault (a declarative script carries no last-wins ambiguity).
Doc { text: String },
}
/// A per-op construction fault, by-identifier so the cause names the op.
@@ -92,6 +96,8 @@ pub enum OpError {
/// A holistic finalize fault (totality / injectivity / unbound root role),
/// wrapping the unchanged engine gate's `CompileError`.
Incomplete(CompileError),
/// A second `doc` op — the meaning line is declared at most once.
DuplicateDoc,
}
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
@@ -113,6 +119,7 @@ pub struct GraphSession<'v> {
tap_names: HashSet<String>,
coverage: HashMap<(usize, usize), usize>,
gangs: Vec<Gang>,
doc: Option<String>,
}
impl<'v> GraphSession<'v> {
@@ -135,6 +142,7 @@ impl<'v> GraphSession<'v> {
tap_names: HashSet::new(),
coverage: HashMap::new(),
gangs: Vec::new(),
doc: None,
}
}
@@ -150,6 +158,13 @@ impl<'v> GraphSession<'v> {
Op::Expose { from, as_name } => self.expose(from, as_name),
Op::Tap { from, as_name } => self.tap(from, as_name),
Op::Gang { as_name, into } => self.gang(as_name, into),
Op::Doc { text } => {
if self.doc.is_some() {
return Err(OpError::DuplicateDoc);
}
self.doc = Some(text);
Ok(())
}
}
}
@@ -424,13 +439,14 @@ impl<'v> GraphSession<'v> {
.into_iter()
.map(|(name, source, targets)| Role { name, targets, source })
.collect();
// No doc is attached here: the op-script vocabulary deliberately has no
// doc-carrying surface yet (#125 scope cut) — an op-built composite
// carries no authored rationale until that vocabulary grows a slot.
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
// The doc op (C29, #316) threads through `Composite::with_doc`.
let mut c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
.with_taps(self.taps)
.with_gangs(self.gangs)
.map_err(OpError::Incomplete)?;
if let Some(t) = self.doc {
c = c.with_doc(t);
}
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps()).map_err(|e| match e {
CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort {
@@ -866,6 +882,32 @@ mod tests {
);
}
/// C29 (#316): the doc op sets the composite's meaning line — the
/// op-script twin of the builder's .doc(...).
#[test]
fn doc_op_sets_the_composite_doc() {
let mut s = scaffold();
s.apply(Op::Doc { text: "a described op-built composite".into() }).unwrap();
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] })
.unwrap();
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
let c = s.finish().expect("finishes");
assert_eq!(c.doc(), Some("a described op-built composite"));
}
/// A second doc op is a fault, not a silent overwrite.
#[test]
fn duplicate_doc_op_is_refused() {
let mut s = scaffold();
s.apply(Op::Doc { text: "first".into() }).unwrap();
assert_eq!(
s.apply(Op::Doc { text: "second".into() }),
Err(OpError::DuplicateDoc)
);
}
#[test]
fn finish_reports_incomplete_on_unwired_slot() {
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm
+2
View File
@@ -348,6 +348,7 @@ mod tests {
],
output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
@@ -462,6 +463,7 @@ mod tests {
FieldSpec { name: "lo".into(), kind: ScalarKind::I64 },
],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
+63
View File
@@ -519,6 +519,14 @@ impl Harness {
let mut cycle_id: u64 = 0;
let mut scratch: Vec<Cell> = Vec::new();
// start-of-stream: initialize every node once, in topological order,
// before the first source value (the mirror of the end-of-stream
// finalize flush below). Runs inside the deterministic sequence — no
// within-sim concurrency (C1).
for &nidx in topo.iter() {
nodes[nidx].node.initialize();
}
loop {
// pick the live source head with the smallest (timestamp, source index).
// strictly-`<` replace + source-order scan preserves C4 tie-breaking.
@@ -870,6 +878,7 @@ mod tests {
inputs: kinds.iter().enumerate().map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") }).collect(),
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
@@ -887,6 +896,7 @@ mod tests {
inputs: vec![f64_port(Firing::Any), f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -916,6 +926,7 @@ mod tests {
inputs: vec![f64_port(Firing::Barrier(0)), f64_port(Firing::Barrier(0))],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -943,6 +954,7 @@ mod tests {
inputs: vec![f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -976,6 +988,7 @@ mod tests {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -1014,6 +1027,7 @@ mod tests {
FieldSpec { name: "volume".into(), kind: ScalarKind::F64 },
],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -1049,6 +1063,7 @@ mod tests {
FieldSpec { name: "i".into(), kind: ScalarKind::I64 },
],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -1076,6 +1091,7 @@ mod tests {
inputs: vec![f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -2434,6 +2450,7 @@ mod tests {
FieldSpec { name: "i".into(), kind: ScalarKind::I64 },
],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -2809,6 +2826,52 @@ mod tests {
assert_eq!(fired, vec![0, 1]);
}
struct InitProbe {
id: usize,
tx: mpsc::Sender<String>,
evaled: bool,
}
impl Node for InitProbe {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn initialize(&mut self) {
let _ = self.tx.send(format!("init{}", self.id));
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
if !self.evaled {
self.evaled = true;
let _ = self.tx.send(format!("eval{}", self.id));
}
None
}
}
#[test]
fn run_initializes_every_node_once_before_the_stream() {
let (tx, rx) = mpsc::channel();
// two sink probes, both fed by the single source; no inter-node edges.
let mut h = boot(
vec![
Box::new(InitProbe { id: 0, tx: tx.clone(), evaled: false }),
Box::new(InitProbe { id: 1, tx, evaled: false }),
],
vec![
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec::raw(ScalarKind::F64, vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }])],
vec![],
)
.expect("valid");
// nothing initialized before the run.
assert!(rx.try_recv().is_err());
h.run(vec![Box::new(VecSource::new(f64_stream(&[(1, 1.0), (2, 2.0)])))]);
// both inits fire, in topo order, strictly before any eval.
let fired: Vec<String> = rx.try_iter().collect();
assert_eq!(fired, vec!["init0", "init1", "eval0", "eval1"]);
}
#[test]
fn bind_tap_attaches_a_caller_built_sink_and_records_the_tapped_series() {
// A tap on a producer; bind_tap attaches a caller-built Recorder; the run
+1
View File
@@ -43,6 +43,7 @@ fn recorder_sig(kinds: &[ScalarKind]) -> NodeSchema {
.collect(),
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
+1
View File
@@ -29,6 +29,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let mut h = Harness::bootstrap(FlatGraph {
nodes: vec![
@@ -35,6 +35,7 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box<dyn Source>) -> RunRep
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let mut h = Harness::bootstrap(FlatGraph {
nodes: vec![
+1
View File
@@ -88,6 +88,7 @@ impl Resample {
FieldSpec { name: "close".into(), kind: ScalarKind::F64 },
],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
doc: "emits a completed bar once per resample boundary, never mid-bar",
},
|p| Box::new(Resample::new(p[0].i64())),
)
+2
View File
@@ -72,6 +72,7 @@ impl Session {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![],
doc: "bars elapsed since the session open, from the configured open time and timezone",
},
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)),
)
@@ -99,6 +100,7 @@ impl SessionFrankfurt {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
doc: "bars elapsed since the Frankfurt session open (09:00 Europe/Berlin)",
},
|p| Box::new(Session::new(9, 0, chrono_tz::Europe::Berlin, p[0].i64())),
)
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "aura-measurement"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
# C28 rung 3 (measurement): descriptive statistics over market streams.
# Seeded by #295 with the IC. Depends only inward: the scalar vocabulary
# and the domain-free statistics foundation.
[dependencies]
aura-core = { path = "../aura-core" }
aura-analysis = { path = "../aura-analysis" }
# serde (de)serializes IcMetrics for the run registry (C18), mirroring the
# R-vocabulary metric types (aura-backtest, aura-engine); the aligned pairs
# ride #[serde(skip)] (in-memory null inputs only, never persisted).
serde = { workspace = true }
# The #147 registry-dispatch acceptance tests (tests/registry_dispatch.rs)
# exercise the generic deflation machinery over the IC vocabulary — pure
# library properties relocated from the shell with #295. Dev-only edges,
# C28-layering-exempt.
[dev-dependencies]
aura-engine = { path = "../aura-engine" }
aura-registry = { path = "../aura-registry" }
+224
View File
@@ -0,0 +1,224 @@
//! The Information Coefficient — the measurement rung's first deflatable
//! metric (#295).
use aura_analysis::{one_sided_p_laplace, pearson_corr, permute, MetricVocabulary, SplitMix64};
use aura_core::Timestamp;
/// The IC measurement payload: the scalar plus its in-memory null inputs
/// (the aligned pairs ride #[serde(skip)], mirroring RMetrics.net_trade_rs).
/// The second production implementor of `MetricVocabulary` (#147) — the
/// registry's deflation machinery dispatches to ITS permutation null.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct IcMetrics {
pub information_coefficient: f64,
#[serde(skip)]
pub sigs: Vec<f64>,
#[serde(skip)]
pub frs: Vec<f64>,
}
/// The IC vocabulary's single resolved key.
#[derive(Clone, Copy)]
pub struct IcKey;
impl MetricVocabulary for IcMetrics {
type Key = IcKey;
fn resolve(name: &str) -> Option<IcKey> {
(name == "information_coefficient").then_some(IcKey)
}
fn known() -> &'static [&'static str] {
&["information_coefficient"]
}
fn higher_is_better(_: IcKey) -> bool {
true
}
fn value(&self, _: IcKey) -> f64 {
self.information_coefficient
}
fn has_resampling_null(_: IcKey) -> bool {
true
}
fn null_draw(&self, _: IcKey, _block_len: usize, rng: &mut SplitMix64) -> Option<f64> {
if self.sigs.len() < 2 {
return None; // consumes no rng
}
let mut perm = self.sigs.clone(); // independent draw, not a chained shuffle
permute(&mut perm, rng);
Some(pearson_corr(&perm, &self.frs))
}
}
/// The pure IC reduction result (no CLI context) — unit-testable in isolation.
/// Exercised by the in-crate `tests` module and consumed by the run/command-path
/// wiring in `dispatch_measure_ic`, which constructs `IcReport` from a live
/// measurement run's recorded taps.
pub struct IcOutcome {
pub information_coefficient: f64,
pub overfit_probability: f64,
pub n_pairs: usize,
}
/// `corr(signal_t, forward_return_{t+h})` over two recorded tap series, with a
/// permutation null. `forward_return[i] = (price[i+h] - price[i]) / price[i]` on the
/// price ts-spine (a post-run array shift over recorded data — C2 governs in-graph
/// nodes, not a completed run's trace); signal is aligned to it by EXACT timestamp
/// match (unmatched dropped). `< 2` aligned pairs or zero variance → the degenerate
/// floor `(0.0, 1.0)`. One-sided permutation null via the shared
/// `MetricVocabulary::null_draw` + `one_sided_p_laplace` building blocks (#147);
/// draws are independent permutations, deterministic given `seed`.
pub fn information_coefficient(
signal: &[(Timestamp, f64)],
price: &[(Timestamp, f64)],
horizon: usize,
permutations: usize,
seed: u64,
) -> IcOutcome {
use std::collections::HashMap;
// signal value by timestamp-i64 (`Timestamp` is a tuple struct over epoch-ns; a tap
// fires at most once per ts). `t.0` is the epoch-ns i64.
let sig_at: HashMap<i64, f64> = signal.iter().map(|(t, v)| (t.0, *v)).collect();
// forward returns on the price spine, paired with the signal at the SAME ts
let (mut sigs, mut frs) = (Vec::new(), Vec::new());
if horizon >= 1 && price.len() > horizon {
for i in 0..price.len() - horizon {
let (t, p0) = (price[i].0, price[i].1);
if p0 == 0.0 {
continue; // undefined return
}
let fr = (price[i + horizon].1 - p0) / p0;
if let Some(&s) = sig_at.get(&t.0) {
sigs.push(s);
frs.push(fr);
}
}
}
let n_pairs = sigs.len();
if n_pairs < 2 {
return IcOutcome { information_coefficient: 0.0, overfit_probability: 1.0, n_pairs };
}
let raw = pearson_corr(&sigs, &frs);
let member = IcMetrics { information_coefficient: raw, sigs, frs };
let mut rng = SplitMix64::new(seed);
let mut ge = 0usize;
for _ in 0..permutations {
let draw = member
.null_draw(IcKey, 0, &mut rng)
.expect("n_pairs >= 2 checked above");
if draw >= raw {
ge += 1;
}
}
let overfit_probability = one_sided_p_laplace(ge, permutations);
IcOutcome { information_coefficient: raw, overfit_probability, n_pairs }
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(i: i64) -> Timestamp {
Timestamp(i) // Timestamp is a tuple struct over epoch-ns i64 (pub field, per report.rs)
}
#[test]
fn ic_engineered_signal_is_significant() {
// price path; forward return fr[i] = (p[i+1]-p[i])/p[i]. Engineer signal_t = fr[i]
// exactly (a hand-built OFFLINE series — a look-ahead signal is impossible in a
// causal run, C2, so this property lives at the unit level, not E2E).
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0]
.iter()
.enumerate()
.map(|(i, &p)| (ts(i as i64), p))
.collect();
let signal: Vec<(Timestamp, f64)> = (0..price.len() - 1)
.map(|i| (ts(i as i64), (price[i + 1].1 - price[i].1) / price[i].1))
.collect();
let out = information_coefficient(&signal, &price, 1, 1000, 0);
assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient);
assert!(out.overfit_probability < 0.05, "p = {}", out.overfit_probability);
assert!(out.n_pairs >= 6);
}
#[test]
fn ic_constant_signal_is_not_significant() {
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0]
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
let signal: Vec<(Timestamp, f64)> =
(0..price.len()).map(|i| (ts(i as i64), 1.0)).collect(); // constant → zero variance
let out = information_coefficient(&signal, &price, 1, 1000, 0);
assert_eq!(out.information_coefficient, 0.0);
assert_eq!(out.overfit_probability, 1.0);
}
#[test]
fn ic_varying_uncorrelated_signal_is_not_significant() {
// The false-positive control on a NON-degenerate null: a VARYING signal (unlike the
// constant case above, whose null is degenerate) that is exactly uncorrelated with the
// forward returns. price is chosen so forward returns are exactly [0.01, 0.02, 0.03,
// 0.04]; signal is a permutation of those values arranged orthogonal to them
// (Σ centred products = 0 → IC = 0), so the permutation null genuinely varies yet the
// raw IC sits in its bulk (~half the permutations exceed it), not its tail → not
// significant. This is the core guarantee of a significance test.
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 103.02, 106.1106, 110.355024]
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
let signal: Vec<(Timestamp, f64)> = [0.03, 0.01, 0.04, 0.02]
.iter().enumerate().map(|(i, &s)| (ts(i as i64), s)).collect();
let out = information_coefficient(&signal, &price, 1, 1000, 0);
assert_eq!(out.n_pairs, 4);
assert!(out.information_coefficient.abs() < 1e-6, "ic ~ 0 expected, got {}", out.information_coefficient);
assert!(
out.overfit_probability > 0.1,
"an uncorrelated signal must not be significant: p = {}",
out.overfit_probability
);
}
#[test]
fn ic_is_deterministic_given_seed() {
let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5]
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
let signal: Vec<(Timestamp, f64)> =
[0.3, -0.1, 0.4, -0.2, 0.1, 0.5, -0.3].iter().enumerate()
.map(|(i, &s)| (ts(i as i64), s)).collect();
let a = information_coefficient(&signal, &price, 1, 500, 7);
let b = information_coefficient(&signal, &price, 1, 500, 7);
assert_eq!(a.information_coefficient, b.information_coefficient);
assert_eq!(a.overfit_probability, b.overfit_probability);
assert_eq!(a.n_pairs, b.n_pairs);
}
#[test]
fn ic_degenerate_floor_on_too_few_pairs() {
let price = vec![(ts(0), 100.0)]; // one row → no forward return → 0 pairs
let signal = vec![(ts(0), 1.0)];
let out = information_coefficient(&signal, &price, 1, 1000, 0);
assert_eq!(out.information_coefficient, 0.0);
assert_eq!(out.overfit_probability, 1.0);
assert_eq!(out.n_pairs, 0);
}
#[test]
fn ic_pairs_only_on_exact_timestamp_overlap() {
// Property: alignment is by EXACT ts match, unmatched dropped (the cross-cadence
// case). A price ts carrying no signal is dropped, and a signal ts absent from the
// price spine is ignored — so n_pairs is the OVERLAP count, never the price length,
// and the dropped rows never enter the correlation.
//
// price spine ts 0..8 → horizon-1 forward returns exist at ts 0..7. Signal is placed
// ONLY at ts {1,2,4,5} (leaving price ts {0,3,6} without a signal → dropped) plus two
// decoy ts {50,60} absent from the price spine (→ never looked up). Where present, the
// signal equals the forward return exactly, so the 4 matched pairs correlate perfectly.
let price: Vec<(Timestamp, f64)> =
[100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0]
.iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect();
let fr = |i: usize| (price[i + 1].1 - price[i].1) / price[i].1;
let mut signal: Vec<(Timestamp, f64)> =
[1usize, 2, 4, 5].iter().map(|&i| (ts(i as i64), fr(i))).collect();
signal.push((ts(50), 999.0)); // decoy ts not on the price spine → unmatched, dropped
signal.push((ts(60), -999.0));
let out = information_coefficient(&signal, &price, 1, 1000, 0);
assert_eq!(out.n_pairs, 4, "only the 4 overlapping ts pair; price ts 0/3/6 and the decoys drop");
assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! aura-measurement — the C28 measurement rung (rung 3).
//!
//! Descriptive statistics over market streams. Seeded (#295) with the
//! Information Coefficient: the `IcMetrics`/`IcKey` metric vocabulary and
//! the `information_coefficient` reduction.
pub mod ic;
pub use ic::{information_coefficient, IcKey, IcMetrics, IcOutcome};
@@ -0,0 +1,101 @@
//! #147 acceptance at the library boundary: the registry's generic
//! deflation machinery dispatches to the IC vocabulary's OWN permutation
//! null, deterministically (C1), and the C10 wall refuses cross-vocabulary
//! metric names both ways. Relocated from the shell's test module with #295
//! (these are pure library properties — a library-only consumer must be able
//! to run them; the registry/engine edges are dev-only, layering-exempt).
use aura_measurement::IcMetrics;
fn ic_report(sigs: Vec<f64>, frs: Vec<f64>) -> aura_engine::RunReport<IcMetrics> {
aura_engine::RunReport {
manifest: aura_engine::RunManifest {
commit: "c".to_string(),
params: vec![("p".to_string(), aura_core::Scalar::f64(1.0))],
defaults: vec![],
window: (aura_core::Timestamp(1), aura_core::Timestamp(2)),
seed: 0,
broker: "b".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: IcMetrics {
information_coefficient: aura_analysis::pearson_corr(&sigs, &frs),
sigs,
frs,
},
}
}
fn ic_family(members: Vec<aura_engine::RunReport<IcMetrics>>) -> aura_engine::SweepFamily<IcMetrics> {
aura_engine::SweepFamily {
space: vec![],
points: members
.into_iter()
.enumerate()
.map(|(i, report)| aura_engine::SweepPoint {
params: vec![aura_core::Cell::from_f64(i as f64)],
report,
})
.collect(),
}
}
/// #147 acceptance: the registry's deflation dispatches to the IC
/// vocabulary's OWN permutation null — a genuinely predictive family
/// deflates to a low overfit probability, a noise family to a high one.
#[test]
fn ic_family_deflation_dispatches_the_permutation_null() {
let ramp: Vec<f64> = (0..24).map(|i| i as f64).collect();
let engineered = ic_family(vec![
ic_report(ramp.clone(), ramp.clone()), // corr = 1.0: a real edge
ic_report(ramp.clone(), ramp.iter().rev().cloned().collect()), // corr = -1.0
]);
let (winner, sel) =
aura_registry::optimize_deflated(&engineered, "information_coefficient", 500, 5, 0)
.expect("ic family deflates");
assert!((winner.report.metrics.information_coefficient - 1.0).abs() < 1e-12);
let p = sel.overfit_probability.expect("resampling-null arm sets a probability");
assert!(p < 0.05, "perfectly predictive family must not look like overfit: p={p}");
// orthogonal signal — IC exactly 0.0; permuted draws beat it ~half the time.
let noise = ic_family(vec![ic_report(
vec![0.03, 0.01, 0.04, 0.02, 0.05, 0.00, 0.06, -0.01],
vec![0.01, 0.02, 0.03, 0.04, -0.04, -0.03, -0.02, -0.01],
)]);
let (_, sel) =
aura_registry::optimize_deflated(&noise, "information_coefficient", 500, 5, 0)
.expect("noise family deflates");
let p = sel.overfit_probability.expect("probability present");
assert!(p > 0.1, "a noise family must carry a high overfit probability: p={p}");
}
/// Same seed → identical selection provenance (C1 through the generic path).
#[test]
fn ic_family_deflation_is_deterministic_given_seed() {
let ramp: Vec<f64> = (0..16).map(|i| i as f64).collect();
let fam = ic_family(vec![ic_report(ramp.clone(), ramp)]);
let a = aura_registry::optimize_deflated(&fam, "information_coefficient", 300, 5, 7)
.expect("deflate a");
let b = aura_registry::optimize_deflated(&fam, "information_coefficient", 300, 5, 7)
.expect("deflate b");
assert_eq!(a.1, b.1, "same seed must yield identical FamilySelection");
}
/// C10 guard: cross-vocabulary names are refused, and the refusal names
/// the family's REAL vocabulary.
#[test]
fn cross_vocabulary_names_are_refused_both_ways() {
let ramp: Vec<f64> = (0..8).map(|i| i as f64).collect();
let fam = ic_family(vec![ic_report(ramp.clone(), ramp)]);
let err = aura_registry::optimize_deflated(&fam, "sqn", 100, 5, 0).unwrap_err();
let prose = err.to_string();
assert!(
prose.contains("unknown metric 'sqn'") && prose.contains("information_coefficient"),
"refusal must name the IC vocabulary: {prose}"
);
// and the R side still refuses the IC name with ITS roster:
assert!(aura_registry::check_r_metric("information_coefficient").is_err());
}
+149 -10
View File
@@ -21,8 +21,8 @@ use aura_core::PrimitiveBuilder;
use aura_analysis::{one_sided_p_laplace, MetricStats, MetricVocabulary, SplitMix64};
use aura_backtest::{RunMetrics, RunReport};
use aura_engine::{
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, FamilySelection,
SelectionMode,
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, BlueprintDoc,
CompositeData, FamilySelection, NodeData, SelectionMode,
};
use aura_research::{CampaignDoc, DocRef};
@@ -37,7 +37,10 @@ pub use lineage::{
};
mod trace_store;
pub use trace_store::{FamilyMember, NameKind, RunTraces, TraceStore, TraceStoreError, WriteKind};
pub use trace_store::{
FamilyMember, NameKind, RunTraces, TapTraceWriter, TapWriterOpener, TraceStore,
TraceStoreError, TraceStreamer, WriteKind,
};
/// An append-only run registry over a JSONL file: one serde_json line per
/// `RunReport`.
@@ -140,12 +143,26 @@ impl Registry {
/// so a repeated write re-writes identical content. The registry does NOT
/// verify `sha256(bytes) == hash` (no `sha2` dep here): the caller owns the
/// hash, and reproduction's bit-identical metric compare is the integrity check.
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
/// Raw write — C29-gated callers go through [`Registry::put_blueprint`]; this
/// stays for store-mechanics tests and pre-C29 store content.
fn put_blueprint_unchecked(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
fs::create_dir_all(self.blueprints_dir())?;
fs::write(self.blueprint_path(hash), canonical_json)?;
Ok(())
}
/// Write-once content-addressed put, C29-gated (#316): only a canonical
/// blueprint whose root composite and every named nested composite carry
/// a gate-passing doc enters the store. The gate guards the store
/// boundary itself — verb, sugar, and any future caller alike — and the
/// write path only: already-registered doc-less entries stay readable.
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
let doc: BlueprintDoc = serde_json::from_str(canonical_json)
.map_err(RegistryError::MalformedBlueprint)?;
gate_composite_docs(&doc.blueprint)?;
self.put_blueprint_unchecked(hash, canonical_json)
}
/// Read a stored blueprint by content id; `Ok(None)` if absent — the same
/// treat-as-empty discipline `load` applies to a missing runs store.
pub fn get_blueprint(&self, hash: &str) -> Result<Option<String>, RegistryError> {
@@ -189,6 +206,28 @@ impl Registry {
}
}
/// Every content id currently stored for one document kind — the
/// filename stems under `doc_dir(kind)` (`doc_path`'s write-one/read-one
/// mapping, read back), for prefix resolution (#302). A missing store
/// dir (nothing registered yet) is treat-as-empty, not an error, the
/// same discipline `find_blueprint_by_identity`'s scan applies to a
/// missing blueprints dir.
fn list_doc_ids(&self, kind: &str) -> Result<Vec<String>, RegistryError> {
let entries = match fs::read_dir(self.doc_dir(kind)) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(RegistryError::Io(e)),
};
let mut ids = Vec::new();
for entry in entries {
let entry = entry?;
if let Some(id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from)) {
ids.push(id);
}
}
Ok(ids)
}
/// Store a canonical process document; returns its content id.
pub fn put_process(&self, canonical_json: &str) -> Result<String, RegistryError> {
self.put_doc("processes", canonical_json)
@@ -199,6 +238,12 @@ impl Registry {
self.get_doc("processes", content_id)
}
/// Every content id currently registered in the process store — the
/// candidate list `show`'s prefix resolution (#302) filters against.
pub fn list_process_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("processes")
}
/// Store a canonical campaign document; returns its content id.
pub fn put_campaign(&self, canonical_json: &str) -> Result<String, RegistryError> {
self.put_doc("campaigns", canonical_json)
@@ -209,6 +254,12 @@ impl Registry {
self.get_doc("campaigns", content_id)
}
/// Every content id currently registered in the campaign store — the
/// candidate list `show`'s prefix resolution (#302) filters against.
pub fn list_campaign_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("campaigns")
}
/// The store path a process document with this content id lives at —
/// the same single mapping the put/get pair routes through, exposed so
/// consumers never re-derive the layout.
@@ -222,6 +273,25 @@ impl Registry {
}
}
/// C29 walk (#316): the root and every named nested composite must carry a
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
/// the Empty fault ("carries no doc").
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
let fault = match c.doc.as_deref() {
None => Some(aura_core::DocGateFault::Empty),
Some(d) => aura_core::doc_gate(&c.name, d).err(),
};
if let Some(fault) = fault {
return Err(RegistryError::UndescribedComposite { name: c.name.clone(), fault });
}
for n in &c.nodes {
if let NodeData::Composite(nested) = n {
gate_composite_docs(nested)?;
}
}
Ok(())
}
/// Referential-validation findings for a campaign document. By-identifier
/// and Display-free (the CLI phrases them).
#[derive(Clone, Debug, PartialEq)]
@@ -860,6 +930,14 @@ pub enum RegistryError {
NonRMetric(String),
/// A generalization was asked for with fewer than two instruments.
TooFewInstruments(usize),
/// C29 register seam (#316): the canonical bytes offered to the blueprint
/// store do not parse as a canonical blueprint — the gate cannot certify
/// what it cannot read.
MalformedBlueprint(serde_json::Error),
/// C29 register seam (#316): a root or named nested composite entering
/// the store without a gate-passing doc. Registered artifacts are never
/// retroactively invalidated — the gate guards the write path only.
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
}
impl fmt::Display for RegistryError {
@@ -883,6 +961,23 @@ impl fmt::Display for RegistryError {
f,
"a generalization needs >= 2 instruments, got {n}"
),
RegistryError::MalformedBlueprint(e) => {
write!(f, "blueprint: not a canonical blueprint: {e}")
}
RegistryError::UndescribedComposite { name, fault } => match fault {
aura_core::DocGateFault::Empty => write!(
f,
"blueprint: composite `{name}` carries no doc — a registered \
composite describes itself (C29); add a doc line (builder \
.doc(\"...\"), blueprint \"doc\" member, or op-script \
{{\"op\":\"doc\"}}) before register"
),
aura_core::DocGateFault::RestatesName => write!(
f,
"blueprint: composite `{name}` has a doc that merely restates \
its name a registered composite describes itself (C29)"
),
},
}
}
}
@@ -1033,13 +1128,50 @@ mod tests {
fn blueprint_store_round_trips_by_content_id() {
let reg = Registry::open(temp_family_dir("blueprint_store_round_trip"));
let canonical = r#"{"format_version":1,"input_roles":[],"nodes":[],"edges":[]}"#;
reg.put_blueprint("deadbeef", canonical).expect("put");
reg.put_blueprint_unchecked("deadbeef", canonical).expect("put");
// exact bytes come back, keyed by content id
assert_eq!(reg.get_blueprint("deadbeef").expect("get"), Some(canonical.to_string()));
// an absent id is Ok(None), not an error (treat-as-empty discipline)
assert_eq!(reg.get_blueprint("never-written").expect("get"), None);
}
/// C29 (#316): the store refuses a doc-less root (None and "" alike),
/// a name-restating doc, and admits a described composite. The raw
/// unchecked path still round-trips pre-C29 doc-less bytes — registered
/// artifacts are never retroactively invalidated.
#[test]
fn blueprint_store_gates_docs_at_the_write_boundary() {
let reg = Registry::open(temp_family_dir("blueprint_store_doc_gate"));
let docless = r#"{"format_version":1,"blueprint":{"name":"s","nodes":[]}}"#;
let e = reg.put_blueprint("h1", docless).expect_err("doc-less root must refuse");
assert!(
e.to_string().contains("composite `s` carries no doc"),
"prose names the composite and the rule: {e}"
);
let restated = r#"{"format_version":1,"blueprint":{"name":"echo","doc":"Echo","nodes":[]}}"#;
let e = reg.put_blueprint("h2", restated).expect_err("restated doc must refuse");
assert!(e.to_string().contains("merely restates"), "prose names the rule: {e}");
let described = r#"{"format_version":1,"blueprint":{"name":"s","doc":"a described fixture blueprint","nodes":[]}}"#;
reg.put_blueprint("h3", described).expect("described root registers");
assert_eq!(reg.get_blueprint("h3").expect("get"), Some(described.to_string()));
// never-retroactive witness: a pre-C29 doc-less entry stays readable
reg.put_blueprint_unchecked("h4", docless).expect("raw write");
assert_eq!(reg.get_blueprint("h4").expect("get"), Some(docless.to_string()));
}
/// A doc-less named NESTED composite refuses even when the root is
/// described — the walk covers every named nested composite (C29).
#[test]
fn blueprint_store_gates_nested_composite_docs() {
let reg = Registry::open(temp_family_dir("blueprint_store_nested_doc_gate"));
let nested_docless = r#"{"format_version":1,"blueprint":{"name":"root","doc":"a described root","nodes":[{"composite":{"name":"inner","nodes":[]}}]}}"#;
let e = reg.put_blueprint("h1", nested_docless).expect_err("doc-less nested must refuse");
assert!(
e.to_string().contains("composite `inner` carries no doc"),
"prose names the nested composite: {e}"
);
}
#[test]
fn document_stores_round_trip_by_content_id() {
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
@@ -1773,7 +1905,8 @@ mod tests {
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let space = composite.param_space();
let real = space.first().expect("fixture composite has an open param");
let real_name = real.name.clone();
@@ -1904,6 +2037,7 @@ mod tests {
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test")
}
/// A second, identity-distinct fixture: same shape over the zero-arg
@@ -1918,6 +2052,7 @@ mod tests {
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test")
}
#[test]
@@ -2058,7 +2193,8 @@ mod tests {
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let (_json_b, content_b, identity_twin) = seed_blueprint(&reg, &twin);
assert_eq!(identity, identity_twin, "twins must share one identity id");
assert_ne!(content_a, content_b, "twins must have distinct content ids");
@@ -2113,7 +2249,8 @@ mod tests {
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
assert!(composite.param_space().is_empty(), "fixture is fully bound");
let bound = composite.bound_param_space();
let bound_param = bound.first().expect("fixture has one bound param");
@@ -2206,7 +2343,8 @@ mod tests {
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let space = composite.param_space();
assert_eq!(space.len(), 2, "fixture exposes two open params");
let covered = space[0].name.clone(); // e.g. "b1.scale"
@@ -2279,7 +2417,8 @@ mod tests {
source: Some(aura_core::ScalarKind::F64),
}],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let space = composite.param_space();
let axis = space.first().expect("fixture has an open param").name.clone();
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
+389 -17
View File
@@ -7,8 +7,11 @@
use std::fmt;
use std::fs;
use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use aura_core::{Cell, ScalarKind, Timestamp};
use aura_engine::{ColumnarTrace, RunManifest};
use serde::{Deserialize, Serialize};
@@ -97,24 +100,19 @@ impl TraceStore {
let run_dir = self.dir.join(name);
fs::create_dir_all(&run_dir)?;
for tap in taps {
let path = run_dir.join(format!("{}.json", tap.tap));
let body = serde_json::to_string(tap).map_err(|source| TraceStoreError::Parse {
file: path.display().to_string(),
source,
})?;
fs::write(&path, body)?;
write_tap_file(&run_dir, tap)?;
}
let index = Index {
manifest: manifest.clone(),
taps: taps.iter().map(|t| t.tap.clone()).collect(),
};
let index_path = run_dir.join("index.json");
let body = serde_json::to_string(&index).map_err(|source| TraceStoreError::Parse {
file: index_path.display().to_string(),
source,
})?;
fs::write(&index_path, body)?;
Ok(())
let order: Vec<String> = taps.iter().map(|t| t.tap.clone()).collect();
write_index(&run_dir, manifest, &order)
}
/// Begin a streamed run write under `traces/<name>/` (directories created
/// now; `index.json` only at [`TraceStreamer::finish`] — the crash
/// discipline `read` already honors: no index → `NotFound`).
pub fn begin_run(&self, name: &str) -> Result<TraceStreamer, TraceStoreError> {
let run_dir = self.dir.join(name);
fs::create_dir_all(&run_dir)?;
Ok(TraceStreamer { run_dir })
}
/// Read a run's index + taps back, in index order. A missing run directory (no
@@ -239,6 +237,216 @@ impl TraceStore {
}
}
/// One tap's canonical `<tap>.json` under `run_dir` — the single write shape
/// both `TraceStore::write` and `TraceStreamer::write_full` share.
fn write_tap_file(run_dir: &Path, tap: &ColumnarTrace) -> Result<(), TraceStoreError> {
let path = run_dir.join(format!("{}.json", tap.tap));
let body = serde_json::to_string(tap).map_err(|source| TraceStoreError::Parse {
file: path.display().to_string(),
source,
})?;
fs::write(&path, body)?;
Ok(())
}
/// The run's `index.json` (manifest + tap order) — shared by `write` and
/// `TraceStreamer::finish`. Written last by both paths (crash discipline:
/// no `index.json` → `NotFound`, treat-as-absent).
fn write_index(
run_dir: &Path,
manifest: &RunManifest,
taps: &[String],
) -> Result<(), TraceStoreError> {
let index = Index { manifest: manifest.clone(), taps: taps.to_vec() };
let index_path = run_dir.join("index.json");
let body = serde_json::to_string(&index).map_err(|source| TraceStoreError::Parse {
file: index_path.display().to_string(),
source,
})?;
fs::write(&index_path, body)?;
Ok(())
}
/// A begun streamed run write: hands out per-tap incremental writers and
/// writes the closing `index.json`. Tap order in the index is the caller's
/// (declared-tap order — the caller tracks it; this type keeps no order
/// state so record-writer threads and post-run fold writes can interleave).
pub struct TraceStreamer {
run_dir: PathBuf,
}
impl TraceStreamer {
/// An incremental writer for one tap, opened now. `Send`. Expressed
/// through [`TraceStreamer::register_tap`] so there is exactly one open
/// path and the two cannot drift.
pub fn tap_writer(&self, tap: &str, kind: ScalarKind) -> Result<TapTraceWriter, TraceStoreError> {
self.register_tap(tap, kind).open()
}
/// The deferred twin of [`TraceStreamer::tap_writer`]: everything needed
/// to open one tap's incremental writer later — from inside a consumer
/// node's `initialize`, at run start — without holding the streamer.
/// `Send`; opens no handles until [`TapWriterOpener::open`].
pub fn register_tap(&self, tap: &str, kind: ScalarKind) -> TapWriterOpener {
TapWriterOpener { run_dir: self.run_dir.clone(), tap: tap.to_string(), kind }
}
/// Write one complete tap file at once (the fold path's one-row trace) —
/// the same bytes `TraceStore::write` produces for it.
pub fn write_full(&self, tap: &ColumnarTrace) -> Result<(), TraceStoreError> {
write_tap_file(&self.run_dir, tap)
}
/// Close the run: write `index.json` (manifest + `taps` in caller order).
pub fn finish(self, manifest: &RunManifest, taps: &[String]) -> Result<(), TraceStoreError> {
write_index(&self.run_dir, manifest, taps)
}
}
/// See [`TraceStreamer::register_tap`].
pub struct TapWriterOpener {
run_dir: PathBuf,
tap: String,
kind: ScalarKind,
}
impl TapWriterOpener {
/// Open the two temp column streams and return the incremental writer.
pub fn open(self) -> Result<TapTraceWriter, TraceStoreError> {
let final_path = self.run_dir.join(format!("{}.json", self.tap));
let ts_path = self.run_dir.join(format!("{}.ts.tmp", self.tap));
let col_path = self.run_dir.join(format!("{}.col.tmp", self.tap));
// Read+write handles, NOT `File::create`: `finish` seeks these same
// handles back and `io::copy`-reads them, and a `File::create` handle
// is O_WRONLY — reading it back fails with EBADF (os error 9).
let ts_w = BufWriter::new(
OpenOptions::new().read(true).write(true).create(true).truncate(true).open(&ts_path)?,
);
let col_w = BufWriter::new(
OpenOptions::new().read(true).write(true).create(true).truncate(true).open(&col_path)?,
);
Ok(TapTraceWriter {
tap: self.tap,
kind: self.kind,
final_path,
ts_path,
col_path,
ts_w,
col_w,
any: false,
})
}
}
/// The four-kind tag string (the on-disk `ColumnarTrace.kinds` form). A local
/// copy of `report.rs`'s private `kind_tag`; the streamed-vs-write
/// byte-equality test below is the drift guard.
fn tag(kind: ScalarKind) -> &'static str {
match kind {
ScalarKind::F64 => "F64",
ScalarKind::I64 => "I64",
ScalarKind::Bool => "Bool",
ScalarKind::Timestamp => "Timestamp",
}
}
/// Coerce one cell to the f64 a trace column stores — the same table as
/// `ColumnarTrace::from_rows` (`scalar_to_f64`): f64 as-is, i64 as f64,
/// bool 1.0/0.0, timestamp epoch as f64.
fn cell_to_f64(kind: ScalarKind, cell: Cell) -> f64 {
match kind {
ScalarKind::F64 => cell.f64(),
ScalarKind::I64 => cell.i64() as f64,
ScalarKind::Bool => {
if cell.bool() {
1.0
} else {
0.0
}
}
ScalarKind::Timestamp => cell.ts().0 as f64,
}
}
/// Incremental single-column trace writer: appends each `(ts, cell)` to two
/// temp streams, then `finish` assembles the canonical `ColumnarTrace` JSON
/// by streaming concatenation (constant memory) and removes the temps. The
/// output is byte-equal to `serde_json::to_string(&ColumnarTrace)` for the
/// same rows — pinned by test against `TraceStore::write`, which is the
/// anchor (not a frozen byte-shape assumption).
pub struct TapTraceWriter {
tap: String,
kind: ScalarKind,
final_path: PathBuf,
ts_path: PathBuf,
col_path: PathBuf,
ts_w: BufWriter<File>,
col_w: BufWriter<File>,
any: bool,
}
impl TapTraceWriter {
/// Append one recorded point. Number formatting is `serde_json`'s own
/// (`to_writer` per value) — identical to the whole-struct serialize.
pub fn append(&mut self, ts: Timestamp, cell: Cell) -> Result<(), TraceStoreError> {
if self.any {
self.ts_w.write_all(b",")?;
self.col_w.write_all(b",")?;
}
serde_json::to_writer(&mut self.ts_w, &ts.0).map_err(|source| TraceStoreError::Parse {
file: self.ts_path.display().to_string(),
source,
})?;
serde_json::to_writer(&mut self.col_w, &cell_to_f64(self.kind, cell)).map_err(
|source| TraceStoreError::Parse {
file: self.col_path.display().to_string(),
source,
},
)?;
self.any = true;
Ok(())
}
/// Assemble `{"tap":…,"kinds":[…],"ts":[⟨ts-tmp⟩],"columns":[[⟨col-tmp⟩]]}`
/// and remove the temps.
pub fn finish(self) -> Result<(), TraceStoreError> {
let TapTraceWriter { tap, kind, final_path, ts_path, col_path, ts_w, col_w, any: _ } = self;
let mut ts_f = ts_w.into_inner().map_err(|e| TraceStoreError::Io(e.into_error()))?;
let mut col_f = col_w.into_inner().map_err(|e| TraceStoreError::Io(e.into_error()))?;
ts_f.flush()?;
col_f.flush()?;
ts_f.seek(SeekFrom::Start(0))?;
col_f.seek(SeekFrom::Start(0))?;
let mut out = BufWriter::new(File::create(&final_path)?);
// Field order (tap, kinds, ts, columns) = ColumnarTrace declaration
// order; string fields serialized via serde for correct escaping.
write!(
out,
"{{\"tap\":{},\"kinds\":[{}],\"ts\":[",
serde_json::to_string(&tap).map_err(|source| TraceStoreError::Parse {
file: final_path.display().to_string(),
source,
})?,
serde_json::to_string(tag(kind)).map_err(|source| TraceStoreError::Parse {
file: final_path.display().to_string(),
source,
})?,
)?;
io::copy(&mut ts_f, &mut out)?;
out.write_all(b"],\"columns\":[[")?;
io::copy(&mut col_f, &mut out)?;
out.write_all(b"]]}")?;
out.flush()?;
drop(out);
drop(ts_f);
drop(col_f);
fs::remove_file(&ts_path)?;
fs::remove_file(&col_path)?;
Ok(())
}
}
/// What can go wrong reading or writing the trace store.
#[derive(Debug)]
pub enum TraceStoreError {
@@ -447,4 +655,168 @@ mod tests {
assert!(store.ensure_name_free("fresh", WriteKind::Run).is_ok());
let _ = fs::remove_dir_all(&root);
}
/// The load-bearing compatibility pin: for the same rows, the streamed
/// writer's file bytes equal `TraceStore::write`'s — the legacy path's
/// bytes, whatever they are, define the shape. Covers all four
/// `ScalarKind` arms (f64 fractional/negative, bool, i64, timestamp) —
/// every arm `tag`/`cell_to_f64` dispatch on, since those two helpers are
/// a local copy of `report.rs`'s private `kind_tag`/`scalar_to_f64` and
/// this is the only drift guard against them — plus the empty
/// (never-warm) tap.
#[test]
fn streamed_writer_is_byte_equal_to_write_for_the_same_rows() {
use aura_core::Cell;
type Case<'a> = (&'a str, ScalarKind, Vec<(Timestamp, Cell)>);
let cases: Vec<Case> = vec![
(
"f64_tap",
ScalarKind::F64,
vec![
(Timestamp(2), Cell::from_f64(0.30000000000000004)),
(Timestamp(3), Cell::from_f64(-1.5)),
(Timestamp(5), Cell::from_f64(2.0)),
],
),
(
"bool_tap",
ScalarKind::Bool,
vec![(Timestamp(1), Cell::from_bool(true)), (Timestamp(2), Cell::from_bool(false))],
),
(
"i64_tap",
ScalarKind::I64,
vec![(Timestamp(1), Cell::from_i64(-7)), (Timestamp(2), Cell::from_i64(42))],
),
(
"timestamp_tap",
ScalarKind::Timestamp,
vec![
(Timestamp(1), Cell::from_ts(Timestamp(100))),
(Timestamp(2), Cell::from_ts(Timestamp(-3))),
],
),
("empty_tap", ScalarKind::F64, vec![]),
];
for (tap, kind, rows) in cases {
// legacy path
let legacy_root = temp_traces_root(&format!("byteeq-legacy-{tap}"));
let legacy = TraceStore::open(&legacy_root);
let scalar_rows: Vec<(Timestamp, Vec<Scalar>)> = rows
.iter()
.map(|&(t, c)| (t, vec![Scalar::from_cell(kind, c)]))
.collect();
let trace = ColumnarTrace::from_rows(tap, &[kind], &scalar_rows);
legacy.write("run", &sample_manifest(), &[trace]).expect("legacy write");
let legacy_bytes =
fs::read(legacy_root.join(format!("traces/run/{tap}.json"))).expect("legacy bytes");
// streamed path
let stream_root = temp_traces_root(&format!("byteeq-stream-{tap}"));
let store = TraceStore::open(&stream_root);
let streamer = store.begin_run("run").expect("begin_run");
let mut w = streamer.tap_writer(tap, kind).expect("tap_writer");
for (t, c) in rows {
w.append(t, c).expect("append");
}
w.finish().expect("finish");
let streamed_bytes =
fs::read(stream_root.join(format!("traces/run/{tap}.json"))).expect("streamed bytes");
assert_eq!(
streamed_bytes, legacy_bytes,
"streamed and legacy write bytes must be identical for {tap}"
);
// temps removed
assert!(!stream_root.join(format!("traces/run/{tap}.ts.tmp")).exists());
assert!(!stream_root.join(format!("traces/run/{tap}.col.tmp")).exists());
let _ = fs::remove_dir_all(&legacy_root);
let _ = fs::remove_dir_all(&stream_root);
}
}
/// A begun-but-unfinished streamed run (crash shape: tap file written or
/// half-written, no `index.json`) reads as `NotFound` — treat-as-absent.
#[test]
fn begun_but_unfinished_streamed_run_reads_not_found() {
use aura_core::Cell;
let root = temp_traces_root("stream-crash");
let store = TraceStore::open(&root);
let streamer = store.begin_run("crashed").expect("begin_run");
let mut w = streamer.tap_writer("t", ScalarKind::F64).expect("tap_writer");
w.append(Timestamp(1), Cell::from_f64(1.0)).expect("append");
// no w.finish(), no streamer.finish(): the crash shape.
drop(w);
match store.read("crashed") {
Err(TraceStoreError::NotFound(name)) => assert_eq!(name, "crashed"),
other => panic!("expected NotFound, got {other:?}"),
}
let _ = fs::remove_dir_all(&root);
}
/// `register_tap`/`TapWriterOpener::open` produce the exact same bytes as
/// `tap_writer` for the same rows — the deferred opener and the
/// open-now writer are one write path (`tap_writer` delegates to
/// `register_tap(..).open()`), so they cannot drift.
#[test]
fn register_tap_open_is_the_same_write_path_as_tap_writer() {
use aura_core::Cell;
let root = temp_traces_root("deferred-open");
let store = TraceStore::open(&root);
let rows =
[(Timestamp(2), Cell::from_f64(0.25)), (Timestamp(3), Cell::from_f64(-1.5))];
// run "a": open-now.
let sa = store.begin_run("a").expect("begin a");
let mut wa = sa.tap_writer("t", ScalarKind::F64).expect("open now");
for &(ts, c) in &rows {
wa.append(ts, c).expect("append a");
}
wa.finish().expect("finish a");
// run "b": deferred — the opener is Send and opens at use time.
let sb = store.begin_run("b").expect("begin b");
let opener = sb.register_tap("t", ScalarKind::F64);
fn assert_send<T: Send>(_: &T) {}
assert_send(&opener);
let mut wb = opener.open().expect("deferred open");
for &(ts, c) in &rows {
wb.append(ts, c).expect("append b");
}
wb.finish().expect("finish b");
let a = fs::read(root.join("traces").join("a").join("t.json")).expect("read a");
let b = fs::read(root.join("traces").join("b").join("t.json")).expect("read b");
assert_eq!(a, b, "one open path, identical bytes");
}
/// `TraceStreamer::finish` + `write_full` produce a run `read` returns in
/// caller order — the fold path's one-row trace beside a streamed tap.
#[test]
fn streamer_finish_indexes_streamed_and_full_taps_in_caller_order() {
use aura_core::Cell;
let root = temp_traces_root("stream-index");
let store = TraceStore::open(&root);
let streamer = store.begin_run("mixed").expect("begin_run");
let mut w = streamer.tap_writer("series", ScalarKind::F64).expect("tap_writer");
w.append(Timestamp(1), Cell::from_f64(1.0)).expect("append");
w.finish().expect("finish series");
let fold_row = ColumnarTrace::from_rows(
"agg",
&[ScalarKind::I64],
&[(Timestamp(1), vec![Scalar::i64(1)])],
);
streamer.write_full(&fold_row).expect("write_full");
streamer
.finish(&sample_manifest(), &["series".to_string(), "agg".to_string()])
.expect("streamer finish");
let back = store.read("mixed").expect("read");
let names: Vec<&str> = back.taps.iter().map(|t| t.tap.as_str()).collect();
assert_eq!(names, vec!["series", "agg"], "index order = caller order");
let _ = fs::remove_dir_all(&root);
}
}
+171 -32
View File
@@ -13,7 +13,7 @@
use std::collections::BTreeMap;
use aura_core::{Scalar, ScalarKind};
use aura_core::{doc_gate, DocGateFault, Scalar, ScalarKind};
use serde::{Deserialize, Serialize};
/// The one accepted document format version.
@@ -295,10 +295,17 @@ fn select_from(
) -> Result<SelectRule, String> {
let s = require_str(m, "select", block)?;
// SelectRule's own derived Deserialize (which carries the #[serde(rename)]
// wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare:
// no second hardcoded copy of the three select-rule wire strings.
serde_json::from_value(serde_json::Value::String(s.clone()))
.map_err(|_| format!("block {block}: unknown select rule \"{s}\""))
// wire strings) is the type oracle for *parsing*, mirroring
// kind_tag/scalar_from_bare. The refusal's roster text below is sourced
// from slot_kind_label(SlotKind::SelectRule) rather than a second
// hardcoded copy of the three wire strings: CLI describe output and the
// document refusal stay in sync by construction (add a SelectRule
// variant, update one string).
serde_json::from_value(serde_json::Value::String(s.clone())).map_err(|_| {
let roster =
slot_kind_label(SlotKind::SelectRule).trim_start_matches("select rule: ");
format!("block {block}: unknown select rule \"{s}\" (accepted: {roster})")
})
}
fn mode_from(
@@ -720,6 +727,14 @@ pub fn content_id_of(canonical: &str) -> String {
// Intrinsic validation (store-free).
// ---------------------------------------------------------------------------
/// A shipped metric with its one-line meaning (C29). One carrier — the
/// #190 guard keeps this list, the RMetrics serde fields, and the CLI
/// count in lockstep; the doc column adds no fourth roster site.
pub struct MetricSchema {
pub id: &'static str,
pub doc: &'static str,
}
/// The closed declared-metric vocabulary: the scalar field names of the
/// shipped metrics/selection types (RMetrics + RunMetrics scalars, plus the
/// FamilySelection annotation scores). Single source for gate/stage checks
@@ -739,25 +754,25 @@ pub fn content_id_of(canonical: &str) -> String {
/// hand when the upstream field sets change (this list stays a hand-copy by
/// the crate's ratified core-only isolation; #147 single-sourced the rankable
/// roster elsewhere and the extended #190 guard pins this list's nesting).
pub fn metric_vocabulary() -> &'static [&'static str] {
pub fn metric_vocabulary() -> &'static [MetricSchema] {
&[
"expectancy_r",
"win_rate",
"avg_win_r",
"avg_loss_r",
"profit_factor",
"max_r_drawdown",
"sqn",
"sqn_normalized",
"net_expectancy_r",
"n_trades",
"n_open_at_end",
"total_pips",
"max_drawdown",
"bias_sign_flips",
"deflated_score",
"overfit_probability",
"neighbourhood_score",
MetricSchema { id: "expectancy_r", doc: "mean realized R per closed trade — the headline E[R]" },
MetricSchema { id: "win_rate", doc: "fraction of closed trades with positive realized R" },
MetricSchema { id: "avg_win_r", doc: "mean realized R over winning trades" },
MetricSchema { id: "avg_loss_r", doc: "mean realized R over losing trades" },
MetricSchema { id: "profit_factor", doc: "gross winning R divided by absolute gross losing R" },
MetricSchema { id: "max_r_drawdown", doc: "deepest peak-to-trough decline of the cumulative R curve" },
MetricSchema { id: "sqn", doc: "System Quality Number: mean R over its std deviation, scaled by trade count" },
MetricSchema { id: "sqn_normalized", doc: "SQN normalized to a 100-trade basis for n-independent comparison" },
MetricSchema { id: "net_expectancy_r", doc: "expectancy net of the cost model's per-trade cost in R" },
MetricSchema { id: "n_trades", doc: "count of closed trades in the run" },
MetricSchema { id: "n_open_at_end", doc: "positions still open when the run ends" },
MetricSchema { id: "total_pips", doc: "summed pip result over all closed trades" },
MetricSchema { id: "max_drawdown", doc: "deepest peak-to-trough decline of the pip equity curve" },
MetricSchema { id: "bias_sign_flips", doc: "count of sign changes in the strategy's bias stream" },
MetricSchema { id: "deflated_score", doc: "selection score deflated for multiple testing against the bootstrap null" },
MetricSchema { id: "overfit_probability", doc: "probability the selected edge is overfit, from the bootstrap null" },
MetricSchema { id: "neighbourhood_score", doc: "plateau robustness: score aggregated over the param-space neighbourhood" },
]
}
@@ -766,12 +781,23 @@ pub fn emit_vocabulary() -> &'static [&'static str] {
&["family_table", "selection_report"]
}
/// A closed-vocabulary persisted-tap slot with its one-line meaning (C29).
pub struct TapSchema {
pub id: &'static str,
pub doc: &'static str,
}
/// The wrap convention's persisted sink names — the closed tap vocabulary
/// (#201 decision 1). A genuinely new observable becomes a new entry or an
/// authored sink in the strategy blueprint (C22: the choice of sinks is part
/// of the experiment) — never an open node-path namespace in the document.
pub fn tap_vocabulary() -> &'static [&'static str] {
&["equity", "exposure", "r_equity", "net_r_equity"]
pub fn tap_vocabulary() -> &'static [TapSchema] {
&[
TapSchema { id: "equity", doc: "cumulative pip equity per cycle" },
TapSchema { id: "exposure", doc: "signed position exposure per cycle" },
TapSchema { id: "r_equity", doc: "cumulative gross R per cycle" },
TapSchema { id: "net_r_equity", doc: "cumulative net R per cycle, after the cost model" },
]
}
/// The closed archive-column vocabulary a campaign `data.bindings` VALUE may
@@ -814,16 +840,24 @@ pub enum DocFault {
UnknownTap { index: usize, tap: String },
UnknownBindingColumn { role: String, column: String },
ProcessRefMustBeContentId,
/// C29 (#316): a document's optional top-level `description` is present
/// but fails the shape gate. Absent is never a fault.
BadDescription { subject: String, fault: DocGateFault },
}
fn is_known_metric(name: &str) -> bool {
metric_vocabulary().contains(&name)
metric_vocabulary().iter().any(|m| m.id == name)
}
/// Intrinsic validation of a process document (P1 constraints included).
/// Returns ALL findings, not the first.
pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
let mut faults = Vec::new();
if let Some(desc) = &doc.description
&& let Err(fault) = doc_gate(&doc.name, desc)
{
faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault });
}
if doc.pipeline.is_empty() {
faults.push(DocFault::EmptyPipeline);
}
@@ -887,6 +921,11 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
/// Returns ALL findings, not the first.
pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
let mut faults = Vec::new();
if let Some(desc) = &doc.description
&& let Err(fault) = doc_gate(&doc.name, desc)
{
faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault });
}
if doc.data.instruments.is_empty() {
faults.push(DocFault::EmptyInstruments);
}
@@ -993,7 +1032,7 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
}
}
for (i, t) in doc.presentation.persist_taps.iter().enumerate() {
if !tap_vocabulary().contains(&t.as_str()) {
if !tap_vocabulary().iter().any(|k| k.id == t.as_str()) {
faults.push(DocFault::UnknownTap { index: i, tap: t.clone() });
}
}
@@ -1273,7 +1312,7 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
"presentation",
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
));
}
@@ -1381,6 +1420,52 @@ mod tests {
assert!(matches!(parse_process("not json"), Err(DocError::NotJson(_))));
}
/// #301 (fieldtest cycle-300 df_3): the unknown-select-rule refusal must
/// teach the accepted vocabulary, not just refuse — an author who learned
/// bare `plateau` at the CLI pastes it into a document and needs the
/// roster (argmax | plateau:mean | plateau:worst) in the refusal prose to
/// self-serve the fix. The schema stays strict: bare "plateau" remains
/// refused in documents (#300 F7 — it is a CLI-only argv alias), and the
/// prose stays sparse — the roster only, no alias-bridging sentence.
#[test]
fn unknown_select_rule_refusal_enumerates_accepted_rules() {
let bare_plateau = PROCESS_FIXTURE.replacen("plateau:worst", "plateau", 1);
let err =
parse_process(&bare_plateau).expect_err("bare plateau stays refused in documents");
let DocError::Malformed(msg) = err else {
panic!("expected DocError::Malformed, got {err:?}");
};
// The existing contract survives: the offending value stays named, quoted.
assert!(
msg.contains("unknown select rule \"plateau\""),
"offending value no longer named: {msg}"
);
// The new behaviour: the refusal enumerates the accepted rules.
for accepted in ["argmax", "plateau:mean", "plateau:worst"] {
assert!(
msg.contains(accepted),
"accepted rule {accepted:?} not enumerated in the refusal: {msg}"
);
}
// Precise and sparse: the roster alone rescues the paste case — no
// alias-hint sentence bridging plateau -> plateau:mean.
assert!(!msg.contains("alias"), "refusal grew an alias hint: {msg}");
}
/// The refusal derives its roster from slot_kind_label by trimming the
/// "select rule: " prefix. That trim is a positional contract: if the
/// label's wording ever drifts, trim_start_matches degrades to a no-op
/// and the refusal grows a redundant prefix without any test noticing.
/// This pin makes that drift loud.
#[test]
fn select_rule_label_keeps_the_prefix_the_refusal_trims() {
assert!(
slot_kind_label(SlotKind::SelectRule).starts_with("select rule: "),
"slot_kind_label(SelectRule) no longer starts with \"select rule: \"\
update select_from's roster derivation alongside the label"
);
}
pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{
"format_version": 1,
"kind": "campaign",
@@ -1828,6 +1913,56 @@ mod tests {
assert_eq!(validate_process(&doc), Vec::new());
}
/// C29 (#316): a present document description passes the shape gate;
/// absent is never a fault. Both fault arms fire and carry the doc name.
#[test]
fn document_description_gate_flags_empty_and_restatement() {
let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let mut doc: ProcessDoc = serde_json::from_str(base).expect("fixture parses");
assert!(validate_process(&doc).is_empty(), "no description → no fault");
doc.description = Some(" ".into());
assert!(matches!(
validate_process(&doc).as_slice(),
[DocFault::BadDescription { subject, fault: DocGateFault::Empty }] if subject == "p"
));
doc.description = Some("P".into());
assert!(matches!(
validate_process(&doc).as_slice(),
[DocFault::BadDescription { fault: DocGateFault::RestatesName, .. }]
));
doc.description = Some("walk-forward validation methodology".into());
assert!(validate_process(&doc).is_empty(), "described → clean");
}
/// The campaign side takes the identical gate, exercised over the
/// crate's existing valid campaign fixture (`CAMPAIGN_FIXTURE`, which
/// validates clean — pinned by `validate_campaign_accepts_a_valid_risk_section`).
#[test]
fn campaign_description_gate_mirrors_process() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
assert!(validate_campaign(&doc).is_empty(), "fixture validates clean");
doc.description = Some("".into());
assert!(matches!(
validate_campaign(&doc).as_slice(),
[DocFault::BadDescription { fault: DocGateFault::Empty, .. }]
));
doc.description = Some("GER40 walk-forward over the momentum stack".into());
assert!(validate_campaign(&doc).is_empty(), "described → clean");
}
/// C29 (#316): a present description participates in the content id
/// (absent ⇒ byte-identical is pinned by the existing round-trips).
#[test]
fn document_description_participates_in_the_content_id() {
let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let bare: ProcessDoc = serde_json::from_str(base).unwrap();
let mut described: ProcessDoc = serde_json::from_str(base).unwrap();
described.description = Some("walk-forward validation methodology".into());
let bare_id = content_id_of(&process_to_json(&bare));
let described_id = content_id_of(&process_to_json(&described));
assert_ne!(bare_id, described_id, "description must move the content id");
}
#[test]
fn validate_process_accepts_the_fixture_and_reports_each_fault() {
let ok = parse_process(PROCESS_FIXTURE).unwrap();
@@ -2019,7 +2154,8 @@ mod tests {
/// never an open node-path namespace in the document.
#[test]
fn tap_vocabulary_is_the_wrap_conventions_four_sink_names() {
assert_eq!(tap_vocabulary(), ["equity", "exposure", "r_equity", "net_r_equity"]);
let ids: Vec<&str> = tap_vocabulary().iter().map(|t| t.id).collect();
assert_eq!(ids, ["equity", "exposure", "r_equity", "net_r_equity"]);
}
/// Each `presentation.persist_taps` entry outside `tap_vocabulary()` is an
@@ -2039,7 +2175,7 @@ mod tests {
);
// every in-vocabulary name passes
let mut all = ok.clone();
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.to_string()).collect();
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.id.to_string()).collect();
assert_eq!(validate_campaign(&all), Vec::new());
}
@@ -2104,7 +2240,10 @@ mod tests {
assert_eq!(slot.kind, SlotKind::TapKinds);
assert_eq!(
slot_kind_label(slot.kind),
format!("list of: {}", tap_vocabulary().join(" | "))
format!(
"list of: {}",
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
)
);
assert_eq!(
slot_kind_label(slot.kind),
@@ -2135,7 +2274,7 @@ mod tests {
pres.hint,
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
)
);
assert_eq!(
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "aura-runner"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
# C28 assembly position (#295): composes the ladder rungs, the ingestion
# edge, and the process column into the canonical member-run recipe — the
# harness assembly, input binding (C26), the param<->config translators, and
# the shipped default MemberRunner. Imported by the shell and by downstream
# World programs; imported by no ladder or column crate.
[dependencies]
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" }
aura-composites = { path = "../aura-composites" }
aura-ingest = { path = "../aura-ingest" }
aura-registry = { path = "../aura-registry" }
aura-research = { path = "../aura-research" }
aura-campaign = { path = "../aura-campaign" }
aura-vocabulary = { path = "../aura-vocabulary" }
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# sha2: the project.rs dylib load boundary's identity stamp (dylib_sha256) —
# a vetted RustCrypto crate (per-case C16 review, SHA256 user-settled).
sha2 = "0.10"
libloading = "0.8"
toml = "0.8"
[dev-dependencies]
# zip: hand-packs a tiny synthetic M1 archive for the library-reachability E2E
# (tests/world_member_run_e2e.rs) — already transitive via the data-server
# dependency above (a normal, non-dev dependency there); declared directly
# here so test code may `use zip::...` (mirrors aura-ingest's Cargo.toml and
# aura-cli's tests/common/mod.rs, no new dependency actually enters the build
# graph).
zip = "2"
+62
View File
@@ -0,0 +1,62 @@
//! Capture the git HEAD sha at compile time and expose it as `AURA_COMMIT`, so
//! `member::sim_optimal_manifest`'s `RunManifest.commit` is self-identifying
//! (C8/C18 audit trail: this run = this commit) even when aura-runner is
//! consumed by a downstream World program with no `aura` binary involved.
//! `cargo:rustc-env` is per-compilation-unit (aura-cli's own `build.rs` does
//! not propagate its env to this crate's `option_env!("AURA_COMMIT")`), so
//! aura-runner needs this identical capture rather than inheriting the
//! shell's. When there is no git (e.g. a packaged source tree), `AURA_COMMIT`
//! is left unset and `member.rs`'s `option_env!(...)` fallback to `"unknown"`
//! holds.
//!
//! Zero runtime/build dependency by commitment (C14/C18): we shell out to the
//! `git` already in PATH rather than pulling in a libgit crate. Verbatim twin
//! of `aura-cli/build.rs` (#295) — same repo-root depth (`crates/aura-runner`
//! sits beside `crates/aura-cli`), same capture logic, so the two crates'
//! `AURA_COMMIT` cannot disagree within one build.
use std::path::Path;
use std::process::Command;
fn main() {
// build.rs runs with CWD = the crate dir (crates/aura-runner); the repo
// root, which owns `.git`, is two levels up.
let repo_root = Path::new("../..");
let git_dir = repo_root.join(".git");
if !git_dir.exists() {
// No git: emit nothing; the `"unknown"` fallback in member.rs holds.
return;
}
// Re-run when HEAD moves or its working tree changes so AURA_COMMIT never
// goes stale across commits. `.git/logs/HEAD` grows on every HEAD move
// (commit, checkout, reset), covering branch switches without resolving the
// current branch's ref file by hand.
println!("cargo:rerun-if-changed=../../.git/HEAD");
println!("cargo:rerun-if-changed=../../.git/logs/HEAD");
let head = run_git(repo_root, &["rev-parse", "HEAD"]);
let Some(head) = head else { return };
// Append `-dirty` when the working tree has uncommitted changes, so a run
// built off an unclean tree is not silently attributed to the clean HEAD.
let dirty = run_git(repo_root, &["status", "--porcelain"])
.map(|s| !s.is_empty())
.unwrap_or(false);
let commit = if dirty { format!("{head}-dirty") } else { head };
println!("cargo:rustc-env=AURA_COMMIT={commit}");
}
/// Run `git <args>` in `dir`, returning trimmed stdout on success, `None` on any
/// failure (git missing, non-zero exit, non-utf8). Never panics: a build must
/// not fail because git is unavailable.
fn run_git(dir: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git").current_dir(dir).args(args).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
Some(s.trim().to_string())
}
@@ -0,0 +1,43 @@
//! A World program driving the canonical member-run recipe as a library —
//! no `aura` binary involved (#295 acceptance evidence: builds against
//! library crates only). Runs one member backtest of the shipped breakout
//! blueprint over real archive data when present; skips cleanly otherwise.
use std::collections::BTreeMap;
use aura_campaign::{CellSpec, MemberRunner};
use aura_ingest::default_data_server;
use aura_runner::{DefaultMemberRunner, Env};
const SYMBOL: &str = "GER40";
// September 2024, inclusive epoch-ms — the same month the ger40 examples use.
const WINDOW_MS: (i64, i64) = (1_725_148_800_000, 1_727_740_799_999);
fn main() {
let server = default_data_server();
if !server.has_symbol(SYMBOL) {
println!("skip: no local archive — nothing to demonstrate.");
return;
}
let blueprint_json = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../aura-cli/examples/r_breakout.json"
))
.expect("shipped example blueprint");
let env = Env::std();
let runner = DefaultMemberRunner::new(&env, server, BTreeMap::new(), Vec::new());
let cell = CellSpec {
strategy_ordinal: 0,
strategy_id: aura_research::content_id_of(&blueprint_json),
blueprint_json,
axes: BTreeMap::new(),
instrument: SYMBOL.to_string(),
window_ms: WINDOW_MS,
regime: None,
regime_ordinal: 0,
};
match runner.run_member(&cell, &[], WINDOW_MS) {
Ok(report) => println!("member ran: {:?}", report.metrics),
Err(fault) => println!("member fault (data-dependent): {fault:?}"),
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Axis / risk-regime conventions.
//!
//! The RAW campaign-axis namespace (a campaign document's own, #203) and the
//! WRAPPED param-space namespace (`blueprint_axis_probe`'s output, one node
//! segment prefixed) are two coordinate systems over the same open params;
//! this module owns the one translation between them (`wrapped_to_raw_axis`
//! <-> `raw_matches_wrapped`) and the pure axis-binding resolution
//! (`bind_axes`) built on it, plus the content-id shape predicate
//! (`is_content_id`) shared by every FILE-or-id CLI surface.
use std::collections::HashSet;
use aura_core::{Cell, ParamSpec, Scalar};
use aura_campaign::MemberFault;
use aura_engine::Composite;
/// A bare store address: exactly 64 lowercase hex chars (the content-id key
/// shape). Shared by every FILE-or-id CLI surface (`aura campaign run`'s
/// target resolution, `graph_construct`'s `--params <FILE|ID>` resolution)
/// so the two cannot drift apart on the id shape a second time.
pub fn is_content_id(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// Strip the wrapper's exactly-one leading node segment from a WRAPPED param
/// path, yielding the RAW campaign-axis name the doc speaks (#203): the bind
/// convention prefixes a strategy's own param paths with one root-composite
/// segment before they enter the runnable (wrapped) param space, so the raw
/// axis is everything after that segment's dot. Returns `name` unchanged if
/// there is no dot (defensive; every wrapped param space produced by
/// `blueprint_axis_probe` has one). This is the single definition of "the
/// wrapper segment" — [`raw_matches_wrapped`] is built on it so a wrap-depth
/// change cannot desync the two directions of the #203 convention.
pub fn wrapped_to_raw_axis(name: &str) -> &str {
name.split_once('.').map(|(_, rest)| rest).unwrap_or(name)
}
/// Does a RAW campaign-axis name (the doc's own namespace) address this ONE
/// wrapped param path? True for an exact match (an unwrapped param, if one
/// ever exists) or when stripping the wrapper's one node segment
/// ([`wrapped_to_raw_axis`]) yields exactly `raw`. Inverse of
/// `wrapped_to_raw_axis`, co-located and built on it (#203): the two are the
/// only two places the wrapper-segment convention is expressed.
pub fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool {
wrapped == raw || wrapped_to_raw_axis(wrapped) == raw
}
/// Suffix-join each raw campaign axis onto exactly one wrapped param
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
/// node segment yields raw), then require every wrapped slot to be covered.
/// Pure and independent of the env/data seam so the three fault arms are
/// unit-testable without a project, a store, or a loaded blueprint.
pub fn bind_axes(
space: &[ParamSpec],
strategy_id: &str,
params: &[(String, Scalar)],
) -> Result<Vec<Cell>, MemberFault> {
let mut slots: Vec<Option<Scalar>> = vec![None; space.len()];
for (raw, value) in params {
let hits: Vec<usize> = space
.iter()
.enumerate()
.filter(|(_, p)| raw_matches_wrapped(raw, &p.name))
.map(|(i, _)| i)
.collect();
match hits.as_slice() {
[i] => slots[*i] = Some(*value),
[] => {
return Err(MemberFault::Bind(format!(
"axis \"{raw}\" matches no open param of the wrapped strategy {strategy_id}"
)));
}
_ => {
let names: Vec<&str> = hits.iter().map(|&i| space[i].name.as_str()).collect();
return Err(MemberFault::Bind(format!(
"axis \"{raw}\" is ambiguous in the wrapped param space of \
strategy {strategy_id}: matches {}",
names.join(", ")
)));
}
}
}
let mut point: Vec<Cell> = Vec::with_capacity(space.len());
for (spec, slot) in space.iter().zip(&slots) {
match slot {
Some(v) => point.push(v.cell()),
None => {
// Speak the RAW campaign-axis namespace (the doc's own): the
// wrapped space prefixes the signal's params with one node
// segment, so the raw path is everything after it (#203).
let raw = wrapped_to_raw_axis(&spec.name);
return Err(MemberFault::Bind(format!(
"open param \"{raw}\" of strategy {strategy_id} is bound by no \
campaign axis (wrapped path: {})",
spec.name
)));
}
}
}
Ok(point)
}
/// The #246 override subset of one member's campaign-axis names (already RAW
/// — a campaign document speaks the raw namespace, #203, hence the `raw_`
/// name prefix distinguishing this from `member`'s WRAPPED-input siblings):
/// every name that matches no entry of the un-reopened wrapped OPEN
/// `open_space` but names a BOUND param of `raw_signal` (`bound_param_space()`'s
/// `.name` is already path-qualified in strategy/RAW coordinates, exactly
/// like a raw campaign axis — no wrap-prefix stripping needed, unlike
/// `member::wrapped_bound_overrides_of`/`member::override_paths`, which start
/// from WRAPPED CLI axis names). Silent like `member::wrapped_bound_overrides_of`
/// (skips a name matching neither space), NOT the validating `member::override_paths`:
/// `run_member` runs inside a sweep worker and must never call
/// `std::process::exit` — an unmatched name still surfaces as `bind_axes`'s
/// own named `MemberFault::Bind` once the (reopened) space below is built.
pub fn raw_bound_overrides_of(
param_names: &[String],
open_space: &[ParamSpec],
raw_signal: &Composite,
) -> Vec<String> {
let bound: HashSet<String> =
raw_signal.bound_param_space().into_iter().map(|b| b.name).collect();
param_names
.iter()
.filter(|raw| {
!open_space.iter().any(|p| raw_matches_wrapped(raw, &p.name))
&& bound.contains(raw.as_str())
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::ScalarKind;
fn spec(name: &str) -> ParamSpec {
ParamSpec { name: name.to_string(), kind: ScalarKind::I64 }
}
#[test]
/// The only shape treated as a direct store address is a bare 64-char
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex)
/// falls through to `run_campaign`'s file-path branch instead.
fn is_content_id_accepts_only_64_lowercase_hex() {
assert!(is_content_id(&"a".repeat(64)));
assert!(!is_content_id(&"A".repeat(64)));
assert!(!is_content_id(&"a".repeat(63)));
assert!(!is_content_id(&format!("{}g", "a".repeat(63))));
}
#[test]
/// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse
/// pair of one wrapper-segment convention — stripping the wrapper off a
/// wrapped name must satisfy the match predicate against that SAME
/// wrapped name, and a raw name from a DIFFERENT wrapped param must not.
fn raw_matches_wrapped_and_wrapped_to_raw_axis_are_inverses() {
let wrapped = "sma_signal.fast.length";
assert_eq!(wrapped_to_raw_axis(wrapped), "fast.length");
assert!(raw_matches_wrapped(wrapped_to_raw_axis(wrapped), wrapped));
assert!(!raw_matches_wrapped("fast.length", "sma_signal.slow.length"));
}
#[test]
/// bind_axes resolves a raw axis name against the ONE wrapped param whose
/// path ends with ".{raw}" (or equals it), producing a co-indexed point.
fn bind_axes_resolves_a_unique_suffix_match() {
let space = vec![spec("sma.length")];
let params = vec![("length".to_string(), Scalar::i64(14))];
let point = bind_axes(&space, "strat", &params).unwrap();
assert_eq!(point, vec![Scalar::i64(14).cell()]);
}
#[test]
/// A raw campaign axis that matches no open param of the wrapped
/// strategy is a named Bind fault naming the axis, never a silently
/// dropped binding.
fn bind_axes_refuses_an_unmatched_axis() {
let space = vec![spec("sma.length")];
let params = vec![("period".to_string(), Scalar::i64(14))];
let err = bind_axes(&space, "strat", &params).unwrap_err();
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
assert!(m.contains("period") && m.contains("matches no open param"));
}
#[test]
/// A raw axis matching MORE THAN ONE wrapped param is a named Bind fault
/// that lists every ambiguous candidate, never a silent first-match pick.
fn bind_axes_refuses_an_ambiguous_axis() {
let space = vec![spec("fast.length"), spec("slow.length")];
let params = vec![("length".to_string(), Scalar::i64(14))];
let err = bind_axes(&space, "strat", &params).unwrap_err();
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
assert!(m.contains("ambiguous") && m.contains("fast.length") && m.contains("slow.length"));
}
#[test]
/// An open wrapped param left unbound by any campaign axis is a named
/// Bind fault naming the param, not an implicit default.
fn bind_axes_refuses_an_uncovered_param() {
let space = vec![spec("sma.length"), spec("sma.threshold")];
let params = vec![("length".to_string(), Scalar::i64(14))];
let err = bind_axes(&space, "strat", &params).unwrap_err();
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
// The refusal speaks the RAW campaign-axis namespace (what the doc
// must say), with the wrapped bind-time path as a parenthetical (#203).
assert!(
m.contains("open param \"threshold\"") && m.contains("(wrapped path: sma.threshold)"),
"raw-namespace prose expected: {m}"
);
}
#[test]
/// #246: `raw_bound_overrides_of` names exactly the raw axis names that
/// miss the un-reopened wrapped OPEN space but match a BOUND param of the
/// raw signal (in strategy/RAW coordinates, per its own doc comment) — an
/// axis already covered by the open space is not re-flagged as an
/// override, and an axis matching neither space is silently skipped here
/// (bind_axes surfaces THAT case as its own named fault once the
/// reopened space is built downstream).
fn raw_bound_overrides_of_selects_axes_naming_a_bound_param() {
use aura_strategy::Bias;
use aura_std::Sma;
let raw_signal = Composite::new(
"fixture",
vec![
Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(),
Bias::builder().named("exp").into(),
],
vec![],
vec![],
vec![],
);
// The un-reopened wrapped OPEN space carries one extra wrap segment
// (the CLI's own outer wrap, mirroring `blueprint_axis_probe`'s
// output shape) ahead of the composite's own "exp.scale" path.
let open_space = vec![ParamSpec { name: "wrap.exp.scale".to_string(), kind: ScalarKind::F64 }];
let param_names =
vec!["exp.scale".to_string(), "fast.length".to_string(), "nope.thing".to_string()];
let overrides = raw_bound_overrides_of(&param_names, &open_space, &raw_signal);
assert_eq!(
overrides,
vec!["fast.length".to_string()],
"only the bound-param axis is selected; the already-open axis and the \
unmatched axis are excluded"
);
}
}
@@ -9,12 +9,6 @@
//! root roles (`wrap_r`) — in the same canonical `M1Field` declaration order,
//! so the two halves of the old single-price weld can no longer drift apart
//! (C4 merge tie-break = role declaration order = source open order).
//!
//! `dead_code` is allowed module-wide only while nothing outside this
//! module consumes it; the allow is scaffolding to REMOVE with the first
//! consumer (the wrap seam), at which point genuinely unused items must
//! surface again.
#![allow(dead_code)]
use std::collections::BTreeMap;
@@ -35,7 +29,7 @@ pub(crate) const COLUMN_VOCABULARY: &str =
/// common case at the wrap seam, so declaring a port for a vocabulary-named
/// role needs no per-build leak; only an override-renamed (non-vocabulary)
/// role falls back to leaking its name.
pub(crate) fn static_role_name(name: &str) -> Option<&'static str> {
pub fn static_role_name(name: &str) -> Option<&'static str> {
match name {
"open" => Some("open"),
"high" => Some("high"),
@@ -76,7 +70,7 @@ pub(crate) fn column_name(column: M1Field) -> &'static str {
/// The scalar kind a column streams (mirrors `aura_ingest::decode`: Volume is
/// the one `i64` column; everything else is `f64`).
pub(crate) fn column_kind(column: M1Field) -> ScalarKind {
pub fn column_kind(column: M1Field) -> ScalarKind {
match column {
M1Field::Volume => ScalarKind::I64,
_ => ScalarKind::F64,
@@ -100,7 +94,7 @@ fn rank(column: M1Field) -> usize {
/// name), the archive column it binds, and whether it feeds the signal
/// (`false` only for the synthesized broker/executor-only close entry).
#[derive(Debug)]
pub(crate) struct BindingEntry {
pub struct BindingEntry {
pub role: String,
pub column: M1Field,
pub feeds_signal: bool,
@@ -110,25 +104,25 @@ pub(crate) struct BindingEntry {
/// openers (column opening) share: entries in canonical column order, with a
/// close entry guaranteed (the broker/executor price feed).
#[derive(Debug)]
pub(crate) struct ResolvedBinding {
pub struct ResolvedBinding {
entries: Vec<BindingEntry>,
}
impl ResolvedBinding {
pub(crate) fn entries(&self) -> &[BindingEntry] {
pub fn entries(&self) -> &[BindingEntry] {
&self.entries
}
/// The columns to open, one per entry, in canonical order — exactly the
/// order the entries' roles are declared in, so source index i always
/// feeds role i.
pub(crate) fn columns(&self) -> Vec<M1Field> {
pub fn columns(&self) -> Vec<M1Field> {
self.entries.iter().map(|e| e.column).collect()
}
/// True iff the strategy consumes the close column only — the one shape
/// the synthetic walk (a single close series) can drive.
pub(crate) fn close_only(&self) -> bool {
pub fn close_only(&self) -> bool {
self.columns() == [M1Field::Close]
}
}
@@ -153,7 +147,7 @@ fn finish(mut entries: Vec<BindingEntry>) -> ResolvedBinding {
/// override wins over the name default; a role that is neither
/// vocabulary-named nor overridden is a refusal naming the vocabulary and the
/// override remedy.
pub(crate) fn resolve_binding(
pub fn resolve_binding(
strategy: &str,
roles: &[Role],
overrides: &BTreeMap<String, String>,
@@ -229,7 +223,7 @@ fn first_duplicate_role(roles: &[Role]) -> Option<&str> {
/// fallback is a placeholder exactly like the probe's synthetic pip, so
/// probing never refuses a blueprint the (strictly resolved) run path may
/// still bind via campaign overrides.
pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding {
pub fn probe_binding(roles: &[Role]) -> ResolvedBinding {
let entries: Vec<BindingEntry> = roles
.iter()
.map(|role| BindingEntry {
@@ -243,7 +237,7 @@ pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding {
/// The refusal for a multi-column binding on synthetic data (which generates
/// a close series only). Callers invoke it only when `!binding.close_only()`.
pub(crate) fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
pub fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
let beyond: Vec<&str> = binding
.entries()
.iter()
+161
View File
@@ -0,0 +1,161 @@
//! Coverage reporting: the single interior-gap-month walk, shared by a
//! campaign cell's coverage annotation
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s
//! archive-wide report — two independent walk implementations before this
//! module existed (#295 dedup).
/// `"YYYY-MM"` rendering of a `(year, month)` pair.
pub fn fmt_year_month((y, m): (u16, u8)) -> String {
format!("{y:04}-{m:02}")
}
/// The `(year, month)` immediately after `(y, m)`.
pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) {
if m == 12 { (y + 1, 1) } else { (y, m + 1) }
}
/// The whole `(year, month)`s missing INSIDE `months`' own span that also
/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
/// is a flat list an aggregate counts directly. `months` is assumed sorted
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk
/// implementation (#295): both `DefaultMemberRunner::window_coverage`
/// (a swept cell's own window) and [`data_coverage_report`] (the full
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead
/// of maintaining independent copies.
pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1);
let mut out = Vec::new();
for pair in months.windows(2) {
let (prev, next) = (pair[0], pair[1]);
let mut ym = next_year_month(prev);
while ym != next {
if ym >= from_ym && ym <= to_ym {
out.push(fmt_year_month(ym));
}
ym = next_year_month(ym);
}
}
out
}
/// A Unix-ms window that decodes (via `unix_ms_to_year_month`) to year/month
/// bounds — 0001-01 and 9999-01, exact epoch-ms for those UTC instants, not
/// an approximation — comfortably outside any realistic archive month, so an
/// unbounded, archive-wide [`interior_gap_months`] walk never ms-filters out
/// a real gap. Safely inside `chrono`'s valid calendar range, so the
/// conversion inside `interior_gap_months` never panics.
pub const FULL_ARCHIVE_WINDOW_MS: (i64, i64) = (-62_135_596_800_000, 253_370_764_800_000);
/// `aura data coverage <SYMBOL>`'s pure report body (#264): render `symbol`'s
/// coverage report from its sorted `(year, month)` file list — one `span:`
/// line framing the first/last present month, then either a `no gaps` line
/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a
/// ten-month hole is one line, not ten). `Err` exactly when `months` is
/// empty — no archive file exists for `symbol` at all (the caller's "unknown
/// symbol" refusal, stderr + exit 1). The gap detection itself is
/// [`interior_gap_months`] over the full archive span
/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into
/// contiguous ranges is presentation-only regrouping, done here since it
/// stays byte-identical to the pre-dedup report.
pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result<Vec<String>, String> {
let Some(&first) = months.first() else {
return Err(format!("no archive files found for symbol \"{symbol}\""));
};
let last = *months.last().expect("non-empty checked above");
let mut lines =
vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))];
let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS);
if gap_months.is_empty() {
lines.push(format!("{symbol} no gaps"));
} else {
for (from, to) in collapse_contiguous_year_months(&gap_months) {
lines.push(format!("{symbol} missing: {from}..{to}"));
}
}
Ok(lines)
}
/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into
/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to
/// one pair, not ten. Presentation-only regrouping: the gap WALK itself
/// already ran in `interior_gap_months`.
fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for m in months {
let ym = parse_year_month(m);
match out.last_mut() {
Some((_, to)) if next_year_month(parse_year_month(to)) == ym => {
*to = m.clone();
}
_ => out.push((m.clone(), m.clone())),
}
}
out
}
/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape
/// [`interior_gap_months`] always emits.
fn parse_year_month(s: &str) -> (u16, u8) {
let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM");
(y.parse().expect("YYYY digits"), m.parse().expect("MM digits"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// #272: `interior_gap_months` names a whole calendar month missing
/// BETWEEN two present archive months (the Copper failure shape) when the
/// requested window spans across it — not just when the window's own
/// bounds fall short of full coverage. GAPSYM-shaped input (files at
/// 2024-01..02 and 2024-05..06, absent 03..04) over a Jan-through-June
/// window must name exactly the two missing interior months.
fn interior_gap_months_names_a_whole_missing_month_spanned_by_the_window() {
let months = [(2024u16, 1u8), (2024, 2), (2024, 5), (2024, 6)];
// 2024-01-01T00:00:00Z .. 2024-07-01T00:00:00Z (Unix ms) — spans every
// present month plus the March/April hole.
let window_ms = (1_704_067_200_000i64, 1_719_792_000_000i64);
let gaps = interior_gap_months(&months, window_ms);
assert_eq!(
gaps,
vec!["2024-03".to_string(), "2024-04".to_string()],
"both interior gap months must be named, in order"
);
}
/// An unknown symbol (no archive files at all — an empty month list)
/// refuses rather than reporting a bogus empty-span coverage (#264): the
/// caller eprintln's the message and exits 1.
#[test]
fn data_coverage_report_refuses_an_unknown_symbol() {
let err = data_coverage_report("GHOST", &[]).unwrap_err();
assert!(err.contains("GHOST"), "names the unknown symbol: {err}");
}
/// A symbol with a fully contiguous file index reports its span plus an
/// explicit `no gaps` line — never a bare span with no gap-status line at
/// all, which would leave "no gaps" indistinguishable from "gaps not yet
/// checked" (#264).
#[test]
fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() {
let months = [(2024, 1), (2024, 2), (2024, 3)];
let lines = data_coverage_report("SYMA", &months).expect("known symbol");
assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]);
}
/// The Copper failure shape itself: one interior gap collapses to a single
/// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not
/// one line per missing month (#264).
#[test]
fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() {
let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)];
let lines = data_coverage_report("GAPSYM", &months).expect("known symbol");
assert_eq!(
lines,
vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"]
);
}
}
+840
View File
@@ -0,0 +1,840 @@
//! Family assembly and orchestration (#295).
//!
//! The blueprint sweep / walk-forward / Monte-Carlo family builders — pure
//! member-run recipes driven off a [`DataSource`] (the shared synthetic/real
//! data provider every family builder threads, plus the `--select` objective
//! [`Selection`] walk-forward resolves its winner under) — together with the
//! winner-selection (`select_winner`) and axis-grid validation
//! (`validate_axis_grid`) machinery they share. No `aura` binary is needed to
//! drive a family: the shell (`aura-cli`) wraps these builders with
//! persistence + stdout rendering (`run_blueprint_sweep`,
//! `run_blueprint_walkforward`, `run_blueprint_mc`), which stay in the shell,
//! along with the `--select`/`--real` argv grammar (`parse_select`,
//! `select_rule_of`) and the `topology_hash`/`content_id` naming primitive
//! (inlined here instead, mirroring `member::run_signal_r`'s own copy — the
//! CLI shell still needs its own for call sites outside any family builder).
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use aura_campaign::{catch_member_panic, member_fault_prose, MemberFault};
use aura_composites::StopRule;
use aura_core::{Cell, ParamSpec, Scalar, Timestamp};
use aura_engine::{
blueprint_from_json, walk_forward, window_of, BindError, Composite, FamilySelection,
RollMode, RunManifest, SyntheticSpec, VecSource, WindowBounds, WindowRoller,
};
use aura_registry::{
optimize_deflated, optimize_plateau, PlateauMode, DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
};
use aura_backtest::{
monte_carlo, McFamily, RunMetrics, RunReport, SweepFamily, SweepPoint, WalkForwardResult,
WindowRun, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS,
};
use crate::binding::ResolvedBinding;
use crate::member::{
blueprint_axis_probe, blueprint_axis_probe_reopened, no_real_data, override_paths,
pip_or_refuse, probe_window, reopen_all, run_blueprint_member, wrapped_bound_overrides_of,
SYNTHETIC_PIP_SIZE,
};
use crate::project::Env;
use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH};
/// The demo default stop regime, shared by every family builder in this module.
const DEFAULT_STOP: StopRule = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
/// The in-sample winner-selection objective for walk-forward's per-window IS
/// refit (cycle 0077). `Argmax` is the bare-best pick deflated for trials
/// (#144, the default); `Plateau` argmaxes the neighbourhood-smoothed surface
/// instead (opt-in via `--select`). The CLI shell's `--select` grammar
/// (`parse_select`) and campaign-rule mapping (`select_rule_of`) build this
/// value; they stay in the shell since only this type crosses the boundary.
#[derive(Clone, Copy)]
pub enum Selection {
Argmax,
Plateau(PlateauMode),
}
/// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising
/// again) used as `DataSource::Synthetic`'s full-window stream for the built-in
/// blueprint/campaign sweep, walk-forward, and MC families. Deterministic and
/// fixed (C1).
pub fn showcase_prices() -> Vec<(Timestamp, Scalar)> {
[
1.0000_f64, 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,
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p)))
.collect()
}
/// A longer deterministic stream than `showcase_prices` — enough for several
/// IS/OOS windows with SMA warm-up. Seed-determined via `SyntheticSpec` (C1).
pub fn walkforward_prices() -> Vec<(Timestamp, Scalar)> {
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
let mut src = spec.source(7);
let mut out = Vec::new();
while let Some(item) = aura_engine::Source::next(&mut src) {
out.push(item);
}
out
}
/// The in-memory windowed source the synthetic path uses (the firewall mapping to
/// `DataServer::stream_m1_windowed` is the real-data path; synthetic stays in-memory,
/// mirroring `run_blueprint_sweep`'s `showcase_prices`). Inclusive `[from, to]`.
pub fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource {
VecSource::new(
walkforward_prices()
.into_iter()
.filter(|&(t, _)| t >= from && t <= to)
.collect(),
)
}
/// What `--real` parsing yields: the synthetic default, or a real symbol + an
/// optional window (parsed, not yet opened). Pure, so the grammar is unit-testable.
#[derive(Debug, Clone, PartialEq)]
pub enum DataChoice {
Synthetic,
Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
}
/// The source provider threaded into the family builders: synthetic built-in
/// streams, or real M1 close bars from the data-server archive. Replaces the
/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come
/// from one place (Fork B/D/F).
///
/// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed
/// series: the full-window consumers (`full_window` / `run_sources`, used by
/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers
/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar
/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two
/// faces never reach one consumer (a family is either full-window or windowed), so
/// the split is invisible per call site but real across the type — read both
/// family builders to see it whole.
pub enum DataSource {
Synthetic,
Real {
server: Arc<data_server::DataServer>,
symbol: String,
from_ms: Option<i64>,
to_ms: Option<i64>,
pip: f64,
},
}
impl DataSource {
/// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol
/// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G),
/// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses.
pub fn from_choice(choice: DataChoice, env: &Env) -> DataSource {
match choice {
DataChoice::Synthetic => DataSource::Synthetic,
DataChoice::Real { symbol, from_ms, to_ms } => {
let server = Arc::new(data_server::DataServer::new(env.data_path()));
let pip = pip_or_refuse(&server, &symbol, env);
if !server.has_symbol(&symbol) {
no_real_data(&symbol, env);
}
DataSource::Real { server, symbol, from_ms, to_ms, pip }
}
}
}
pub fn pip_size(&self) -> f64 {
match self {
DataSource::Synthetic => SYNTHETIC_PIP_SIZE,
DataSource::Real { pip, .. } => *pip,
}
}
/// The full run window, probed once. Synthetic: the showcase span. Real:
/// `probe_window` drains a separate single-pass probe source for first/last ts
/// (the same helper `open_real_source` uses for its manifest window).
pub fn full_window(&self, env: &Env) -> (Timestamp, Timestamp) {
match self {
DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
window_of(&s).expect("non-empty showcase stream")
}
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms, env)
}
}
}
/// The full walk-forward span. Synthetic draws the 60-bar `walkforward_prices`
/// span — NOT `showcase_prices` (which `full_window` uses): walk-forward is a
/// *windowed* consumer whose roller `(24,12,12)` needs 36 bars, so it uses the
/// longer built-in stream (byte-unchanged from the retired pre-`DataSource`
/// `walkforward_family`, which derived its span the same way). Real: the same
/// probed `--from..--to` window as `full_window`.
pub fn wf_full_span(&self, env: &Env) -> (Timestamp, Timestamp) {
match self {
DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(walkforward_prices()))];
window_of(&s).expect("non-empty walkforward stream")
}
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms, env)
}
}
}
/// A fresh full-window source set per member (single-pass): the synthetic
/// showcase close stream, or one real source per resolved binding column
/// in canonical order (callers guard the synthetic arm to `{close}`).
pub fn run_sources(&self, env: &Env, fields: &[aura_ingest::M1Field]) -> Vec<Box<dyn aura_engine::Source>> {
match self {
DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))],
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields)
.unwrap_or_else(|| no_real_data(symbol, env))
}
}
}
/// A fresh windowed source set for an IS/OOS sub-window (walk-forward).
/// Synthetic: `walkforward_window_source`. Real: one source per resolved
/// binding column over the ns-native window.
pub fn windowed_sources(
&self, from: Timestamp, to: Timestamp, env: &Env, fields: &[aura_ingest::M1Field],
) -> Vec<Box<dyn aura_engine::Source>> {
match self {
DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))],
DataSource::Real { server, symbol, .. } => {
aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields)
.unwrap_or_else(|| no_real_data(symbol, env))
}
}
}
/// WindowRoller sizes per data kind (Fork F): bar-index for synthetic (24/12/12
/// over the 60-bar span), calendar-ns for real.
pub fn wf_window_sizes(&self) -> (i64, i64, i64) {
match self {
DataSource::Synthetic => (24, 12, 12),
DataSource::Real { .. } => (WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS),
}
}
}
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
/// originate on these paths and the instrument context is genuinely inert.
/// Named once so every such call site states its intent by reference instead
/// of repeating the justifying comment.
const NO_INSTRUMENT_CONTEXT: &str = "";
/// The winner-selection objective for walk-forward's per-window IS refit — the
/// deflation-aware SQN variant (#144 default). Named once so `select_winner`
/// and `blueprint_walkforward_family` (the only two family-builder-side call
/// sites) cannot drift apart on the token; the CLI shell's campaign-sugar
/// bridge keeps its own copy of this token (main.rs `WINNER_SELECTION_METRIC`)
/// since it is not itself a family builder.
const WINNER_SELECTION_METRIC: &str = "sqn_normalized";
/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward
/// winner selection. Recorded on each winner's manifest (so `overfit_probability`
/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The
/// resample count and block length are the shared `aura_registry::DEFLATION_*`.
const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
/// Renders a [`BindError`] as one-line prose in `member::override_paths`' sibling
/// register above (#247) — never the raw Rust `Debug` struct name
/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep
/// terminal actually raises past `override_paths`' own pre-flight (which
/// already rejects an unresolvable axis name as prose before either call
/// site below ever reaches the terminal). `UnknownKnob` already carries a
/// fully-prosed message string (wrapped from `override_paths`' own
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
/// the walkforward path's rejection reaches stderr as bare prose too.
pub fn render_bind_error(e: &BindError) -> String {
match e {
BindError::MissingKnob(name) => format!(
"axis {name}: an open param with no axis and no bound default — \
bind it with `--axis {name}=<value>` see `aura sweep <bp> --list-axes`"
),
BindError::KindMismatch { knob, expected, got } => format!(
"axis {knob}: expected {expected:?}, supplied {got:?} — \
see `aura sweep <bp> --list-axes`"
),
BindError::UnknownKnob(msg) => msg.clone(),
BindError::DuplicateBinding(name) => format!(
"axis {name}: bound twice — each param takes exactly one axis — \
see `aura sweep <bp> --list-axes`"
),
BindError::EmptyAxis(name) => format!(
"axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`"
),
BindError::EmptyRange(name) => format!(
"axis {name}: the named range is empty — give it at least one value"
),
// A blueprint defect, not an axis usage error: the point passed name
// resolution but its bootstrap failed. Prose frame with the compile
// detail explicitly labelled as internal — per-variant prose for
// CompileError belongs to the graph-build surface, not this boundary.
BindError::Compile(e) => format!(
"the resolved axis point failed to bootstrap — the blueprint is \
defective at this point, re-validate it with `aura graph build` \
(internal detail: {e:?})"
),
}
}
/// Resolve the in-sample winner under the chosen selection objective. `Argmax`
/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid
/// surface — it needs the grid lattice, so a sweep with no lattice (a future random
/// walk-forward producer) is refused rather than silently argmaxed. The metric is
/// always known at the call sites, so a metric error is unreachable (`expect`); the
/// only fallible outcome is the plateau-without-lattice refusal, returned as
/// `Err(message)` for the caller to print and exit 2.
pub fn select_winner(
family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>,
) -> Result<(SweepPoint, FamilySelection), String> {
match select {
Selection::Argmax => Ok(optimize_deflated(
family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED,
).expect("walk-forward metrics are known")),
Selection::Plateau(mode) => match lattice {
Some(lens) => Ok(optimize_plateau(family, lens, metric, mode)
.expect("walk-forward metrics are known")),
None => Err(
"--select plateau requires a grid sweep; a random sweep has no parameter lattice"
.to_string(),
),
},
}
}
/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal
/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all
/// run BEFORE this closure is invoked per grid point, so its body never influences the
/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to
/// satisfy the terminal, at O(1) cost per point instead of a full member run. Reused
/// (#278) as the discarded slot value for a member run [`catch_member_panic`]
/// contains: the caller always resolves the captured fault and exits before this
/// value is ever rendered or persisted, so its content is equally irrelevant there.
fn axis_grid_probe_report() -> RunReport {
RunReport {
manifest: RunManifest {
commit: String::new(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "wf-axis-preflight-placeholder".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
}
}
/// The LOWEST enumeration index's captured member-panic message, found by
/// walking `family.points` — guaranteed by `assemble_sweep`'s own
/// `collect()` to be in grid-enumeration order regardless of which worker
/// finished first (C1) — for the first point whose params match a captured
/// fault. Thread-order-independent by construction (mirrors the lowest-
/// index-fault convention `aura-campaign::exec::run_members` establishes for
/// the identical #272 seam), without needing a separate numeric-index
/// side-channel: the returned family already carries the points in order.
fn lowest_point_fault(family: &SweepFamily, faults: Mutex<Vec<(Vec<Cell>, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.points
.iter()
.find_map(|pt| captured.iter().find(|(p, _)| *p == pt.params).map(|(_, m)| m.clone()))
}
/// The LOWEST seed's captured member-panic message (#278), found by walking
/// `family.draws` — `monte_carlo`'s own contract guarantees seed-**input**
/// order regardless of thread completion (C1) — for the first draw whose
/// seed matches a captured fault. Thread-order-independent by construction,
/// mirroring [`lowest_point_fault`]'s and `lowest_window_fault`'s identical
/// convention one level over (per-point sweep / per-window walk-forward).
fn lowest_seed_fault(family: &McFamily, faults: Mutex<Vec<(u64, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.draws
.iter()
.find_map(|d| captured.iter().find(|(s, _)| *s == d.seed).map(|(_, m)| m.clone()))
}
/// Render + exit(3) on a contained member panic (#278: the deliberate
/// failed-cells exit, C14, never the raw 101 an uncontained panic would
/// yield) — reuses `aura_campaign::member_fault_prose`'s established
/// "a member panicked: …" wording rather than re-wording it, and the
/// `aura: warning: ` class marker (#278 decision) the CLI's own `diag`
/// macro would emit, hand-written here since that macro is presentation-
/// crate-private (#295 boundary) and this fires from `aura-runner`.
fn exit_on_member_panic(msg: &str) -> ! {
eprintln!("aura: warning: {}", member_fault_prose(&MemberFault::Panic(msg.to_string())));
std::process::exit(3);
}
/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any
/// member (#253). Reuses the SAME strict, erroring axis-name check
/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two
/// paths cannot drift to differently-worded rejections) to derive the #246 override
/// set, then drives the sweep terminal's own `resolve_axes`/arity/kind checks
/// (`SweepBinder::sweep_with_lattice`) with [`axis_grid_probe_report`] standing in for
/// the run closure — no data access, no sim engine tick. Axis resolution is
/// window-agnostic, so the caller derives or passes no window at all.
fn validate_axis_grid(
doc: &str, axes: &[(String, Vec<Scalar>)], raw_space: &[ParamSpec], probe_signal: &Composite,
env: &Env,
) -> Result<(), BindError> {
let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ())
}
/// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it
/// keeps the shape of the retired `r_sma_sweep_family` demo builder (#159), with three
/// deviations. (1) The signal source is
/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is
/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3)
/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs):
/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a
/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message
/// string — a named error, never a panic. An axis naming a bound param re-opens it
/// (#246: bound value = default); an axis matching neither space is refused by
/// `override_paths` before any run. Every member manifest carries the shared
/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired
/// mirror's default (no-trace) arm.
pub fn blueprint_sweep_family(
doc: &str,
axes: &[(String, Vec<Scalar>)],
data: &DataSource,
env: &Env,
) -> Result<SweepFamily, String> {
// Identity + binding read the AUTHORED doc, raw (no override re-open):
// topology and the resolved role plan are properties of the document, not
// of any one sweep's axis choices.
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at
// `aura_research`.
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// Strict binding resolution (name defaults — the verb path carries no
// campaign overrides): the family's open plan and wrap plan in one value.
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
}
let pip = data.pip_size();
let window = data.full_window(env);
// The un-reopened wrapped OPEN space (#246): derives the override set (which
// named axes re-open a bound param) before the real, reopened probe is built —
// probe and per-member reloads must re-open identically so points resolve
// against one space. A name matching neither space is the error here.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
// The doc is parse-validated at the dispatch boundary (with file-path context),
// so every reload here is infallible: the builder has a single error contract —
// the `BindError` returned by the sweep terminal — and no hidden process exit.
// Member reloads re-open the SAME override set derived above, so every member
// resolves its axes against the identical (reopened) param space the probe used.
let reload = |d: &str| {
reopen_all(
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
&overrides,
)
};
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
// sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked.
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
// otherwise unwind straight through this sweep to an uncaught exit 101 —
// contained the same way the real/campaign path contains it, via
// `catch_member_panic` + a per-point capture, resolved to the lowest
// enumeration index's message after the sweep joins (thread-order-
// independent, C1).
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let family = binder
.sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs.
match catch_member_panic(|| {
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})
// render the sweep terminal's BindError to prose (#247), the fn's String error
// contract — never the raw Debug struct.
.map_err(|e| render_bind_error(&e))?;
if let Some(msg) = lowest_point_fault(&family, faults) {
exit_on_member_panic(&msg);
}
Ok(family)
}
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
/// `[from,to]` — the windowed, lattice-carrying twin of
/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select
/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the
/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis
/// naming a bound param re-opens it (#246: bound value = default, same
/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is
/// its walk-forward in-sample twin); an axis matching neither space is refused
/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired
/// "fully bound; nothing to sweep" refusal) before any member runs.
///
/// The returned `Option<String>` (#278) is this fn's OWN resolved member-panic
/// capture: this fn's nested sweep runs inside `blueprint_walkforward_family`'s
/// per-window parallel closure, so a contained member panic here must NOT
/// `eprintln!`+`exit` on the spot (that would race across windows, the exact
/// `#177` duplicated-rejection class the dispatch-boundary axis pre-flight
/// above already avoids) — captured internally, resolved to the lowest-index
/// message via [`lowest_point_fault`] before returning, for the caller to push
/// onto its own per-window fault list only after every window has joined.
pub fn blueprint_sweep_over(
doc: &str, axes: &[(String, Vec<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
env: &Env, binding: &ResolvedBinding,
) -> Result<(SweepFamily, Vec<usize>, Option<String>), BindError> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let pip = data.pip_size();
let probe_signal = reload(doc);
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set (which named axes re-open a bound param) before
// the reopened probe is built — probe and per-member reloads must re-open
// identically so points resolve against one space, exactly like
// `blueprint_sweep_family`.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let (family, lattice) = binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty in-sample window");
match catch_member_panic(|| {
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})?;
let fault = lowest_point_fault(&family, faults);
Ok((family, lattice, fault))
}
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
/// blueprint. The reduce-mode member
/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching
/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides`
/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and
/// resolved `space`/`params` against — the OOS reload must re-open it too, or a
/// bound-param axis's winner point (kind-checked against the REOPENED space) fails
/// `bootstrap_with_cells`'s arity check against this still-closed reload.
#[allow(clippy::too_many_arguments)]
pub fn run_oos_blueprint(
doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp,
topo: &str, data: &DataSource, env: &Env, binding: &ResolvedBinding,
overrides: &[String],
) -> (Vec<(Timestamp, f64)>, RunReport) {
let reload = reopen_all(
blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
overrides,
);
let pip = data.pip_size();
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty out-of-sample window");
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT);
(Vec::new(), report)
}
/// A discarded placeholder [`WindowRun`] for a window whose IS refit or OOS
/// run was contained after a member panic (#278): `chosen_params` matches
/// `space`'s arity with inert zero cells (the `assemble_walk_forward` arity
/// invariant), `oos_equity` is empty (an empty segment leaves the stitched
/// curve unbroken, same convention as a genuinely equity-less window), and
/// the report reuses [`axis_grid_probe_report`]'s zero-cost placeholder.
/// Never rendered: the caller always resolves the captured window fault and
/// exits before touching a faulted window's run.
fn placeholder_window_run(space: &[ParamSpec]) -> WindowRun {
WindowRun {
chosen_params: space.iter().map(|_| Cell::from_i64(0)).collect(),
oos_equity: Vec::new(),
oos_report: axis_grid_probe_report(),
}
}
/// The LOWEST roll-order window's captured member-panic message (#278),
/// found by walking `result.windows` — guaranteed by `assemble_walk_forward`
/// to be in roll order regardless of thread completion (C1) — for the first
/// window whose bounds match a captured fault. Thread-order-independent by
/// construction, mirroring [`lowest_point_fault`]'s identical convention one
/// level down (per-point within one window's IS sweep) and
/// `aura-campaign::exec`'s own lowest-index walk-forward fault attribution.
fn lowest_window_fault(result: &WalkForwardResult, faults: Mutex<Vec<(WindowBounds, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
result
.windows
.iter()
.find_map(|w| captured.iter().find(|(b, _)| *b == w.bounds).map(|(_, m)| m.clone()))
}
/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the
/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the
/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`;
/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors
/// (a bad `--axis`) `exit(2)` with the sweep terminal's message.
pub fn blueprint_walkforward_family(
doc: &str, axes: &[(String, Vec<Scalar>)], data: &DataSource, select: Selection,
env: &Env,
) -> WalkForwardResult {
let span = data.wf_full_span(env);
let (is_len, oos_len, step) = data.wf_window_sizes();
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
Ok(r) => r,
Err(e) => {
eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}");
std::process::exit(2);
}
};
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set ONCE for the whole family — every per-window
// sweep AND the OOS reload re-open the SAME set, mirroring
// `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant
// (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis
// matching neither space is simply not an override here — the strict check + its
// established error message stay single-sourced in `blueprint_sweep_over`'s own
// pre-flight call below, so this derivation cannot double-validate with a
// differently-worded rejection.
let axis_names: Vec<String> = axes.iter().map(|(n, _)| n.clone()).collect();
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal);
let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space();
// Strict binding resolution, once per family; refusal is the established
// `aura: ` + exit-1 register (the roller's usage refusals stay exit 2).
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(probe_signal.name(), &binding));
std::process::exit(1);
}
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
// (which resolves its axes a single time before any member runs). `walk_forward` fans
// the per-window closure out across the windows in parallel, so a `BindError` raised
// *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one
// exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without
// running a single member — no IS window (or a second roller) is needed here at all.
if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) {
eprintln!("aura: {}", render_bind_error(&e));
std::process::exit(2);
}
// #278: `blueprint_sweep_over`'s inner sweep and `run_oos_blueprint`'s single
// member run both drive `run_blueprint_member` bare, so either could
// otherwise unwind an uncaught member-compile panic straight through this
// window closure. Both are contained (`blueprint_sweep_over` resolves its
// own capture and returns it; `run_oos_blueprint` via `catch_member_panic`
// directly) and captured PER WINDOW here rather than printed on the spot —
// this closure runs across windows in parallel (`walk_forward`), so an
// inline `eprintln!`+`exit` would race the same way the axis pre-flight
// above was written to avoid (#177). The lowest roll-order window's fault
// is resolved once, after every window has joined.
let window_faults: Mutex<Vec<(WindowBounds, String)>> = Mutex::new(Vec::new());
let result = walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice, is_fault) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
.expect("axes validated in the dispatch-boundary pre-flight");
if let Some(msg) = is_fault {
window_faults.lock().expect("fault capture lock").push((w, msg));
return placeholder_window_run(&space);
}
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
match catch_member_panic(|| {
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides)
}) {
Ok((oos_equity, mut oos_report)) => {
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
}
Err(msg) => {
window_faults.lock().expect("fault capture lock").push((w, msg));
placeholder_window_run(&space)
}
}
});
if let Some(msg) = lowest_window_fault(&result, window_faults) {
exit_on_member_panic(&msg);
}
result
}
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s
/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the
/// `aura mc <blueprint.json>` persist path AND the reproduce MonteCarlo branch, so the
/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded
/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades.
pub fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> {
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
vec![Box::new(spec.source(seed))]
}
/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed
/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The
/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free
/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling
/// [`blueprint_sweep_family`]: the IO wrapper `run_blueprint_mc` renders it to stderr +
/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw
/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce
/// re-runs), so reproduction is bit-identical (C1); every member carries the shared
/// `topology_hash`.
pub fn blueprint_mc_family(
doc: &str, n_seeds: u64, data: &DataSource, env: &Env,
) -> Result<McFamily, String> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let probe_signal = reload(doc);
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// Strict binding resolution (name defaults — mc's synthetic family binds
// no campaign overrides); the exit-free Err contract of this builder.
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
if !binding.close_only() {
// MC draws ALWAYS run the seeded synthetic close walk (real-data mc
// routes through the campaign sugar and never reaches this builder).
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
}
let pip = data.pip_size();
// probe the wrapped param_space (the same probe the sweep resolves against);
// MC needs it empty. `blueprint_axis_probe` is the single source of that wrap.
let space = blueprint_axis_probe(doc, env).param_space();
if !space.is_empty() {
// Exit-free like blueprint_sweep_family: the builder's single error contract is this
// returned message (no hidden process exit), so the rejection is unit-testable; the IO
// wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary.
return Err(format!(
"mc requires a closed blueprint (no free parameters); {} free knob(s) — \
bind them or use `aura sweep --axis`",
space.len()
));
}
// Closed blueprint -> an empty base point (as `aura run <blueprint.json>`); the MC
// draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws
// to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1),
// deterministic in seed-input order. Each draw
// re-runs the shared reduce-mode member path over its own seeded synthetic walk.
let seeds: Vec<u64> = (1..=n_seeds).collect();
let base_point: Vec<Scalar> = Vec::new();
// #278: `run_blueprint_member` ran bare here, the one family builder without
// the #272 fault boundary its sweep/walk-forward siblings gained in 51096a3
// — a member-compile panic (e.g. an `Sma::new` length assert) would otherwise
// unwind straight through `monte_carlo`'s `run_indexed` to an uncaught exit
// 101. Contained the same way: `catch_member_panic` + a per-seed capture,
// resolved to the LOWEST seed's message after the join (thread-order-
// independent, C1) via `lowest_seed_fault`.
let faults: Mutex<Vec<(u64, String)>> = Mutex::new(Vec::new());
let family = monte_carlo(&base_point, &seeds, |seed, _base| {
let sources = synthetic_walk_sources(seed);
let window = window_of(&sources).expect("non-empty synthetic walk");
match catch_member_panic(|| {
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((seed, msg));
axis_grid_probe_report()
}
}
});
// The captured fault must win BEFORE the vacuous-mc guard below: a faulted
// draw's placeholder report is metrics-identical across every faulted seed,
// so a run with >= 2 faulted seeds (or one faulted + one real draw sharing
// its placeholder's zero metrics) could otherwise trip the vacuous refusal
// instead of surfacing the real member fault.
if let Some(msg) = lowest_seed_fault(&family, faults) {
exit_on_member_panic(&msg);
}
// Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's
// metrics are bit-identical to the first, no seed reached a distinguishable realization —
// the strategy never warmed over the fixed synthetic walk (e.g. a lookback as deep as the
// walk is long), so the "distribution" is a single point masquerading as a family: a wrong
// result with no error. Compare `metrics`, not the whole `RunReport` — the manifest's
// `seed` differs per draw by construction, so a whole-report compare could never detect the
// collapse; the metrics are the realization the seed is meant to move. A single-draw MC
// (n == 1) is trivially "all identical" and is NOT this cross-seed condition, so it passes.
if family.draws.len() >= 2
&& family
.draws
.iter()
.all(|d| d.report.metrics == family.draws[0].report.metrics)
{
return Err(
"mc is vacuous: every seed produced an identical result — the strategy never warmed \
over the synthetic walk, so no seed reached a distinguishable realization; use a \
shallower-lookback blueprint or a longer walk"
.to_string(),
);
}
Ok(family)
}
+42
View File
@@ -0,0 +1,42 @@
//! aura-runner — the C28 assembly position (#295).
//!
//! The canonical member-run recipe as a library: harness assembly, input
//! binding (C26), the C1-load-bearing param<->config translators, and the
//! axis/risk-regime conventions. [`DefaultMemberRunner`] is the shipped
//! `aura_campaign::MemberRunner` implementation over that recipe, plus the
//! campaign trace-persistence disk-layout writers (`runner` module) and the
//! shared coverage-report walk (`coverage` module). A downstream World
//! program reaches the entire family/validation machinery through this
//! crate plus `aura-campaign`, with no `aura` binary involved.
pub mod axes;
pub mod binding;
pub mod coverage;
pub mod family;
pub mod measure;
pub mod member;
pub mod project;
pub mod reproduce;
pub mod runner;
pub mod tap_plan;
pub mod tap_recorder;
pub mod translate;
pub use project::Env;
pub use runner::DefaultMemberRunner;
pub use tap_plan::{
bind_tap_plan, BoundTaps, FoldBuildCtx, FoldEntry, FoldOutput, FoldRegistry, FoldSink, TapPlan,
TapPlanError, TapSubscription,
};
pub use tap_recorder::TapRecorder;
/// A refusal a library function reports instead of exiting the process
/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place
/// that maps it back to the identical stderr bytes + exit code, so the
/// binary's observable behaviour stays byte-unchanged (#295, spec §Error
/// handling).
#[derive(Debug)]
pub struct RunnerError {
pub exit_code: i32,
pub message: String,
}
+200
View File
@@ -0,0 +1,200 @@
//! The measurement run path (#295).
//!
//! `run_measurement` is `member::run_signal_r` MINUS `wrap_r` and the
//! eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist
//! (C27) — see the fn doc for the full rationale. The `aura measure ic`
//! reduction (reading an already-recorded run's tap traces and folding them
//! into an `IcReport`) is unrelated to this run path and stays in the CLI
//! shell (`dispatch_measure_ic`), which assembles and prints that DTO.
use std::collections::BTreeMap;
use aura_core::{Scalar, Timestamp};
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData};
use crate::project::Env;
use crate::tap_plan::{bind_tap_plan, TapPlan};
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit`
/// reads this one const, mirroring `member::ENGINE_COMMIT` and the CLI shell's
/// own copy (all three resolve the same process-wide env var at compile time,
/// so they cannot disagree).
const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") {
Some(c) => c,
None => "unknown",
};
/// The measurement-run manifest: the honest subset of
/// `member::sim_optimal_manifest` with no broker/R specifics.
/// `broker` carries the interim `"measurement"`
/// sentinel — `RunManifest.broker` is a required `String` today; the honest
/// `Option<String>` model is deferred to the #147 metric-genericity block so this
/// phase stays #147-free and the strategy-path C18 goldens byte-identical.
pub fn measurement_manifest(
params: Vec<(String, Scalar)>,
window: (Timestamp, Timestamp),
seed: u64,
) -> RunManifest {
RunManifest {
commit: ENGINE_COMMIT.to_string(),
params,
defaults: Vec::new(),
window,
seed,
broker: "measurement".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
}
}
/// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the
/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27).
/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this
/// is where the measured O(cycles) retention is removed. The tap machinery is
/// the shared `bind_tap_plan`/`BoundTaps` pair — one wiring for both entry
/// points, so they cannot drift (#283).
pub fn run_measurement(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan,
) -> MeasurementReport {
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at
// `aura_research`.
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed
let run_name = signal.name().to_string();
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
if matches!(data, RunData::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding));
std::process::exit(1);
}
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
let defaults = wrapped_bound_defaults(&signal);
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
// Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks.
let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| {
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
std::process::exit(1);
});
// Bind each declared tap per the plan's subscription (mirrors
// run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror).
let bound = bind_tap_plan(&mut flat, plan, env, &run_name).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
let mut h = Harness::bootstrap(flat).expect("valid measurement harness");
h.run_bound(key_supply(&binding, sources))
.expect("sources opened against `binding` key-match that binding's own roles by construction");
let named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = measurement_manifest(named_params, window, seed);
manifest.defaults = defaults;
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
// Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
let tap_names: Vec<String> = bound.declared_names().to_vec();
bound.finish(&manifest).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
MeasurementReport { manifest, taps: tap_names }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::ProjectEnv;
use crate::tap_plan::TapSubscription;
use aura_engine::blueprint_from_json;
use aura_vocabulary::std_vocabulary;
use std::path::PathBuf;
/// A fresh project root so the run's `runs/` lands in a temp dir.
fn temp_project_env(name: &str) -> (Env, PathBuf) {
let root = std::env::temp_dir()
.join(format!("aura-measure-plan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp project root");
let env = Env::with_project(ProjectEnv {
root: root.clone(),
toml: Default::default(),
commit: None,
native: None,
});
(env, root)
}
/// The shipped `examples/r_sma.json` with one declared tap on node 0
/// ("fast", SMA(length=2)) field 0 — the same patch shape as
/// `aura-cli/tests/tap_recording.rs`.
fn tapped_r_sma() -> Composite {
let doc = include_str!("../../aura-cli/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}}]);
let patched = serde_json::to_string(&v).expect("re-serialize");
blueprint_from_json(&patched, &|t| std_vocabulary(t)).expect("tapped r_sma loads")
}
/// The exact synthetic price array the `RunData::Synthetic` path feeds
/// (`r_sma_prices()`, main.rs) — duplicated so the fold value is pinned
/// against an independently-derived expectation, not a blind capture
/// (the `tap_recording.rs` discipline).
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,
];
#[test]
fn measurement_fold_plan_writes_one_row_trace_through_the_shared_pair() {
let (env, root) = temp_project_env("fold");
let mut plan = TapPlan::empty();
plan.subscribe("fast_tap", TapSubscription::named("mean"));
let report = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan);
assert_eq!(report.taps, vec!["fast_tap".to_string()]);
let text = std::fs::read_to_string(
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("one-row fold trace persisted");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
assert_eq!(v["kinds"], serde_json::json!(["F64"]));
let ts = v["ts"].as_array().expect("ts");
let col = v["columns"][0].as_array().expect("col");
assert_eq!((ts.len(), col.len()), (1, 1), "exactly one summary row");
// SMA(2) series (warm from sample 2), folded mean — sequential sum,
// same operation order as FoldState (bit-identical, IEEE-754).
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
.collect();
let mean = sma.iter().sum::<f64>() / sma.len() as f64;
assert_eq!(col[0].as_f64().expect("f64 row"), mean, "streamed mean == slice mean");
assert_eq!(ts[0].as_i64().expect("ts"), R_SMA_PRICES.len() as i64, "last folded ts");
}
#[test]
fn measurement_tap_free_blueprint_writes_nothing() {
let (env, root) = temp_project_env("tapfree");
let doc = include_str!("../../aura-cli/examples/r_sma.json");
let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads");
let report =
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
assert!(report.taps.is_empty(), "no declared taps");
assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry");
}
}
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
//! merged resolver, runs root, data path, provenance.
use aura_core::PrimitiveBuilder;
use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate};
use aura_core::project::{
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
@@ -63,6 +63,11 @@ pub enum ProjectError {
/// handing off to `load_crate`, so this never reaches the cargo-metadata
/// probe (whose raw os-error text would otherwise leak through).
PointerDirMissing(PathBuf),
/// C29 load seam (#316): a resolved extension-vocabulary entry whose doc
/// fails the shape gate — the load refuses rather than admit an
/// undescribed node. Field presence is compile-enforced; shape is not,
/// so the gate carries the failed rule for the refusal prose.
UndescribedVocabularyEntry { type_id: String, fault: DocGateFault },
}
impl fmt::Display for ProjectError {
@@ -100,6 +105,19 @@ impl fmt::Display for ProjectError {
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
),
Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
Self::UndescribedVocabularyEntry { type_id, fault } => {
let rule = match fault {
DocGateFault::Empty => "has an empty doc",
DocGateFault::RestatesName => {
"has a doc that merely restates its name"
}
};
write!(
f,
"vocabulary entry `{type_id}` {rule} — every vocabulary \
entry ships a one-line meaning (C29)"
)
}
}
}
}
@@ -444,7 +462,7 @@ fn stale_warning(
) -> Option<String> {
if source_mtime > dylib_mtime {
Some(format!(
"warning: {} may be stale — dylib built {} but a project source \
"aura: warning: {} may be stale — dylib built {} but a project source \
file was modified {} (run `cargo build` to refresh); proceeding \
with the existing dylib",
dylib_path.display(),
@@ -579,6 +597,18 @@ fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectErro
let type_id_list = (desc.type_ids)();
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
// C29 load seam (#316): every resolved entry must describe itself. The
// charter cross-check above guarantees each listed id resolves, so a
// `None` here is unreachable — skipped rather than panicked on (a
// hostile dylib must never panic the host).
for &t in type_id_list {
if let Some(builder) = resolver(t) {
doc_gate(t, builder.schema().doc).map_err(|fault| {
ProjectError::UndescribedVocabularyEntry { type_id: t.to_string(), fault }
})?;
}
}
Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
}
@@ -886,6 +916,7 @@ mod tests {
let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
let path = PathBuf::from("/tmp/x.so");
let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
assert!(msg.starts_with("aura: warning: "), "carries the warning class marker (#278): {msg}");
assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
}
@@ -903,4 +934,32 @@ mod tests {
"older source: not stale"
);
}
/// The C29 load-seam refusal prose (`UndescribedVocabularyEntry`) names
/// the entry and the failed rule for BOTH `DocGateFault` arms. Only
/// `Empty` is reachable end-to-end today (the fixture in
/// `aura-cli/tests/project_load.rs` uses `doc: ""`); `RestatesName` has
/// no such fixture, so this pins its rendered message directly against
/// `Display` — a regression there would otherwise pass every test.
#[test]
fn undescribed_vocabulary_entry_display_names_the_entry_and_rule() {
let empty = ProjectError::UndescribedVocabularyEntry {
type_id: "ns::Foo".to_string(),
fault: DocGateFault::Empty,
};
assert_eq!(
empty.to_string(),
"vocabulary entry `ns::Foo` has an empty doc — every vocabulary \
entry ships a one-line meaning (C29)"
);
let restates = ProjectError::UndescribedVocabularyEntry {
type_id: "ns::Foo".to_string(),
fault: DocGateFault::RestatesName,
};
assert_eq!(
restates.to_string(),
"vocabulary entry `ns::Foo` has a doc that merely restates its \
name every vocabulary entry ships a one-line meaning (C29)"
);
}
}
+360
View File
@@ -0,0 +1,360 @@
//! Bit-identical reproduction (`aura reproduce`) — #295.
//!
//! A library function here reports a refusal as a returned
//! [`crate::RunnerError`] rather than ending the process. The shell dispatch
//! arm (`aura-cli`'s `dispatch_reproduce`) is the single place that prints
//! the error's message to stderr and calls `std::process::exit` on its code,
//! keeping the observable stderr/exit bytes unchanged (C18).
use std::collections::BTreeMap;
use std::sync::mpsc;
use aura_engine::{blueprint_from_json, window_of};
use aura_registry::{group_families, Family, FamilyKind, Registry};
use aura_backtest::point_from_params;
use crate::family::{synthetic_walk_sources, DataChoice, DataSource};
use crate::member::{reopen_all, run_blueprint_member, wrapped_bound_overrides_of, SYNTHETIC_PIP_SIZE};
use crate::project::Env;
use crate::runner::render_value;
use crate::translate::{cost_specs_from_params, stop_rule_from_params};
use crate::RunnerError;
/// The outcome of reproducing one persisted family: per member, whether its re-run
/// metrics are bit-identical to the stored metrics (C1).
pub struct ReproduceReport {
pub outcomes: Vec<(String, bool)>,
}
/// Look up a persisted family by id, or refuse (exit code 1: unknown id / registry
/// load failure) — the single place `reproduce_family` and `reproduce_family_in`
/// resolve a family, so the two exit-1 error phrasings can't drift out of sync
/// between the call sites.
///
/// `id` is tried first against the derived `family_id` handle (`"{family}-{run}"`,
/// exact match, first precedence). If that misses, it falls back to the bare
/// enumeration `family` name (`FamilyRunRecord::family` / `Registry::
/// load_family_members()`'s natural list-then-reproduce workflow, #298): a name
/// that names exactly one stored run resolves to it, but a name with more than
/// one stored run is ambiguous and refused rather than guessed, listing the
/// candidate handles.
pub fn load_family(reg: &Registry, id: &str) -> Result<Family, RunnerError> {
let members = reg.load_family_members().map_err(|e| RunnerError {
exit_code: 1,
message: format!("{e}"),
})?;
let families = group_families(members);
if let Some(family) = families.iter().find(|f| f.id == id) {
return Ok(family.clone());
}
let by_name: Vec<&Family> =
families.iter().filter(|f| f.members.iter().any(|m| m.family == id)).collect();
match by_name.as_slice() {
[only] => Ok((*only).clone()),
// reproduce is an action, not a lookup: an unknown id is a hard error (distinct
// from `runs family <id>`'s treat-as-empty exit 0).
[] => Err(RunnerError { exit_code: 1, message: format!("no such family '{id}'") }),
many => {
let candidates =
many.iter().map(|f| f.id.clone()).collect::<Vec<_>>().join(", ");
Err(RunnerError {
exit_code: 1,
message: format!("ambiguous family '{id}': candidates {candidates}"),
})
}
}
}
/// Re-derive every member of a persisted sweep family from the content-addressed store
/// and compare to the stored result, against an explicit registry (testable seam).
pub fn reproduce_family_in(
reg: &Registry,
id: &str,
data: &DataSource,
env: &Env,
) -> Result<ReproduceReport, RunnerError> {
let family = load_family(reg, id)?;
let pip = data.pip_size();
let mut outcomes = Vec::new();
for member in &family.members {
let stored = &member.report;
let hash = stored.manifest.topology_hash.clone().ok_or_else(|| RunnerError {
exit_code: 1,
message: "family member has no topology_hash; not a generated run".to_string(),
})?;
let doc = reg
.get_blueprint(&hash)
.map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })?
.ok_or_else(|| RunnerError {
exit_code: 1,
message: format!("blueprint {hash} missing from store"),
})?;
// The #246 override set (silent variant): re-derived from the RECORDED
// manifest param names, against a raw probe space + raw strategy load — a
// name matching neither space is simply not an override (falls through to
// `point_from_params`'s existing missing-knob refusal below), unlike the
// sweep boundary's `override_paths`, which errors on it.
let recorded: Vec<String> =
stored.manifest.params.iter().map(|(n, _)| n.clone()).collect();
let raw_space = crate::member::blueprint_axis_probe(&doc, env).param_space();
let raw_signal = blueprint_from_json(&doc, &|t| env.resolve(t)).map_err(|e| RunnerError {
exit_code: 1,
message: format!("stored blueprint {hash} does not parse: {e:?}"),
})?;
let overrides = wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal);
// Reload the stored blueprint per use: a Composite is !Clone, and both the
// param-space probe (below) and the re-run each consume one. The doc was
// canonical-serialized at store time, so every reload is infallible. Every
// reload re-opens the same override set derived above, so the recorded
// param names resolve against the space they were minted under (#246).
let reload = || -> Result<_, RunnerError> {
Ok(reopen_all(
blueprint_from_json(&doc, &|t| env.resolve(t)).map_err(|e| RunnerError {
exit_code: 1,
message: format!("stored blueprint {hash} does not parse: {e:?}"),
})?,
&overrides,
))
};
// The param_space of the WRAPPED signal — its knobs carry the `r_sma`
// wrapper's `sma_signal.` node-path prefix, exactly the names the manifest
// recorded at write time. Mirrors `blueprint_sweep_family`'s probe so the
// reproduce-side space name-matches the stored params (raw `signal.param_space()`
// would drop the prefix and `point_from_params` could not find the knobs).
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
let stop = stop_rule_from_params(&stored.manifest.params);
// The member's cost model, re-derived from its manifest (the #233
// stop pattern): a costed family re-runs under the exact components it
// was minted with, so `net_expectancy_r` reproduces bit-identically.
let cost = cost_specs_from_params(&stored.manifest.params)
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
// The member's binding, re-derived from the stored blueprint's own
// input roles (name defaults — family manifests carry no overrides).
let binding = crate::binding::resolve_binding(&hash, reload()?.input_roles(), &BTreeMap::new())
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
return Err(RunnerError {
exit_code: 1,
message: crate::binding::synthetic_refusal(&hash, &binding),
});
}
let space = crate::member::wrap_r(reload()?, tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding, None).param_space();
let point = point_from_params(&space, &stored.manifest.params)
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
// A MonteCarlo member carries no tuning params (the params-join is empty), so its
// reproduce line would print a BLANK member label; the seed IS its realization
// identity, so surface `seed=<N>` instead. Sweep / walk-forward members echo their
// tuning params (the params-join), unchanged.
let label = match family.kind {
FamilyKind::MonteCarlo => format!("seed={}", stored.manifest.seed),
_ => stored
.manifest
.params
.iter()
.map(|(n, v)| format!("{n}={}", render_value(v)))
.collect::<Vec<_>>()
.join(", "),
};
// Realization-aware: a MonteCarlo member ran over a seed-driven synthetic walk,
// not the showcase — reconstruct it from manifest.seed so the re-run matches (C1).
// The seed is manifest-carried and identical across realizations; only the sources
// and window vary by kind.
let seed = stored.manifest.seed;
let (sources, member_window) = match family.kind {
FamilyKind::MonteCarlo => {
let s = synthetic_walk_sources(seed);
let w = window_of(&s).expect("non-empty synthetic walk");
(s, w)
}
FamilyKind::WalkForward => {
// each member is one OOS window: rebuild its windowed slice from the
// stored window bounds; the winner params come from the shared
// manifest->cells recovery below (as Sweep members do).
let (from, to) = stored.manifest.window;
let s = data.windowed_sources(from, to, env, &binding.columns());
let w = window_of(&s).expect("non-empty OOS window");
(s, w)
}
// Sweep / plain-run members: a Real source reopens the exact per-member
// window the manifest recorded, the same `windowed_sources` loader
// WalkForward uses above (and `DefaultMemberRunner` uses at mint time, #229)
// — never a fresh full-archive probe, which need not match the window the
// family was minted over. Synthetic keeps the pre-#229 full-window path
// (byte-identical: `data.full_window` is a pure, no-IO computation).
_ => match data {
DataSource::Real { .. } => {
let (from, to) = stored.manifest.window;
(data.windowed_sources(from, to, env, &binding.columns()), (from, to))
}
DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)),
},
};
let rerun = run_blueprint_member(
reload()?,
&point,
&space,
sources,
member_window,
seed,
pip,
&hash,
env,
stop,
&binding,
&cost,
// The re-run specs come from the stamp and are scalar by
// construction (`cost_specs_from_params`), so the instrument is
// inert; the fallback is never resolved against a map.
stored.manifest.instrument.as_deref().unwrap_or(""),
);
outcomes.push((label, rerun.metrics == stored.metrics));
}
Ok(ReproduceReport { outcomes })
}
/// `aura reproduce <family-id>`: re-derive a persisted sweep family from the
/// content-addressed blueprint store and verify each member reproduces bit-identically
/// (C18 "re-derives full results on demand"). The data source is reconstructed from
/// the family's own manifest (#229) — a hardcoded `DataSource::Synthetic` here would
/// re-derive a real-data family over the wrong stream; see `reproduce_family_in`'s
/// per-member window loader for how the reconstructed source is actually used.
///
/// Prints its own per-member + summary lines to stdout (unchanged from the
/// pre-#295 shell function — this is the report's normal output, not an error
/// path); only the final "not every member reproduced" outcome is a refusal,
/// returned with an EMPTY message (there is no accompanying stderr line today,
/// so the shell dispatch arm must not synthesize one either — see
/// `dispatch_reproduce`).
pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> {
let reg = env.registry();
let family = load_family(&reg, id)?;
// Reconstruct the DataSource the family was minted over: `None` instrument
// (every synthetic-family member, pre-#229 lines) stays the built-in synthetic
// stream; a real instrument reopens the same local archive `--real` runs use,
// via the same named-data refusal (exit 1) on a missing sidecar/archive.
let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) {
None => DataSource::Synthetic,
Some(symbol) => {
DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)
}
};
let rep = reproduce_family_in(&reg, id, &data, env)?;
let total = rep.outcomes.len();
let ok = rep.outcomes.iter().filter(|(_, b)| *b).count();
for (label, identical) in &rep.outcomes {
let verdict = if *identical { "bit-identical" } else { "DIVERGED" };
println!("{id} member {label} reproduced: {verdict}");
}
println!("reproduced {ok}/{total} members bit-identically");
if ok != total {
return Err(RunnerError { exit_code: 1, message: String::new() });
}
Ok(())
}
#[cfg(test)]
mod tests {
use aura_backtest::{RunMetrics, RunReport};
use aura_core::{Scalar, Timestamp};
use aura_engine::RunManifest;
use aura_registry::{FamilyKind, Registry};
use super::load_family;
/// A registry over a fresh per-test directory (the #258 tag-keyed pattern:
/// fixed name under the build-tree tmp anchor, pre-create wipe).
fn temp_registry(tag: &str) -> Registry {
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
.join(format!("aura-runner-reproduce-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp registry dir");
Registry::open(dir.join("runs.jsonl"))
}
/// One minimal persisted member (the aura-registry test fixture shape).
fn minimal_report() -> RunReport {
RunReport {
manifest: RunManifest {
commit: "c".to_string(),
params: vec![("p".to_string(), Scalar::f64(1.0))],
defaults: vec![],
window: (Timestamp(1), Timestamp(2)),
seed: 0,
broker: "b".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 1.0, max_drawdown: 0.5, bias_sign_flips: 0, r: None },
}
}
/// One id vocabulary across enumeration and reproduction (C18, #298): the
/// family-identity string a consumer lifts off the registry's own member
/// enumeration (`FamilyRunRecord.family`) resolves through the reproduce
/// lookup when it is unambiguous (a single stored run for the name), and
/// reaches the same family the canonical derived handle
/// (`"{family}-{run}"`, C18) names. Today the lookup exact-matches the
/// derived handle only, so the natural list-then-reproduce library
/// workflow dead-ends on `no such family`.
///
/// Deliberately NOT pinned here: the ambiguous case (a name with more
/// than one stored run) and name/handle collision precedence — those
/// semantics are the fix's to settle (refuse-don't-guess).
#[test]
fn load_family_accepts_the_enumerated_family_identity() {
let reg = temp_registry("enumerated-id");
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()]).expect("append family");
let members = reg.load_family_members().expect("enumerate members");
let enumerated = members[0].family.clone();
let canonical = members[0].family_id();
let family = load_family(&reg, &enumerated).unwrap_or_else(|e| {
panic!("the enumerated family identity '{enumerated}' must resolve: {}", e.message)
});
assert_eq!(family.id, canonical);
}
/// A name shared by more than one stored run must refuse rather than guess
/// (#298 fix): `load_family` lists every candidate handle in the refusal
/// message so the caller can pick the intended one, instead of silently
/// resolving to whichever run happens first.
#[test]
fn load_family_refuses_a_name_with_more_than_one_stored_run() {
let reg = temp_registry("ambiguous-name");
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()])
.expect("append family f, run 0");
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()])
.expect("append family f, run 1");
let err = load_family(&reg, "f")
.expect_err("a name naming two stored runs must not resolve silently");
assert!(err.message.contains("ambiguous family 'f'"), "message: {}", err.message);
assert!(err.message.contains("f-0"), "message: {}", err.message);
assert!(err.message.contains("f-1"), "message: {}", err.message);
}
/// The derived handle keeps first precedence over the name fallback (#298
/// fix): when a family named `"f-0"` collides with the canonical handle of
/// another family `"f"`'s run 0, looking up `"f-0"` resolves to the family
/// whose HANDLE is `"f-0"`, not the family whose NAME happens to match.
#[test]
fn load_family_exact_handle_precedes_a_name_collision() {
let reg = temp_registry("handle-name-collision");
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()])
.expect("append family f, run 0 -> handle f-0");
reg.append_family("f-0", FamilyKind::Sweep, &[minimal_report()])
.expect("append family f-0, run 0 -> handle f-0-0, name f-0");
let family = load_family(&reg, "f-0").unwrap_or_else(|e| {
panic!("the exact handle 'f-0' must resolve: {}", e.message)
});
assert_eq!(family.id, "f-0");
assert_eq!(family.members[0].family, "f");
}
}
+801
View File
@@ -0,0 +1,801 @@
//! [`DefaultMemberRunner`]: the shipped `aura_campaign::MemberRunner`
//! implementation over the loaded-blueprint machinery, plus the disk-layout
//! writers that persist a campaign run's traces (#295).
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{mpsc, Arc};
use aura_backtest::{point_from_params, summarize, summarize_r, RunReport};
use aura_campaign::{
CampaignOutcome, CellOutcome, CellSpec, MemberFault, MemberRunner,
};
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_engine::{blueprint_from_json, f64_field, ColumnarTrace};
use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns};
use aura_registry::WriteKind;
use aura_research::{CampaignDoc, CostSpec};
use crate::axes::{bind_axes, raw_bound_overrides_of};
use crate::coverage::interior_gap_months;
use crate::member::{
blueprint_axis_probe, blueprint_axis_probe_reopened, key_supply, reopen_all,
run_blueprint_member, wrap_r, wrapped_bound_overrides_of, CostLeg,
};
use crate::project::Env;
use crate::translate::{cost_nodes_for, stop_rule_for_regime};
/// Map any byte outside the portable directory-name charset `[A-Za-z0-9._-]`
/// to `_`. The single source of filesystem-portability for an on-disk path
/// component (valid on Linux / Windows / macOS, also URL-path- and
/// cloud-sync-safe). Used by the campaign trace layout's per-cell/per-member
/// keys ([`campaign_cell_key`], [`member_trace_key`]) — a durable disk-layout
/// contract, not stdout presentation.
pub fn sanitize_component(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
.collect()
}
/// Render a scalar value case-lessly: integers/timestamps as decimal digits,
/// bool as `true`/`false`, f64 via Rust's `Display` (decimal, shortest
/// round-trip, NO scientific notation). Case-less rendering is what keeps two
/// members of one family from ever differing only by letter case
/// (case-insensitive-FS safety). The single source for the trace layout's
/// member-key label AND the shell's `aura reproduce` rendering — the two must
/// stay byte-identical (#224).
pub fn render_value(v: &Scalar) -> String {
match v {
Scalar::I64(n) => n.to_string(),
Scalar::F64(x) => x.to_string(),
Scalar::Bool(b) => b.to_string(),
Scalar::Timestamp(t) => t.0.to_string(),
}
}
/// The shipped harness/data binding seam for `aura_campaign::execute`: members
/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode
/// via `run_blueprint_member`) over windowed real M1 close bars
/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this
/// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the
/// library to surface; never a process exit inside a sweep worker.
pub struct DefaultMemberRunner<'a> {
env: &'a Env,
server: Arc<data_server::DataServer>,
/// The campaign's `data.bindings` overrides (role -> column), threaded
/// into per-member binding resolution (#231: rebind per campaign without
/// touching the blueprint's content id).
bindings: BTreeMap<String, String>,
/// The campaign's cost model (#234), threaded into every member run via
/// `cost_nodes_for` (empty = zero costs, today's graph).
cost: Vec<CostSpec>,
}
impl<'a> DefaultMemberRunner<'a> {
pub fn new(
env: &'a Env,
server: Arc<data_server::DataServer>,
bindings: BTreeMap<String, String>,
cost: Vec<CostSpec>,
) -> Self {
Self { env, server, bindings, cost }
}
/// The runner's own data server, shared with the trace-persistence tail
/// (`persist_campaign_traces` re-runs members over the same archive).
pub fn server(&self) -> Arc<data_server::DataServer> {
Arc::clone(&self.server)
}
}
impl MemberRunner for DefaultMemberRunner<'_> {
fn run_member(
&self,
cell: &CellSpec,
params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault> {
// The member's blueprint, loaded RAW (no re-open yet) — the SAME
// reload the probe below wraps, so `bound_param_space()` here and
// the probe's `param_space()` after `blueprint_axis_probe_reopened`
// resolve against one topology.
let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t))
.expect("stored blueprint passed the referential gate; reload is infallible");
// The un-reopened wrapped OPEN space (#246) — the SAME probe the
// sweep verbs resolve against (single-sourced in main.rs; identical
// `false, true, None` wrap) — derives which of this cell's RAW axis
// names re-open a bound param before the real, reopened probe/reload
// are built: probe and member reload must re-open identically so
// `params` resolves against one space.
let raw_space = blueprint_axis_probe(&cell.blueprint_json, self.env).param_space();
let param_names: Vec<String> = params.iter().map(|(n, _)| n.clone()).collect();
let overrides = raw_bound_overrides_of(&param_names, &raw_space, &signal);
let space =
blueprint_axis_probe_reopened(&cell.blueprint_json, self.env, &overrides)
.param_space();
let point = bind_axes(&space, &cell.strategy_id, params)?;
let signal = reopen_all(signal, &overrides);
// The member's resolved input binding (campaign data.bindings
// overrides win over name defaults). A refusal is a member fault,
// never a process exit.
let binding =
crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &self.bindings)
.map_err(MemberFault::Bind)?;
// Real windowed data — geometry BEFORE bar data (the shipped pre-data
// refusal order of `open_real_source`), both as member faults.
let geo = instrument_geometry(&self.server, &cell.instrument).ok_or_else(|| {
MemberFault::Run(format!(
"no recorded geometry for symbol '{}' at {} — refusing to run a \
real instrument with a guessed pip",
cell.instrument,
self.env.data_path()
))
})?;
let from = unix_ms_to_epoch_ns(window_ms.0);
let to = unix_ms_to_epoch_ns(window_ms.1);
// One source per resolved binding column, canonical order (the same
// order `wrap_r` declares the roles in — the shared plan).
let sources = match open_columns_window(
&self.server,
&cell.instrument,
Some(from),
Some(to),
&binding.columns(),
) {
Some(s) => s,
// No archived file overlaps the window at all.
None => {
return Err(MemberFault::NoData {
instrument: cell.instrument.clone(),
window_ms,
});
}
};
// A window that overlaps a file but holds zero matching bars yields
// sources whose first peek is None (open_window's documented contract)
// — the same no-data condition (all columns decode the same bars, so
// probing the first source covers the set).
if aura_engine::Source::peek(sources[0].as_ref()).is_none() {
return Err(MemberFault::NoData {
instrument: cell.instrument.clone(),
window_ms,
});
}
// The shipped member recipe (wrap, bind, run, summarize):
// `run_blueprint_member` verbatim. Seed 0 (seed-free real-data runs),
// topology_hash = the strategy's content id, project provenance
// stamped inside the helper.
let stop = stop_rule_for_regime(cell.regime);
let mut report = run_blueprint_member(
signal,
&point,
&space,
sources,
(from, to),
0,
geo.pip_size,
&cell.strategy_id,
self.env,
stop,
&binding,
&self.cost,
&cell.instrument,
);
report.manifest.instrument = Some(cell.instrument.clone());
Ok(report)
}
/// #272: derive the cell's archive coverage from the #264 primitives —
/// `None` when the archive has no file for the instrument at all (the
/// `NoData` member fault's job, not coverage) or when the effective
/// evaluated span covers the requested window exactly with no interior
/// gap month. One call per cell (not per member): coverage is a property
/// of the cell's (instrument, window), never a swept param point.
fn window_coverage(&self, cell: &CellSpec) -> Option<aura_registry::CellCoverage> {
let data_path = PathBuf::from(self.env.data_path());
let months = aura_ingest::list_m1_months(&data_path, &cell.instrument);
if months.is_empty() {
return None; // no archive at all is the NoData fault's job, not coverage
}
let (eff_from_ts, eff_to_ts) = aura_ingest::archive_extent(
&self.server,
&data_path,
&cell.instrument,
Some(cell.window_ms.0),
Some(cell.window_ms.1),
)?;
let eff_from = aura_ingest::epoch_ns_to_unix_ms(eff_from_ts);
let eff_to = aura_ingest::epoch_ns_to_unix_ms(eff_to_ts);
let gap_months = interior_gap_months(&months, cell.window_ms);
if eff_from == cell.window_ms.0 && eff_to == cell.window_ms.1 && gap_months.is_empty() {
return None; // fully covered — nothing to annotate
}
Some(aura_registry::CellCoverage {
effective_from_ms: eff_from,
effective_to_ms: eff_to,
gap_months,
})
}
}
/// Which drained wrap-convention channel a requested tap persists from on a
/// campaign trace re-run. The routing follows the wrap-convention channels:
/// equity <- the SimBroker equity recorder (tx_eq), exposure <- the Bias
/// recorder (tx_ex), r_equity <- the cum_realized_r + unrealized_r recorder
/// (tx_req), net_r_equity <- the cost leg's LinComb(4) net-curve recorder
/// (tx_net, #234) — produced only when the campaign document carries a cost
/// block (the caller's producibility check).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TapChannel {
Equity,
Exposure,
REquity,
Net,
}
/// Route one requested tap of the closed vocabulary to its drained channel;
/// `None` marks a name outside the vocabulary (unreachable — `validate_campaign`
/// refuses it — and mapped defensively rather than guessed at).
pub fn tap_channel(tap: &str) -> Option<TapChannel> {
// The emit_vocabulary debug_assert's twin: every vocabulary tap must be
// routable here — a future fifth vocabulary entry fails loudly instead of
// silently skipping.
debug_assert!(
aura_research::tap_vocabulary()
.iter()
.all(|t| matches!(t.id, "equity" | "exposure" | "r_equity" | "net_r_equity")),
"tap_vocabulary drifted from the channels persist_campaign_traces routes"
);
match tap {
"equity" => Some(TapChannel::Equity),
"exposure" => Some(TapChannel::Exposure),
"r_equity" => Some(TapChannel::REquity),
"net_r_equity" => Some(TapChannel::Net),
_ => None,
}
}
/// The note text for a requested tap outside the closed vocabulary (#278):
/// pure over just the tap name, so the exact marker-bearing text
/// `persist_campaign_traces` writes to stderr is unit-testable without
/// booting a real archive run (the `tap_channel` `None` arm that reaches
/// this is defensive — `validate_campaign` already refuses an unknown tap
/// name before this function ever runs).
fn tap_not_produced_note(tap: &str) -> String {
format!("aura: note: tap \"{tap}\" is not produced by this run; skipped")
}
/// The note text for a cell with neither a nominee nor a non-empty terminal
/// family (#278): pure over the record's own cell fields, so the exact
/// marker-bearing text `persist_campaign_traces` writes to stderr is
/// unit-testable without booting a real archive run — the `cell_member_fanout`
/// twin above already covers the emptiness logic itself.
fn no_nominee_note(strategy: &str, instrument: &str, window_ms: (i64, i64)) -> String {
format!(
"aura: note: cell {strategy}/{instrument}/[{}, {}]: no nominee; no traces persisted",
window_ms.0, window_ms.1
)
}
/// The content-derived on-disk key of one campaign cell under
/// `traces/<trace_name>/` — the `member_key` discipline (content, never a
/// runtime ordinal): strategy content-id prefix + instrument +
/// doc-positional window ordinal, sanitized to one portable path component.
/// `regime_ordinal` (#219/#212) discriminates two risk regimes over the same
/// (strategy, instrument, window) cell so their traces never collide: the
/// default regime (ordinal 0) keeps the pre-#219 key unchanged (no stored
/// trace dir is renamed by this change), a non-default regime appends
/// `-r{ordinal}`.
pub fn campaign_cell_key(
strategy: &str,
instrument: &str,
window_ordinal: usize,
regime_ordinal: usize,
) -> String {
let strategy8 = strategy.get(..8).unwrap_or(strategy);
let base = format!("{strategy8}-{instrument}-w{window_ordinal}");
let base =
if regime_ordinal == 0 { base } else { format!("{base}-r{regime_ordinal}") };
sanitize_component(&base)
}
/// The per-member subdirectory key under a swept cell's trace dir (#224):
/// the member's own manifest params label — the exact `"name=value, ..."`
/// join `aura reproduce` prints — sanitized for filesystem use (a raw label
/// carries `=`/`, ` which `sanitize_component` maps to `_`). The member's
/// ordinal into the family is the fallback when the label is empty (a closed
/// blueprint / a monte-carlo seed member carries no tuning params).
pub fn member_trace_key(report: &RunReport, ordinal: usize) -> String {
let label = report
.manifest
.params
.iter()
.map(|(n, v)| format!("{n}={}", render_value(v)))
.collect::<Vec<_>>()
.join(", ");
let key = if label.is_empty() { ordinal.to_string() } else { label };
sanitize_component(&key)
}
/// The per-cell member fan-out (#224): a nominee cell writes its one trace
/// directly at `<cell_key>/` (`None` subdir key, unchanged since 0109); a
/// no-nominee cell with a completed terminal family (the selection-free sweep
/// shape) writes every one of that family's members under its own
/// `<cell_key>/<member_key>/` subdirectory instead of silently dropping every
/// member but a (non-existent) nominee. An empty return (no nominee AND no
/// non-empty terminal family) is the caller's cue to skip the cell loudly.
/// Pure over the executed outcome's own `CellOutcome`, so it is unit-testable
/// without booting a real archive run.
pub fn cell_member_fanout(cell_out: &CellOutcome) -> Vec<(Option<String>, &RunReport)> {
match &cell_out.nominee {
Some((_, nominee_report)) => vec![(None, nominee_report)],
None => match cell_out.families.last() {
Some(fam) if !fam.reports.is_empty() => fam
.reports
.iter()
.enumerate()
.map(|(i, r)| (Some(member_trace_key(r, i)), r))
.collect(),
_ => Vec::new(),
},
}
}
/// Persist the requested `persist_taps` for every cell under
/// `traces/<trace_name>/<cell_key>/` (0109, #201 d5; #224): a cell with a
/// nominee (walkforward/generalize/mc — selection-bearing pipelines) writes
/// its single trace directly at `<cell_key>/`, unchanged since 0109. A cell
/// with NO nominee but a completed terminal family (a selection-free sweep,
/// #224's headline: "each swept member's tap series") writes EVERY member of
/// that family under its own `<cell_key>/<member_key>/` subdirectory — never
/// narrowing to one nominated member, which would silently drop the others
/// the sweep actually produced. Each written member is independently re-run
/// once in non-reduce trace mode over its own recorded `manifest.window`,
/// asserting the re-run METRICS equal the recorded member metrics (the C1
/// drift alarm — manifest fields are fresh-context and not compared), and
/// writes the requested-AND-producible taps through the sweep verbs'
/// member-dir mechanism: a slash-joined `"{name}/{key}"` handed to
/// `TraceStore::write` (the slash-joined member-dir layout the sweep verbs
/// established). The written index manifest is the RECORDED
/// member's — the trace documents that run's provenance, not a re-derived
/// fresh-context one. Loud stderr per no-candidate cell (neither a nominee
/// nor a non-empty terminal family) and ONCE per unproducible requested tap
/// (producibility is run-configuration-level, not per-cell); one summary
/// line at the end. Every `Err` is a refusal `campaign_cmd` renders as
/// `aura: {msg}` + exit 1.
pub fn persist_campaign_traces(
trace_name: &str,
taps: &[String],
outcome: &CampaignOutcome,
campaign: &CampaignDoc,
strategies: &[(String, String)],
server: &Arc<data_server::DataServer>,
env: &Env,
) -> Result<(), String> {
let store = env.trace_store();
store
.ensure_name_free(trace_name, WriteKind::Family)
.map_err(|e| e.to_string())?;
// Requested ∩ producible, in request order. `net_r_equity` is producible
// exactly when the document carries a cost block (#234); the skip notice
// names the remedy — the tap is optional presentation, not a refusal.
// When nothing producible was requested, no member dir is written and the
// summary honestly reports 0 tap(s).
let mut routed: Vec<(&str, TapChannel)> = Vec::new();
for tap in taps {
match tap_channel(tap) {
Some(TapChannel::Net) if campaign.cost.is_empty() => eprintln!(
"aura: note: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped"
),
Some(ch) => routed.push((tap.as_str(), ch)),
None => eprintln!("{}", tap_not_produced_note(tap)),
}
}
// `outcome.cells` is index-aligned with `outcome.record.cells` by
// construction: `execute` pushes both in the same cell-loop iteration.
let mut persisted_cells = 0usize;
for (cell_rec, cell_out) in outcome.record.cells.iter().zip(&outcome.cells) {
// A nominee cell writes its one trace directly at `<cell_key>/`
// (`None` subdir key, unchanged since 0109); a no-nominee cell with a
// completed terminal family (the selection-free sweep shape, #224)
// writes every one of that family's members under its own
// `<cell_key>/<member_key>/` subdirectory instead of silently
// dropping every member but a (non-existent) nominee.
let members = cell_member_fanout(cell_out);
if members.is_empty() {
eprintln!(
"{}",
no_nominee_note(&cell_rec.strategy, &cell_rec.instrument, cell_rec.window_ms)
);
continue;
}
if routed.is_empty() {
continue;
}
// The cell key is doc-derived: the window ordinal is the POSITION
// of the cell's window in campaign.data.windows (mirroring the
// family-name convention), never a loop counter re-derived here.
let window_ordinal = campaign
.data
.windows
.iter()
.position(|w| (w.from_ms, w.to_ms) == cell_rec.window_ms)
.ok_or_else(|| {
format!(
"cell window [{}, {}] is not one of campaign.data.windows",
cell_rec.window_ms.0, cell_rec.window_ms.1
)
})?;
let cell_key = campaign_cell_key(
&cell_rec.strategy,
&cell_rec.instrument,
window_ordinal,
cell_rec.regime_ordinal,
);
// The cell's blueprint: the run's own index-aligned resolution, by
// content id (exactly the bytes the executor's members ran).
let (_, blueprint_json) = strategies
.iter()
.find(|(id, _)| *id == cell_rec.strategy)
.ok_or_else(|| {
format!(
"cell strategy {} is not among the run's resolved strategies",
cell_rec.strategy
)
})?;
// The #246 override set (silent variant, `reproduce_family_in`'s
// recipe): re-derived from the cell's own recorded manifest param
// names, against a raw probe space + raw strategy load — the members
// of one cell share the same knob NAMES (only values differ), so this
// is safe to compute once, cell-invariant, from the first member.
let raw_space = blueprint_axis_probe(blueprint_json, env).param_space();
let raw_signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t))
.expect("stored blueprint passed the referential gate; reload is infallible");
let recorded: Vec<String> = members
.first()
.map(|(_, r)| r.manifest.params.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_default();
let overrides = wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal);
// Cell-invariant across every member of this cell (the wrapped param
// space depends only on the cell's blueprint; the resolved geometry
// only on the cell's instrument) — computed once here rather than
// redundantly inside the member loop below.
let space =
blueprint_axis_probe_reopened(blueprint_json, env, &overrides).param_space();
// Same resolved pip the member ran at (`DefaultMemberRunner::run_member`'s
// `geo.pip_size`), else the C1 drift-alarm equality below would trip
// spuriously on a re-run computed at a different (default) pip.
let geo = instrument_geometry(server, &cell_rec.instrument).ok_or_else(|| {
format!(
"no recorded geometry for symbol '{}' at {} — refusing to re-run a \
real instrument with a guessed pip",
cell_rec.instrument,
env.data_path()
)
})?;
for (member_subdir, member_report) in members.iter().cloned() {
// Re-run the member, non-reduce: the SAME member the executor ran
// (same wrapped space, same params, same window, seed-free real
// data), mirroring `DefaultMemberRunner::run_member` with the
// reduce fold off so the per-cycle tap streams exist. The window
// is the member report's own `manifest.window` — already
// epoch-ns (`run_blueprint_member` stamped the post-seam
// bounds), so no second ms->ns crossing here.
let point = point_from_params(&space, &member_report.manifest.params)?;
let (from, to) = member_report.manifest.window;
let no_data = || {
format!(
"no data for instrument {} in the member window [{}, {}] (epoch-ns)",
cell_rec.instrument, from.0, to.0
)
};
let signal = reopen_all(
blueprint_from_json(blueprint_json, &|t| env.resolve(t))
.expect("stored blueprint passed the referential gate; reload is infallible"),
&overrides,
);
// Campaign data.bindings overrides win over name defaults — the
// SAME resolution the member ran under, so the C1 drift alarm
// compares like with like.
let binding = crate::binding::resolve_binding(
&cell_rec.strategy,
signal.input_roles(),
&campaign.data.bindings,
)?;
let sources = open_columns_window(
server,
&cell_rec.instrument,
Some(from),
Some(to),
&binding.columns(),
)
.ok_or_else(no_data)?;
if aura_engine::Source::peek(sources[0].as_ref()).is_none() {
return Err(no_data());
}
// The cell's OWN regime (#219/#212), not the hardcoded default: the
// recorded member ran under `cell_rec.regime`, bound through the same
// `stop_rule_for_regime` helper `DefaultMemberRunner::run_member`
// uses, so the two sites cannot drift apart again (the #219
// divergence class) and the re-run binds the same stop the C1
// drift alarm below relies on.
let stop = stop_rule_for_regime(cell_rec.regime);
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
// The campaign's OWN cost model (#234), bound the same way
// `DefaultMemberRunner::run_member` binds it (via
// `cost_nodes_for`): empty = no leg, so a cost-less campaign's
// re-run is unchanged. A non-empty model MUST be threaded here
// too — the recorded member ran net (#295's
// `run_blueprint_member`); leaving this re-run gross would trip
// the C1 drift alarm below on every legitimate costed campaign,
// not just on a real divergence.
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, rx_net) = mpsc::channel();
let cost_leg = (!campaign.cost.is_empty()).then(|| CostLeg {
nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument),
tx_cost,
tx_net,
});
let mut h =
wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg)
.bootstrap_with_cells(&point)
.expect("the member's point re-bootstraps (it already ran this realization)");
// The recorded member already ran this realization once (see the
// `.expect` immediately above); `sources` are re-opened against the
// SAME `binding` this trace re-run resolved, so a `SourceBindError`
// here can only be an internal wiring inconsistency, not a fresh
// user input mistake. Panic like the sibling bootstrap invariant
// instead of a process-exit dressed as a refusal message.
h.run_bound(key_supply(&binding, sources))
.expect("sources re-opened against `binding` key-match that binding's own roles by construction");
// Drain ALL SIX channels (`run_signal_r` leaves req undrained; the
// trace path must not): eq/ex/req/net feed the taps, r+cost feed
// the metrics.
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 r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
let net_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_net.try_iter().collect();
// The C1 drift alarm: metrics equality against the recorded
// member. The reduce-mode fold shares its arithmetic with this
// non-reduce reduction (SeriesFold via `summarize`; GatedRecorder
// emits exactly the rows `summarize_r`'s ledger reads), so equality
// is bit-exact.
let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
rerun_metrics.r = Some(summarize_r(&r_rows, &cost_rows));
if rerun_metrics != member_report.metrics {
return Err(format!(
"trace re-run diverged from the recorded member (C1 violation): cell \
{cell_key} of trace {trace_name} does not reproduce its recorded \
metrics; refusing to persist a silently-wrong trace"
));
}
let traces: Vec<ColumnarTrace> = routed
.iter()
.map(|&(tap, ch)| {
let rows: &[(Timestamp, Vec<Scalar>)] = match ch {
TapChannel::Equity => &eq_rows,
TapChannel::Exposure => &ex_rows,
TapChannel::REquity => &req_rows,
TapChannel::Net => &net_rows,
};
ColumnarTrace::from_rows(tap, &[ScalarKind::F64], rows)
})
.collect();
let write_key = match &member_subdir {
Some(sub) => format!("{trace_name}/{cell_key}/{sub}"),
None => format!("{trace_name}/{cell_key}"),
};
store
.write(&write_key, &member_report.manifest, &traces)
.map_err(|e| e.to_string())?;
}
persisted_cells += 1;
}
eprintln!(
"aura: traces persisted: {trace_name} ({} tap(s) x {persisted_cells} cell(s))",
routed.len()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aura_campaign::StageFamily;
use aura_engine::RunManifest;
/// A minimal `RunReport` fixture carrying exactly the manifest params
/// `member_trace_key`/`cell_member_fanout` read; the metrics are an empty
/// `summarize`, irrelevant to either function under test.
fn report_with_params(params: Vec<(String, Scalar)>) -> RunReport {
RunReport {
manifest: RunManifest {
commit: "test".to_string(),
params,
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&[], &[]),
}
}
#[test]
/// 0109: the campaign cell key is content-derived (the `member_key`
/// discipline — never a runtime ordinal): strategy content-id prefix +
/// instrument + doc-positional window ordinal, one portable component.
fn campaign_cell_key_is_content_derived() {
let strategy = "bb34aa55".repeat(8); // a 64-hex content id
assert_eq!(campaign_cell_key(&strategy, "GER40", 0, 0), "bb34aa55-GER40-w0");
}
#[test]
/// A non-portable instrument byte maps to `_` (the shared
/// `sanitize_component` charset) — one path component, never a nested
/// path or an invalid directory name.
fn campaign_cell_key_sanitizes_non_portable_bytes() {
let strategy = "0123abcd".repeat(8);
assert_eq!(campaign_cell_key(&strategy, "GER/40", 3, 0), "0123abcd-GER_40-w3");
}
#[test]
/// A non-default regime_ordinal (#219/#212) appends `-r{ordinal}` so two
/// regimes over the same (strategy, instrument, window) cell land in
/// distinct trace dirs; ordinal 0 (the default regime) is covered above
/// and stays unsuffixed for pre-#219 key stability.
fn campaign_cell_key_appends_regime_suffix_for_non_default_ordinal() {
let strategy = "bb34aa55".repeat(8);
assert_eq!(campaign_cell_key(&strategy, "GER40", 0, 2), "bb34aa55-GER40-w0-r2");
}
#[test]
/// #224: `member_trace_key` renders the SAME `"name=value, ..."` join
/// `aura reproduce` prints for a member's params (single-sourced label
/// format), then sanitizes it for filesystem use — a raw label's `=` and
/// `, ` separators are hostile to a path component and must map to `_`
/// (the shared `sanitize_component` charset), never survive verbatim.
fn member_trace_key_mirrors_reproduce_label_and_sanitizes_it() {
let report = report_with_params(vec![
("fast".to_string(), Scalar::i64(2)),
("slow".to_string(), Scalar::i64(4)),
]);
// The raw reproduce-format label is "fast=2, slow=4"; sanitizing maps
// each of `=`, `,`, ` ` individually to `_` (so the ", " separator
// becomes two underscores, not one).
assert_eq!(member_trace_key(&report, 0), "fast_2__slow_4");
}
#[test]
/// #224: a member with no tuning params (a closed blueprint, or a
/// monte-carlo seed member) renders an empty reproduce label, so
/// `member_trace_key` falls back to the member's own ordinal into the
/// family — never an empty (invalid) path component.
fn member_trace_key_falls_back_to_the_ordinal_when_params_are_empty() {
let report = report_with_params(Vec::new());
assert_eq!(member_trace_key(&report, 3), "3");
}
#[test]
/// #224: two members with different params must land at distinct keys —
/// the whole point of per-member subdirectories is that the sweep's
/// members never collide into one on-disk slot.
fn member_trace_key_differs_across_members_with_different_params() {
let a = report_with_params(vec![("length".to_string(), Scalar::i64(10))]);
let b = report_with_params(vec![("length".to_string(), Scalar::i64(20))]);
assert_ne!(member_trace_key(&a, 0), member_trace_key(&b, 1));
}
/// A no-nominee `CellOutcome` whose terminal family holds two members
/// (a plain selection-free sweep, #224's headline shape).
fn two_member_family_cell() -> CellOutcome {
CellOutcome {
families: vec![StageFamily {
stage: 0,
block: "std::sweep",
family_id: "fam".to_string(),
reports: vec![
report_with_params(vec![("length".to_string(), Scalar::i64(10))]),
report_with_params(vec![("length".to_string(), Scalar::i64(20))]),
],
}],
selections: Vec::new(),
nominee: None,
}
}
#[test]
/// #224: a no-nominee cell with a non-empty terminal family fans out to
/// ONE (subdir, report) pair PER member — never narrowing to a single
/// nominated member, which would silently drop every other member the
/// sweep actually produced. Each pair carries a distinct `Some(key)`
/// subdir (never `None`, which is reserved for the nominee case).
fn cell_member_fanout_yields_one_entry_per_family_member() {
let cell = two_member_family_cell();
let members = cell_member_fanout(&cell);
assert_eq!(members.len(), 2, "one dir per member, not one nominee");
let Some(key0) = &members[0].0 else { panic!("member 0 must carry a subdir key") };
let Some(key1) = &members[1].0 else { panic!("member 1 must carry a subdir key") };
assert_ne!(key0, key1, "distinct members must land at distinct subdirs");
}
#[test]
/// #224: a nominee cell (walkforward/generalize/mc) still writes its one
/// trace directly at `<cell_key>/` — a `None` subdir key, unchanged since
/// 0109 — regardless of how many stage families it also carries.
fn cell_member_fanout_prefers_the_nominee_with_no_subdir() {
let mut cell = two_member_family_cell();
let nominee_report = report_with_params(vec![("length".to_string(), Scalar::i64(30))]);
cell.nominee = Some((vec![("length".to_string(), Scalar::i64(30))], nominee_report));
let members = cell_member_fanout(&cell);
assert_eq!(members.len(), 1, "the nominee is the cell's single trace");
assert!(members[0].0.is_none(), "the nominee writes directly at <cell_key>/, no subdir");
}
#[test]
/// #224: a cell with neither a nominee nor a non-empty terminal family
/// (every family is empty, or there are none) fans out to nothing — the
/// caller's cue to skip the cell loudly rather than write an empty dir.
fn cell_member_fanout_is_empty_with_no_nominee_and_no_members() {
let cell = CellOutcome { families: Vec::new(), selections: Vec::new(), nominee: None };
assert!(cell_member_fanout(&cell).is_empty());
}
#[test]
/// #234 — a DELIBERATE pin move (was: `net_r_equity` routes to `None` on
/// the cost-free wrap): the full closed tap vocabulary now routes,
/// `net_r_equity` to the cost leg's net-curve channel. Producibility
/// (does THIS run carry a cost model?) is the caller's check, not the
/// routing's.
fn tap_channel_routes_the_full_vocabulary() {
assert_eq!(tap_channel("equity"), Some(TapChannel::Equity));
assert_eq!(tap_channel("exposure"), Some(TapChannel::Exposure));
assert_eq!(tap_channel("r_equity"), Some(TapChannel::REquity));
assert_eq!(tap_channel("net_r_equity"), Some(TapChannel::Net));
assert_eq!(tap_channel("bogus"), None);
}
#[test]
/// #278: the unrecognized-tap note carries the `note` class marker AND
/// names the offending tap — the exact text `persist_campaign_traces`
/// writes to stderr on `tap_channel`'s defensive `None` arm.
fn tap_not_produced_note_carries_the_note_marker_and_names_the_tap() {
let msg = tap_not_produced_note("bogus");
assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}");
assert!(msg.contains("\"bogus\""), "names the offending tap: {msg}");
assert!(msg.contains("is not produced by this run"), "states the reason: {msg}");
}
#[test]
/// #278: the no-candidate-cell note carries the `note` class marker AND
/// names the cell (strategy/instrument/window) — the exact text
/// `persist_campaign_traces` writes to stderr when `cell_member_fanout`
/// (unit-tested above) reports no nominee and no non-empty family.
fn no_nominee_note_carries_the_note_marker_and_names_the_cell() {
let msg = no_nominee_note("bb34aa55", "GER40", (1_000, 2_000));
assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}");
assert!(msg.contains("bb34aa55/GER40/[1000, 2000]"), "names the cell: {msg}");
assert!(msg.contains("no nominee; no traces persisted"), "states the reason: {msg}");
}
}
+640
View File
@@ -0,0 +1,640 @@
//! The per-run tap subscription plan (#283, 2026-07-21 design revision) and
//! the layered fold registry it resolves against. A tap stays a pure
//! declaration (C27); this module is where the run/measurement side declares
//! what consumes it. Record, fold, and live are ONE mechanism — a consumer
//! of the tap's `(Timestamp, Cell)` stream; the only real axis is data-layer
//! serializability: a `Named` subscription (label + scalar params) is fully
//! expressible as data, a `Live` closure is the single deliberately non-data
//! variant. The registry mirrors the node-vocabulary pattern
//! (`std_vocabulary` + `Env::resolve`): a closed, typed vocabulary whose
//! growth is a new Rust entry (C25) — core seeds here, higher layers
//! (e.g. trading) register their own entries without bleeding into the core.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::mpsc::{self, Receiver, Sender};
use aura_core::{Cell, Firing, Node, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp};
use aura_engine::{ColumnarTrace, FlatGraph, RunManifest, TapBindError};
use aura_registry::{TapWriterOpener, TraceStoreError, TraceStreamer};
use aura_std::{fold_binds_at, fold_output_kind, FoldKind, TapFold, TapLive};
use crate::project::Env;
use crate::tap_recorder::TapRecorder;
/// What consumes one declared tap during a run — the drain policy, declared
/// on the run/measurement side (#283), never on the tap.
pub enum TapSubscription {
/// A consumer from the plan's fold registry, by label, with scalar-typed
/// construction parameters (v1 core entries take none).
Named { label: String, params: Vec<(String, Scalar)> },
/// An in-process consumer; loss policy is the closure's own.
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
}
impl TapSubscription {
/// A named subscription with no construction parameters.
pub fn named(label: &str) -> Self {
TapSubscription::Named { label: label.to_string(), params: Vec::new() }
}
/// A named subscription with construction parameters.
pub fn named_with(label: &str, params: Vec<(String, Scalar)>) -> Self {
TapSubscription::Named { label: label.to_string(), params }
}
/// Sugar for a live subscription around a closure.
pub fn live(consumer: impl FnMut(Timestamp, Cell) + Send + 'static) -> Self {
TapSubscription::Live(Box::new(consumer))
}
}
/// Per-run tap plan: name → subscription; unnamed taps get the default. The
/// plan carries the registry it resolves against (the `Env`-at-resolve
/// pattern), so a layered host injects its extended vocabulary as data. The
/// only defaults are `named("record")` (`record_all` — the one entry that
/// binds at every kind and takes no params) and `None` (empty plan; unnamed
/// taps stay unbound, inert per C27) — fold/live subscriptions are per-tap,
/// never a default, structurally (the field is private and no constructor
/// sets another).
pub struct TapPlan {
pub(crate) registry: FoldRegistry,
pub(crate) default_named: Option<(String, Vec<(String, Scalar)>)>,
pub(crate) by_name: BTreeMap<String, TapSubscription>,
}
impl TapPlan {
/// Record every declared tap — the CLI's plan on both verbs.
pub fn record_all() -> Self {
Self::record_all_with(FoldRegistry::core())
}
/// `record_all` over an injected (layered) registry.
pub fn record_all_with(registry: FoldRegistry) -> Self {
TapPlan {
registry,
default_named: Some(("record".to_string(), Vec::new())),
by_name: BTreeMap::new(),
}
}
/// No default: only explicitly subscribed taps are consumed.
pub fn empty() -> Self {
TapPlan { registry: FoldRegistry::core(), default_named: None, by_name: BTreeMap::new() }
}
/// Subscribe one tap by name (replaces an earlier subscription for it).
pub fn subscribe(&mut self, tap: &str, sub: TapSubscription) {
self.by_name.insert(tap.to_string(), sub);
}
}
/// Persistence shape of an entry's consumer.
pub enum FoldOutput {
/// The full series, streamed (the `record` entry).
Series,
/// One summary row at finalize, of this kind.
Row(ScalarKind),
}
/// The sink a build call receives — matching the entry's declared
/// [`FoldOutput`]: `Row` entries get the one-row channel, `Series` entries
/// the deferred writer opener + terminal-outcome channel.
pub enum FoldSink {
Row(Sender<(Timestamp, Vec<Scalar>)>),
Series(TapWriterOpener, Sender<Result<(), TraceStoreError>>),
}
/// What the wiring hands a registry entry's `build`.
pub struct FoldBuildCtx {
/// The tap's column kind (already `binds_at`-validated).
pub kind: ScalarKind,
/// Construction parameters, already validated against the entry's schema.
pub params: Vec<(String, Scalar)>,
pub sink: FoldSink,
}
/// One registry entry: a labelled consumer constructor with docs, a param
/// schema, bind rules, and its persistence shape.
pub struct FoldEntry {
pub label: &'static str,
/// One line for help generation and roster-enumerating refusals.
pub doc: &'static str,
/// Scalar-typed construction parameters (the node vocabulary's param
/// plane). All v1 core entries: empty — the seam ships now because it
/// sits in every entry's build signature (C25: a param that would carry
/// logic is a new entry, never a freetext hole).
pub params: Vec<ParamSpec>,
/// May this entry bind a tap column of this kind?
pub binds_at: Box<dyn Fn(ScalarKind) -> bool>,
/// What the consumer persists, given the tap's column kind.
pub output: Box<dyn Fn(ScalarKind) -> FoldOutput>,
/// Build the consumer node. `ctx.params` arrive validated; `ctx.sink`
/// matches `output` (the wiring consults `output` first — an entry may
/// `panic!` on the wrong variant as a wiring-contract violation).
pub build: Box<dyn Fn(FoldBuildCtx) -> Box<dyn Node>>,
}
/// The label → entry map. Layered: `core()` seeds the standard vocabulary;
/// `register` adds (or deliberately shadows) an entry — dependency
/// injection, no core edit.
pub struct FoldRegistry {
entries: BTreeMap<&'static str, FoldEntry>,
}
impl FoldRegistry {
/// The core vocabulary: `record`, `count`, `sum`, `mean`, `min`, `max`,
/// `first`, `last`.
pub fn core() -> Self {
let mut r = FoldRegistry { entries: BTreeMap::new() };
r.register(FoldEntry {
label: "record",
doc: "persist the full series, lossless, at constant memory (any kind)",
params: Vec::new(),
binds_at: Box::new(|_| true),
output: Box::new(|_| FoldOutput::Series),
build: Box::new(|ctx| {
let FoldSink::Series(opener, outcome_tx) = ctx.sink else {
panic!("wiring contract: record is a Series entry");
};
Box::new(TapRecorder::new(ctx.kind, opener, outcome_tx))
}),
});
for (label, doc, fold) in [
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum),
("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean),
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max),
(
"first",
"first warm value, at its own timestamp (any kind; kind-preserving row)",
FoldKind::First,
),
("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last),
] {
r.register(FoldEntry {
label,
doc,
params: Vec::new(),
binds_at: Box::new(move |k| fold_binds_at(fold, k)),
output: Box::new(move |k| FoldOutput::Row(fold_output_kind(fold, k))),
build: Box::new(move |ctx| {
let FoldSink::Row(tx) = ctx.sink else {
panic!("wiring contract: {fold:?} is a Row entry");
};
Box::new(TapFold::new(ctx.kind, fold, tx))
}),
});
}
r
}
/// Add an entry. A later registration under an existing label replaces
/// it (layers may deliberately shadow).
pub fn register(&mut self, entry: FoldEntry) {
self.entries.insert(entry.label, entry);
}
pub fn get(&self, label: &str) -> Option<&FoldEntry> {
self.entries.get(label)
}
/// `(label, doc)` in label order — the help surface and the refusal
/// roster.
pub fn roster(&self) -> Vec<(&'static str, &'static str)> {
self.entries.values().map(|e| (e.label, e.doc)).collect()
}
/// The labels alone, label order (refusal messages).
pub fn labels(&self) -> Vec<&'static str> {
self.entries.keys().copied().collect()
}
}
/// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal
/// store fault. Entry points map every variant to the established
/// `aura: ` + exit-1 refusal register via `Display`.
pub enum TapPlanError {
/// The plan names a tap the blueprint does not declare.
UnknownTap { name: String },
/// The plan names a label the registry does not carry.
UnknownLabel { label: String, roster: Vec<&'static str> },
/// The entry's bind rule rejects the tap's column kind.
KindMismatch { tap: String, label: String, kind: ScalarKind },
/// A param binding names no schema param (`takes` = the schema's names).
UnknownParam { label: String, name: String, takes: Vec<String> },
/// A schema param is unbound.
MissingParam { label: String, name: String, kind: ScalarKind },
/// A param binding's kind mismatches its schema declaration.
ParamKind { label: String, name: String, expected: ScalarKind, got: ScalarKind },
/// Two declared taps share a name (the caller-owned dedup guard).
Bind(aura_engine::TapBindError),
/// The trace store failed (begin/write/finish) — the terminal register.
Store(TraceStoreError),
}
impl fmt::Display for TapPlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TapPlanError::UnknownTap { name } => {
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap")
}
TapPlanError::UnknownLabel { label, roster } => {
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
}
TapPlanError::KindMismatch { tap, label, kind } => {
write!(f, "fold '{label}' cannot bind tap '{tap}' of kind {kind:?}")
}
TapPlanError::UnknownParam { label, name, takes } => {
if takes.is_empty() {
write!(f, "fold '{label}' takes no parameters (got '{name}')")
} else {
write!(f, "fold '{label}' has no parameter '{name}' — takes: {}", takes.join(", "))
}
}
TapPlanError::MissingParam { label, name, kind } => {
write!(f, "fold '{label}' requires parameter '{name}' ({kind:?})")
}
TapPlanError::ParamKind { label, name, expected, got } => {
write!(f, "fold '{label}' parameter '{name}' is {expected:?}, got {got:?}")
}
TapPlanError::Bind(e) => write!(f, "{e}"),
TapPlanError::Store(e) => write!(f, "writing tap traces failed: {e}"),
}
}
}
impl fmt::Debug for TapPlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TapPlanError({self})")
}
}
impl From<TraceStoreError> for TapPlanError {
fn from(e: TraceStoreError) -> Self {
TapPlanError::Store(e)
}
}
/// Validate one `Named` subscription's param bindings against the entry's
/// schema: every binding names a schema param of the right kind; every
/// schema param is bound.
pub(crate) fn validate_params(
entry: &FoldEntry,
bound: &[(String, Scalar)],
) -> Result<(), TapPlanError> {
let takes: Vec<String> = entry.params.iter().map(|p| p.name.clone()).collect();
for (name, value) in bound {
match entry.params.iter().find(|p| &p.name == name) {
None => {
return Err(TapPlanError::UnknownParam {
label: entry.label.to_string(),
name: name.clone(),
takes,
})
}
Some(p) if p.kind != value.kind() => {
return Err(TapPlanError::ParamKind {
label: entry.label.to_string(),
name: name.clone(),
expected: p.kind,
got: value.kind(),
})
}
Some(_) => {}
}
}
for p in &entry.params {
if !bound.iter().any(|(n, _)| n == &p.name) {
return Err(TapPlanError::MissingParam {
label: entry.label.to_string(),
name: p.name.clone(),
kind: p.kind,
});
}
}
Ok(())
}
/// The one-input sink schema every tap consumer binds with (empty output =
/// pure consumer, C8; the port name is a non-load-bearing debug symbol).
fn tap_sink_schema(kind: ScalarKind) -> NodeSchema {
NodeSchema {
inputs: vec![PortSpec { kind, firing: Firing::Any, name: "in".to_string() }],
output: vec![],
params: vec![],
doc: "internal recording sink bound to a declared tap",
}
}
/// The bound half of a tap plan: what `bind_tap_plan` leaves for the caller
/// to drain after the run. Both declared-tap entry points call the pair, so
/// the mechanism cannot drift between them.
#[allow(clippy::type_complexity)]
pub struct BoundTaps {
streamer: Option<TraceStreamer>,
declared: Vec<String>,
/// Persisting taps (Series and Row alike), declared-tap order — the
/// `index.json` tap order.
persisted: Vec<String>,
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
}
impl BoundTaps {
/// Every declared tap, declared order (the measurement report's roster).
pub fn declared_names(&self) -> &[String] {
&self.declared
}
/// Drain the ≤1-message-per-tap channels and close the run: record
/// outcomes first (declared order), then fold rows written through the
/// same streamer, then `index.json` last. Any fault returns before the
/// index is written — the store's crash discipline (no `index.json` →
/// treat-as-absent) covers the partial directory, exactly like today's
/// failed `write`. A plan that persisted nothing began no streamer and
/// writes nothing.
pub fn finish(mut self, manifest: &RunManifest) -> Result<(), TapPlanError> {
for (_, rx) in &self.outcomes {
// finalize ran inside the harness's end-of-stream flush, so the
// single outcome is already in the channel.
if let Ok(Err(e)) = rx.try_recv() {
return Err(TapPlanError::Store(e));
}
}
let streamer = self.streamer.take();
for (name, kind, rx) in self.rows {
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect(); // ≤1
let trace = ColumnarTrace::from_rows(&name, &[kind], &rows);
streamer
.as_ref()
.expect("a Row tap implies a begun streamer")
.write_full(&trace)?;
}
if let Some(s) = streamer {
s.finish(manifest, &self.persisted)?;
}
Ok(())
}
}
/// Validate the plan against `flat.taps`, begin the streamed run write when
/// anything persists, and bind one consumer per subscribed tap — the shared
/// pre-bootstrap half of both declared-tap entry points. Refusals are typed
/// and complete BEFORE any store I/O (a refused run never half-writes).
pub fn bind_tap_plan(
flat: &mut FlatGraph,
mut plan: TapPlan,
env: &Env,
run_name: &str,
) -> Result<BoundTaps, TapPlanError> {
let declared_taps: Vec<aura_engine::FlatTap> = flat.taps.clone();
// Dedup guard (caller-owned per TapBindError::DuplicateBind's doc).
let mut seen: BTreeSet<String> = BTreeSet::new();
for tap in &declared_taps {
if !seen.insert(tap.name.clone()) {
return Err(TapPlanError::Bind(TapBindError::DuplicateBind { name: tap.name.clone() }));
}
}
// Unknown-tap guard: every plan name must be declared.
for name in plan.by_name.keys() {
if !declared_taps.iter().any(|t| &t.name == name) {
return Err(TapPlanError::UnknownTap { name: name.clone() });
}
}
// Resolve each declared tap to its subscription; validate all Named
// subscriptions fully before any store I/O.
enum Resolved {
Named { label: String, params: Vec<(String, Scalar)> },
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
}
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
for tap in &declared_taps {
let kind = flat.signatures[tap.node].output[tap.field].kind;
let sub = match plan.by_name.remove(&tap.name) {
Some(TapSubscription::Named { label, params }) => Resolved::Named { label, params },
Some(TapSubscription::Live(consumer)) => Resolved::Live(consumer),
None => match &plan.default_named {
Some((label, params)) => {
Resolved::Named { label: label.clone(), params: params.clone() }
}
None => continue, // unbound, inert (C27)
},
};
if let Resolved::Named { label, params } = &sub {
let entry = plan.registry.get(label).ok_or_else(|| TapPlanError::UnknownLabel {
label: label.clone(),
roster: plan.registry.labels(),
})?;
if !(entry.binds_at)(kind) {
return Err(TapPlanError::KindMismatch {
tap: tap.name.clone(),
label: label.clone(),
kind,
});
}
validate_params(entry, params)?;
}
resolved.push((tap.name.clone(), kind, sub));
}
// Anything persisting? Then begin the run (directory creation is the
// pre-run refusal point).
let persists = resolved.iter().any(|(_, _, s)| matches!(s, Resolved::Named { .. }));
let streamer = if persists { Some(env.trace_store().begin_run(run_name)?) } else { None };
// Bind one consumer per resolved tap.
let mut bound = BoundTaps {
streamer,
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
persisted: Vec::new(),
rows: Vec::new(),
outcomes: Vec::new(),
};
for (name, kind, sub) in resolved {
let node: Box<dyn Node> = match sub {
Resolved::Named { label, params } => {
let entry = plan.registry.get(&label).expect("validated above");
bound.persisted.push(name.clone());
match (entry.output)(kind) {
FoldOutput::Series => {
let opener = bound
.streamer
.as_ref()
.expect("persisting tap implies a begun streamer")
.register_tap(&name, kind);
let (tx, rx) = mpsc::channel();
bound.outcomes.push((name.clone(), rx));
(entry.build)(FoldBuildCtx { kind, params, sink: FoldSink::Series(opener, tx) })
}
FoldOutput::Row(out_kind) => {
let (tx, rx) = mpsc::channel();
bound.rows.push((name.clone(), out_kind, rx));
(entry.build)(FoldBuildCtx { kind, params, sink: FoldSink::Row(tx) })
}
}
}
Resolved::Live(consumer) => Box::new(TapLive::new(kind, consumer)),
};
flat.bind_tap(&name, node, tap_sink_schema(kind))
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
}
Ok(bound)
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Ctx};
use std::sync::mpsc;
#[test]
fn roster_lists_the_eight_core_entries_with_docs() {
let r = FoldRegistry::core();
let labels: Vec<&str> = r.roster().iter().map(|(l, _)| *l).collect();
// BTreeMap order — alphabetical.
assert_eq!(
labels,
vec!["count", "first", "last", "max", "mean", "min", "record", "sum"]
);
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
}
#[test]
fn unknown_label_refusal_enumerates_the_roster() {
let r = FoldRegistry::core();
let e = TapPlanError::UnknownLabel { label: "p95".to_string(), roster: r.labels() };
let msg = e.to_string();
assert!(msg.starts_with("unknown fold 'p95'"), "{msg}");
assert!(msg.contains("record") && msg.contains("mean"), "roster enumerated: {msg}");
}
#[test]
fn arithmetic_folds_bind_only_at_f64_record_and_count_anywhere() {
let r = FoldRegistry::core();
for label in ["sum", "mean", "min", "max"] {
let e = r.get(label).expect(label);
assert!((e.binds_at)(ScalarKind::F64), "{label} binds f64");
assert!(!(e.binds_at)(ScalarKind::I64), "{label} refuses i64");
assert!(!(e.binds_at)(ScalarKind::Bool), "{label} refuses bool");
}
for label in ["record", "count", "first", "last"] {
let e = r.get(label).expect(label);
for k in [ScalarKind::F64, ScalarKind::I64, ScalarKind::Bool, ScalarKind::Timestamp] {
assert!((e.binds_at)(k), "{label} binds {k:?}");
}
}
}
#[test]
fn a_param_on_a_param_less_entry_is_refused() {
let r = FoldRegistry::core();
let e = validate_params(
r.get("mean").expect("mean"),
&[("q".to_string(), Scalar::f64(0.95))],
)
.expect_err("core entries take no params");
assert_eq!(e.to_string(), "fold 'mean' takes no parameters (got 'q')");
}
#[test]
fn a_layer_registered_entry_resolves_and_builds() {
// The dependency-injection proof without a trading layer: a test-only
// entry registered on top of core resolves by label and its consumer
// runs (here: a re-labelled count over the committed TapFold core).
let mut r = FoldRegistry::core();
r.register(FoldEntry {
label: "tally",
doc: "test-only: warm-row count under a layer label",
params: Vec::new(),
binds_at: Box::new(|_| true),
output: Box::new(|_| FoldOutput::Row(ScalarKind::I64)),
build: Box::new(|ctx| {
let FoldSink::Row(tx) = ctx.sink else {
panic!("wiring contract: tally is a Row entry");
};
Box::new(TapFold::new(ctx.kind, FoldKind::Count, tx))
}),
});
assert_eq!(r.labels().len(), 9, "core plus the layer entry");
let entry = r.get("tally").expect("layer entry resolves");
let (tx, rx) = mpsc::channel();
let mut node = (entry.build)(FoldBuildCtx {
kind: ScalarKind::F64,
params: Vec::new(),
sink: FoldSink::Row(tx),
});
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
node.initialize();
for (t, v) in [(1_i64, 5.0_f64), (2, 7.0)] {
inputs[0].push(Scalar::f64(v)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
node.finalize();
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
assert_eq!(rows, vec![(Timestamp(2), vec![Scalar::i64(2)])]);
}
/// Exercises the `record` entry's Series-sink build path (destructure
/// `FoldSink::Series` -> construct `TapRecorder`), left uncovered by
/// `a_layer_registered_entry_resolves_and_builds` above, which only
/// drives a Row-sink entry.
#[test]
fn record_entry_builds_a_series_sink_that_persists_the_stream() {
let root = std::env::temp_dir()
.join(format!("aura-runner-tap_plan-record-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp store root");
let store = aura_registry::TraceStore::open(&root);
let streamer = store.begin_run("plan").expect("begin run");
let opener = streamer.register_tap("t", ScalarKind::F64);
let r = FoldRegistry::core();
let entry = r.get("record").expect("record");
let (outcome_tx, outcome_rx) = mpsc::channel();
let mut node = (entry.build)(FoldBuildCtx {
kind: ScalarKind::F64,
params: Vec::new(),
sink: FoldSink::Series(opener, outcome_tx),
});
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
node.initialize();
for (t, v) in [(1_i64, 5.0_f64), (2, 7.0)] {
inputs[0].push(Scalar::f64(v)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
node.finalize();
let outcomes: Vec<Result<(), TraceStoreError>> = outcome_rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_ok(), "record's Series build path writes cleanly: {outcomes:?}");
}
#[test]
fn schema_params_are_validated_missing_mismatched_and_unknown() {
// A schema-carrying test entry reaches the validation branches v1's
// param-less core entries cannot: missing, kind-mismatched, and
// unknown-against-a-non-empty-schema.
let entry = FoldEntry {
label: "windowed",
doc: "test-only: carries a param schema",
params: vec![ParamSpec { name: "length".to_string(), kind: ScalarKind::I64 }],
binds_at: Box::new(|_| true),
output: Box::new(|_| FoldOutput::Row(ScalarKind::F64)),
build: Box::new(|_| panic!("validation-only entry is never built")),
};
let missing = validate_params(&entry, &[]).expect_err("length is required");
assert_eq!(missing.to_string(), "fold 'windowed' requires parameter 'length' (I64)");
let mismatched = validate_params(&entry, &[("length".to_string(), Scalar::f64(2.0))])
.expect_err("kind mismatch");
assert_eq!(mismatched.to_string(), "fold 'windowed' parameter 'length' is I64, got F64");
let unknown = validate_params(&entry, &[("q".to_string(), Scalar::i64(1))])
.expect_err("unknown param");
assert_eq!(unknown.to_string(), "fold 'windowed' has no parameter 'q' — takes: length");
}
}
+196
View File
@@ -0,0 +1,196 @@
//! `TapRecorder` — the `record` fold-registry entry's consumer (C8 sink) for
//! one declared tap: holds the streaming trace writer **in-graph** and
//! appends `(ctx.now(), cell)` per fired warm cycle — 16 B, `Copy`, zero heap
//! per cycle (#77 part 2); no writer thread, no channel (#283 design
//! revision). Lives in the assembly crate, not `aura-std`, because the
//! writer type is `aura-registry`'s (C28 layering). Lifecycle: `initialize`
//! opens the writer (deferred acquisition; an open error is stored and the
//! node degrades to inert — the run completes), `eval` appends (an I/O error
//! mid-run likewise stores + degrades), `finalize` finishes the writer and
//! reports exactly one terminal outcome over the wiring's channel.
use aura_core::{Cell, Ctx, Node, ScalarKind};
use aura_registry::{TapTraceWriter, TapWriterOpener, TraceStoreError};
use aura_std::newest_cell;
use std::sync::mpsc::Sender;
/// A single-column recording sink around an in-graph streaming writer.
pub struct TapRecorder {
kind: ScalarKind,
opener: Option<TapWriterOpener>,
writer: Option<TapTraceWriter>,
error: Option<TraceStoreError>,
emitted: bool,
outcome_tx: Sender<Result<(), TraceStoreError>>,
}
impl TapRecorder {
pub fn new(
kind: ScalarKind,
opener: TapWriterOpener,
outcome_tx: Sender<Result<(), TraceStoreError>>,
) -> Self {
Self { kind, opener: Some(opener), writer: None, error: None, emitted: false, outcome_tx }
}
}
impl Node for TapRecorder {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn initialize(&mut self) {
match self.opener.take().expect("initialize runs once per run").open() {
Ok(w) => self.writer = Some(w),
Err(e) => self.error = Some(e),
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let cell = newest_cell(self.kind, &ctx)?; // None until warm
if let Some(w) = self.writer.as_mut()
&& let Err(e) = w.append(ctx.now(), cell)
{
// I/O failure mid-run: store it, go inert, keep consuming —
// the run completes; the failure surfaces once at finalize.
self.error = Some(e);
self.writer = None;
}
None
}
fn finalize(&mut self) {
if self.emitted {
return;
}
self.emitted = true;
let outcome = match (self.writer.take(), self.error.take()) {
(Some(w), None) => w.finish(),
(_, Some(e)) => Err(e),
(None, None) => Ok(()), // finalize without initialize: benign
};
let _ = self.outcome_tx.send(outcome);
}
fn label(&self) -> String {
"TapRecorder".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
use aura_registry::TraceStore;
use std::path::PathBuf;
use std::sync::mpsc;
/// A fresh unique store root under the OS temp dir (aura-runner has no
/// tempfile dev-dep; mirror of trace_store's own temp_traces_root).
fn temp_store_root(name: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("aura-runner-taprec-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp store root");
dir
}
#[test]
fn initialize_opens_records_after_warmup_and_reports_one_ok_outcome() {
let root = temp_store_root("happy");
let store = TraceStore::open(&root);
let streamer = store.begin_run("rec").expect("begin");
let (tx, rx) = mpsc::channel();
let mut sink =
TapRecorder::new(ScalarKind::F64, streamer.register_tap("t", ScalarKind::F64), tx);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
sink.initialize();
// cold: nothing appended.
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(1))), None);
for (t, v) in [(2_i64, 10.0_f64), (3, 20.0), (4, 30.0)] {
inputs[0].push(Scalar::f64(v)).unwrap();
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
sink.finalize();
sink.finalize(); // second flush must not emit a second outcome
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_ok(), "happy path outcome: {outcomes:?}");
let text = std::fs::read_to_string(root.join("traces").join("rec").join("t.json"))
.expect("finished tap file");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
assert_eq!(v["tap"], "t");
assert_eq!(v["ts"], serde_json::json!([2, 3, 4]));
assert_eq!(v["columns"], serde_json::json!([[10.0, 20.0, 30.0]]));
}
#[test]
fn a_failed_open_degrades_to_inert_and_reports_one_err_outcome() {
let root = temp_store_root("failopen");
let store = TraceStore::open(&root);
let streamer = store.begin_run("rec").expect("begin");
let opener = streamer.register_tap("t", ScalarKind::F64);
// Make the deferred open fail: remove the run dir before initialize.
std::fs::remove_dir_all(root.join("traces").join("rec")).expect("remove run dir");
let (tx, rx) = mpsc::channel();
let mut sink = TapRecorder::new(ScalarKind::F64, opener, tx);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
sink.initialize();
// inert but alive: eval consumes without panicking.
inputs[0].push(Scalar::f64(1.0)).unwrap();
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(2))), None);
sink.finalize();
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_err(), "the stored open error surfaces terminally");
}
/// Exercises the mid-run branch at `eval` (lines 51-57): a successfully
/// opened writer whose *write* later faults must store the error, drop
/// `self.writer` (go inert — no further append attempts), and still
/// report exactly one terminal `Err` at `finalize`, same as a failed
/// open. `/dev/full` always faults on write, so pointing the ts temp
/// stream at it (after a normal open) is a deterministic mid-run I/O
/// fault once the `BufWriter`'s internal buffer forces a real flush.
#[test]
#[cfg(unix)]
fn a_mid_run_append_failure_degrades_to_inert_and_reports_one_err_outcome() {
let root = temp_store_root("appendfail");
let store = TraceStore::open(&root);
let streamer = store.begin_run("rec").expect("begin");
let opener = streamer.register_tap("t", ScalarKind::F64);
// Retarget the ts temp stream at a permanently-full device: `open`
// still succeeds (create/truncate are no-ops on a char device), only
// a later flushed write faults.
std::os::unix::fs::symlink(
"/dev/full",
root.join("traces").join("rec").join("t.ts.tmp"),
)
.expect("symlink t.ts.tmp to /dev/full");
let (tx, rx) = mpsc::channel();
let mut sink = TapRecorder::new(ScalarKind::F64, opener, tx);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
sink.initialize();
// Enough warm evals to force the BufWriter past its internal
// capacity and trigger a real (faulting) flush; `eval` never emits a
// row regardless of the underlying write outcome.
for t in 0..20_000_i64 {
inputs[0].push(Scalar::f64(1.0)).unwrap();
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
sink.finalize();
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_err(), "the mid-run write fault surfaces terminally");
}
}
+249
View File
@@ -0,0 +1,249 @@
//! The C1-load-bearing param<->config translators.
//!
//! Every value that rides OUTSIDE the wrapped param_space (the stop regime,
//! the cost model) must still round-trip through a manifest, so a stored
//! member (family, reproduce) re-derives bit-identically. These translators
//! are the single source for each such write<->read pair — the manifest
//! `stop_length`/`stop_k`/`stop_period_minutes` <-> [`StopRule`] binding, and
//! the `cost[k].<knob>` <-> [`aura_research::CostSpec`] binding — so the two
//! halves of each pair cannot drift out of sync between the run/campaign
//! paths and reproduce/persist.
use aura_composites::StopRule;
use aura_core::{PrimitiveBuilder, Scalar};
use aura_strategy::{CarryCost, ConstantCost, VolSlippageCost};
/// The r-sma vol-stop EWMA length (cycles). Single source for the `StopRule::Vol`
/// the blueprint embeds and the `stop` param the manifest records — kept honest by one
/// constant instead of a hand-synced literal across the function boundary.
pub const R_SMA_STOP_LENGTH: i64 = 3;
/// The r-sma vol-stop multiplier (1R = `k`·σ). Single source for the embedded
/// `StopRule::Vol` and its manifest record, like [`R_SMA_STOP_LENGTH`].
pub const R_SMA_STOP_K: f64 = 2.0;
/// Re-derive the `StopRule` a member was minted under from its manifest params
/// (`stop_length`/`stop_k`/`stop_period_minutes`, stamped by `run_blueprint_member`):
/// if `stop_period_minutes` is present alongside `stop_length`/`stop_k`, this
/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default
/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233
/// members), mirroring [`stop_rule_for_regime`]'s `None` arm for the same
/// one-directional widening `point_from_params` already applies to missing
/// manifest params.
pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
let period_minutes =
params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64());
let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64());
let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64());
match (period_minutes, length, k) {
(Some(period_minutes), Some(length), Some(k)) => {
StopRule::VolTf { period_minutes, length, k }
}
(None, Some(length), Some(k)) => StopRule::Vol { length, k },
_ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
}
}
/// Re-derive the cost model a member was minted under from its manifest params
/// (`cost[k].<knob>`, stamped by `run_blueprint_member`) — the #233 stop-regime
/// pattern: like the stop, the cost model rides OUTSIDE the wrapped
/// param_space, so `point_from_params` cannot recover it. Components are read
/// in index order; the builder knob name discriminates the variant (each
/// shipped cost node has exactly one, distinctly named knob). No `cost[0].*`
/// param = the empty (gross) model — every pre-cost member widens to it,
/// mirroring `stop_rule_from_params`'s default arm.
///
/// `Err` carries the exact refusal message a corrupted-on-disk cost param has
/// always produced (#295: a library function reports a refusal, it does not
/// end the process itself — the caller turns this into `aura:` + exit 1,
/// byte-identical to before).
pub fn cost_specs_from_params(
params: &[(String, Scalar)],
) -> Result<Vec<aura_research::CostSpec>, String> {
let mut specs = Vec::new();
for k in 0.. {
let prefix = format!("cost[{k}].");
let Some((knob, v)) = params
.iter()
.find_map(|(n, s)| n.strip_prefix(&prefix).map(|rest| (rest, *s)))
else {
break;
};
specs.push(match knob {
"cost_per_trade" => aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()),
},
"slip_vol_mult" => aura_research::CostSpec::VolSlippage {
slip_vol_mult: aura_research::CostValue::Scalar(v.as_f64()),
},
"carry_per_cycle" => aura_research::CostSpec::Carry {
carry_per_cycle: aura_research::CostValue::Scalar(v.as_f64()),
},
other => {
return Err(format!(
"manifest cost param cost[{k}].{other} names no cost component"
));
}
});
}
Ok(specs)
}
/// The one regime->StopRule binding, shared by `CliMemberRunner::run_member`
/// and `persist_campaign_traces`'s drift-alarm re-run (#219): `None` is the
/// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own
/// params. Single-sourced so the persist-side re-run structurally cannot
/// diverge from the run-side binding again (the #219 divergence class).
pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_composites::StopRule {
match regime {
None => aura_composites::StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
Some(aura_research::RiskRegime::Vol { length, k }) => {
aura_composites::StopRule::Vol { length, k }
}
Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => {
aura_composites::StopRule::VolTf { period_minutes, length, k }
}
}
}
/// The one CostSpec -> cost-node binding (#234), beside its risk sibling
/// `stop_rule_for_regime` and shared the same way: `CliMemberRunner::run_member`
/// (via `run_blueprint_member`) and `persist_campaign_traces`'s drift-alarm
/// re-run both bind through here, so the persist-side re-run structurally
/// cannot diverge from the run-side cost model (the #219 divergence class,
/// cost edition). The bound knob names are the builders' own `ParamSpec` names
/// — the `CostSpec` serde vocabulary conforms to them.
pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Vec<PrimitiveBuilder> {
specs
.iter()
.map(|s| {
let (knob, v) = cost_knob(s, instrument);
match s {
aura_research::CostSpec::Constant { .. } => {
ConstantCost::builder().bind(knob, Scalar::f64(v))
}
aura_research::CostSpec::VolSlippage { .. } => {
VolSlippageCost::builder().bind(knob, Scalar::f64(v))
}
aura_research::CostSpec::Carry { .. } => {
CarryCost::builder().bind(knob, Scalar::f64(v))
}
}
})
.collect()
}
/// The one CostSpec-variant -> (knob name, value) mapping, shared by
/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp:
/// the stamp key must equal the bind key for reproduce to re-derive a
/// `CostSpec` from a stored manifest, so both sites read the name off this
/// single function rather than each carrying its own literal.
pub fn cost_knob(spec: &aura_research::CostSpec, instrument: &str) -> (&'static str, f64) {
let (knob, value) = match spec {
aura_research::CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade),
aura_research::CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult),
aura_research::CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle),
};
let v = value.resolve(instrument).unwrap_or_else(|| {
// Unreachable after intrinsic validation (map keys ≡ instruments);
// refuse loudly rather than charging 0 silently if it ever surfaces.
eprintln!("aura: cost {knob}: no entry for instrument {instrument}");
std::process::exit(1);
});
(knob, v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// #262 round-trip: a manifest carrying stop_period_minutes re-derives
/// the VolTf stop; one without it keeps the Vol path — a stored VolTf
/// member must never silently reproduce under a default Vol stop.
fn stop_rule_from_params_round_trips_the_vol_tf_stamp() {
let vol_tf = vec![
("stop_period_minutes".to_string(), Scalar::i64(60)),
("stop_length".to_string(), Scalar::i64(14)),
("stop_k".to_string(), Scalar::f64(2.0)),
];
assert_eq!(
stop_rule_from_params(&vol_tf),
StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 }
);
let vol = vec![
("stop_length".to_string(), Scalar::i64(3)),
("stop_k".to_string(), Scalar::f64(2.0)),
];
assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 });
}
#[test]
/// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to
/// `StopRule::VolTf` field-for-field — the resolve-side half of the
/// write/resolve pair the manifest round-trip test above covers from
/// the stamp side; together the two arms this regime touches
/// (this module's bind and `run_blueprint_member`'s stamp) are both exercised.
fn stop_rule_for_regime_binds_vol_tf_field_for_field() {
let regime =
Some(aura_research::RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 });
assert_eq!(
stop_rule_for_regime(regime),
aura_composites::StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 }
);
}
#[test]
/// #234: the one CostSpec -> builder binding maps each component to its
/// shipped cost node with the knob BOUND — a bound component adds no open
/// param, so the wrapped param_space stays cost-invariant (the probe/member
/// space equivalence the campaign path relies on).
fn cost_nodes_for_maps_each_component_to_its_bound_builder() {
let nodes = cost_nodes_for(
&[
aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::Scalar(2.0),
},
aura_research::CostSpec::VolSlippage {
slip_vol_mult: aura_research::CostValue::Scalar(0.5),
},
aura_research::CostSpec::Carry {
carry_per_cycle: aura_research::CostValue::Scalar(0.1),
},
],
"GER40",
);
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
assert!(
nodes.iter().all(|n| n.params().is_empty()),
"every component must be fully bound (no open param leaks into param_space)"
);
assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes");
}
#[test]
/// #234: the manifest-stamp/reproduce round-trip (`cost[k].<knob>` ->
/// `cost_specs_from_params`) reconstructs a component from its knob NAME —
/// sound only while every shipped cost node declares exactly one,
/// distinctly-named knob. A second knob (or a name collision) would break
/// variant reconstruction with no compiler error; this pin makes it loud.
fn every_cost_builder_declares_exactly_one_distinct_knob() {
let knobs: Vec<String> = [
aura_strategy::ConstantCost::builder(),
aura_strategy::VolSlippageCost::builder(),
aura_strategy::CarryCost::builder(),
]
.iter()
.map(|b| {
let params = b.params();
assert_eq!(params.len(), 1, "one knob per cost node: {}", b.label());
params[0].name.clone()
})
.collect();
let mut dedup = knobs.clone();
dedup.sort();
dedup.dedup();
assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}");
}
}
@@ -0,0 +1,152 @@
//! Property (#295): the canonical member-run recipe — [`DefaultMemberRunner`]
//! driven through [`aura_campaign::CellSpec`] — is reachable as a **pure
//! library call**: no `aura` binary, no `aura-cli` crate in the dependency
//! graph (this test binary links only `aura-runner` + its own deps). This is
//! the acceptance evidence `examples/world_member_run.rs` demonstrates by
//! printing; here it is pinned by assertion instead of eyeballed output, and
//! runs over a tiny synthetic archive rather than skipping when no local host
//! archive is mounted.
//!
//! A second property rides along for free at no extra fixture cost (C1,
//! determinism): two independently constructed runners (fresh `DataServer`,
//! fresh `DefaultMemberRunner`) driven over byte-identical input produce a
//! byte-identical `RunReport` — same input, same run, reproducibly.
use std::collections::BTreeMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use aura_campaign::{CellSpec, MemberRunner};
use aura_ingest::DataServer;
use aura_runner::{DefaultMemberRunner, Env};
const SYMBOL: &str = "TESTSYM";
// 2024-06-03T08:00:00Z (a real Monday) — arbitrary, fixed, deterministic.
const BASE_MS: i64 = 1_717_401_600_000;
const N_BARS: i64 = 60;
/// A unique scratch directory under the OS temp root, removed on drop —
/// mirrors `aura-ingest`'s own `tests::ScratchDir` (pid + atomic counter, no
/// `tempfile` dev-dependency needed for one throwaway fixture dir).
struct ScratchDir(PathBuf);
impl ScratchDir {
fn new() -> Self {
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("aura-runner-e2e-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Packs one `RawM1Record`-shaped 48-byte little-endian record — the exact
/// on-disk format `data-server` reads (mirrors `aura-ingest`'s and
/// `aura-cli`'s own synthetic-archive test helpers).
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
}
/// Writes a tiny, deterministic one-month M1 archive (`N_BARS` consecutive
/// 1-minute bars, strictly ascending close so the shipped Donchian breakout
/// blueprint actually latches a non-flat bias) plus its geometry sidecar,
/// under `dir`. Returns the inclusive `(first_ms, last_ms)` bar span.
fn write_tiny_archive(dir: &Path) -> (i64, i64) {
let path = dir.join(format!("{SYMBOL}_2024_06.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 i in 0..N_BARS {
let ms = BASE_MS + i * 60_000;
let close = 100.0 + i as f64 * 2.0; // strictly ascending -> a real breakout
let open = close - 1.0;
let high = close + 0.5;
let low = open - 0.5;
zip.write_all(&pack_record(ms, open, high, low, close)).expect("write record");
}
zip.finish().expect("finish synthetic m1 zip");
std::fs::write(
dir.join(format!("{SYMBOL}.meta.json")),
"{\"schemaVersion\":2,\"symbol\":\"TESTSYM\",\"digits\":1,\"pipSize\":1,\
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"TESTSYM\",\"quoteAsset\":\"EUR\"}",
)
.expect("write synthetic geometry sidecar");
(BASE_MS, BASE_MS + (N_BARS - 1) * 60_000)
}
/// One fresh `DefaultMemberRunner` over its own `DataServer` instance pointed
/// at `data_dir`, running `cell` end to end.
fn run_once(
data_dir: &Path,
cell: &CellSpec,
window_ms: (i64, i64),
) -> Result<aura_backtest::RunReport, aura_campaign::MemberFault> {
let env = Env::std();
let server = std::sync::Arc::new(DataServer::new(data_dir));
let runner = DefaultMemberRunner::new(&env, server, BTreeMap::new(), Vec::new());
runner.run_member(cell, &[], window_ms)
}
#[test]
/// Property: `DefaultMemberRunner::run_member` — the shipped C28 assembly
/// recipe — runs a real member backtest to completion through nothing but
/// `aura-runner` + `aura-campaign` library calls, and does so deterministically:
/// two independently constructed runners over byte-identical archive input
/// produce a byte-identical `RunReport` (C1).
fn default_member_runner_is_reachable_as_a_pure_library_call_and_is_deterministic() {
let scratch = ScratchDir::new();
let (first_ms, last_ms) = write_tiny_archive(scratch.path());
let blueprint_json =
include_str!("../../aura-cli/examples/r_breakout.json").to_string();
let cell = CellSpec {
strategy_ordinal: 0,
strategy_id: aura_research::content_id_of(&blueprint_json),
blueprint_json,
axes: BTreeMap::new(),
instrument: SYMBOL.to_string(),
window_ms: (first_ms, last_ms),
regime: None,
regime_ordinal: 0,
};
let report_a = run_once(scratch.path(), &cell, (first_ms, last_ms))
.expect("the member run must succeed over its own tiny archive");
let report_b = run_once(scratch.path(), &cell, (first_ms, last_ms))
.expect("a second, independently constructed runner must succeed identically");
assert_eq!(
report_a, report_b,
"same cell, same archive, two independent DefaultMemberRunner instances: \
the RunReport must be byte-identical (C1 determinism)"
);
assert!(
report_a.metrics.total_pips.abs() > 0.0,
"the fixture's strictly-ascending closes must drive a non-flat run, else \
the determinism check above is vacuous (both sides equally empty): {:?}",
report_a.metrics
);
}
+1
View File
@@ -23,6 +23,7 @@ impl Abs {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "absolute value of the input series",
},
|_| Box::new(Abs::new()),
)
+1
View File
@@ -38,6 +38,7 @@ impl Add {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "elementwise sum of two input series",
},
|_| Box::new(Add::new()),
)
+1
View File
@@ -37,6 +37,7 @@ impl And {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![],
doc: "logical AND of two boolean series",
},
|_| Box::new(And::new()),
)
+1
View File
@@ -33,6 +33,7 @@ impl Const {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "clock".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "value".into(), kind: ScalarKind::F64 }],
doc: "constant-valued stream from a single param",
},
|p| Box::new(Const::new(p[0].f64())),
)
+1
View File
@@ -53,6 +53,7 @@ impl CumSum {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "running cumulative sum of the input series",
},
|_| Box::new(CumSum::new()),
)
+1
View File
@@ -65,6 +65,7 @@ impl Delay {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "lag".into(), kind: ScalarKind::I64 }],
doc: "emits the input from `lag` cycles ago — the explicit delay register (C9)",
},
|p| Box::new(Delay::new(p[0].i64() as usize)),
)
+1
View File
@@ -30,6 +30,7 @@ impl Div {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "elementwise quotient of two input series",
},
|_| Box::new(Div::new()),
)
+1
View File
@@ -63,6 +63,7 @@ impl Ema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "exponential moving average over the input series",
},
|p| Box::new(Ema::new(p[0].i64() as usize)),
)
+1
View File
@@ -40,6 +40,7 @@ impl EqConst {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "value".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![ParamSpec { name: "target".into(), kind: ScalarKind::I64 }],
doc: "true where the input equals a constant param",
},
|p| Box::new(EqConst::new(p[0].i64())),
)
+6 -1
View File
@@ -41,7 +41,12 @@ impl GatedRecorder {
let build_kinds = kinds.clone();
PrimitiveBuilder::new(
"GatedRecorder",
NodeSchema { inputs, output: vec![], params: vec![] }, // sink: empty output (C8)
NodeSchema {
inputs,
output: vec![],
params: vec![],
doc: "recording sink that persists its input while the gate is true, plus a final row at finalize",
}, // sink: empty output (C8)
move |_| Box::new(GatedRecorder::new(&build_kinds, gate_col, tx.clone())),
)
}
+1
View File
@@ -39,6 +39,7 @@ impl Gt {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![],
doc: "true where the first input exceeds the second",
},
|_| Box::new(Gt::new()),
)

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