Compare commits

93 Commits

Author SHA1 Message Date
claude 9cfe2965c0 feat(aura-cli, aura-runner): JSON data namespace — info verb, NDJSON list, coverage retired
Reshape the aura data namespace around one principle: every verb emits
JSON, no format flag (the headless-agent posture #264 named).

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

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

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

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

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

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

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

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

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

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

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

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

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

closes #331
refs #328, #311

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

New shape, per the ailang precedent:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #295
2026-07-21 05:20:27 +02:00
422 changed files with 25644 additions and 8276 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
+40 -4
View File
@@ -160,19 +160,21 @@ dependencies = [
"aura-core",
"aura-engine",
"aura-ingest",
"aura-market",
"aura-measurement",
"aura-registry",
"aura-research",
"aura-runner",
"aura-std",
"aura-strategy",
"aura-vocabulary",
"chrono-tz",
"clap",
"data-server",
"libc",
"libloading",
"serde",
"serde_json",
"sha2",
"toml",
"zip",
]
@@ -192,6 +194,7 @@ dependencies = [
name = "aura-core"
version = "0.1.0"
dependencies = [
"chrono-tz",
"serde",
"serde_json",
]
@@ -239,6 +242,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 +279,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"
@@ -272,8 +310,6 @@ dependencies = [
"aura-backtest",
"aura-core",
"aura-strategy",
"chrono",
"chrono-tz",
]
[[package]]
+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",
]
+7 -3
View File
@@ -38,7 +38,7 @@ Invoke it as `aura <command> …` (examples below use the plain name).
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
in a content-addressed store and can be listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
bound with a comma-separated value list. A **gang** fuses several sibling
knobs into one axis (one value drives all members).
@@ -119,8 +119,12 @@ 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`, `doc`, and `use` (`tap` declares a recorded measurement point on an
interior wire; `doc` declares the composite's one-line meaning, required at
register — C29; `use` splices a registered blueprint in as a nested composite,
by content id or label; `add` additionally takes an `args` object for
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
non-scalar construction consumed before `bind`; see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics.
+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.487171,
"run_ms": 3.376192
},
"fingerprint": "run_line_fnv=792dc4434f8e3dee",
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "13d8500",
"date": "2026-07-17"
"commit": "10570b7",
"date": "2026-07-25"
}
@@ -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"
}
+4 -4
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)
@@ -106,9 +106,9 @@ fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<Stri
"sweep",
file,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
name,
],
+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"
);
}
+29 -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,25 @@ clap = { version = "4", features = ["derive"] }
# data-server dependency above; declared directly here so test code may
# `use zip::...` (#250, no new dependency actually enters the build graph).
zip = "2"
# aura-composites: the RiskExecutor / vol_stop composite-builders — production
# use moved to aura-runner (#295); the shell's own use is test-only
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
aura-composites = { path = "../aura-composites" }
# aura-market: the shell's own use is test-only (#271's identity-bridge test,
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
# `Session::configured` as the Rust-authored twin of the args op-script path)
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
aura-market = { path = "../aura-market" }
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
# `Session::configured` — test-only, pinned to the same version every other
# crate's chrono-tz entry uses.
chrono-tz = { version = "0.10", default-features = false }
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
aura-analysis = { path = "../aura-analysis" }
# sha2: production topology_hash / dylib_sha256 hashing now lives entirely in
# aura-runner (#295); the shell's own use is test-only (tests/cli_run.rs
# re-hashes a stored blueprint to check a golden), so this rides the same
# dev-only-edge idiom as `zip`/`aura-analysis` above.
sha2 = "0.10"
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
//! Stable stderr class markers (C14): `aura: note: <text>` for benign
//! diagnostics on a continuing run (exit code unaffected),
//! `aura: warning: <text>` for recorded faults the run survives.
//! Error lines that accompany a non-zero exit — and plain info lines
//! such as the run-record summary — keep the bare `aura:` prefix; for
//! errors, the exit-code partition is the machine contract.
macro_rules! note {
($($arg:tt)*) => {
eprintln!("aura: note: {}", format_args!($($arg)*))
};
}
macro_rules! warning {
($($arg:tt)*) => {
eprintln!("aura: warning: {}", format_args!($($arg)*))
};
}
pub(crate) use {note, warning};
/// The zero-trade note's message text (#313), pluralization-aware — the
/// milestone fieldtest tripped over "all 1 walk-forward windows"
/// (captured: `fieldtests/milestone-stderr-honesty/captured/
/// example2_singular.err`). Pure over the count so the wording is
/// unit-pinned.
fn zero_trade_note_text(n_windows: usize) -> String {
if n_windows == 1 {
"the single walk-forward window recorded zero trades".to_string()
} else {
format!("all {n_windows} walk-forward windows recorded zero trades")
}
}
/// The #313 zero-trade note, 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"
);
}
}
+572 -74
View File
@@ -8,22 +8,61 @@ use std::collections::BTreeMap;
use std::path::Path;
use aura_engine::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, name_gate, replay, ArgOpError,
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, NameGateFault, Op,
OpError, Scalar, ScalarKind,
};
use aura_runner::runner::render_value;
use serde::Deserialize;
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
use crate::research_docs::resolve_id_prefix;
// `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;
/// The op-list reference `aura graph build --help` appends (#323): the eleven
/// op kinds with their fields and one worked element each (#331: `name` joins
/// the roster, ten -> eleven). Lives beside [`OpDoc`] so a new op variant is
/// one screen away from its help line.
pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order):
{"op":"source","role":"price","kind":"F64"}
declare a bound root input role of a scalar kind
{"op":"input","role":"price"}
declare an open input role (wired by an enclosing graph)
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
instantiate a node ("name" optional; "bind" maps param -> typed scalar)
{"op":"add","type":"Session","name":"ny","args":{"tz":"America/New_York","open":"09:30"},"bind":{"period_minutes":{"I64":15}}}
an arg-bearing type applies "args" (closed, per-type-declared string
pairs) BEFORE "bind" — see graph introspect --node <T> for its args
{"op":"feed","role":"price","into":["fast.series","slow.series"]}
wire a role into one or more input slots
{"op":"connect","from":"fast.value","to":"sub.lhs"}
wire a node output into an input slot
{"op":"expose","from":"sub.value","as":"bias"}
name a graph output field
{"op":"tap","from":"sub.value","as":"spread"}
declare a recordable tap on a wire (expose's output-side twin)
{"op":"gang","as":"length","into":["fast.length","slow.length"]}
fuse two or more sibling params into one public knob
{"op":"doc","text":"..."}
declare the composite's one-line meaning (C29)
{"op":"use","ref":{"name":"agree"},"name":"gate","bind":{"sma.length":{"I64":9}}}
splice a registered blueprint (by "content_id" or "name") under an
instance name ("bind" path-qualifies the spliced instance's params)
{"op":"name","name":"ny_momentum"}
set the composite's render name, at most once per script (default
"graph" if omitted)
Node types and their ports: aura graph introspect --vocabulary | --node <T>"#;
/// The wire DTO for one construction op — the document's by-identifier shape,
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
#[derive(Debug, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
#[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
enum OpDoc {
Source { role: String, kind: ScalarKind },
Input { role: String },
@@ -34,6 +73,12 @@ enum OpDoc {
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
#[serde(rename = "name", default)]
as_name: Option<String>,
// Construction args (#271) — the typed, closed channel applied BEFORE
// `bind`. A `BTreeMap` (deterministic iteration order into `Op::Add`'s
// `Vec`, mirroring `bind`'s own convention) — absent for every
// args-free type, so an old-style `add` document is unaffected.
#[serde(default)]
args: BTreeMap<String, String>,
#[serde(default)]
bind: BTreeMap<String, Scalar>,
},
@@ -54,37 +99,116 @@ 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 },
/// Splice a registered blueprint into the building graph as a nested
/// composite (#317) — the DTO the engine's `Op::Use` never sees
/// directly: the CLI resolves `ref` (a store content id, a unique
/// content-id prefix, or a registry name label) to the full content id
/// at conversion time, before the engine ever runs.
Use {
#[serde(rename = "ref")]
r#ref: UseRef,
#[serde(default)]
name: Option<String>,
#[serde(default)]
bind: BTreeMap<String, Scalar>,
},
/// Set the composite's render name (#331) — script-level, at most once;
/// the op-script twin of `replay`'s seeded default name (`"graph"`).
Name { name: String },
}
/// A `use` op's reference (#317) — exactly one of a store content id (full
/// or a unique prefix, #302 semantics) or a registry name label
/// (`graph register --name`, latest-wins).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
enum UseRef {
#[serde(rename = "content_id")]
ContentId(String),
#[serde(rename = "name")]
Name(String),
}
impl OpDoc {
/// The op-kind label for the `op N (kind): cause` message.
fn kind_label(&self) -> &'static str {
/// The op-kind label for the `op N (kind): cause` message. A `use` op
/// carries its instance name (when the author gave one) so a `use`
/// fault reads `use "gate"`, not a bare, undifferentiated `use` — the
/// only kind whose label is not a fixed string (#317).
fn kind_label(&self) -> String {
match self {
OpDoc::Source { .. } => "source",
OpDoc::Input { .. } => "input",
OpDoc::Add { .. } => "add",
OpDoc::Feed { .. } => "feed",
OpDoc::Connect { .. } => "connect",
OpDoc::Expose { .. } => "expose",
OpDoc::Tap { .. } => "tap",
OpDoc::Gang { .. } => "gang",
OpDoc::Source { .. } => "source".to_string(),
OpDoc::Input { .. } => "input".to_string(),
OpDoc::Add { .. } => "add".to_string(),
OpDoc::Feed { .. } => "feed".to_string(),
OpDoc::Connect { .. } => "connect".to_string(),
OpDoc::Expose { .. } => "expose".to_string(),
OpDoc::Tap { .. } => "tap".to_string(),
OpDoc::Gang { .. } => "gang".to_string(),
OpDoc::Doc { .. } => "doc".to_string(),
OpDoc::Use { name: Some(n), .. } => format!("use {n:?}"),
OpDoc::Use { name: None, .. } => "use".to_string(),
OpDoc::Name { .. } => "name".to_string(),
}
}
}
impl From<OpDoc> for Op {
/// Infallible, context-free conversion — used directly only by
/// build-free introspection paths (`introspect --unwired`, #317's
/// spec: "Build-free introspection paths pass a `|_| None` closure"),
/// which never resolve a `use` ref through the registry. A bare
/// `OpDoc::Use` therefore maps its `UseRef` payload verbatim into
/// `ref_id` (unresolved) — that session's `subgraph` closure is always
/// `&|_| None`, so any `use` op there faults `UnknownSubgraph`
/// regardless of the exact `ref_id` text; `graph build`'s real path
/// (`composite_from_str`) never reaches this arm — it resolves and
/// replaces each `Op::Use` before conversion (see `resolve_use_op`).
fn from(d: OpDoc) -> Op {
match d {
OpDoc::Source { role, kind } => Op::Source { role, kind },
OpDoc::Input { role } => Op::Input { role },
OpDoc::Add { type_id, as_name, bind } => {
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
}
OpDoc::Add { type_id, as_name, args, bind } => Op::Add {
type_id,
as_name,
args: args.into_iter().collect(),
bind: bind.into_iter().collect(),
},
OpDoc::Feed { role, into } => Op::Feed { role, into },
OpDoc::Connect { from, to } => Op::Connect { from, to },
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
OpDoc::Doc { text } => Op::Doc { text },
OpDoc::Use { r#ref, name, bind } => Op::Use {
ref_id: match r#ref {
UseRef::ContentId(id) => id,
UseRef::Name(name) => name,
},
name,
bind: bind.into_iter().collect(),
},
OpDoc::Name { name } => Op::Name { name },
}
}
}
/// Phrase one `ArgOpError` (#271) as a human cause, WITHOUT the node prefix —
/// shared by `format_op_error`'s `BadArg` arm (the `add`-op path) and
/// `blueprint_load_prose`'s `BadArg` arm (the blueprint LOAD path), so the two
/// error families cannot phrase the same fault differently. Exhaustive over
/// `ArgOpError`; `BadValue` names the arg, its declared `ArgKind`, AND the
/// closed per-kind `hint()` line (the same hint `introspect --node` shows).
fn arg_op_error_prose(err: &ArgOpError) -> String {
match err {
ArgOpError::NotArgBearing => "takes no construction args".to_string(),
ArgOpError::UnknownArg(a) => format!("has no construction arg {a:?}"),
ArgOpError::DuplicateArg(a) => format!("was given arg {a:?} more than once"),
ArgOpError::MissingArg(a) => format!("is missing required arg {a:?}"),
ArgOpError::BadValue { arg, kind, got } => {
format!("arg {arg:?} expects {kind:?} ({}) — got {got:?}", kind.hint())
}
}
}
@@ -117,10 +241,13 @@ fn format_op_error(e: &OpError) -> String {
BindOpError::KindMismatch { param, expected, got } => {
format!("param {node}.{param} expects {expected:?} but got {got:?}")
}
BindOpError::AlreadyGanged { param, gang } => {
format!("cannot bind {node}.{param} — member of gang {gang:?}")
}
},
OpError::BadArg { node, err } => format!("node {node} {}", arg_op_error_prose(err)),
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"),
OpError::GangKindMismatch { member, expected, got } => {
format!("gang: member `{member}` is {got:?}, expected {expected:?}")
}
@@ -129,18 +256,191 @@ 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(),
// #317: `graph build`'s real path (`composite_from_str`) resolves and
// fetches every `use` op before replay, so this never fires there —
// but `introspect --unwired` stays build-free/subgraph-free by spec
// (`&|_| None`, see `From<OpDoc> for Op`), so a `use` op in a partial
// document reaches this arm through THAT path, by-identifier on the
// (unresolved) `ref_id` text.
OpError::UnknownSubgraph { ref_id } => {
format!("use: no subgraph for content id {ref_id:?}")
}
OpError::DuplicateName => "a script names its blueprint at most once".to_string(),
OpError::BadName { name, fault } => name_gate_fault_prose(name, fault),
}
}
/// Phrase a `name_gate` shape violation as prose (#331): shared by this
/// module's op-intake `format_op_error` `BadName` arm and the
/// blueprint-envelope intake's root-name gate (`composite_from_authored_text`
/// below) — the shape rule has exactly one seam-independent cause per fault,
/// so both data-borne birth routes for a name read identically.
fn name_gate_fault_prose(name: &str, fault: &NameGateFault) -> String {
let cause = match fault {
NameGateFault::Empty => "must be non-empty",
NameGateFault::ContainsSeparator => "must not contain '/' or '\\'",
NameGateFault::DotSegment => "must not be \".\" or \"..\"",
};
format!("blueprint name {name:?} is invalid: {cause} (a single path segment)")
}
/// Resolve one `use` op's [`UseRef`] to the full store content id (#317):
/// verbatim if it already IS a 64-hex content id, else a unique content-id
/// prefix (#302 semantics, reusing [`resolve_id_prefix`]) or a registry name
/// label (latest-wins, [`aura_registry::Registry::resolve_blueprint_label`]).
/// Returns the full id plus the `<label-or-prefix> -> <full id>` text the
/// resolution echo shows — the bare cause on a miss (unknown label / unknown
/// or ambiguous prefix), for the caller to attribute by op index.
fn resolve_use_ref_id(r: &UseRef, registry: &aura_registry::Registry) -> Result<(String, String), String> {
match r {
UseRef::Name(label) => {
let id = registry.resolve_blueprint_label(label).ok_or_else(|| {
let labels = registry.blueprint_labels();
if labels.is_empty() {
format!("no registered blueprint labeled {label:?} — no labels registered")
} else {
let names = labels.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ");
format!("no registered blueprint labeled {label:?} — registered labels: {names}")
}
})?;
Ok((id.clone(), format!("{label} -> {id}")))
}
UseRef::ContentId(raw) => {
if aura_runner::axes::is_content_id(raw) {
return Ok((raw.clone(), format!("{raw} -> {raw}")));
}
let candidates = registry.list_blueprint_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(raw, &candidates)? {
Some(full) => {
let shown = format!("{raw} -> {full}");
Ok((full, shown))
}
None => Err(format!("no registered blueprint matches content id {raw:?}")),
}
}
}
}
/// The 12-char content-id prefix convention `graph introspect --registered`
/// shows (#317), reused in the use-seam C29 refusal so both surfaces name a
/// blueprint the same shortened way.
fn id_prefix12(id: &str) -> &str {
&id[..id.len().min(12)]
}
/// The use-seam C29 doc-gate refusal cause (#317): re-run
/// [`aura_registry::gate_composite_docs`] — the SAME walk `graph register`
/// gates the write path with — over a FETCHED composite, before it ever
/// reaches the session; names the fetched blueprint's id-prefix and the
/// failing (root or nested — from the consumer's view, everything fetched is
/// "nested") composite by identifier, mirroring `research_docs.rs`'s
/// `BadDescription` phrasing for the same two `DocGateFault` kinds.
fn use_doc_gate_cause(full_id: &str, e: &aura_registry::RegistryError) -> String {
let aura_registry::RegistryError::UndescribedComposite { name, fault } = e else {
// Defensive: `gate_composite_docs` only ever constructs
// `UndescribedComposite`; every other `RegistryError` variant is
// unreachable from this call, but the match stays total rather than
// panicking on a surprise variant.
return format!("registered blueprint {} fails the description gate", id_prefix12(full_id));
};
let rule = match fault {
aura_core::DocGateFault::Empty => format!(
"nested composite {name:?} has no description — re-register it with a \"doc\" op (C29)"
),
aura_core::DocGateFault::RestatesName => {
format!("nested composite {name:?} has a doc that merely restates its name (C29)")
}
};
format!("registered blueprint {} fails the description gate: {rule}", id_prefix12(full_id))
}
/// Resolve, fetch, C29-gate, and echo one `use` op (#317) — the CLI-side
/// half of the store/engine split (the engine never resolves a label, a
/// prefix, or the registry itself). On success, caches the fetched
/// CANONICAL JSON (not the parsed `Composite` — `Composite` is not `Clone`,
/// mirroring the engine's own `use_fixture`-reload test pattern) under the
/// full content id, so the `subgraph` closure handed to `replay` is a pure,
/// registry-free lookup that re-parses on every call. Returns the bare
/// cause on any refusal; the caller attributes it by op index.
fn resolve_use_op(
r#ref: UseRef,
name: Option<String>,
bind: BTreeMap<String, Scalar>,
env: &aura_runner::project::Env,
cache: &mut std::collections::HashMap<String, String>,
) -> Result<Op, String> {
let registry = env.registry();
let (full_id, shown) = resolve_use_ref_id(&r#ref, &registry)?;
let json = registry
.get_blueprint(&full_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no registered blueprint {full_id}"))?;
let doc: BlueprintDoc = serde_json::from_str(&json)
.map_err(|e| format!("registered blueprint {full_id} is not a valid blueprint: {e}"))?;
// C29 at the use seam: gate the DATA form (no vocabulary needed) before
// the composite ever reaches the session.
if let Err(e) = aura_registry::gate_composite_docs(&doc.blueprint) {
return Err(use_doc_gate_cause(&full_id, &e));
}
// Resolve the fetched envelope through the FULL vocabulary here, at DTO
// conversion (review finding, #317 follow-up): deferring this to the
// `subgraph` closure's lazy re-parse let a deserialize/vocab-resolution
// failure fold into `.ok() -> None`, which `replay` then misreports as
// `UnknownSubgraph` — a fetched-but-unloadable blueprint is a runtime
// content fault (exit 1), not a missing reference. `blueprint_load_prose`
// + `unresolved_namespace_hint` are the same house-style pair
// `blueprint_slot_prose`/`composite_from_any` already use over a
// `LoadError`.
if let Err(e) = blueprint_from_json(&json, &|t| env.resolve(t)) {
let mut msg = blueprint_load_prose(&e);
if let Some(hint) = unresolved_namespace_hint(&e, env) {
msg.push_str("");
msg.push_str(&hint);
}
return Err(format!("registered blueprint {} is invalid: {msg}", id_prefix12(&full_id)));
}
let instance = name.clone().unwrap_or_else(|| doc.blueprint.name.clone());
// The resolution echo (C14 benign class): stderr only, so stdout stays
// clean payload (#164's convention, extended to this new note).
crate::diag::note!("use {instance:?}: {shown}");
cache.insert(full_id.clone(), json);
Ok(Op::Use { ref_id: full_id, name, bind: bind.into_iter().collect() })
}
/// Parse a JSON op-list document and replay it through the env's vocabulary into
/// 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> {
/// past the last op). Every `use` op resolves through the registry HERE, before
/// replay (#317): a resolution/doc-gate fault is attributed exactly like any
/// other per-op construction fault (same `op N (kind): cause` shape, exit 1).
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();
replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
let labels: Vec<String> = docs.iter().map(OpDoc::kind_label).collect();
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut ops: Vec<Op> = Vec::with_capacity(docs.len());
for (idx, d) in docs.into_iter().enumerate() {
let op = match d {
OpDoc::Use { r#ref, name, bind } => match resolve_use_op(r#ref, name, bind, env, &mut cache) {
Ok(op) => op,
Err(cause) => return Err(format!("op {idx} ({}): {cause}", labels[idx])),
},
other => Op::from(other),
};
ops.push(op);
}
// The injected `subgraph` lookup (#317): a pure, registry-free map read —
// every `use` op's blueprint was already fetched, gated, AND resolved
// (`resolve_use_op`'s eager `blueprint_from_json` check) above; this
// closure only re-parses the cached bytes (`Composite` is not `Clone`).
// The `.ok()` is defensive, not a fallible path in practice: re-parsing
// the SAME cached JSON through the SAME pure resolver cannot fail here
// once it has already succeeded once above (review finding, #317
// follow-up — the failure case now surfaces eagerly, at DTO conversion).
let subgraph = |ref_id: &str| {
cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok())
};
replay("graph", ops, &|t| env.resolve(t), &subgraph).map_err(|(idx, err)| {
let mut cause = format_op_error(&err);
// #244: the op-script path reports an unresolvable namespaced type as
// `OpError::UnknownNodeType`, a different error family than the
@@ -162,14 +462,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,10 +492,22 @@ 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());
// C29 (#315): the head line carries the node's one-line meaning.
let mut out = format!("{}{}\n", builder.label(), schema.doc);
// #271: an arg-bearing (pending) type declares its ArgSpecs but no real
// ports/params yet — those form only once `try_args` runs (`make`). Show
// the arg rows first (they must be supplied before anything else can be
// discovered) plus the one-line note the spec's worked discovery example
// pins.
for spec in builder.arg_specs() {
out.push_str(&format!(" arg {}: {:?} ({})\n", spec.name, spec.kind, spec.kind.hint()));
}
if builder.is_pending() {
out.push_str(" note ports and params form at construction; args are required\n");
}
for port in &schema.inputs {
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
}
@@ -210,10 +522,14 @@ 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);
// #317: build-free introspection stays subgraph-free by design (spec:
// "Build-free introspection paths pass a `|_| None` closure") — a `use`
// op here always misses (`OpError::UnknownSubgraph`, `From<OpDoc>`'s own
// doc comment), never a registry read.
let mut session = GraphSession::new("introspect", &resolver, &|_: &str| None);
for (i, d) in docs.into_iter().enumerate() {
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
}
@@ -224,26 +540,82 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
Ok(out)
}
/// `aura graph introspect --registered` (#317): one row per registry label —
/// `<label> <12-char id prefix> <root doc line>`, latest-wins (the label
/// sidecar's own resolution rule) — the discovery surface the worked
/// acceptance program's last step reads. An empty store is `no labels
/// registered` on stdout, exit 0 (a listing, not a fault); dangling label
/// rows (content id no longer resolves) are already skipped by
/// `blueprint_labels()`.
fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, String> {
let registry = env.registry();
let labels = registry.blueprint_labels();
if labels.is_empty() {
return Ok("no labels registered\n".to_string());
}
let mut out = String::new();
for (name, id) in labels {
let json = registry
.get_blueprint(&id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("label {name:?} names {id}, which is not in the store"))?;
let doc: BlueprintDoc = serde_json::from_str(&json)
.map_err(|e| format!("registered blueprint {id} is not a valid blueprint: {e}"))?;
let root_doc = doc.blueprint.doc.as_deref().unwrap_or("");
out.push_str(&format!("{name} {} {root_doc}\n", id_prefix12(&id)));
}
Ok(out)
}
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
/// 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
+ cmd.folds as usize
+ cmd.registered as usize
+ cmd.params.is_some() as usize
+ (cmd.content_id.is_some() || cmd.identity_id) as usize;
if count != 1 {
eprintln!(
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
);
std::process::exit(2);
}
if cmd.vocabulary {
// C29 (#315): each type id carries its schema's one-line meaning —
// bare names teach nothing (What do Latch, When, Select, Bias do?).
// A rostered id that fails to resolve is an invariant breach; better
// loud than a silent C29-incomplete bare row.
for t in env.type_ids() {
println!("{t}");
let b = env.resolve(t).expect("every rostered type id resolves to a builder");
println!("{t:<18} {}", b.schema().doc);
}
} else if cmd.folds {
// The fold vocabulary binds at graph-declared taps (C27), so the
// graph introspect namespace is its discovery surface (#315). #332:
// this renders the fold-REGISTRY roster — the same roster `aura run
// --tap TAP=FOLD` resolves labels against (aura-runner's layered
// `FoldRegistry`) — not the aura-std `FoldKind` table: labels here
// must be exactly what `--tap` accepts (lowercase), including the
// `record` entry that has no `FoldKind` counterpart.
for (label, doc) in aura_runner::FoldRegistry::core().roster() {
println!("{label:<7}{doc}");
}
} else if cmd.registered {
// The label sidecar's discovery surface (#317): one row per
// registered label — nothing to build, so a store-only read, exit 0
// even on an empty store (a listing, not a fault).
match introspect_registered(env) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
} else if let Some(type_id) = cmd.node.as_deref() {
match introspect_node(type_id, env) {
@@ -268,10 +640,12 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
}
}
} else if let Some(target) = cmd.params.as_deref() {
// --params <FILE|ID> (#196): the RAW composite param space — exactly the
// namespace campaign axes are validated against (`validate_campaign_refs`
// checks the raw space; the wrapped `--list-axes` namespace on `aura sweep`
// is the sweep-verb view, not the campaign view).
// --params <FILE|ID> (#196): the RAW composite param space — the one
// namespace campaign axes are validated against
// (`validate_campaign_refs`) and `aura sweep --list-axes` prints
// (#328: the wrapped `<blueprint>.<node>.<param>` form was retired —
// there is exactly one axis namespace now, so the two discovery
// surfaces agree line-for-line, see `params_lines`'s own doc comment).
match params_lines(target, env) {
Ok(s) => print!("{s}"),
Err(m) => {
@@ -300,7 +674,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
std::process::exit(1);
}
};
match composite_from_any(&text, env) {
match composite_from_authored_text(&text, env) {
Ok(c) => c,
Err(m) => {
eprintln!("aura: {m}");
@@ -359,6 +733,7 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
}
}
@@ -404,7 +779,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 +798,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 +822,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 +844,19 @@ 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> {
///
/// **Ungated by design (#331 review finding).** This is the STORE READ-BACK
/// shape: `params_lines`'s content-id fetch (`resolve_blueprint_text`'s
/// non-file branch) — `introspect --params <ID>` and, by the same
/// convention, `validate_campaign_refs`'s already-ungated `blueprint_from_json`
/// call — reads whatever is already sitting in the store, and C29 says a
/// registered artifact is never retroactively invalidated. Freshly authored
/// FILE text (a hand-edited document that has not yet passed through a gated
/// intake) goes through [`composite_from_authored_text`] instead, never here
/// — `params_lines`'s OWN file branch is such a case (#331 delta re-review:
/// it used to call straight through to this fn, missing the gate; fixed by
/// branching on `resolve_blueprint_text`'s file-vs-store flag).
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 {
@@ -486,26 +873,101 @@ pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Resul
}
}
/// The FILE-intake counterpart of [`composite_from_any`] (#331 review
/// finding): identical parse, plus the blueprint-envelope root-name shape
/// gate. Every call site that builds a `Composite` from text freshly read
/// off disk (`graph register`, `graph introspect --content-id <FILE>`, the
/// bare `aura graph <FILE>` viewer, and `graph introspect --params <FILE>`'s
/// file branch) goes through this wrapper — never the ungated
/// `composite_from_any` — because a hand-edited envelope with
/// `"name":"../x"` would otherwise register/build cleanly and write
/// `traces/../x/` at run time (`trace_store.rs` joins the name unsanitized).
/// Gating the composite's name unconditionally (not just on the Object
/// branch) is harmless: an op-script-built composite's name already passed
/// the op-intake gate (`GraphSession::set_name`), so re-checking it here is
/// redundant, not restrictive — it keeps this wrapper a single shared choke
/// point rather than one that has to re-discriminate the JSON shape. Only
/// the ROOT name is checked: the filesystem seam consumes exclusively the
/// root name. Store read-back (reproduce, `use`-splice resolution from the
/// registry, and `params_lines`'s content-id branch) never reaches this
/// function — C29's "registered artifacts are never retroactively
/// invalidated" stays intact.
///
/// **Deliberate exceptions — direct `gate_authored_root_name` callers
/// (main.rs).** A handful of fresh-FILE intakes reach the same unsanitized
/// `traces/<name>/` seam (or `put_blueprint` the loaded envelope straight
/// into the registry) without going through this wrapper, because each
/// already parses the document its own way and only needs the shared root-
/// name gate bolted on, not the shape-discrimination `composite_from_any`
/// does:
/// - `aura run <blueprint.json>` (`dispatch_run`): envelope-only grammar (no
/// op-script fallback), feeds `signal.name()` into
/// `run_signal_r`/`run_measurement` -> `bind_tap_plan` ->
/// `TraceStore::begin_run`.
/// - `validate_and_register_axes` (shared by `generalize`,
/// `sweep --real`, `walkforward --real`, `mc --real`): `put_blueprint`s
/// the loaded envelope by topology hash before any of those four verbs
/// touches an archive.
/// - `run_blueprint_sweep` / `run_blueprint_walkforward` / `run_blueprint_mc`
/// (the synthetic, no-`--real` family builders): same `put_blueprint`
/// reason, on the routes that bypass `validate_and_register_axes`
/// entirely (#331 cycle-close — these used to plant an ungated envelope
/// in the store; see each fn's own comment).
/// - `list_blueprint_axes` (`aura sweep <FILE> --list-axes`, main.rs): no
/// registry write and no trace directory here, but a shape-violating root
/// name otherwise mangles through `wrapped_to_raw_axis` into a printed,
/// non-bindable axis name instead of refusing (#331 fieldtest finding
/// c331_2e) — the class rule is every CLI intake reading an authored
/// envelope from a file gates the root name, not only the writing ones.
///
/// All of these share the identical `name_gate` + `name_gate_fault_prose`
/// primitives this wrapper uses, so the refusal wording is byte-identical
/// across every authored-file-intake route even though the shape-
/// discrimination step is not shared by them.
pub(crate) fn composite_from_authored_text(
text: &str,
env: &aura_runner::project::Env,
) -> Result<Composite, String> {
let composite = composite_from_any(text, env)?;
gate_authored_root_name(composite.name())?;
Ok(composite)
}
/// The root-name shape gate itself (#331 delta re-review), factored out of
/// [`composite_from_authored_text`] so `dispatch_run`'s narrower-grammar file
/// intake (see that fn's doc comment) can share the exact `name_gate` call and
/// `name_gate_fault_prose` wording without going through the shape-discriminating
/// wrapper.
pub(crate) fn gate_authored_root_name(name: &str) -> Result<(), String> {
name_gate(name).map_err(|fault| name_gate_fault_prose(name, &fault))
}
/// 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> {
/// cannot drift apart on what counts as a store address. The `bool` names
/// which branch fired: `true` for a FRESH FILE read, `false` for a STORE
/// (content-id) fetch — `params_lines` (#331 delta re-review) uses it to
/// decide whether the root-name gate applies (a fresh file must gate; a
/// store read-back must not, C29) without re-deriving the FILE-vs-id
/// distinction a second time.
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<(String, bool), 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(|text| (text, true))
.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(Some(json)) => Ok((json, false)),
Ok(None) => Err(format!("no blueprint {target} in the project store")),
Err(e) => Err(e.to_string()),
};
@@ -517,27 +979,45 @@ fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<Str
/// line per open param of the RAW composite (no harness wrap) — the
/// 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> {
/// the same form `introspect --node` already uses for param kinds. Followed
/// by one `{name}:{kind:?} default={value}` line per BOUND param (#328):
/// `bound_param_space()`'s names are already RAW (#203), so no blueprint-name
/// concatenation is needed — line-identical to the reconciled `--list-axes`
/// bound pass (`list_blueprint_axes`, main.rs), same `render_value` lexicon.
/// #331 delta re-review: the FILE target is a FOURTH freshly-authored-file
/// intake this fn's own `composite_from_any` call had missed gating — a FILE
/// routes through [`composite_from_authored_text`] instead; a content id
/// (STORE read-back) keeps the ungated `composite_from_any` (C29).
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)?;
let (text, is_file) = resolve_blueprint_text(target, env)?;
// Shape-discriminated like file-mode --content-id: an op-script (array)
// builds through the one-build tail, an envelope (object) loads (#202).
let composite = composite_from_any(&text, env)?;
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
let mut out = String::new();
for p in composite.param_space() {
let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
}
for b in composite.bound_param_space() {
let _ = writeln!(out, "{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
}
Ok(out)
}
/// `aura graph register <blueprint.json>` (#196): parse the blueprint through
/// 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) {
match register_blueprint(file, env) {
Ok(line) => println!("{line}"),
/// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
/// #317): parse the blueprint through the project vocabulary, canonicalize,
/// content-address, and store — the `process register` pattern, printing
/// the store path so the trail is followable. With `--name`, additionally
/// labels the stored content id (`aura_registry::Registry::put_blueprint_label`),
/// echoing the repoint when the label already pointed elsewhere. Prose to
/// stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, name: Option<&str>, env: &aura_runner::project::Env) {
match register_blueprint(file, name, env) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
}
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
@@ -545,20 +1025,37 @@ 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,
name: Option<&str>,
env: &aura_runner::project::Env,
) -> Result<Vec<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
// canonicalizes to the envelope form, so the stored id is shape-invariant.
let composite = composite_from_any(&text, env)?;
// Gated (#331 review finding): this text is freshly authored FILE bytes,
// not a store read-back, so `composite_from_authored_text` applies.
let composite = composite_from_authored_text(&text, env)?;
let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?;
let id = crate::content_id(&canonical);
let registry = env.registry();
registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
Ok(format!(
let mut lines = vec![format!(
"registered blueprint {id} ({})",
registry.blueprint_path(&id).display()
))
)];
if let Some(label) = name {
// Resolve BEFORE writing, so the repoint echo compares against the
// pre-write target (#317).
let previous = registry.resolve_blueprint_label(label);
registry.put_blueprint_label(label, &id).map_err(|e| e.to_string())?;
lines.push(match previous {
Some(old) if old != id => format!("label {label:?} -> {id} (was {old})"),
_ => format!("label {label:?} -> {id}"),
});
}
Ok(lines)
}
#[cfg(test)]
@@ -587,7 +1084,8 @@ mod tests {
assert_eq!(docs[1].kind_label(), "add");
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
// both SMA lengths are bound, so the only open param is bias.scale (f64).
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
let composite =
replay("graph", ops, &std_vocabulary, &|_: &str| None).expect("replay resolves");
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
}
@@ -605,7 +1103,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 +1119,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 +1141,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 +1150,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 +1162,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 +1171,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 +1192,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 +1203,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 +1230,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}");
}
+772 -3489
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),
+268 -9
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();
@@ -104,10 +107,12 @@ fn print_metric_roster() {
(true, false) => "rankable",
(false, false) => "annotation",
};
// C29 (#315): the roster carries each metric's one-line meaning, not
// only where it may be used.
if generalize {
println!("{name:<24} {base} | generalize");
println!("{name:<24} {base} | generalize{}", m.doc);
} else {
println!("{name:<24} {base}");
println!("{name:<24} {base}{}", m.doc);
}
}
}
@@ -179,7 +184,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
DocFault::UnknownTap { index, tap } => format!(
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
DocFault::UnknownBindingColumn { role, column } => format!(
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
@@ -188,6 +193,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::ProcessRefMustBeContentId => {
"process.ref: a process is referenced by content id in this version".into()
}
DocFault::BadDescription { subject, fault } => match fault {
aura_core::DocGateFault::Empty => format!(
"description: `{subject}`'s description is empty — omit the \
field or write a one-line meaning (C29)"
),
aura_core::DocGateFault::RestatesName => format!(
"description: `{subject}`'s description merely restates the \
document name (C29)"
),
},
}
}
@@ -208,6 +223,7 @@ pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
introspect_process(i)
}
ProcessSub::Register { file } => register_process(file, env),
ProcessSub::Show { id } => show_process(id, env),
};
if let Err(m) = result {
eprintln!("aura: {m}");
@@ -247,6 +263,15 @@ fn validate_process_file(file: &PathBuf) -> Result<(), String> {
}
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"process register needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let doc = parse_valid_process(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = process_to_json(&doc);
@@ -259,6 +284,60 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
Ok(())
}
/// Resolve `id` against a document store's full candidate content-id list —
/// mirroring the exact-first / unique-match / else-refuse shape
/// `aura_runner::reproduce::load_family` established for #298's family/run
/// handle. Called only once the caller's own exact-match lookup has already
/// missed, so a filter match here is always a strict, shorter prefix (ids
/// are fixed-length hashes; a full-length miss cannot be a prefix of
/// another). `Ok(None)`: no candidate matches — genuinely unknown, the
/// caller's existing not-found prose applies unchanged. `Err`: two or more
/// candidates share the prefix — refused by name, listing every candidate,
/// rather than guessed.
pub(crate) fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
match matches.as_slice() {
[] => Ok(None),
[only] => Ok(Some((*only).clone())),
many => {
let listed = many.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
}
}
}
fn show_process(id: &str, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"process show needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let registry = env.registry();
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
// `print!`, not `println!`: `bytes` is the content-addressed canonical
// form the store keyed `id` on — the CLI is a transport and must not
// frame the canonical bytes with a trailing newline (#164, mirroring
// `graph_construct::build_cmd`).
print!("{bytes}");
return Ok(());
}
let candidates = registry.list_process_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(id, &candidates)? {
Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? {
Some(bytes) => {
print!("{bytes}");
Ok(())
}
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
},
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
}
}
fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
if cmd.metrics {
print_metric_roster();
@@ -295,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> {
for s in schema.slots {
let req = if s.required { "required" } else { "optional" };
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
// C29 (#315): the tap slot's closed value vocabulary carries its
// per-entry meanings, indented under the slot line.
if matches!(s.kind, aura_research::SlotKind::TapKinds) {
for t in aura_research::tap_vocabulary() {
println!(" {:<14} {}", t.id, t.doc);
}
}
}
Ok(())
}
@@ -341,6 +427,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 +442,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) => {
@@ -366,8 +462,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
RefFault::StrategyUnloadable { id, error } => {
format!("strategy {id} cannot be loaded: {error}")
}
RefFault::AxisNotInParamSpace { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
if let Some(candidate) = raw_candidate {
msg.push_str(&format!(
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
));
}
msg
}
RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
@@ -460,6 +562,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 +653,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 +674,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();
@@ -625,6 +767,7 @@ mod tests {
ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
}),
ref_fault_prose(&RefFault::AxisKindMismatch {
strategy: "9f3a".into(),
@@ -645,6 +788,39 @@ mod tests {
}
}
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
/// and verbatim, exactly when `raw_candidate` is present — the document-
/// side refusal seam's mirror of the sweep `--axis` intake translation
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
/// `axis names are raw node.param paths` clause pinned there.
#[test]
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "graph.fast.length".into(),
raw_candidate: Some("fast.length".into()),
});
assert_eq!(
msg,
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
axis names are raw node.param paths — did you mean \"fast.length\"?"
);
}
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
/// leading segment yields no param-space hit, or the axis has no leading
/// segment to strip at all) renders today's prose unchanged — no
/// speculative suggestion.
#[test]
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
});
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
}
#[test]
/// #194 (the prefix trap): a doc ref that carries the display `content:`
/// prefix (a copy-paste from register/introspect output) is bare-only in
@@ -700,6 +876,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 +923,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);
}
}
+61 -11
View File
@@ -152,6 +152,8 @@ impl Scale {
pub fn new(factor: f64) -> Self {
Self { factor, out: [Cell::from_f64(0.0)] }
}
// Replace `doc` below when renaming this sample node — it must keep
// describing the node's own behaviour, not this scaffolding step.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"__NS__::Scale",
@@ -163,6 +165,9 @@ impl Scale {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
// C29: every vocabulary entry ships a one-line meaning — the
// doc field is required (E0063 without it) and gated at load.
doc: "scalar gain: emits the input times the factor param",
},
|p| Box::new(Scale::new(p[0].f64())),
)
@@ -219,7 +224,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)
@@ -229,12 +234,21 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
- Run: `aura run blueprints/signal.json` (the starter is closed — all
params bound; bound values are defaults — any `--axis` may override them
(#246))
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
- Sweep: `aura sweep blueprints/signal.json --axis fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes, RAW
`<node>.<param>` names — #328)
- Native nodes: when the project needs its first project-specific node,
`aura nodes new <name>` scaffolds a node crate beside this project and
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
- Topology is data (`blueprints/*.json`); results land in `runs/`.
- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
continuously-tracked target position; a protective stop defines the risk
unit R, and quality metrics are R-based. Entry signals become held state
via the signal-side latch/edge-pulse idiom (see
`aura graph introspect --vocabulary`).
- Data plane: the research verbs are sugar over registered process/campaign
documents — author them directly with `aura process` / `aura campaign`,
growing one from a bare `{}` via `aura campaign introspect --unwired`.
"#;
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
@@ -280,7 +294,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 +335,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 +368,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 +385,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)"
);
}
}
@@ -446,10 +460,46 @@ mod tests {
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("demo-lab"));
// The CLAUDE.md sweep quickstart targets the closed starter itself
// with the rendered blueprint-name axis prefix (#246: a bound param
// is a default an axis overrides).
// with the RAW axis name (#246: a bound param is a default an axis
// overrides; #328: the axis namespace is raw, no blueprint-name
// prefix).
assert!(claude.contains("blueprints/signal.json"));
assert!(claude.contains("demo_lab_signal.fast.length"));
assert!(claude.contains("--axis fast.length"));
}
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
/// (bias as held target position, protective stop, R) and the document
/// data plane — the two things the 2026-07-22 field agent had to discover
/// forensically.
#[test]
fn project_claude_md_teaches_execution_semantics_and_the_data_plane() {
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("target position"), "names the held-target model: {claude}");
assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}");
assert!(claude.contains("aura process"), "points at the document layer: {claude}");
assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}");
}
/// #323: the authoring guide's S0 worked literal is byte-identical to the
/// scaffold's `LIB_RS` template (rendered with the guide's `my_lab`
/// namespace). The scaffold e2e tests compile that template for real, so
/// this equality pin is a transitive compile guard — the guide literal
/// can no longer drift silently from the schema (it broke against the
/// required `NodeSchema.doc` once).
#[test]
fn guide_worked_example_matches_the_scaffold_template() {
let guide = include_str!("../../../docs/authoring-guide.md");
let anchor = "### Worked example: `Scale`";
let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..];
let start = after.find("```rust\n").expect("worked example is a rust fence") + 8;
let fence = &after[start..];
let fence = &fence[..fence.find("\n```").expect("fence closes") + 1];
let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..];
let expected = body.replace("__NS__", "my_lab");
assert_eq!(
fence, expected,
"docs/authoring-guide.md S0 literal drifted from the compiled scaffold template"
);
}
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
+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);
}
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,7 @@ impl Identity {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "one-input f64 pass-through",
},
|_| Box::new(Identity::new()),
)
@@ -29,6 +29,7 @@ impl Identity {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "one-input f64 pass-through",
},
|_| Box::new(Identity::new()),
)
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
@@ -0,0 +1 @@
# restated fixture — static project context only (C17); paths only (cycle 0102).
@@ -0,0 +1,15 @@
[package]
name = "restated-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1,83 @@
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
//! pass-through node under the `restated` namespace whose `NodeSchema` doc
//! merely restates its own type id (`DocGateFault::RestatesName`, the fault
//! arm `undescribed-project` does not exercise). The charter is satisfied
//! (prefixed id, list/resolver in sync) so the refusal under test is exactly
//! the doc gate's name-restatement branch.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through whose doc is a no-content restatement of its
/// own name.
pub struct Echo {
out: [Cell; 1],
}
impl Echo {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"restated::Echo",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
// The one deliberate violation this fixture exists for: this
// norm-equals the type id "restated::Echo" once punctuation
// and case are stripped, so it carries no meaning beyond the
// name itself.
doc: "Restated Echo",
},
|_| Box::new(Echo::new()),
)
}
}
impl Default for Echo {
fn default() -> Self {
Self::new()
}
}
impl Node for Echo {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"restated::Echo".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"restated::Echo" => Some(Echo::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["restated::Echo"]
}
aura_core::aura_project! {
namespace: "restated",
vocabulary: vocabulary,
type_ids: type_ids,
}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
@@ -0,0 +1 @@
# undescribed fixture — static project context only (C17); paths only (cycle 0102).
@@ -0,0 +1,15 @@
[package]
name = "undescribed-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1,78 @@
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
//! pass-through node under the `und` namespace whose `NodeSchema` carries the
//! empty-string alibi `doc: ""` — compile-legal (the field is present), but
//! the load gate must refuse it. The charter is satisfied (prefixed id,
//! list/resolver in sync) so the refusal under test is exactly the doc gate.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through with a deliberately empty doc.
pub struct Opaque {
out: [Cell; 1],
}
impl Opaque {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"und::Opaque",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
// The one deliberate violation this fixture exists for.
doc: "",
},
|_| Box::new(Opaque::new()),
)
}
}
impl Default for Opaque {
fn default() -> Self {
Self::new()
}
}
impl Node for Opaque {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"und::Opaque".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"und::Opaque" => Some(Opaque::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["und::Opaque"]
}
aura_core::aura_project! {
namespace: "und",
vocabulary: vocabulary,
type_ids: type_ids,
}
+790 -22
View File
@@ -54,6 +54,77 @@ 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"}
]"#;
/// SIGNAL_DOC with a leading `{"op":"name",...}` (#331) — authors a render
/// name distinct from the CLI's own default seed ("graph"), otherwise
/// structurally identical to SIGNAL_DOC.
const SIGNAL_DOC_NAMED: &str = r#"[
{"op":"name","name":"ny_momentum"},
{"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_NAME_RESTATED with an authored name (#331 spec test 7) instead
/// of the CLI's default seed: the `doc` op's text restates the *authored*
/// name ("ny_momentum") rather than the literal "graph" — the same
/// RestatesName arm of the C29 shape gate must fire against whichever name
/// the composite actually carries, not a hardcoded "graph" comparison.
const SIGNAL_DOC_NAMED_NAME_RESTATED: &str = r#"[
{"op":"name","name":"ny_momentum"},
{"op":"doc","text":"Ny Momentum"},
{"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 +145,21 @@ fn graph_build_emits_blueprint_for_a_valid_document() {
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
}
/// C29 (#316): the op-script `doc` op's text lands verbatim in the built
/// blueprint's `doc` field -- `graph build` is a pure translation from ops to
/// the canonical envelope, so a doc op is not consumed or reworded on the way
/// through, only threaded (the shape gate is a separate, register-time check;
/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`).
#[test]
fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(ok, "exit success");
assert!(
stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#),
"the doc op's exact text appears in the blueprint's doc field: {stdout}"
);
}
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
@@ -121,8 +207,9 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
33,
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
36,
"the std-only (no project) vocabulary has exactly the roster's 36 entries (#271: \
Session/LinComb/CostSum moved in): {stdout}"
);
}
@@ -134,6 +221,78 @@ fn graph_introspect_node_answers_ports_without_a_build() {
assert!(stdout.contains("value"), "lists the output field: {stdout}");
}
/// #271 acceptance criterion 1: an op-script reaching BOTH new arg-bearing
/// roster entries (a `Session` for any IANA timezone/open, a `LinComb` of any
/// arity — no Rust authored) builds via `aura graph build`; the emitted
/// blueprint is `"version": 2` (the args-bearing tier) and carries the
/// accepted timezone verbatim.
///
/// Deviation from the spec's literal worked example
/// (`docs/specs/construction-args.md` §"The user-facing program"): that JSON
/// wires `ny.bars_since_open` (`Session`'s I64 output) directly into
/// `mix.term[0]` (`LinComb`'s F64 input) — a genuine kind mismatch the
/// engine's eager `connect` gate refuses (I64 != F64), not a property of
/// this feature. This fixture keeps every construction-args property the
/// criterion asks for (Session + args, LinComb + args, version 2, tz
/// carried) but feeds `LinComb` from two same-kind SMAs and exposes
/// `ny.bars_since_open` at the boundary directly (`expose` has no kind
/// constraint) instead of wiring it into the mismatched slot.
#[test]
fn graph_build_accepts_the_session_args_example() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"Session","name":"ny",
"args":{"tz":"America/New_York","open":"09:30"},
"bind":{"period_minutes":{"I64":15}}},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"LinComb","name":"mix","args":{"arity":"2"},
"bind":{"weights[0]":{"F64":1.0},"weights[1]":{"F64":0.25}}},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["ny.trigger","fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"mix.term[0]"},
{"op":"connect","from":"slow.value","to":"mix.term[1]"},
{"op":"connect","from":"mix.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"expose","from":"ny.bars_since_open","as":"session_bar"},
{"op":"doc","text":"NY-session-gated SMA blend"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(ok, "the worked example builds: {stderr}");
assert!(stdout.contains(r#""format_version":2"#), "an args-bearing document is version 2: {stdout}");
assert!(stdout.contains("America/New_York"), "carries the accepted tz verbatim: {stdout}");
}
/// #271: a malformed construction-arg value (a bad IANA tz string) refuses at
/// the `add` op with exit 1, naming the offending arg (not a Debug leak).
#[test]
fn graph_build_bad_tz_is_exit_1_naming_the_arg() {
let doc = r#"[
{"op":"add","type":"Session","name":"ny",
"args":{"tz":"not_a_zone","open":"09:30"}}
]"#;
let (stderr, code) = run_code(&["graph", "build"], doc);
assert_eq!(code, Some(1), "a construction-arg fault is exit 1: {stderr}");
assert!(stderr.contains("tz"), "names the offending arg: {stderr}");
assert!(!stderr.contains("{"), "no Debug-struct leak: {stderr}");
}
/// #271: `graph introspect --node Session` self-describes its declared
/// construction args (name, kind, hint) plus the pending note — the
/// discovery surface an author reads before writing the `args` object.
#[test]
fn graph_introspect_node_session_lists_arg_rows() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "Session"], "");
assert!(ok, "exit success");
assert!(stdout.contains("tz"), "lists the tz arg: {stdout}");
assert!(stdout.contains("open"), "lists the open arg: {stdout}");
assert!(stdout.contains("IANA timezone"), "shows the tz hint: {stdout}");
assert!(
stdout.contains("ports and params form at construction"),
"shows the pending note: {stdout}"
);
}
#[test]
fn graph_introspect_unwired_reports_open_slots() {
let doc = r#"[
@@ -305,6 +464,21 @@ fn graph_build_bad_stdin_content_is_runtime_exit_1() {
assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
}
/// Property (#326): an op-list element carrying an unknown/typo'd key (e.g.
/// `params` where the schema expects `bind`) is refused at parse — not
/// silently dropped. Previously the unrecognized key vanished, the field it
/// meant to set kept its default, and the wrong graph built with zero
/// signal. Same content-fault family as `graph_build_bad_stdin_content_is_
/// runtime_exit_1` (both fire from the same top-level deserialize), so the
/// exit class matches: exit 1, stderr names the offending key.
#[test]
fn graph_build_rejects_an_unknown_op_field() {
let doc = r#"[{"op":"add","type":"Const","params":{"value":{"F64":1.0}}}]"#;
let (stderr, code) = run_code(&["graph", "build"], doc);
assert_eq!(code, Some(1), "unknown op field is a content fault -> exit 1; stderr: {stderr}");
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
}
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
@@ -402,6 +576,24 @@ fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>)
)
}
/// Run `aura <args>` in `dir` with `stdin_doc` piped in; return (stderr, exit
/// code) — the `run_code`/`run_in` cross, for a `use`-seam fault that needs
/// BOTH a project cwd (the label sidecar lives under `<cwd>/runs/`) and a
/// piped op-list.
fn run_code_in(dir: &std::path::Path, args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
let mut child = Command::new(BIN)
.args(args)
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura");
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura");
(String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code())
}
/// The absolute path of a blueprint fixture shipped with this test crate.
fn fixture(name: &str) -> String {
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
@@ -462,18 +654,87 @@ 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`
/// shows. The open fixture leaves exactly the two SMA lengths unbound
/// (`bias.scale` is bound in the document).
/// lowering order (#328: the one raw axis namespace — `aura sweep
/// --list-axes` prints the identical lines for the same blueprint, see
/// `graph_params_and_sweep_list_axes_are_line_identical` below). The open
/// fixture leaves exactly the two SMA lengths unbound (`bias.scale` is bound
/// in the document).
#[test]
fn graph_params_lists_raw_axis_namespace() {
let dir = temp_cwd("params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
assert_eq!(
stdout, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"the raw open params, then the raw bound param with its default"
);
}
/// Property (#328, cycle-close tidy fix): `graph introspect --params` and
/// `aura sweep --list-axes` are two independently-dispatched discovery
/// surfaces over the SAME raw axis namespace — the open pass shares one
/// wrap-strip convention, the bound pass shares one `bound_param_space()` +
/// `render_value` lexicon (`list_blueprint_axes`, main.rs; `params_lines`,
/// this crate). Pinned here as a same-fixture LINE EQUALITY (not just two
/// independently-asserted shapes) so the duplicated formatting cannot
/// silently drift apart across an edit to either surface.
#[test]
fn graph_params_and_sweep_list_axes_are_line_identical() {
let dir = temp_cwd("params-vs-list-axes");
let bp = fixture("r_sma_open.json");
let (params_out, params_err, params_code) =
run_in(&dir, &["graph", "introspect", "--params", &bp]);
assert_eq!(params_code, Some(0), "graph introspect --params: {params_out} {params_err}");
let (axes_out, axes_err, axes_code) = run_in(&dir, &["sweep", &bp, "--list-axes"]);
assert_eq!(axes_code, Some(0), "sweep --list-axes: {axes_out} {axes_err}");
assert_eq!(
params_out, axes_out,
"the two discovery surfaces must print byte-identical lines for the same blueprint"
);
}
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
@@ -523,7 +784,10 @@ fn graph_params_by_content_id_matches_params_by_file() {
run_in(&dir, &["graph", "introspect", "--params", &bp]);
assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
assert_eq!(
by_id_out, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"the raw open params, then the raw bound param with its default"
);
}
/// #194 (the prefix trap): a `--params <ID>` target may carry the display
@@ -540,7 +804,10 @@ fn graph_params_tolerates_content_prefix_on_target() {
let (pfx_out, pfx_err, pfx_code) =
run_in(&dir, &["graph", "introspect", "--params", &format!("content:{id}")]);
assert_eq!(pfx_code, Some(0), "content:-prefixed --params must resolve: stdout {pfx_out} stderr {pfx_err}");
assert_eq!(pfx_out, "fast.length:I64\nslow.length:I64\n", "same raw axis namespace as the bare id");
assert_eq!(
pfx_out, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"same raw axis namespace as the bare id"
);
}
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
@@ -647,11 +914,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 +937,147 @@ 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}");
}
/// (#331 spec test 7) The C29 restates-name gate checks against the
/// *authored* name, not a hardcoded default: a `doc` op restating the name
/// set by a `name` op ("ny_momentum") refuses at register exactly like the
/// default-name case above (`graph_register_refuses_an_op_script_doc_that_
/// restates_the_composite_name`, which must stay green byte-identical).
#[test]
fn graph_register_refuses_a_doc_that_restates_an_authored_name() {
let dir = temp_cwd("register-op-script-restated-authored-name-doc");
let restated_script = dir.join("restated-authored-name-doc-script.json");
std::fs::write(&restated_script, SIGNAL_DOC_NAMED_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), "authored-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}");
}
/// (#331 spec test 6) `graph build` on an ops doc with a `name` op emits
/// blueprint JSON carrying the authored name, not the CLI's own seeded
/// default (`"graph"`, `replay("graph", ...)` in `composite_from_str`).
#[test]
fn graph_build_emits_the_authored_name_from_a_name_op() {
let (stdout, stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_NAMED);
assert!(ok, "build succeeds: {stderr}");
assert!(stdout.contains("\"name\":\"ny_momentum\""), "authored name carries into the blueprint: {stdout}");
assert!(!stdout.contains("\"name\":\"graph\""), "the default seed name must not leak in: {stdout}");
}
/// (#331 spec test 8) Refusal prose for a duplicate `name` op and a
/// shape-violating name is op-indexed (`op N (name): ...`) — the same
/// by-identifier labeling every other op fault gets.
#[test]
fn graph_build_name_op_refusals_are_op_indexed() {
let twice_named = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"name","name":"a"},
{"op":"name","name":"b"},
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"out"}
]"#;
let (_out, stderr, ok) = run(&["graph", "build"], twice_named);
assert!(!ok, "a second name op refuses");
assert!(stderr.contains("op 3 (name):"), "op-indexed at the second name op: {stderr}");
let slash_named = r#"[{"op":"name","name":"a/b"}]"#;
let (_out2, stderr2, ok2) = run(&["graph", "build"], slash_named);
assert!(!ok2, "a shape-violating name refuses");
assert!(stderr2.contains("op 0 (name):"), "op-indexed at the offending op: {stderr2}");
}
/// (#331 spec test 9, at the binary seam — mirrors
/// `graph_introspect_identity_id_bridges_renamed_op_scripts`'s pattern for
/// the debug-name field): a named and an unnamed build of the SAME
/// structural script share the identity id (the name is stripped as a
/// debug symbol before identity hashing, C23) and differ in the content id
/// (the name is part of the serialized bytes).
#[test]
fn graph_named_and_unnamed_twins_share_identity_id_and_differ_content_id() {
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC_NAMED);
assert!(ok2, "exit success");
assert_eq!(ia.trim(), ib.trim(), "the name is stripped before identity hashing (C23)");
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC_NAMED);
assert!(ok3 && ok4, "exit success");
assert_ne!(ca.trim(), cb.trim(), "the name is part of the serialized bytes -> different content id");
}
/// (#331 spec test 10, the skeptic's bypass route): the op intake
/// (`GraphSession::set_name`) is not the name's only data-borne birth
/// route — a hand-edited blueprint ENVELOPE (not an op-script) can carry a
/// shape-violating root name directly. `aura graph register` on such a file
/// is refused naming the offending name (exit 1, the refusal convention
/// every sibling test in this module asserts), not silently stored under a
/// name that would write `traces/../x/` at run time. Shipped envelope
/// fixtures with plain root names (`doc_blueprint.json`'s "sig",
/// `nested_doc_blueprint.json`'s "root", every op-script's default "graph")
/// stay accepted — the surrounding suite is that regression gate.
///
/// Spec test 10 also names `build` as a gated envelope-intake route
/// alongside `register`. The `graph build` SUBCOMMAND takes only a stdin
/// op-script (`Vec<OpDoc>`, never a JSON object) — it cannot ingest this
/// envelope fixture at all (a shape mismatch fires before any name is ever
/// read). The file-consuming envelope-intake sibling `build` route is
/// `graph introspect --content-id <FILE>` (its own doc comment: "one
/// build, then each requested id…") — the other call site this iteration
/// moved onto the same `composite_from_authored_text` choke point as
/// `register`, so it is the leg exercised here, sharing the identical
/// `name_gate_fault_prose` text.
#[test]
fn graph_build_and_register_refuse_a_hand_crafted_envelope_with_a_bad_root_name() {
let dir = temp_cwd("register-bad-root-name");
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(built, "graph build produces the envelope");
let bad = envelope_bytes.replacen("\"name\":\"graph\"", "\"name\":\"../x\"", 1);
assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}");
let file = dir.join("bad-root-name.json");
std::fs::write(&file, &bad).expect("write envelope fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", file.to_str().unwrap()]);
assert_eq!(code, Some(1), "a shape-violating root name refuses (not a panic): {out} {err}");
assert!(err.contains("../x"), "names the offending root name: {err}");
// The other file-consuming envelope intake (same fixture, same choke
// point) refuses identically.
let (out2, err2, code2) =
run_in(&dir, &["graph", "introspect", "--content-id", file.to_str().unwrap()]);
assert_eq!(code2, Some(1), "the sibling envelope intake refuses too: {out2} {err2}");
assert_eq!(err2, err, "both file-consuming envelope intakes share the identical fault prose");
}
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
@@ -696,6 +1105,35 @@ fn graph_params_accepts_an_op_script_matching_its_envelope() {
assert_eq!(op_out, env_out, "op-script and envelope agree on the raw axis namespace");
}
/// (#331 review finding, the C29 exemption's ratifying pin): the root-name
/// shape gate lives at the CLI's file-consuming envelope intakes (`register`,
/// `introspect --content-id <FILE>`) — never at STORE read-back. A blueprint
/// with a shape-violating root name registered DIRECTLY through the
/// `aura-registry` store API (bypassing the CLI's gate entirely, the way a
/// pre-#331 artifact would already sit in a project's store) must still be
/// introspectable: `introspect --params <ID>` reads the store, not a file, so
/// it must never re-punish an artifact the store already accepted — C29,
/// "registered artifacts are never retroactively invalidated".
#[test]
fn graph_introspect_params_accepts_a_pre_331_store_artifact_with_a_bad_root_name() {
let dir = temp_cwd("introspect-store-readback-bad-root-name");
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(built, "graph build produces the envelope");
let bad = envelope_bytes.replacen("\"name\":\"graph\"", "\"name\":\"../x\"", 1);
assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}");
// Bypass the CLI's gate entirely: the registry's own store API is the
// simulated pre-#331 write path.
let id = aura_research::content_id_of(&bad);
let registry = aura_registry::Registry::open(dir.join("runs/runs.jsonl"));
registry
.put_blueprint(&id, &bad)
.expect("the registry's own store API has no root-name gate to bypass");
let (out, err, code) = run_in(&dir, &["graph", "introspect", "--params", &id]);
assert_eq!(code, Some(0), "store read-back stays ungated (C29): {out} {err}");
}
/// Property (#226, the render positional closes the #202 on-ramp family):
/// `aura graph <op-script.json>` (the render positional) accepts an op-script
/// document (a JSON array) exactly as it accepts a #155 blueprint envelope (a
@@ -741,7 +1179,10 @@ fn shipped_r_sma_example_is_genuinely_closed() {
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
assert_eq!(
stdout, "fast.length:I64 default=2\nslow.length:I64 default=4\nbias.scale:F64 default=0.5\n",
"zero OPEN params — every knob is now printed as a raw bound default instead"
);
}
/// Property (#159): the open fixture (`tests/fixtures/r_sma_open.json`) is a
@@ -759,8 +1200,8 @@ fn open_r_sma_fixture_lists_its_axis_namespace() {
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "fast.length:I64\nslow.length:I64\n",
"the raw open params, in order, from the public gallery copy"
stdout, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"the raw open params, then the raw bound param, from the public gallery copy"
);
}
@@ -797,12 +1238,17 @@ fn shipped_r_breakout_example_is_genuinely_closed() {
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
assert_eq!(
stdout,
"delay.lag:I64 default=1\nchannel_hi.length:I64 default=3\nchannel_lo.length:I64 default=3\n",
"zero OPEN params — every knob is now printed as a raw bound default instead"
);
}
/// Property (#159 cut 2): the open fixture (`tests/fixtures/r_breakout_open.json`)
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
/// one knob).
/// one knob) followed by the still-bound `delay.lag` default (#328: bound params
/// are listed too, line-identical to `--list-axes`).
#[test]
fn open_r_breakout_fixture_lists_its_axis_namespace() {
let dir = temp_cwd("r-breakout-example-open-params");
@@ -810,8 +1256,9 @@ fn open_r_breakout_fixture_lists_its_axis_namespace() {
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_breakout_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "channel_length:I64\n",
"the ganged channel knob — one public axis for the two rolling windows (#61)"
stdout, "channel_length:I64\ndelay.lag:I64 default=1\n",
"the ganged channel knob — one public axis for the two rolling windows (#61)\
then the still-bound delay.lag default"
);
}
@@ -840,7 +1287,7 @@ fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() {
let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&["sweep", &fixture("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
&["sweep", &fixture("r_breakout_open.json"), "--axis", "channel_length=3"],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
@@ -862,7 +1309,11 @@ fn shipped_r_meanrev_example_is_genuinely_closed() {
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
assert_eq!(
stdout,
"mean_window.length:I64 default=3\nvar_window.length:I64 default=3\nband.factor:F64 default=2\n",
"zero OPEN params — every knob is now printed as a raw bound default instead"
);
}
/// Property (#159 cut 3): the open fixture (`tests/fixtures/r_meanrev_open.json`)
@@ -900,8 +1351,8 @@ fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() {
&sweep_dir,
&[
"sweep", &fixture("r_meanrev_open.json"),
"--axis", "r_meanrev_signal.window=3",
"--axis", "r_meanrev_signal.band.factor=2.0",
"--axis", "window=3",
"--axis", "band.factor=2.0",
],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
@@ -1157,6 +1608,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` ->
@@ -1398,3 +1869,300 @@ fn tap_authored_via_op_script_runs_and_persists_the_series() {
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
}
}
// ---- `use` / label sidecar / open patterns (#317) ------------------------
/// A small reusable pattern: an open `x` input role feeds an `SMA`, whose
/// value re-exports as `out` — the fixture every `use`-seam e2e test below
/// registers under a label. `sma.length` stays UNBOUND: the pattern's own
/// open param, so a consumer's `--list-axes` sees the bare (unbound) form.
const OPEN_PATTERN_DOC: &str = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
/// Build `doc` via `aura graph build` in `dir` and write the resulting
/// envelope to `dir/<stem>.bp.json`, returning its path.
fn build_envelope_in(dir: &std::path::Path, doc: &str, stem: &str) -> std::path::PathBuf {
let (bytes, _e, ok) = run(&["graph", "build"], doc);
assert!(ok, "the fixture document builds: {doc}");
let path = dir.join(format!("{stem}.bp.json"));
std::fs::write(&path, &bytes).expect("write built envelope");
path
}
/// `graph register --name` (#317): the label sidecar echo on first
/// registration, and the repoint echo (`(was <old-id>)`) on a second
/// registration of DIFFERENT bytes under the SAME label.
#[test]
fn graph_register_name_labels_and_repoints() {
let dir = temp_cwd("register-name");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (out1, err1, code1) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(code1, Some(0), "first register --name: {out1} {err1}");
let id1 = registered_id(&out1);
assert!(
out1.lines().any(|l| l == format!("label \"smooth\" -> {id1}")),
"the plain label echo (no repoint on a fresh label): {out1}"
);
// Different bytes (a bound sma.length), same label -> a repoint.
let doc2 = OPEN_PATTERN_DOC.replace(
r#"{"op":"add","type":"SMA","name":"sma"}"#,
r#"{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":7}}}"#,
);
let bp2 = build_envelope_in(&dir, &doc2, "pattern2");
let (out2, err2, code2) =
run_in(&dir, &["graph", "register", bp2.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(code2, Some(0), "second register --name: {out2} {err2}");
let id2 = registered_id(&out2);
assert_ne!(id1, id2, "the two registrations are distinct content");
assert!(
out2.lines().any(|l| l == format!("label \"smooth\" -> {id2} (was {id1})")),
"the repoint echo names both ids: {out2}"
);
}
/// `use` resolves a registry label (#317): the resolution echo lands on
/// STDERR (`aura: note: use "<instance>": <label> -> <full id>`), stdout
/// stays clean payload — the existing e2e convention this cycle extends.
#[test]
fn graph_build_use_resolves_a_label_and_echoes_the_id() {
let dir = temp_cwd("use-resolves-label");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (reg_out, reg_err, reg_code) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let mut child = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura graph build");
child.stdin.take().unwrap().write_all(consumer.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura graph build");
assert!(out.status.success(), "build succeeds: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
stderr.trim(),
format!("aura: note: use \"trend\": smooth -> {id}"),
"the resolution echo names the instance, the label, and the full id: {stderr}"
);
assert!(stdout.starts_with('{'), "stdout stays clean blueprint payload: {stdout}");
assert!(!stdout.contains("aura: note:"), "the note never leaks onto stdout: {stdout}");
assert!(
stdout.contains(&format!("\"doc\":\"{}\"", "a reusable smoothing pattern")),
"the spliced instance's own doc survives inline: {stdout}"
);
}
/// An unknown `use` label enumerates every registered label (C29's "name the
/// closed set" idiom) and refuses at exit 1 (op-list content fault, #175).
#[test]
fn graph_build_use_unknown_label_enumerates_labels_exit_1() {
let dir = temp_cwd("use-unknown-label");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (reg_out, reg_err, reg_code) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"nope"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let out = std::process::Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
child.wait_with_output()
})
.expect("run aura graph build");
assert_eq!(out.status.code(), Some(1), "unknown label is a content fault -> exit 1");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stdout.is_empty(), "no blueprint emitted on refusal: {stdout}");
assert!(stderr.contains("op 1 (use \"trend\")"), "names the op by index and instance: {stderr}");
assert!(stderr.contains("no registered blueprint labeled \"nope\""), "names the miss: {stderr}");
assert!(stderr.contains("registered labels: smooth"), "enumerates the closed set: {stderr}");
}
/// The empty-store form of the unknown-label refusal: no labels to
/// enumerate reads as prose, not a bare empty list.
#[test]
fn graph_build_use_unknown_label_empty_store_reads_as_prose() {
let dir = temp_cwd("use-unknown-label-empty-store");
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"nope"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let (stderr, code) = run_code_in(&dir, &["graph", "build"], consumer);
assert_eq!(code, Some(1), "still a content fault -> exit 1");
assert!(
stderr.contains("no registered blueprint labeled \"nope\" — no labels registered"),
"the empty-store form reads as prose: {stderr}"
);
}
/// C29 at the `use` seam (#317): a doc-less registered blueprint refuses
/// entering a NEW composition, naming the ref's id-prefix, the failing
/// (here root, "nested" from the consumer's view) composite, and the rule —
/// exit 1. The doc-less entry reaches the store via the RAW file write
/// existing pre-C29 fixtures use (`register_refuses_undescribed_composites_
/// end_to_end`'s technique): `graph register` itself always C29-gates, so a
/// doc-less entry can only reach the store by writing the file directly.
#[test]
fn graph_build_use_docless_source_refuses_with_the_c29_shape_exit_1() {
let dir = temp_cwd("use-docless-source");
// A doc-less pattern: same shape as OPEN_PATTERN_DOC minus the `doc` op.
let docless = r#"[
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":3}}},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let (envelope, _e, built) = run(&["graph", "build"], docless);
assert!(built, "the doc-less pattern still builds (C29 gates register/use, not build)");
let (id_out, _e2, id_ok) = run(&["graph", "introspect", "--content-id"], docless);
assert!(id_ok, "content-id computes without a doc");
let id = id_out.trim().to_string();
let store_dir = dir.join("runs").join("blueprints");
std::fs::create_dir_all(&store_dir).expect("create store dir");
std::fs::write(store_dir.join(format!("{id}.json")), &envelope).expect("raw store write");
let consumer = format!(
r#"[
{{"op":"source","role":"price","kind":"F64"}},
{{"op":"use","ref":{{"content_id":"{id}"}},"name":"gate"}},
{{"op":"feed","role":"price","into":["gate.x"]}},
{{"op":"expose","from":"gate.out","as":"bias"}}
]"#
);
let (stderr, code) = run_code_in(&dir, &["graph", "build"], &consumer);
assert_eq!(code, Some(1), "a doc-less fetched composite refuses -> exit 1: {stderr}");
assert!(stderr.contains("op 1 (use \"gate\")"), "names the op by index and instance: {stderr}");
assert!(stderr.contains(&id[..12]), "names the ref's id-prefix: {stderr}");
assert!(stderr.contains("fails the description gate"), "names the rule: {stderr}");
assert!(
stderr.contains("has no description") && stderr.contains("re-register it with a \"doc\" op"),
"carries the re-register hint: {stderr}"
);
assert!(stderr.contains("C29"), "cites the contract: {stderr}");
}
/// The worked acceptance flow's last leg (spec §Concrete code shapes): a
/// pattern registered with an open param splices under an instance, and
/// `aura sweep --list-axes` on the CONSUMER shows the path-qualified bare
/// (unbound) axis `graph.<instance>.<node>.<param>:<kind>` — the existing
/// nested-composite param-prefix discipline, reached through `use` this
/// time, not a Rust-authored nested composite.
#[test]
fn graph_build_use_end_to_end_axes() {
let dir = temp_cwd("use-end-to-end-axes");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (reg_out, reg_err, reg_code) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let consumer_path = dir.join("consumer.ops.json");
std::fs::write(&consumer_path, consumer).expect("write consumer fixture");
let build = std::process::Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
child.wait_with_output()
})
.expect("run aura graph build");
assert!(build.status.success(), "consumer builds: {}", String::from_utf8_lossy(&build.stderr));
let consumer_bp = dir.join("consumer.bp.json");
std::fs::write(&consumer_bp, &build.stdout).expect("write consumer envelope");
let (axes_out, axes_err, axes_code) =
run_in(&dir, &["sweep", consumer_bp.to_str().unwrap(), "--list-axes"]);
assert_eq!(axes_code, Some(0), "list-axes: {axes_out} {axes_err}");
assert_eq!(
axes_out, "trend.sma.length:I64\n",
"the spliced instance's open param surfaces path-qualified, bare (unbound) RAW form (#328)"
);
}
/// Open patterns (#317 §"Open patterns", acceptance criterion 6): an
/// `input`-role op-script — no bound root role at all — builds cleanly:
/// `finish()` no longer gates root-role boundness, only `compile`/bootstrap
/// do. The agree flow's first half (register is exercised by the other
/// `use`-seam tests above; this pins the build step alone).
#[test]
fn graph_build_accepts_an_open_input_pattern() {
let (stdout, stderr, ok) = run(&["graph", "build"], OPEN_PATTERN_DOC);
assert!(ok, "an input-role pattern builds: {stderr}");
assert!(stdout.contains("\"input_roles\""), "carries the open root role: {stdout}");
assert!(!stdout.contains("\"source\""), "the role carries no bound kind (open, #317): {stdout}");
}
/// Open patterns, the OTHER half: running an open blueprint standalone
/// still refuses — the runnability gate moved nowhere, only `finish()`
/// stopped pre-empting it (#317). `aura run`'s `bias`-output arm re-roots
/// through `wrap_r` (an existing, unrelated nesting mechanism unaffected by
/// this cycle), so a bare-tap (no-`bias`) pattern is used here: its
/// `run_measurement` path compiles the signal directly, hitting
/// `CompileError::UnboundRootRole` at bootstrap — via the EXISTING `{e:?}`
/// Debug rendering (a raw index, not a role name); a name mapping there is
/// left for a follow-up (out of this cycle's scope, per the plan's own
/// escape hatch), so this pins the existing form.
#[test]
fn running_an_open_blueprint_refuses_at_bootstrap() {
let doc = r#"[
{"op":"input","role":"price"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"tap","from":"fast.value","as":"fast_ma"}
]"#;
let dir = temp_cwd("open-pattern-run-refuses");
let (build_out, _e, built) = run(&["graph", "build"], doc);
assert!(built, "an open (Input) root role finishes without root-role boundness (#317)");
let bp = dir.join("open.bp.json");
std::fs::write(&bp, &build_out).expect("write built blueprint");
let (stdout, stderr, code) = run_in(&dir, &["run", bp.to_str().unwrap()]);
assert_eq!(code, Some(1), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}");
assert!(stdout.is_empty(), "no report emitted on a bootstrap refusal: {stdout}");
assert!(
stderr.contains("UnboundRootRole"),
"the runnability gate (compile), unchanged, names the fault: {stderr}"
);
}
@@ -0,0 +1,249 @@
//! End-to-end pins for the self-description surfaces (#315, #323): the help
//! opens with the two-layer concept, the sugar verbs name the document shape
//! they desugar to, every introspection roster carries per-entry meanings,
//! and `graph build --help` carries the op-list reference. Driven over the
//! built binary — the zero-setup surface a reader without repo access gets.
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// Run `aura <args>` (no stdin); return (stdout, stderr, success).
fn run(args: &[&str]) -> (String, String, bool) {
let out = Command::new(BIN).args(args).output().expect("spawn aura");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
/// #315: `aura --help` opens with the concepts paragraph — the two layers,
/// the bias-as-target-position + protective-stop execution model, and how
/// traces come to exist. Each pin sits on one authored line of the explicit
/// `long_about` string, so clap's wrapping cannot split it.
#[test]
fn top_level_help_opens_with_the_two_layer_concept() {
let (out, err, ok) = run(&["--help"]);
assert!(ok, "aura --help failed: {err}");
assert!(out.contains("research verbs"), "names the sugar layer: {out}");
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
assert!(out.contains("introspect --unwired"), "names the bare-{{}} ramp: {out}");
assert!(out.contains("aura chart"), "names the trace consumers: {out}");
}
/// #315: each document-bridged sugar verb's long help names the process
/// shape it desugars to and points at the document layer. `run` (not
/// document-bridged) points at the canonical document-first form instead.
#[test]
fn sugar_verbs_name_their_document_shape() {
for (verb, needle) in [
("sweep", "std::sweep"),
("walkforward", "std::walk_forward"),
("mc", "std::monte_carlo"),
("generalize", "std::generalize"),
] {
let (out, err, ok) = run(&[verb, "--help"]);
assert!(ok, "aura {verb} --help failed: {err}");
assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}");
assert!(out.contains(needle), "{verb} --help names {needle}: {out}");
assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}");
}
let (out, err, ok) = run(&["run", "--help"]);
assert!(ok, "aura run --help failed: {err}");
assert!(out.contains("document-first"), "run --help names the canonical form: {out}");
}
/// #323: `graph build --help` carries the op-list reference — the op kinds
/// and fields are learnable from the binary, not only from serde refusals.
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
/// joins the roster (ten -> eleven).
#[test]
fn graph_build_help_carries_the_op_reference() {
let (out, err, ok) = run(&["graph", "build", "--help"]);
assert!(ok, "aura graph build --help failed: {err}");
for op in [
r#"{"op":"source""#,
r#"{"op":"input""#,
r#"{"op":"add""#,
r#"{"op":"feed""#,
r#"{"op":"connect""#,
r#"{"op":"expose""#,
r#"{"op":"tap""#,
r#"{"op":"gang""#,
r#"{"op":"doc""#,
r#"{"op":"use""#,
r#"{"op":"name""#,
] {
assert!(out.contains(op), "op reference carries {op}: {out}");
}
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
}
/// #317: `graph introspect --registered` lists every registry label — row
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
/// as prose, not a blank/garbled listing.
#[test]
fn graph_introspect_registered_lists_labels() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
// Empty store: prose, not a blank listing, exit 0.
let out_empty = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out_empty.status.success(), "empty store still exits 0");
assert_eq!(
String::from_utf8_lossy(&out_empty.stdout),
"no labels registered\n",
"the empty-store form reads as prose"
);
// Register + label a described open pattern, then list it.
let pattern = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let bp_path = dir.join("pattern.ops.json");
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
let built = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
child.wait_with_output()
})
.expect("build the pattern");
assert!(built.status.success(), "the pattern builds");
let envelope = dir.join("pattern.bp.json");
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
let reg = Command::new(BIN)
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
.current_dir(&dir)
.output()
.expect("spawn aura register");
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(&reg.stderr));
let out = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out.status.success(), "listing succeeds");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
let fields: Vec<&str> = row.split_whitespace().collect();
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
}
/// #315: the node vocabulary listing carries each type's one-line meaning —
/// no more bare names that teach nothing.
#[test]
fn graph_introspect_vocabulary_carries_meanings() {
let (out, err, ok) = run(&["graph", "introspect", "--vocabulary"]);
assert!(ok, "vocabulary listing failed: {err}");
let sma = out
.lines()
.find(|l| l.split_whitespace().next() == Some("SMA"))
.unwrap_or_else(|| panic!("no SMA line: {out}"));
assert!(sma.contains("moving average"), "SMA line carries its meaning: {sma}");
for l in out.lines().filter(|l| !l.trim().is_empty()) {
assert!(l.split_whitespace().count() >= 2, "bare name without meaning: {l}");
}
}
/// #315: `--node <T>`'s head line carries the type's meaning beside its label.
#[test]
fn graph_introspect_node_head_line_carries_the_meaning() {
let (out, err, ok) = run(&["graph", "introspect", "--node", "SMA"]);
assert!(ok, "node introspect failed: {err}");
let head = out.lines().next().unwrap_or("");
assert!(head.contains("moving average"), "head line carries the meaning: {out}");
}
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
/// `FoldKind` table — lowercase, parseable labels, including the `record`
/// entry that has no `FoldKind` counterpart. Previously this surface printed
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
/// text's own discovery surface described input `--tap` refused.
#[test]
fn graph_introspect_folds_lists_the_fold_registry_roster() {
let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
assert!(ok, "folds listing failed: {err}");
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
assert!(
!out.split_whitespace().any(|w| w == "Mean"),
"no capitalized FoldKind id leaks through: {out}"
);
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
assert!(record.contains("series"), "record row meaning: {record}");
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
}
/// #315: the metric roster carries each metric's one-line meaning beside its
/// applicability tags.
#[test]
fn process_introspect_metrics_carries_meanings() {
let (out, err, ok) = run(&["process", "introspect", "--metrics"]);
assert!(ok, "metric roster failed: {err}");
let er = out
.lines()
.find(|l| l.split_whitespace().next() == Some("expectancy_r"))
.unwrap_or_else(|| panic!("no expectancy_r line: {out}"));
assert!(er.contains("mean realized R"), "meaning text on the roster line: {er}");
for l in out.lines().filter(|l| !l.trim().is_empty()) {
assert!(l.contains(""), "roster line without meaning: {l}");
}
}
/// #315: the persisted-tap vocabulary's meanings are readable where the taps
/// are advertised — under the `std::presentation` block's slot listing.
#[test]
fn campaign_introspect_block_presentation_lists_tap_meanings() {
let (out, err, ok) = run(&["campaign", "introspect", "--block", "std::presentation"]);
assert!(ok, "presentation describe failed: {err}");
assert!(out.contains("cumulative pip equity"), "equity tap meaning: {out}");
assert!(out.contains("after the cost model"), "net_r_equity tap meaning: {out}");
}
/// #323: `introspect --unwired` enumerates the optional C29 `description`
/// slot for both document kinds — the authoring side of the gate is
/// advertised where every other slot is.
#[test]
fn introspect_unwired_lists_the_description_slot() {
let dir = std::env::temp_dir().join(format!("aura-selfdesc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bare = dir.join("bare.json");
std::fs::write(&bare, "{}").unwrap();
for family in ["process", "campaign"] {
let (out, err, ok) = run(&[family, "introspect", "--unwired", bare.to_str().unwrap()]);
assert!(ok, "{family} --unwired failed: {err}");
assert!(
out.contains("open slot: description"),
"{family} --unwired lists the description slot: {out}"
);
assert!(out.contains("C29-gated"), "{family} hint names the gate: {out}");
}
let _ = std::fs::remove_dir_all(&dir);
}
+82 -2
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)
@@ -109,9 +117,9 @@ fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
"proj-anchor",
])
@@ -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) {
+2 -2
View File
@@ -135,7 +135,7 @@ fn data_only_project_sweeps_without_any_build() {
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4"],
&proj,
);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
@@ -157,7 +157,7 @@ fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4,8"],
&proj,
);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
+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:?}")
});
}
+14 -12
View File
@@ -37,7 +37,7 @@ const GER40_TO_MS: &str = "1727740799999";
/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab`
/// vocabulary namespace — `Scale` is the starter node every node-crate
/// scaffold emits (#183).
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","doc":"fast/slow SMA bias scaled by an extension gain node","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
/// The minimal executable process pipeline (one sweep stage) — the campaign
/// cell's methodology reference.
@@ -160,8 +160,8 @@ fn project_node_open_param_sweeps_and_reproduces() {
// the fixture's three BOUND params as `default=`-lines (#246: every bound
// param is an equally re-openable `--axis`, listed regardless of how many
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
// no data, so the discoverability half runs on every host. The wrapped
// name is `<blueprint>.<node>.<param>`.
// no data, so the discoverability half runs on every host. The name is RAW
// `<node>.<param>` (#328: the blueprint name stays out of axis paths).
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
assert!(
axes.status.success(),
@@ -170,10 +170,10 @@ fn project_node_open_param_sweeps_and_reproduces() {
);
assert_eq!(
String::from_utf8_lossy(&axes.stdout),
"scaled_signal.gain.factor:F64\n\
scaled_signal.fast.length:I64 default=2\n\
scaled_signal.slow.length:I64 default=4\n\
scaled_signal.bias.scale:F64 default=0.5\n",
"gain.factor:F64\n\
fast.length:I64 default=2\n\
slow.length:I64 default=4\n\
bias.scale:F64 default=0.5\n",
"the project node's own OPEN param is the one open axis, alongside the \
fixture's bound defaults; stderr: {}",
String::from_utf8_lossy(&axes.stderr)
@@ -198,7 +198,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
"--to",
GER40_TO_MS,
"--axis",
"scaled_signal.gain.factor=0.5,1.0",
"gain.factor=0.5,1.0",
"--name",
"knob",
],
@@ -212,9 +212,11 @@ fn project_node_open_param_sweeps_and_reproduces() {
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
// Each member's manifest carries the swept PROJECT-node param binding under
// its wrapped name — the load-bearing proof that it is MY node's knob that
// varied across the family, not some incidental std axis.
// Each member's manifest carries the swept PROJECT-node param binding
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
// real/campaign route's own `manifest.params` mints raw too now) — the
// load-bearing proof that it is MY node's knob that varied across the
// family, not some incidental std axis.
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
assert_eq!(
@@ -227,7 +229,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
.expect("manifest.params is an array");
let bound = params
.iter()
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
.find(|p| p[0].as_str() == Some("gain.factor"))
.and_then(|p| p[1]["F64"].as_f64());
assert_eq!(
bound,
+127 -20
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}");
@@ -328,9 +329,9 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
"campaign-ref-seed",
],
@@ -376,10 +377,11 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
.trim_start_matches("content:")
.to_string();
// "fast.length": the axis name is the RAW composite's `param_space` name.
// "fast.length": the axis name is the RAW composite's `param_space` name
// the SAME namespace `aura sweep --axis` itself binds against now (#328).
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
// "sma_signal." prefix `aura sweep --axis` binds against.
// sweep's `wrap_r`-wrapped axis probe, so it never carries a wrap prefix
// to begin with.
let campaign = format!(
r#"{{
"format_version": 1,
@@ -447,9 +449,9 @@ fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
"campaign-identity-seed",
],
@@ -596,6 +598,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");
@@ -834,13 +872,14 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
}
/// #216/#234: bound (non-empty) risk AND cost lists close both optional
/// slots — a complete document with both reports no open slots at all.
/// slots — with the optional C29 description also present (#323), a complete
/// document reports no open slots at all.
#[test]
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
let with_both = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
"\"seed\": 1,\n \"description\": \"a screen with bound risk and cost\",\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
1,
);
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
@@ -958,7 +997,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
write_doc(&dir, "draft.campaign.json", draft);
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
assert_eq!(code, Some(0));
assert!(out.contains("open slot: process.ref (required, content id of a process document)"));
assert!(out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"));
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
}
@@ -984,7 +1023,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
"strategy `ref: null` placeholder not enumerated: {out}"
);
assert!(
out.contains("open slot: process.ref (required, content id of a process document)"),
out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"),
"process `ref: {{}}` placeholder not enumerated: {out}"
);
}
@@ -1031,7 +1070,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 +1206,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 +1228,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 +1256,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 } } ],",
@@ -1246,9 +1289,9 @@ fn seed_blueprint(dir: &Path, name: &str) -> String {
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
name,
],
@@ -1411,6 +1454,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 +1993,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 +3749,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 +3852,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}"
);
+82
View File
@@ -56,3 +56,85 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
assert_eq!(trace["tap"], "fast_tap");
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
}
/// #310: the measurement arm honours the `--tap` selector — a `last`
/// fold lands one summary row instead of the full series.
#[test]
fn measurement_run_honours_the_tap_selector() {
let cwd = temp_cwd("selector-last");
let bp_path = cwd.join("measurement.json");
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
let got = rows[0].as_f64().expect("f64 row");
let expected = (1.0097_f64 + 1.0092) / 2.0;
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
}
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
/// normalizes there) instead of refusing at the same root-name choke point
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
/// property that actually matters — the escaped directory is never created.
#[test]
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
let cwd = temp_cwd("bad-root-name");
let mut v: serde_json::Value =
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
v["blueprint"]["name"] = serde_json::json!("../x");
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
let bp_path = cwd.join("bad-root-name.json");
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(
out.status.code(),
Some(2),
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(r#"blueprint name "../x" is invalid"#),
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
);
assert!(
out.stdout.is_empty(),
"a refused run must not print a report: {:?}",
String::from_utf8_lossy(&out.stdout)
);
// The property that actually matters: the escaped write target (one level
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
// was never created — the gate fires before `begin_run` ever touches disk.
assert!(
!cwd.join("runs/x").exists(),
"the escaped trace directory must never be created"
);
assert!(
!cwd.join("runs/traces").exists(),
"no trace directory of any shape is written on a refused run"
);
}
+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));
}
}
+270
View File
@@ -124,6 +124,21 @@ fn duplicate_tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
}
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
/// plan and the record-all default can be compared file-by-file (#310).
fn two_tap_blueprint_json() -> String {
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
v["blueprint"]["taps"] = serde_json::json!([
{"name": "fast_tap", "from": {"node": 0, "field": 0}},
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
]);
serde_json::to_string(&v).expect("re-serialize two-tap blueprint")
}
/// Property: **a blueprint declaring two taps under the same name is refused
/// on the single-run path with a named error and exit code 1, before any
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
@@ -150,3 +165,258 @@ 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");
}
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
/// fold instead of the record-all default — the trace store holds ONE
/// summary row whose value is the mean of the full SMA(2) series, and
/// `index.json` lists exactly the subscribed tap.
#[test]
fn run_tap_selector_persists_a_fold_summary_row() {
let cwd = temp_cwd("tap-selector-mean");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
assert_eq!(trace["tap"], "fast_tap");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
.collect();
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
let got = rows[0].as_f64().expect("f64 row");
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
}
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
/// record-all default — the selector replaces the default without
/// changing what `record` itself writes (C1 determinism across runs).
#[test]
fn run_explicit_record_plan_matches_the_record_all_default() {
let cwd_a = temp_cwd("record-default");
let cwd_b = temp_cwd("record-explicit");
for cwd in [&cwd_a, &cwd_b] {
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
.expect("write two-tap blueprint");
}
let run = |cwd: &std::path::Path, extra: &[&str]| {
let mut args = vec!["run", "two_tap.json"];
args.extend_from_slice(extra);
let out = Command::new(BIN)
.args(&args)
.current_dir(cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
};
run(&cwd_a, &[]);
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
for file in ["fast_tap.json", "slow_tap.json"] {
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
}
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
};
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
}
/// #310: an unknown fold label is refused through the registry's
/// roster-enumerating refusal, before any store write.
#[test]
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
let cwd = temp_cwd("tap-selector-unknown-label");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unknown fold label must refuse");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("medain") && stderr.contains("mean"),
"refusal must name the label and enumerate the roster: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #310/#333: a selection naming a tap the blueprint does not declare is
/// refused typed (UndeclaredTap), before any store write, and the refusal
/// enumerates the blueprint's declared taps (the same way the unknown-fold
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
/// both `fast_tap` and `slow_tap` must be named so the author does not have
/// to reopen the blueprint JSON.
#[test]
fn run_tap_selector_refuses_an_undeclared_tap() {
let cwd = temp_cwd("tap-selector-undeclared");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an undeclared tap must refuse");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
assert!(
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
"refusal must enumerate the blueprint's declared taps: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
/// gets no such note; `slow_tap` (left unlisted) does.
#[test]
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
let cwd = temp_cwd("tap-selector-unbound-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
"stderr must note the unbound declared tap: {stderr}"
);
assert!(
!stderr.contains("\"fast_tap\" unbound"),
"the subscribed tap must not be noted as unbound: {stderr}"
);
}
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
/// unbound — nothing is skipped, so the note must never appear.
#[test]
fn run_with_no_tap_flag_emits_no_unbound_note() {
let cwd = temp_cwd("tap-selector-default-no-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("unbound this run"),
"the record-all default must never note a skipped tap: {stderr}"
);
}
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
/// before anything runs.
#[test]
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
let cwd = temp_cwd("tap-selector-usage");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
for args in [
vec!["--tap", "fast_tapmean"],
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
] {
let mut full = vec!["run", bp_path.to_str().expect("utf-8 path")];
full.extend(args);
let out = Command::new(BIN)
.args(&full)
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
assert!(!cwd.join("runs").exists());
}
}
+3 -3
View File
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
let scale = g.add(match knobs {
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::builder(1).named("stop_k"),
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::configured(1).named("stop_k"),
});
g.feed(price, [delay.input("series"), sub.input("lhs")]);
g.connect(delay.output("value"), sub.input("rhs"));
@@ -174,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
let entry = g.input_role("entry_price");
let stop = g.input_role("stop_price");
let agg = g.add(CostSum::builder(n));
let agg = g.add(CostSum::configured(n));
// Per-role geometry fan targets, collected across all nodes, fed once per role.
let mut closed_t = Vec::with_capacity(n);
@@ -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)] }),
)
+4
View File
@@ -7,6 +7,10 @@ publish.workspace = true
[dependencies]
serde = { workspace = true }
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
# already a workspace dependency of four other crates (see docs/specs/
# construction-args.md § Dependency note).
chrono-tz = { version = "0.10", default-features = false }
[dev-dependencies]
serde_json = { workspace = true }
+2 -1
View File
@@ -44,7 +44,8 @@ pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use node::{
zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder,
};
pub use scalar::{Scalar, ScalarKind, Timestamp};
+469 -5
View File
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
instance_name: Option<String>,
schema: NodeSchema,
bound: Vec<BoundParam>,
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
schema: NodeSchema,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
}
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
/// not yet constructible. `schema` carries only the doc line (empty
/// inputs/output/params — the real signature forms inside `make`, once
/// `try_args` has parsed values to hand it); `build` on a pending builder
/// panics (the `bind` panic-contract twin) — the data path always goes
/// through `try_args` first, so no pending builder ever reaches `build`.
pub fn pending(
name: &'static str,
doc: &'static str,
specs: &'static [ArgSpec],
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
) -> Self {
Self {
name,
instance_name: None,
schema: NodeSchema { doc, ..Default::default() },
bound: Vec::new(),
args: ArgsState::Pending { specs, make },
build: Box::new(move |_| {
panic!(
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
call try_args first"
)
}),
}
}
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
/// configured builder (`args: Configured{..}`) with this builder's
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
/// (uniform op application, #157's try_bind precedent); `Plain` +
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
/// first unknown arg name, then a duplicate, then the first spec-order
/// missing arg, then per-kind parse failures (`BadValue`).
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
let (specs, make) = match &self.args {
ArgsState::Plain | ArgsState::Configured { .. } => {
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
}
ArgsState::Pending { specs, make } => (*specs, *make),
};
for (name, _) in raw {
if !specs.iter().any(|s| s.name == name.as_str()) {
return Err(ArgOpError::UnknownArg(name.clone()));
}
}
for i in 0..raw.len() {
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
}
}
for spec in specs {
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
return Err(ArgOpError::MissingArg(spec.name.to_string()));
}
}
let mut values = Vec::with_capacity(specs.len());
let mut accepted = Vec::with_capacity(specs.len());
for spec in specs {
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
arg: spec.name.to_string(),
kind: spec.kind,
got: raw_val.clone(),
})?;
values.push((spec.name.to_string(), value));
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
}
let mut built = make(&values);
built.instance_name = self.instance_name;
built.args = ArgsState::Configured { values: accepted };
Ok(built)
}
/// The declared construction-arg specs (a view into the pending recipe);
/// empty unless this builder `is_pending()`.
pub fn arg_specs(&self) -> &[ArgSpec] {
match &self.args {
ArgsState::Pending { specs, .. } => specs,
_ => &[],
}
}
/// The accepted, verbatim construction-arg pairs (the serialize surface);
/// empty unless this builder was configured via `try_args`.
pub fn construction_args(&self) -> &[ConstructionArg] {
match &self.args {
ArgsState::Configured { values } => values,
_ => &[],
}
}
/// True for an arg-bearing recipe not yet configured via `try_args`.
pub fn is_pending(&self) -> bool {
matches!(self.args, ArgsState::Pending { .. })
}
/// Set this node instance's explicit name. Must be non-empty: the name forms
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
/// panic contract (and its pinned messages); `try_bind` reports the same three
/// conditions as values.
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
/// follow-up) does, refusing a ganged member's raw path at the use seam
/// before it silently de-fuses the gang.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BindOpError {
/// No still-open param has this name.
@@ -333,6 +438,141 @@ pub enum BindOpError {
AmbiguousParam(String),
/// The value's kind does not equal the param's declared kind.
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
/// `param` (the full qualified path) is a member of the gang named
/// `gang` — binding it directly would freeze that one member while the
/// gang's public knob keeps driving its siblings. Bind the gang's own
/// public knob instead (accepted residue: unbindable at the use seam,
/// #317).
AlreadyGanged { param: String, gang: String },
}
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
/// are bootstrap metadata, never streamed, so the two closedness axes stay
/// separate types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArgKind {
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
Tz,
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
TimeOfDay,
/// A plain positive decimal count (>= 1, no leading zeros).
Count,
}
impl ArgKind {
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
/// the canonical form (spec §Closedness) — there is no normalization
/// layer, so a near-miss string refuses rather than being silently
/// rewritten (content ids stay input-variance-free by refusal).
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
// no information of its own — `try_args` is the only caller and always maps
// it to `ArgOpError::BadValue`, which is where the real detail lives.
#[allow(clippy::result_unit_err)]
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
match self {
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
ArgKind::TimeOfDay => {
let bytes = raw.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return Err(());
}
let (h, m) = (&raw[0..2], &raw[3..5]);
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
let hour: u32 = h.parse().map_err(|_| ())?;
let minute: u32 = m.parse().map_err(|_| ())?;
if hour > 23 || minute > 59 {
return Err(());
}
Ok(ArgValue::TimeOfDay { hour, minute })
}
ArgKind::Count => {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
if raw.len() > 1 && raw.starts_with('0') {
return Err(()); // leading zero: not the canonical form
}
let n: usize = raw.parse().map_err(|_| ())?;
if n == 0 {
return Err(()); // Count is >= 1
}
Ok(ArgValue::Count(n))
}
}
}
/// The static, per-kind hint line (introspection surface, C29) — a closed
/// table keyed only by `ArgKind`, never per-entry freetext.
pub fn hint(&self) -> &'static str {
match self {
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
ArgKind::TimeOfDay => "local wall-clock HH:MM",
ArgKind::Count => "positive integer count",
}
}
}
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ArgSpec {
pub name: &'static str,
pub kind: ArgKind,
}
/// A parsed, validated construction-arg value (in-memory only; documents carry
/// the canonical string form in [`ConstructionArg`]).
#[derive(Clone, Debug, PartialEq)]
pub enum ArgValue {
Tz(chrono_tz::Tz),
TimeOfDay { hour: u32, minute: u32 },
Count(usize),
}
/// A consumed construction arg — the id-bearing, serialized twin of
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
/// (never re-normalized — see `ArgKind::parse`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConstructionArg {
pub name: String,
pub value: String,
}
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
/// for the data-level construction surface.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArgOpError {
/// Args were given, but this type declares none (a `Plain` builder).
NotArgBearing,
/// A given arg name is not among the declared `ArgSpec`s.
UnknownArg(String),
/// The same arg name was given more than once.
DuplicateArg(String),
/// A declared arg has no value in the given set.
MissingArg(String),
/// A given value failed its declared kind's strict-form parse.
BadValue { arg: String, kind: ArgKind, got: String },
}
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
enum ArgsState {
/// No construction args declared (every existing node, unaffected).
Plain,
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
/// parsed value set into the real, configured builder.
Pending {
specs: &'static [ArgSpec],
#[allow(clippy::type_complexity)]
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
},
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
/// pairs — the render/serialize surface (`construction_args`).
Configured { values: Vec<ConstructionArg> },
}
/// A node's declared interface: its inputs (in order) and its output record — an
@@ -345,6 +585,36 @@ pub struct NodeSchema {
pub inputs: Vec<PortSpec>,
pub output: Vec<FieldSpec>,
pub params: Vec<ParamSpec>,
/// One-line meaning of the node — non-load-bearing metadata, shown by
/// introspection surfaces.
pub doc: &'static str,
}
/// Shape fault of a vocabulary entry's meaning line (C29 gate).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocGateFault {
Empty,
RestatesName,
}
/// Deterministic shape check for a vocabulary entry's meaning line —
/// string shape only, never content judgement (the engine must not
/// evaluate prose).
pub fn doc_gate(name: &str, doc: &str) -> Result<(), DocGateFault> {
if doc.trim().is_empty() {
return Err(DocGateFault::Empty);
}
fn norm(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
let short_name = name.rsplit("::").next().unwrap_or(name);
if norm(doc) == norm(name) || norm(doc) == norm(short_name) {
return Err(DocGateFault::RestatesName);
}
Ok(())
}
/// The universal composable dataflow unit (C8): a **producer, consumer, or both**.
@@ -379,6 +649,15 @@ pub trait Node {
"node".to_string()
}
/// Start-of-stream hook: `Harness::run` calls it once per node, in
/// topological order, before the first source value — the mirror of
/// [`Node::finalize`]. A consumer overrides it to acquire run resources
/// (e.g. the file a recording sink streams into). Infallible by
/// signature: an implementation that can fail stores its error, degrades
/// to inert, and surfaces the failure once, terminally, at `finalize`.
/// The default is a no-op, so existing nodes are unaffected.
fn initialize(&mut self) {}
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
/// order, after the source loop drains. A folding sink overrides it to flush
/// its accumulated summary; the default is a no-op, so existing nodes are
@@ -437,13 +716,14 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
assert_eq!(with.label(), "SMA");
let none = PrimitiveBuilder::new(
"Sub",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(none.label(), "Sub");
@@ -473,7 +753,7 @@ mod tests {
fn node_name_strips_namespace_prefix() {
let b = PrimitiveBuilder::new(
"demo::Identity",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| unreachable!("never built in this test"),
);
assert_eq!(b.node_name(), "identity");
@@ -505,7 +785,7 @@ mod tests {
fn primitive_builder_build_runs_the_closure() {
let f = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(f.params(), Vec::<ParamSpec>::new());
@@ -520,6 +800,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -529,6 +810,21 @@ mod tests {
);
}
#[test]
fn schema_carries_declared_doc() {
let b = PrimitiveBuilder::new(
"SMA",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![],
doc: "simple moving average",
},
|_| Box::new(Bare),
);
assert_eq!(b.schema().doc, "simple moving average");
}
/// A node whose label echoes the param vector its constructor received — lets a
/// test read back the positional vector `.build()` reconstructed after binds.
struct Probe(Vec<Cell>);
@@ -556,6 +852,7 @@ mod tests {
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
],
doc: "test-only schema",
},
|p| Box::new(Probe(p.to_vec())),
)
@@ -632,6 +929,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -650,6 +948,7 @@ mod tests {
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -665,6 +964,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -716,6 +1016,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
@@ -737,6 +1038,169 @@ mod tests {
})
);
}
#[test]
fn doc_gate_refuses_empty_and_whitespace() {
assert_eq!(doc_gate("EMA", ""), Err(DocGateFault::Empty));
assert_eq!(doc_gate("EMA", " \t"), Err(DocGateFault::Empty));
}
#[test]
fn doc_gate_refuses_name_restatement() {
assert_eq!(doc_gate("EMA", "EMA"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("EMA", "e_m_a"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("rolling_max", "Rolling Max"), Err(DocGateFault::RestatesName));
}
/// C29 tightening (#316 fieldtest): for a namespaced entry, the bare
/// display segment is a restatement too — a doc that adds nothing beyond
/// the name must not pass just because the name carries a `::` prefix.
/// Genuine meaning lines that merely *contain* the name stay legal.
#[test]
fn doc_gate_refuses_namespaced_short_name_restatement() {
// The most natural alibi at the extension-author seam: the node's
// own display name as its doc.
assert_eq!(doc_gate("lab316_nodes::Scale", "Scale"), Err(DocGateFault::RestatesName));
// Guard: a real meaning line stays Ok even when it contains the
// short name as a substring...
assert_eq!(
doc_gate("lab316_nodes::Scale", "scalar gain: emits the input times the factor param"),
Ok(())
);
// ...and a namespaced entry with a genuine doc is untouched.
assert_eq!(doc_gate("und::Opaque", "one-input f64 pass-through"), Ok(()));
}
#[test]
fn doc_gate_accepts_a_meaning_line() {
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
}
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
const DEMO_ARG_SPECS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// A minimal `make`: the fixture does not need `values` to shape a real
/// schema (that's `Session::make`'s job, Task 2) — it only proves
/// `try_args` reaches `make` with parsed values and carries the result
/// forward as `Configured`.
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let _ = values;
PrimitiveBuilder::new(
"Demo",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|_| Box::new(Bare),
)
}
fn demo_pending() -> PrimitiveBuilder {
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
}
#[test]
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
assert_eq!(
plain.try_args(&[("x".into(), "1".into())]).err(),
Some(ArgOpError::NotArgBearing),
);
}
#[test]
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
let plain = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|_| Box::new(Bare),
);
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
assert_eq!(after.schema().doc, "plain doc");
assert!(after.construction_args().is_empty());
}
#[test]
fn try_args_unknown_arg_names_the_first_unknown() {
assert_eq!(
demo_pending()
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
.err(),
Some(ArgOpError::UnknownArg("nope".into())),
);
}
#[test]
fn try_args_duplicate_arg_names_the_repeated_name() {
assert_eq!(
demo_pending()
.try_args(&[
("tz".into(), "Europe/Berlin".into()),
("tz".into(), "Europe/Berlin".into()),
("open".into(), "09:30".into()),
])
.err(),
Some(ArgOpError::DuplicateArg("tz".into())),
);
}
#[test]
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
assert_eq!(
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
Some(ArgOpError::MissingArg("open".into())),
);
}
#[test]
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
assert_eq!(
demo_pending()
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
.err(),
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
);
}
/// Strict form IS the canonical form (spec §Closedness): no normalization
/// layer, so a near-miss string refuses rather than being rewritten.
#[test]
fn arg_kind_parse_strict_form_refusals_and_accepts() {
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
assert!(ArgKind::Count.parse("2").is_ok());
}
#[test]
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
let configured = demo_pending()
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
.expect("a full valid set configures");
assert!(!configured.is_pending());
assert_eq!(
configured.construction_args(),
&[
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
ConstructionArg { name: "open".into(), value: "09:30".into() },
],
);
}
#[test]
fn pending_arg_specs_are_visible_before_configuration() {
let pending = demo_pending();
assert!(pending.is_pending());
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
assert!(pending.construction_args().is_empty());
}
#[test]
#[should_panic(expected = "unconfigured")]
fn pending_build_panics() {
let _ = demo_pending().build(&[]);
}
}
#[cfg(test)]
+192 -11
View File
@@ -15,7 +15,8 @@
//! C23) and no external dependency (C16).
use aura_core::{
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
@@ -129,7 +130,15 @@ fn derive_signature(c: &Composite) -> NodeSchema {
.collect();
let mut params = Vec::new();
collect_params(c.nodes(), c.gangs(), "", &mut params);
NodeSchema { inputs, output, params }
NodeSchema {
inputs,
output,
params,
// A derived composite signature is graph wiring, not a vocabulary entry:
// no seam walks this doc. The described surface is the composite's own
// `doc`, gated at register (C29, register seam).
doc: "",
}
}
/// The scalar kind of the interior input slot a composite target addresses,
@@ -186,6 +195,38 @@ pub struct GangMember {
pub name: String,
}
/// Deterministic shape gate for authored composite render names (#331) —
/// shape only, never content judgement (C29 discipline): non-empty, no path
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
/// exists because the name keys a trace directory on disk unsanitized
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
/// blueprint-envelope root-name intake — never at store read-back (C29:
/// registered artifacts are never retroactively invalidated), and never on
/// the Rust builder API (`GraphBuilder::new`, role-2 native authoring).
pub fn name_gate(name: &str) -> Result<(), NameGateFault> {
if name.is_empty() {
return Err(NameGateFault::Empty);
}
if name.contains('/') || name.contains('\\') {
return Err(NameGateFault::ContainsSeparator);
}
if name == "." || name == ".." {
return Err(NameGateFault::DotSegment);
}
Ok(())
}
/// The [`name_gate`] shape violation — WHY the name was refused; the caller
/// already holds the offending name (WHAT) to build a message from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameGateFault {
Empty,
ContainsSeparator,
DotSegment,
}
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
/// interior edges (local indices), input roles (role `r` fans into the interior
@@ -232,6 +273,37 @@ impl Composite {
pub fn doc(&self) -> Option<&str> {
self.doc.as_deref()
}
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
/// interior — nodes, edges, roles, output — is untouched; only `name()`
/// changes, so a fetched, registered subgraph renders (and path-prefixes
/// its `param_space()`, via `collect_params`'s composite arm) under the
/// caller-chosen instance identifier instead of its own authored name.
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
self.name = name.into();
self
}
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
/// same underlying mechanic every bind goes through
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
/// nested composite frames by node/composite-name prefix — the segmentation
/// `reopen_in` (#246) also walks, but binding an open param instead of
/// unbinding a bound one. A path matching no node at any level is
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
/// param lives there); a path that reaches a primitive but whose leaf
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
/// own fault to carry the full qualified path in place of the bare leaf
/// name, so every fault out of this walk names the same thing: the path
/// the caller wrote. A path landing on a GANGED member's own param is
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
/// it directly would silently de-fuse the gang (the member frozen while
/// the gang's public knob keeps driving its siblings); the gang's own
/// public knob stays unbindable at the use seam (accepted residue). On
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
/// posture `reopen` uses.
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
Ok(self)
}
/// Install declared measurement taps (the output-side twin of `input_roles`).
/// Fluent, mirroring `with_doc`. Empty by default.
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
@@ -1089,6 +1161,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
hits
}
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
/// `full_path` is the whole original path (named in every fault this
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
/// its (node, original-pos) is checked against every gang member; a hit
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
/// the member out from under its gang's public knob (review finding,
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
/// `BlueprintNode`s by value rather than mutating through a reference.
fn bind_path_in(
items: &mut Vec<BlueprintNode>,
gangs: &[Gang],
full_path: &str,
rest: &str,
value: Scalar,
) -> Result<(), BindOpError> {
// The prefix this frame has already consumed off `full_path` (composite
// names and up) — used to qualify a gang's public name the same way
// `collect_params` does, so the refusal below names the SAME address
// `param_space()` would show for that gang.
let prefix = &full_path[..full_path.len() - rest.len()];
for i in 0..items.len() {
let child_rest = match &items[i] {
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
};
let Some(child_rest) = child_rest else { continue };
let child_rest = child_rest.to_string();
// Gang guard (#317 follow-up review finding): a raw path landing on a
// GANGED member's own param must not silently freeze it while the
// gang's public knob keeps driving its siblings — refuse by
// identifier before ever reaching `try_bind`. The gang's own public
// knob staying unbindable at the use seam is accepted residue.
if let BlueprintNode::Primitive(b) = &items[i]
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
{
return Err(BindOpError::AlreadyGanged {
param: full_path.to_string(),
gang: format!("{prefix}{}", g.name),
});
}
let item = items.remove(i);
return match item {
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
Ok(b2) => {
items.insert(i, BlueprintNode::Primitive(b2));
Ok(())
}
Err(e) => Err(qualify_bind_error(e, full_path)),
},
BlueprintNode::Composite(mut c) => {
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
items.insert(i, BlueprintNode::Composite(c));
r
}
};
}
// no node at any level matched `rest`'s leading segment: no open param
// lives at this path.
Err(BindOpError::UnknownParam(full_path.to_string()))
}
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
/// visibility past the one primitive it was called on).
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
match e {
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
BindOpError::KindMismatch { expected, got, .. } => {
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
}
// `try_bind` never raises this one (it has no gang awareness) — the
// gang guard above constructs it directly, already fully qualified.
// Kept here only so this match stays total over `BindOpError`.
BindOpError::AlreadyGanged { gang, .. } => {
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
}
}
}
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
@@ -1393,6 +1551,19 @@ mod tests {
use aura_strategy::Bias;
use std::sync::mpsc;
/// `name_gate` (#331) unit table: an ordinary name passes; an empty
/// name, one containing either path separator, and the two dot segments
/// each fault with their own fault variant.
#[test]
fn name_gate_accepts_ordinary_names_and_refuses_shape_violations() {
assert_eq!(name_gate("ny_momentum"), Ok(()));
assert_eq!(name_gate(""), Err(NameGateFault::Empty));
assert_eq!(name_gate("a/b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("a\\b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("."), Err(NameGateFault::DotSegment));
assert_eq!(name_gate(".."), Err(NameGateFault::DotSegment));
}
/// One knob fanning into two sibling open params passes the gate; the
/// value carries the gang table.
#[test]
@@ -1695,7 +1866,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -1734,7 +1905,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -1838,6 +2009,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 +2018,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 +2442,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 +2450,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 +2463,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 +2475,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 +3028,7 @@ mod tests {
inputs: vec![f64_any()],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
@@ -3098,7 +3278,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
@@ -3306,7 +3486,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
// fan fast_slow's output into both LinComb terms so every interior slot
// is wired (the totality check, cycle 0040); param order is unaffected.
vec![
@@ -3366,7 +3546,7 @@ mod tests {
use aura_std::{LinComb, Sma};
let bp = Composite::new(
"root",
vec![Sma::builder().into(), LinComb::builder(2).into()],
vec![Sma::builder().into(), LinComb::configured(2).into()],
vec![],
vec![],
vec![], // output
@@ -3454,6 +3634,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 +3737,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");
+219 -15
View File
@@ -7,18 +7,29 @@
//! closure and the declared schema are re-derived on load from the injected
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
//! carries no node logic (C17) and references a closed vocabulary (C24).
//!
//! Construction args (#271) are structural, id-bearing data — like `bound`
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
//! `PrimitiveData.args` untouched.
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder};
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
/// The format version the loader understands. Bumped only by a load-bearing
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
/// discipline activates at the first external ship — which consciously
/// freezes v1 (gangs included).
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
/// The CEILING format version this build's loader understands (and the
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
/// 2026-07-10): while the project is unshipped every document lives in-repo
/// and reader/writer change atomically, so the Tier-2 bump discipline
/// activates at the first external ship.
///
/// #271 (data-driven version): the writer no longer emits a single fixed
/// version. A document emits `1` when args-free (byte-identical to every
/// pre-#271 document — content ids stable, C18) and `2` the moment any
/// primitive, at any nesting depth, carries construction `args` — the
/// must-understand signal a pre-#271 loader needs. The loader accepts the
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
/// Top-level envelope: the version is read before the payload is interpreted.
#[derive(serde::Serialize, serde::Deserialize)]
@@ -61,17 +72,33 @@ pub enum NodeData {
}
/// A primitive node as data: its compiled-in type identity, optional instance
/// name, and bound params. The schema + build closure are re-derived on load.
/// name, its accepted construction args (#271), and bound params. The schema
/// + build closure are re-derived on load.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PrimitiveData {
#[serde(rename = "type")]
pub type_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The consumed `(name, value)` construction-arg pairs (#271) — the
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
/// (empty) for every args-free primitive, so a document with no args
/// anywhere stays byte-identical to its pre-#271 form.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound: Vec<BoundParam>,
}
/// One serialized construction-arg pair (#271) — the wire twin of
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
/// string, stored verbatim (never re-normalized).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ArgData {
pub name: String,
pub value: String,
}
/// Serializer failure (typed, named).
#[derive(Debug)]
pub enum SerializeError {
@@ -111,9 +138,19 @@ fn project_node(n: &BlueprintNode) -> NodeData {
// construction once a multi-param node enters the vocabulary.
let mut bound = b.bound_params().to_vec();
bound.sort_by_key(|bp| bp.pos);
// Construction args (#271) are declared in a fixed `ArgSpec` order
// (not player-chosen like binds), so no re-canonicalization is
// needed — `construction_args()` already returns them in that
// deterministic order.
let args: Vec<ArgData> = b
.construction_args()
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
NodeData::Primitive(PrimitiveData {
type_id: b.label(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}
@@ -121,8 +158,32 @@ fn project_node(n: &BlueprintNode) -> NodeData {
}
}
/// Any primitive, at any nesting depth, carries construction args (#271) —
/// the must-understand signal for the data-driven version (spec §Data-driven
/// format version).
fn has_args(b: &CompositeData) -> bool {
b.nodes.iter().any(|n| match n {
NodeData::Primitive(p) => !p.args.is_empty(),
NodeData::Composite(c) => has_args(c),
})
}
/// The version THIS document must declare (#271): `1` for an args-free
/// document (byte-identical to every pre-#271 document, C18) or `2` the
/// moment any primitive anywhere carries args — never the fixed
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
fn document_version(b: &CompositeData) -> u32 {
if has_args(b) {
2
} else {
1
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
let blueprint = project(c);
let format_version = document_version(&blueprint);
BlueprintDoc { format_version, blueprint }
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
@@ -200,11 +261,16 @@ pub enum LoadError {
/// addition needs finer granularity than a version bump.
UnsupportedVersion { found: u32, supported: u32 },
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
/// vocabulary — unknown node, or a sink node not in #155's set).
UnknownNodeType(String),
/// The document's gangs section fails structural validation against its
/// own nodes (a hand-edited or corrupted document).
Gang(crate::blueprint::CompileError),
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
/// an arg-bearing type serialized/hand-written with an empty `args` —
/// a document naming an arg-bearing type without args must not load.
BadArg(aura_core::ArgOpError),
}
fn reconstruct(
@@ -220,6 +286,15 @@ fn reconstruct(
if let Some(n) = &p.name {
b = b.named(n);
}
// Apply construction args (#271) BEFORE bound params — the
// `try_args` seam. An arg-bearing type with no `args` in the
// document refuses HERE as `MissingArg` (no pending builder
// ever reaches `bind`/enters the built graph); a `Plain` type
// has empty `p.args`, so this is `Ok(self)` unchanged for
// every pre-#271 document.
let args: Vec<(String, String)> =
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
b = b.try_args(&args).map_err(LoadError::BadArg)?;
// Re-apply bound params BY NAME. The effective param vector is
// order-independent (each `bind` computes its slot against the
// shrunk schema); ascending original `pos` is the canonical order.
@@ -251,7 +326,9 @@ pub fn blueprint_from_json(
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
return Err(LoadError::UnsupportedVersion {
found: doc.format_version,
supported: BLUEPRINT_FORMAT_VERSION,
@@ -266,7 +343,8 @@ mod tests {
use crate::blueprint::{Composite, GangMember, OutField, Role};
use crate::harness::{Edge, Target};
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
use aura_market::Session;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
@@ -436,11 +514,14 @@ mod tests {
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
}
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
/// used to be refused (v1 was the only understood version); now that v2 loads
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
#[test]
fn unsupported_version_fails_named() {
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
}
#[test]
@@ -813,4 +894,127 @@ mod tests {
"the gang itself is identity-bearing"
);
}
// ---- construction args (#271) --------------------------------------
// A single-node composite around an arg-configured `Session` (arity-free —
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
Composite::new(
"sess",
vec![Session::configured(9, 30, tz, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
)
}
/// #271 acceptance (engine layer): an args-bearing composite serializes its
/// construction-arg pairs, declares `format_version: 2` (the data-driven
/// must-understand tier, spec §Data-driven format version), and round-trips
/// byte-stably through load -> re-serialize.
#[test]
fn args_round_trip_preserves_pairs_and_version_2() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
let json = blueprint_to_json(&c).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
assert!(
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
"carries the verbatim accepted arg pairs: {json}"
);
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
json,
"args round-trip byte-stably"
);
}
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
/// here under the #271-specific test name the plan names.
#[test]
fn args_free_document_serializes_byte_identical_and_version_1() {
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&signal).expect("serializes");
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
}
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
/// outer composite still trips the outer document's version to 2 — the
/// version walk recurses through `NodeData::Composite`, not just the
/// top-level node list.
#[test]
fn nested_spliced_args_composite_is_version_2() {
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
let outer = Composite::new(
"outer",
vec![crate::blueprint::BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
}
/// #271 (identity is arg-bearing): construction args are structural,
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
/// two builds differing ONLY in one accepted arg value never share an
/// identity JSON, while renaming the composite (a pure debug symbol)
/// still does not affect it.
#[test]
fn identity_json_retains_args() {
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
assert_ne!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&paris).expect("identity-serializes"),
"a differing construction-arg value survives the identity projection"
);
// renaming the composite (a debug symbol) does not affect identity.
let renamed = Composite::new(
"sess_renamed",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
assert_eq!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&renamed).expect("identity-serializes"),
"the composite's own debug name does not affect identity"
);
}
/// #271 (refuse, don't guess): a hand-written document naming an
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
/// must never silently enter the built graph.
#[test]
fn loading_arg_bearing_type_without_args_refuses() {
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
assert!(
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
}
File diff suppressed because it is too large Load Diff
+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
+7 -5
View File
@@ -52,12 +52,13 @@ mod sweep;
mod walkforward;
pub use blueprint::{
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
Tap, TapWire,
};
pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
pub use construction::{replay, GraphSession, Op, OpError};
@@ -88,7 +89,8 @@ pub use walkforward::{
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
// Firing / Timestamp) so a graph builder has one import surface, not two.
pub use aura_core::{
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
Timestamp,
};
#[cfg(test)]
+1
View File
@@ -43,6 +43,7 @@ fn recorder_sig(kinds: &[ScalarKind]) -> NodeSchema {
.collect(),
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
+14 -14
View File
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
fn signal_ops() -> Vec<Op> {
vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
#[test]
fn replayed_construction_compiles_identically_to_graphbuilder() {
// (a) op-script replay through the public construction surface.
let replay_flat = replay("root", signal_ops(), &std_vocabulary)
let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
.expect("op-script resolves")
.compile_with_params(&params())
.expect("replay compiles");
@@ -96,14 +96,14 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
#[test]
fn replay_attributes_eager_fault_to_its_op_index() {
let ops = vec![
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 1 ok
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // 2 fails
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 0 ok
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 1 ok
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, // 3 never runs
];
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
let err = replay("g", ops, &std_vocabulary).err().unwrap();
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
}
@@ -120,15 +120,15 @@ fn replay_attributes_eager_fault_to_its_op_index() {
fn replay_attributes_holistic_fault_to_finalize_step() {
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 3
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 1
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, // 2
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 3
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
// sub.rhs deliberately left unwired -> only finalize can decide totality.
];
let finalize_index = ops.len(); // 6 — the implicit finalize step
let err = replay("g", ops, &std_vocabulary).err().unwrap();
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
"the holistic fault is still attributed to the finalize step, got {err:?}");
}
+1 -1
View File
@@ -110,7 +110,7 @@ fn build_harness(
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(9, 0, Berlin, 15).schema().clone(), // 3
Session::configured(9, 0, Berlin, 15).schema().clone(), // 3
EqConst::builder().schema().clone(), // 4
EqConst::builder().schema().clone(), // 5
And::builder().schema().clone(), // 6
+1 -1
View File
@@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec<f64> {
let sq = g.add(Mul::builder()); // dev * dev
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
let sigma = g.add(Sqrt::builder());
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
let band = g.add(LinComb::configured(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
let upper = g.add(Add::builder()); // mean + k*sigma
let lower = g.add(Sub::builder()); // mean - k*sigma
let gt_hi = g.add(Gt::builder()); // price > upper
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
.schema()
.clone(), // 3
EqConst::builder().schema().clone(), // 4
@@ -248,7 +248,7 @@ pub fn ger40_breakout_blueprint(
);
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let gt = g.add(Gt::builder());
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes));
let session = g.add(Session::configured(open_hour, open_minute, tz, bar_period_minutes));
// The ONLY two params: the EqConst targets, named so the space reads
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
+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())),
)
+84 -9
View File
@@ -33,10 +33,17 @@
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind,
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder, Scalar, ScalarKind,
};
use chrono::{TimeZone, Timelike};
/// The declared construction args of a `Session` recipe (spec §Concrete code
/// shapes): the timezone and local open time — structural, non-scalar
/// configuration, never a swept param.
const SESSION_ARGS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
/// midnight), the IANA timezone, and the bar period — baked at construction,
/// never streamed and never a swept param (a timezone is not a scalar).
@@ -60,22 +67,57 @@ impl Session {
}
}
/// The param-generic recipe for a blueprint primitive. The open time,
/// timezone, and period are **structural** config baked into the closure
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
/// declared param list is empty and the `build_fn` ignores its slice (like
/// `Gt` / `Latch`).
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
/// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
/// timezone and open time come through the `args` channel (`try_args`),
/// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
/// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::pending(
"Session",
"bars elapsed since the session open, from the configured open time and timezone",
SESSION_ARGS,
Self::make,
)
}
/// Turn a validated `(tz, open)` value pair into the real signature: one
/// `trigger` input, one `bars_since_open` output, and `period_minutes` as
/// the sole (now scalar) `ParamSpec` — read from the param slice at build.
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let tz = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("tz", ArgValue::Tz(tz)) => Some(*tz),
_ => None,
})
.expect("try_args validated `tz` as ArgKind::Tz before calling make");
let (open_hour, open_minute) = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("open", ArgValue::TimeOfDay { hour, minute }) => Some((*hour, *minute)),
_ => None,
})
.expect("try_args validated `open` as ArgKind::TimeOfDay before calling make");
PrimitiveBuilder::new(
"Session",
NodeSchema {
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![],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
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)),
move |p| Box::new(Session::new(open_hour, open_minute, tz, p[0].i64())),
)
}
/// Rust-path convenience — the same recipe as the data path (twin
/// identity): `builder().try_args([tz, open]) + .bind("period_minutes", …)`.
pub fn configured(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
Self::builder()
.try_args(&[("tz".to_string(), tz.name().to_string()), ("open".to_string(), format!("{open_hour:02}:{open_minute:02}"))])
.expect("configured: a valid chrono_tz::Tz and in-range hour/minute always parse")
.bind("period_minutes", Scalar::i64(period_minutes))
}
}
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
@@ -99,6 +141,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())),
)
@@ -242,4 +285,36 @@ mod tests {
let inputs = trigger_input();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
/// Twin identity (spec §aura-market): `configured` is exactly
/// `builder().try_args([tz, open]).bind("period_minutes", …)` — same
/// accepted args, same declared signature, whichever path authored it.
#[test]
fn session_configured_twin_equals_builder_try_args() {
let via_configured = Session::configured(9, 30, Berlin, 15);
let via_try_args = Session::builder()
.try_args(&[("tz".into(), "Europe/Berlin".into()), ("open".into(), "09:30".into())])
.expect("valid tz/open configure")
.bind("period_minutes", Scalar::i64(15));
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
assert_eq!(via_configured.schema(), via_try_args.schema());
}
/// Behaviour re-anchor (#271 Task 2): the DST-aware headline property
/// (local 09:45 reads bar 3 in both CEST summer and CET winter) holds
/// identically when the node is built through `configured` instead of
/// `Session::new` directly.
#[test]
fn session_configured_behaviour_unchanged() {
let mut node = Session::configured(9, 0, Berlin, 15).build(&[]);
let mut inputs = trigger_input();
let summer = Berlin.with_ymd_and_hms(2024, 7, 1, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(summer))).map(|r| r[0].i64()), Some(3));
let winter = Berlin.with_ymd_and_hms(2024, 1, 2, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(winter))).map(|r| r[0].i64()), Some(3));
}
}
+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());
}
+537 -22
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> {
@@ -156,6 +173,95 @@ impl Registry {
}
}
/// The label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`,
/// the same "fixed-name sidecar, per-directory isolation" discipline (#191).
fn blueprint_names_path(&self) -> PathBuf {
self.path.with_file_name("blueprint_names.jsonl")
}
/// Raw, ungated append: one label line, unconditionally, under the
/// registry's write mutex (#276). No shape or content-id check —
/// [`Registry::put_blueprint_label`] is the gated public entry; this
/// exists so tests can hand-append a stale line, mirroring
/// `append_identity_index`.
fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let path = self.blueprint_names_path();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&BlueprintNameLine {
name: name.to_string(),
content_id: content_id.to_string(),
})
.expect("two plain strings serialize");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}`
/// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated
/// on a deterministic label shape (nonempty after trim, no whitespace, no
/// control characters — `RegistryError::BadLabel`, C29 discipline: shape
/// only, never content judgement) and on `content_id` resolving via
/// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint`
/// otherwise) — both checked BEFORE the write lock is taken. Re-labelling
/// an existing name appends a new line (a repoint); resolution is
/// latest-wins (see [`Registry::resolve_blueprint_label`]).
pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
gate_label_shape(name)?;
if self.get_blueprint(content_id)?.is_none() {
return Err(RegistryError::UnknownBlueprint(content_id.to_string()));
}
self.append_blueprint_label_line(name, content_id)
}
/// Resolve a label to the content id of the LATEST line naming it, skipping
/// any line whose content id no longer resolves via `get_blueprint`
/// (self-repair spirit, #191) — so "latest" always means the latest VALID
/// entry, even when a later append points at a since-vanished id. Missing
/// sidecar file -> `None` (treat-as-empty, like `load_identity_index`).
pub fn resolve_blueprint_label(&self, name: &str) -> Option<String> {
self.latest_blueprint_labels().remove(name)
}
/// Every registered label, latest-wins-deduped and name-sorted — the
/// discovery surface's data source (`graph introspect --registered`).
/// Dangling entries (content id no longer resolves) are skipped.
pub fn blueprint_labels(&self) -> Vec<(String, String)> {
self.latest_blueprint_labels().into_iter().collect()
}
/// Every content id currently stored in the blueprint store — the
/// candidate list a `use` ref's content-id-PREFIX resolution (#302
/// semantics, #317) filters against, mirroring `list_process_ids`/
/// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")`
/// name the same directory, so `list_doc_ids`'s filename-stem walk
/// applies unchanged.
pub fn list_blueprint_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("blueprints")
}
/// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl`
/// into a name -> content_id map (`BTreeMap` gives the name-sorted order
/// `blueprint_labels` promises for free). A garbage line (torn write,
/// manual edit) is skipped, like every other JSONL sidecar read in this
/// module.
fn latest_blueprint_labels(&self) -> std::collections::BTreeMap<String, String> {
let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else {
return std::collections::BTreeMap::new();
};
let mut map = std::collections::BTreeMap::new();
for raw in text.lines() {
let Ok(line) = serde_json::from_str::<BlueprintNameLine>(raw) else { continue };
if self.get_blueprint(&line.content_id).ok().flatten().is_some() {
map.insert(line.name, line.content_id);
}
}
map
}
/// The content-addressed document-store dir for one document kind —
/// a sibling of the runs store, like `blueprints/`.
fn doc_dir(&self, kind: &str) -> PathBuf {
@@ -189,6 +295,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 +327,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 +343,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 +362,57 @@ impl Registry {
}
}
/// One label->content mapping, one JSON object per line in
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
/// shape, mirrored for the label store.
#[derive(serde::Serialize, serde::Deserialize)]
struct BlueprintNameLine {
name: String,
content_id: String,
}
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
/// after trim, no whitespace, no control characters. String shape only —
/// never content judgement (the C29 discipline `doc_gate` also follows).
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
if name.trim().is_empty() {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
}
if name.chars().any(char::is_whitespace) {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
}
if name.chars().any(|c| c.is_control()) {
return Err(RegistryError::BadLabel {
name: name.to_string(),
rule: "must not contain control characters",
});
}
Ok(())
}
/// 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"). `pub` (#317): the `use` construction
/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
/// before it enters a new composition (fail fast on a doc-less fetched
/// entry) — the one walk, two call sites (register's write-path gate, use's
/// read-path gate), never a second implementation.
pub 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)]
@@ -230,7 +421,18 @@ pub enum RefFault {
StrategyNotFound(String),
IdentityUnmatched(String),
StrategyUnloadable { id: String, error: String },
AxisNotInParamSpace { strategy: String, axis: String },
AxisNotInParamSpace {
strategy: String,
axis: String,
/// #328 translation aid: stripping ONE leading segment off `axis`
/// (the wrapped `<blueprint>.<node>.<param>` shape a stale transcript
/// might quote) and finding that remainder in the strategy's
/// `param_space()` or `bound_param_space()` — `Some` iff the strip-
/// then-hit lands, computed at fault-construction time so the prose
/// layer never re-derives it. `None` when no leading segment strips
/// to a hit (today's prose stays unchanged, no speculation).
raw_candidate: Option<String>,
},
AxisKindMismatch { strategy: String, axis: String },
/// An open param of the resolved strategy is bound by no campaign axis —
/// the executor's every-open-knob-required rule, mirrored at validate
@@ -332,10 +534,22 @@ impl Registry {
});
}
}
None => faults.push(RefFault::AxisNotInParamSpace {
strategy: label.clone(),
axis: axis.clone(),
}),
None => {
// #328 did-you-mean: strip one leading segment off the
// rejected axis (the wrapped-name shape a stale
// transcript might quote) and check whether the
// remainder is a legal raw axis here — never
// speculating when it isn't.
let raw_candidate = axis.split_once('.').map(|(_, rest)| rest).filter(|stripped| {
space.iter().any(|p| p.name == *stripped)
|| bound.iter().any(|b| b.name == *stripped)
}).map(str::to_string);
faults.push(RefFault::AxisNotInParamSpace {
strategy: label.clone(),
axis: axis.clone(),
raw_candidate,
})
}
}
}
// The reverse direction: every open param must be bound by some
@@ -860,6 +1074,22 @@ 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 },
/// Label sidecar (#317): a `put_blueprint_label` name failing the
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
/// control characters). By-identifier, naming the violated rule.
BadLabel { name: String, rule: &'static str },
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
/// labels a blueprint the store does not have.
UnknownBlueprint(String),
}
impl fmt::Display for RegistryError {
@@ -883,6 +1113,29 @@ 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)"
),
},
RegistryError::BadLabel { name, rule } => {
write!(f, "blueprint label {name:?}: {rule}")
}
RegistryError::UnknownBlueprint(id) => {
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
}
}
}
}
@@ -1033,13 +1286,178 @@ 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}"
);
}
/// A described fixture blueprint, ready for `put_blueprint`'s C29 gate —
/// `put_blueprint_label`'s content-id verification routes through
/// `get_blueprint`, so its target must first be a gate-passing entry.
fn described_fixture_blueprint(name: &str) -> String {
format!(
r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"#
)
}
/// Property (#317): a label round-trips to the blueprint it names, and
/// re-labelling the SAME name repoints it — resolution and the discovery
/// listing both answer with the latest target, never the first.
#[test]
fn blueprint_label_round_trips_latest_wins() {
let reg = Registry::open(temp_family_dir("label_round_trip"));
let a = described_fixture_blueprint("a");
let b = described_fixture_blueprint("b");
reg.put_blueprint("ha", &a).expect("put a");
reg.put_blueprint("hb", &b).expect("put b");
reg.put_blueprint_label("x", "ha").expect("label x -> a");
assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string()));
reg.put_blueprint_label("x", "hb").expect("re-label x -> b");
assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins");
assert_eq!(
reg.blueprint_labels(),
vec![("x".to_string(), "hb".to_string())],
"the discovery listing dedups to the latest target",
);
}
/// Property (#317): the label-shape gate is deterministic string shape
/// only (nonempty after trim, no whitespace, no control characters) —
/// each bad shape refuses with `BadLabel` naming a (non-empty) rule.
#[test]
fn blueprint_label_refuses_bad_shapes() {
let reg = Registry::open(temp_family_dir("label_bad_shapes"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
for bad in ["", " ", "a b", "a\tb"] {
match reg.put_blueprint_label(bad, "h1") {
Err(RegistryError::BadLabel { name, rule }) => {
assert_eq!(name, bad);
assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}");
}
other => panic!("expected BadLabel for {bad:?}, got {other:?}"),
}
}
}
/// Property (#317): a label may only ever point at a blueprint the store
/// actually has — labelling an unregistered content id refuses
/// by-identifier, never silently writing a dangling line.
#[test]
fn blueprint_label_refuses_unknown_content_id() {
let reg = Registry::open(temp_family_dir("label_unknown_id"));
match reg.put_blueprint_label("x", "never-written") {
Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"),
other => panic!("expected UnknownBlueprint, got {other:?}"),
}
assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written");
}
/// Property (#317, #191 spirit): a dangling LATEST line (content id no
/// longer resolves) is skipped by resolution, which falls back to the
/// earlier still-valid line for the same name — latest-wins means
/// latest-VALID-wins.
#[test]
fn blueprint_label_resolution_skips_a_dangling_entry() {
let reg = Registry::open(temp_family_dir("label_dangling"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
reg.put_blueprint_label("x", "h1").expect("label x -> h1");
// hand-append a dangling line after the valid one, mirroring
// `append_identity_index`'s stale-line test shape: the content id it
// names was never registered.
reg.append_blueprint_label_line("x", "never-written").expect("append stale line");
assert_eq!(
reg.resolve_blueprint_label("x"),
Some("h1".to_string()),
"the dangling latest line is skipped; the earlier valid line for \"x\" wins",
);
assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]);
}
/// Property (#317, #276 discipline): N threads appending labels through
/// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed —
/// every successful append surviving as its own intact, resolvable line,
/// none torn or merged (mirrors `concurrent_appends_keep_the_family_
/// store_well_formed`).
#[test]
fn concurrent_label_appends_keep_the_sidecar_well_formed() {
let reg = Registry::open(temp_family_dir("concurrent_label"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|tid| {
let reg = &reg;
scope.spawn(move || {
let mut ok = 0usize;
for iter in 0..iters {
let name = format!("t{tid}-l{iter}");
if reg.put_blueprint_label(&name, "h1").is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
assert_eq!(
reg.blueprint_labels().len(),
total_ok,
"every successful label append must survive as its own intact, resolvable entry",
);
}
#[test]
fn document_stores_round_trip_by_content_id() {
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
@@ -1759,21 +2177,20 @@ mod tests {
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
// A minimal fixture composite with one OPEN param, built from a
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
// plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
// risk_executor*) all route through `LinComb`, whose builder needs a
// structural arity argument and is therefore deliberately absent from
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
// — they cannot round-trip through `blueprint_from_json` with this
// resolver, so this fixture stands in as the "real, open-param,
// vocabulary-loadable composite" the test needs.
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
// independent of that construction channel, so it stands in as the
// "real, open-param, vocabulary-loadable composite" the test needs.
let composite = Composite::new(
"fixture",
vec![aura_strategy::Bias::builder().named("b").into()],
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();
@@ -1857,6 +2274,98 @@ mod tests {
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
}
/// #328: a rejected axis shaped like a stale wrapped transcript (one
/// bogus leading segment glued in front of a real raw param name) carries
/// that real name as `AxisNotInParamSpace::raw_candidate` — computed at
/// fault-construction time in `validate_campaign_refs`, never re-derived
/// by the prose layer.
#[test]
fn axis_not_in_param_space_carries_a_raw_candidate_when_strippable() {
use aura_engine::blueprint_to_json;
let reg = Registry::open(temp_family_dir("axis_raw_candidate_hit"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let composite = bias_fixture();
let real = composite.param_space().first().expect("fixture has an open param").clone();
let real_name = real.name.clone();
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
let bp_id = aura_research::content_id_of(&blueprint_json);
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let proc_id = reg.put_process(process).expect("seed process");
let wrapped_axis = format!("graph.{real_name}");
let campaign_text = format!(
concat!(
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
),
bp = bp_id,
axis = wrapped_axis,
proc = proc_id,
);
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
let hit = faults
.iter()
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == &wrapped_axis));
match hit {
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
assert_eq!(raw_candidate.as_deref(), Some(real_name.as_str()));
}
other => panic!("expected AxisNotInParamSpace for {wrapped_axis:?}, got {other:?}"),
}
}
/// #328 (negative): a rejected axis whose stripped remainder ALSO misses
/// the param space carries no `raw_candidate` — the fault never
/// speculates when stripping one leading segment lands nowhere.
#[test]
fn axis_not_in_param_space_carries_no_candidate_when_stripped_remainder_also_misses() {
use aura_engine::blueprint_to_json;
let reg = Registry::open(temp_family_dir("axis_raw_candidate_miss"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let composite = bias_fixture();
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
let bp_id = aura_research::content_id_of(&blueprint_json);
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let proc_id = reg.put_process(process).expect("seed process");
let bogus_axis = "bogus.alsobogus";
let campaign_text = format!(
concat!(
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
),
bp = bp_id,
axis = bogus_axis,
proc = proc_id,
);
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
let hit = faults
.iter()
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == bogus_axis));
match hit {
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
assert_eq!(raw_candidate, &None);
}
other => panic!("expected AxisNotInParamSpace for {bogus_axis:?}, got {other:?}"),
}
}
/// Property (#191): the identity-index sidecar is a last-line-wins cache
/// over identity id → content id — a missing file reads as empty (no
/// error, since the scan is the truth path), a later append for the same
@@ -1904,6 +2413,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 +2428,7 @@ mod tests {
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test")
}
#[test]
@@ -2058,7 +2569,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 +2625,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 +2719,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 +2793,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);
}
}
+195 -37
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() });
}
}
@@ -1063,7 +1102,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
SlotKind::Strings => "list of strings",
SlotKind::Windows => "list of { from_ms, to_ms }",
SlotKind::Ref => "one of { content_id } | { identity_id }",
SlotKind::ContentRef => "content id of a process document",
SlotKind::ContentRef => "{ content_id }: content id of a process document",
SlotKind::Axes => "map: param name -> { kind, values }",
SlotKind::EmitKinds => "list of: family_table | selection_report",
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
@@ -1262,8 +1301,10 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
}
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
// deliberately narrower than the strategy-ref hint: validate_campaign
// refuses an identity_id process ref, so the guide must not offer it
slots.push(open("process.ref", "required, content id of a process document"));
// refuses an identity_id process ref, so the guide must not offer it.
// The tagged { content_id } shape is stated explicitly (#329) so the
// hint cannot be misread as accepting a bare id string.
slots.push(open("process.ref", "required, { content_id }: content id of a process document"));
}
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
slots.push(open("seed", "required, non-negative integer"));
@@ -1273,7 +1314,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(" | ")
),
));
}
@@ -1287,6 +1328,14 @@ fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSl
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
slots.push(open("kind", format!("required, must be \"{kind}\"")));
}
// C29 (#323): the description slot is enumerable like every other
// envelope slot — optional, so only listed while absent.
if v.get("description").is_none() {
slots.push(open(
"description",
"optional, string — a one-line meaning (C29-gated when present)",
));
}
}
#[cfg(test)]
@@ -1381,10 +1430,57 @@ 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",
"name": "ger40-momentum-screen",
"description": "GER40 momentum screen over the fast/slow SMA grid.",
"data": {
"instruments": ["GER40"],
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
@@ -1828,6 +1924,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 +2165,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 +2186,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 +2251,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 +2285,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!(
@@ -2226,6 +2376,14 @@ mod tests {
label.contains("content"),
"process_ref describe must still name the content-id form: {label}"
);
// #329: the label must state the TAGGED shape ({ content_id: ... }) the
// parser actually requires, not read like a bare id string — mirroring
// the `{ content_id } | { identity_id }` bracket notation std::strategy
// already uses, narrowed to the one key process.ref accepts.
assert!(
label.contains("{ content_id }"),
"process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {label}"
);
let strategy = describe_block("std::strategy").expect("strategy describable");
let strat_ref =
@@ -2282,7 +2440,7 @@ mod tests {
"presentation": { "persist_taps": [], "emit": [] } }"#;
let slots = open_slots_campaign(draft).unwrap();
assert!(slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"));
&& s.hint == "required, { content_id }: content id of a process document"));
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
@@ -2318,7 +2476,7 @@ mod tests {
// ...and the `{}` process ref reports the narrower content-id-only hint.
assert!(
slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"),
&& s.hint == "required, { content_id }: content id of a process document"),
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
);
}
+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:?}"),
}
}
+365
View File
@@ -0,0 +1,365 @@
//! 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
}
/// (a) #328: the explicit `--axis` acceptance predicate, shared by BOTH
/// intake routes (`validate_and_register_axes`'s real route and the synthetic
/// sweep-verb intake ahead of `run_blueprint_sweep`). RAW is checked FIRST and
/// independently — a legal RAW name (an open param's [`wrapped_to_raw_axis`]
/// suffix, or a bound param's own already-raw name, #203) is accepted
/// outright, so a pathological name that is both raw-legal and wrapped-exact
/// (e.g. an unwrapped param) resolves as raw, never as a translation refusal.
/// Only once that fails is a WRAPPED hit considered (an exact `param_space()`
/// name, or one leading segment stripped off `name` landing on a bound
/// param): that is a translation refusal naming the raw candidate, never a
/// silent alias. Anything matching neither namespace is [`AxisIntake::Unknown`]
/// — the caller's own unmatched-axis prose applies, unchanged.
///
/// Deliberately NOT built on [`raw_matches_wrapped`]: that predicate's own
/// equality branch also matches an EXACT wrapped name (see its doc comment),
/// so using it as the acceptance gate would silently accept the retired form
/// instead of refusing it.
#[derive(Debug, PartialEq, Eq)]
pub enum AxisIntake {
/// A legal RAW name (`--list-axes`'s own namespace, #328) — accept as-is.
Raw,
/// A retired WRAPPED name; the carried string is its translated RAW
/// candidate — never applied silently, only ever surfaced in a refusal.
WrappedRetired(String),
/// Neither namespace: the caller's own unknown-axis prose applies.
Unknown,
}
/// Classify one `--axis` name against `wrapped_open` (the WRAPPED open-param
/// probe, `blueprint_axis_probe(..).param_space()`) and `raw_bound` (the
/// strategy's own `bound_param_space()` names — already RAW, #203). See
/// [`AxisIntake`] for the precedence.
pub fn classify_axis_intake(
name: &str,
wrapped_open: &[ParamSpec],
raw_bound: &HashSet<String>,
) -> AxisIntake {
let is_raw = wrapped_open.iter().any(|p| wrapped_to_raw_axis(&p.name) == name)
|| raw_bound.contains(name);
if is_raw {
return AxisIntake::Raw;
}
let is_wrapped = wrapped_open.iter().any(|p| p.name == name)
|| raw_bound.iter().any(|b| name.split_once('.').map(|(_, rest)| rest) == Some(b.as_str()));
if is_wrapped {
AxisIntake::WrappedRetired(wrapped_to_raw_axis(name).to_string())
} else {
AxisIntake::Unknown
}
}
/// Translate one RAW axis name onto the ONE wrapped `space` slot it
/// suffix-matches ([`raw_matches_wrapped`]), falling back to `raw` unchanged
/// when no wrapped slot matches — the caller's own downstream name
/// resolution (e.g. the sweep terminal's `UnknownKnob`) surfaces that case,
/// never panicked here. #328: the shared translation `blueprint_sweep_family`
/// established for its `SweepBinder::axis` call, extracted so
/// `blueprint_sweep_over`/`validate_axis_grid` (the walkforward synthetic
/// route's own binder calls, which key by the same exact wrapped
/// `param_space()` name) can translate a RAW `--axis` name too instead of
/// requiring the retired wrapped form.
pub fn wrapped_name_of(raw: &str, space: &[ParamSpec]) -> String {
space
.iter()
.find(|p| raw_matches_wrapped(raw, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| raw.to_string())
}
/// 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]
/// #328: a legal RAW name — either an open param's raw suffix or a bound
/// param's own (already-raw) name — classifies `Raw`, accepted as-is.
fn classify_axis_intake_accepts_a_raw_open_or_bound_name() {
let wrapped_open = vec![spec("sma_signal.fast.length")];
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
assert_eq!(classify_axis_intake("fast.length", &wrapped_open, &raw_bound), AxisIntake::Raw);
assert_eq!(classify_axis_intake("bias.scale", &wrapped_open, &raw_bound), AxisIntake::Raw);
}
#[test]
/// #328: a WRAPPED name — the exact `param_space()` string, or a bound
/// param prefixed by one wrap segment — classifies `WrappedRetired`,
/// carrying its raw candidate, never accepted silently.
fn classify_axis_intake_flags_a_wrapped_name_with_its_raw_candidate() {
let wrapped_open = vec![spec("sma_signal.fast.length")];
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
assert_eq!(
classify_axis_intake("sma_signal.fast.length", &wrapped_open, &raw_bound),
AxisIntake::WrappedRetired("fast.length".to_string())
);
assert_eq!(
classify_axis_intake("sma_signal.bias.scale", &wrapped_open, &raw_bound),
AxisIntake::WrappedRetired("bias.scale".to_string())
);
}
#[test]
/// #328: a name matching neither namespace is `Unknown` — the caller's own
/// unmatched-axis prose applies, unchanged by this predicate.
fn classify_axis_intake_reports_unknown_for_neither_space() {
let wrapped_open = vec![spec("sma_signal.fast.length")];
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
assert_eq!(classify_axis_intake("nope", &wrapped_open, &raw_bound), AxisIntake::Unknown);
}
#[test]
/// #328: raw is checked FIRST — an unwrapped (no-dot) param whose raw name
/// happens to equal its own wrapped `param_space()` string (a root-level
/// knob with no node-path prefix) resolves as `Raw`, never misclassified
/// as a wrapped-exact hit needing translation.
fn classify_axis_intake_prefers_raw_when_a_name_is_both() {
let wrapped_open = vec![spec("length")]; // no dot: raw == wrapped
let raw_bound: HashSet<String> = HashSet::new();
assert_eq!(classify_axis_intake("length", &wrapped_open, &raw_bound), AxisIntake::Raw);
}
#[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()
+66
View File
@@ -0,0 +1,66 @@
//! The interior-gap-month walk behind a campaign cell's coverage annotation
//! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura
//! data coverage`'s archive-wide report (#295 dedup: two independent walk
//! implementations before this module existed) before that verb retired in
//! favor of per-cell fault isolation (#272, #273).
/// `"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). `DefaultMemberRunner::
/// window_coverage` (a swept cell's own window) is the sole caller since
/// `aura data coverage`'s archive-wide report retired (#273); the walk stays
/// its own function because it was single-sourced with that verb before then
/// (#295).
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
}
#[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"
);
}
}
+888
View File
@@ -0,0 +1,888 @@
//! 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.
///
/// #328 render-seam fix: `BindError::MissingKnob`/`KindMismatch` carry the
/// WRAPPED knob name (the terminal's own bind-time frame — untouched by this
/// cycle, per the minuted deviation on acceptance-criterion 2). No
/// user-facing surface may print a wrapped axis name, so this renderer strips
/// the one leading node segment (`wrapped_to_raw_axis`, the same convention
/// `axes.rs` defines) before the name ever reaches prose — the fix lives at
/// the RENDER seam only, not in the error type or the resolution machinery.
pub fn render_bind_error(e: &BindError) -> String {
match e {
BindError::MissingKnob(name) => {
let name = crate::axes::wrapped_to_raw_axis(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 } => {
let knob = crate::axes::wrapped_to_raw_axis(knob);
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 space = probe.param_space();
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
// preflight ahead of this call, mirroring the plain sweep verb) — translate
// each onto the ONE wrapped `space` slot it suffix-matches
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds its
// own `SweepBinder`) before this terminal's exact-name resolution.
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(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), 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: `axes` names are RAW (#328 — refused at intake before
// this fn ever runs), so each is translated to the ONE wrapped `space` slot
// it suffix-matches (`raw_matches_wrapped`, the same convention
// `override_paths` just resolved the override set through) before feeding
// `Composite::axis`/`SweepBinder::axis`, which still key by the exact
// wrapped `param_space()` name. `resolve_axes` name- and kind-checks the
// translated axes at the sweep terminal, so an UnknownKnob / KindMismatch
// is returned, not panicked (translation cannot itself introduce one: every
// incoming name was already validated raw-or-bound at the intake / override
// preflight above).
let wrapped_name_of = |raw: &str| -> String {
space
.iter()
.find(|p| crate::axes::raw_matches_wrapped(raw, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| raw.to_string())
};
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
let mut binder = probe.axis(&wrapped_name_of(first_name), first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(&wrapped_name_of(n), vals.clone());
}
// manifest.params records RAW names (#328): a name-only reshaping of `space`
// (order/kind untouched) fed to `run_blueprint_member`, which only zips it
// against `point` BY POSITION (`zip_params`) — never re-resolves by name —
// so renaming here is purely the manifest's own namespace, with no effect
// on member resolution.
let manifest_space: Vec<ParamSpec> = space
.iter()
.map(|p| ParamSpec { name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(), kind: p.kind })
.collect();
// #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, &manifest_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();
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
// preflight ahead of the caller, mirroring the plain sweep verb) —
// translate each onto the ONE wrapped `space` slot it suffix-matches
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds
// its own `SweepBinder`) before this terminal's exact-name resolution.
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(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), 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, raw_bound_defaults, resolve_run_data, 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 = raw_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)"
);
}
}
+385
View File
@@ -0,0 +1,385 @@
//! 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_core::Scalar;
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();
// #328: `stored.manifest.params` is RAW on every mint route now (the
// synthetic sweep family and, since this cycle's tidy fix, the
// real/campaign route too) — but a family minted before this fix may
// still carry WRAPPED names on disk (C29: no retroactive rewrite of a
// registered artifact), so `reproduce_family_in` reproduces either
// shape generically: each recorded name is translated onto its
// matching WRAPPED `space` slot (`raw_matches_wrapped`, tolerant of
// both shapes) before `point_from_params`, which still keys by the
// exact wrapped name. Non-axis stamps (`stop_length`, `cost[k].<knob>`)
// carry no wrap segment either way and translate to themselves (no
// `space` hit).
let wrapped_params: Vec<(String, Scalar)> = stored
.manifest
.params
.iter()
.map(|(n, v)| {
let wrapped = space
.iter()
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| n.clone());
(wrapped, *v)
})
.collect();
let point = point_from_params(&space, &wrapped_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");
}
}
+837
View File
@@ -0,0 +1,837 @@
//! [`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);
// manifest.params records RAW names (#328: the real/campaign route was
// the one mint left wrapped after the synthetic route's own switch) —
// a name-only reshaping of `space` (order/kind untouched) fed to
// `run_blueprint_member`, which only zips it against `point` BY
// POSITION (`zip_params`) — never re-resolves by name — so renaming
// here is purely the manifest's own namespace, with no effect on
// member resolution (`space` itself stays wrapped for `bind_axes`
// above, which still keys by the exact wrapped `param_space()` name).
let manifest_space: Vec<aura_core::ParamSpec> = space
.iter()
.map(|p| aura_core::ParamSpec {
name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(),
kind: p.kind,
})
.collect();
// 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,
&manifest_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.
//
// #328: `member_report.manifest.params` is RAW (this cycle's tidy
// fix put the real/campaign mint on the raw frame too), but
// `point_from_params` still keys by the exact WRAPPED `space`
// name — translate each recorded name onto its matching wrapped
// slot first (`raw_matches_wrapped`, tolerant of either shape, the
// same `reproduce_family_in` recipe), never re-resolving by a
// renamed identity.
let wrapped_params: Vec<(String, Scalar)> = member_report
.manifest
.params
.iter()
.map(|(n, v)| {
let wrapped = space
.iter()
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| n.clone());
(wrapped, *v)
})
.collect();
let point = point_from_params(&space, &wrapped_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}");
}
}
+720
View File
@@ -0,0 +1,720 @@
//! 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); one row at the last warm ts", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
(
"mean",
"arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
FoldKind::Mean,
),
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
(
"first",
"first warm value, at its own timestamp (any kind; kind-preserving row)",
FoldKind::First,
),
("last", "last warm value, at its own timestamp (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, declared: Vec<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, declared } => {
write!(
f,
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
declared.join(", ")
)
}
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(),
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
});
}
}
// 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() }
}
// Unbound, inert (C27) — but only reachable when `plan` carries
// no default (an EXPLICIT plan, e.g. from `--tap`): record-all
// (`default_named` = `Some(("record", …))`) always resolves the
// Some arm above, so this arm never fires under record-all and
// the note is exactly the C14 benign skipped-tap class (#334).
// Emitted here (aura-runner), beside the pre-existing
// runner-side `eprintln!` registers in this module/`member.rs`/
// `measure.rs` — the runner→CLI print migration is #297.
None => {
eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name);
continue;
}
},
};
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");
}
/// C29 entry seam for the registry roster: every entry ships a gate-clean
/// one-line meaning, and the bind/output prose inside it matches the
/// entry's executable rules — the drift-pin the retired aura-std
/// `fold_vocabulary` table carried, moved here with the surface (#332).
#[test]
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
let r = FoldRegistry::core();
for entry in r.entries.values() {
aura_core::doc_gate(entry.label, entry.doc)
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
if entry.doc.contains("f64 taps") {
assert!(
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
"{} claims f64-only but binds wider",
entry.label
);
} else {
assert!(
entry.doc.contains("any kind"),
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
entry.label,
entry.doc
);
assert!(
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
"{} claims any-kind but refuses a kind",
entry.label
);
}
if entry.doc.contains("i64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
"{} claims an i64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("f64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
"{} claims an f64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("kind-preserving row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
"{} claims kind-preserving but outputs otherwise",
entry.label
);
} else {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
"{}: doc names no row kind, so it must be the series entry",
entry.label
);
}
}
}
#[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
);
}

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