Compare commits

57 Commits

Author SHA1 Message Date
claude 3596c3cdc4 fieldtest: single-run delivery — 5 examples, 1 bug / 3 friction / 2 spec-gaps / 4 working
A source-blind consumer drove both binaries — HEAD and the cycle's parent — across
the tapped single-run path, a bias cross-check, a GER40 month through both the
process and campaign legs, and the retired flags.

The cycle's central promise is now measured rather than merely pinned. Ten
single-run invocations are byte-identical across the change modulo the manifest's
commit sha, both trace handles are unmoved, and a one-cell GER40 campaign
reproduces identically including its 930 kB equity series byte for byte. Scaling
the bias parameter 0.5 → 0.25 → 0.1 moves the pip metrics exactly 1×/2×/5× while
every R metric and the sign-flip count stay bit-identical — the delivery change
carried no measurement.

Retiring the unread r-equity sink cost the documented campaign surface nothing:
a document requesting all four presentation taps still persists three and skips
the fourth with its own remedy.

The uncomfortable finding is that the improvement has no workload a consumer can
reach. The single-run leg runs only an 18-cycle synthetic fixture, so peak RSS is
8.2 MB before and after; the one long run a consumer can author goes through the
campaign leg, which this cycle did not touch. That interlocks with a refusal
naming a flag retired two cycles ago. Both are routed, not fixed here: the right
prose depends on whether a real-data single run is meant to exist at all, which
is a direction question rather than a wording one.

refs #308
2026-07-27 23:35:55 +02:00
claude 7c27c070f4 audit: single-run delivery cycle close — one prose residue fixed, dead senders re-queued
Architect drift review over 2add97e..acd3d2c, plus the regression gate.

What holds, confirmed against the diff and the contracts:

- **C1 bit-identity, structurally.** `SeriesFold` is the single arithmetic source
  for both sides of the migration, and the entry point's new positional read is
  line-for-line what the member path already does. `RunMetrics` carries exactly
  the four fields read — no value silently dropped.
- **Ledger discipline.** The superseded C08 clause sits verbatim in its history
  sidecar, appended newest-last, with nothing above it rewritten. C10, C18 and
  C22 were renames carrying no superseded substance, so they owe no sidecar
  entry; the asymmetry follows the substance rather than being an oversight. No
  further live contract was falsified: the remaining `--trace` mentions are
  absence framings, and the remaining `reduce` mentions name the run mode, which
  the cycle's rule excludes explicitly.
- **C8 / C27 seams untouched.** Under folded delivery the r-equity branch is not
  built at all, so no zero-output node is left dangling; the declared-tap bind
  and its shared wiring pair are unchanged, and the trace channel still has one
  consumer.

Drift found and resolved in this commit:

- **[high] A twin comment the sweep could not see.** `crates/aura-runner/src/measure.rs`
  distinguished the bare measurement run by claiming it is "where the measured
  O(cycles) retention is removed" — a contrast against the single-run path that
  this cycle made false. Corrected: the R leg is *absent* there rather than
  merely folded. Notably the cycle's prose sweep could not have found it, since
  the sentence contains none of the words the sweep keyed on. That is the third
  instance this cycle of a keyword-driven enumeration under-covering prose a
  change falsified; the lesson is that such a sweep needs a semantic pass beside
  it, not a longer word list.

- **[medium] A deferral recorded only in ephemeral places.** The two auxiliary
  senders left dead in the wrap's signature were documented in a git-ignored spec
  and an issue comment, so `closes #308` would have dropped them from the forward
  queue. Filed as its own tracker item instead.

- **[low] Out of scope, agreed.** A CLI test's two-part property claim describes
  grammar its body no longer drives. The site is now retirement-framed and
  spec-conformant; the rest is a test-scope correction, not a prose fix.

Regression gate: `cargo run --release -p aura-bench -- run` exit 0, all five
fingerprints OK — `engine_throughput`, `ingest_throughput`, `campaign_sweep`,
`campaign_heavy`, `cli_fixed_cost`. No baseline updated, so no ratify statement
is owed. Timing deltas and a `campaign_sweep` peak-RSS move of +9.0% rode under
a `WARNING: loadavg 14.9` emitted by the harness itself; the campaign path is
untouched by this cycle and its fingerprint is unmoved, so the figure is host
noise rather than a metric shift.

Suite 1652 passed / 0 failed (exit 0); clippy exit 0.

refs #308
2026-07-27 23:19:00 +02:00
claude acd3d2c341 feat(aura-runner, aura-cli): the single-run path folds what it only aggregates
`run_signal_r` retained one row per cycle for the equity, exposure and dense
position-management series, then derived nothing from them but three summary
values — and bound a fourth sink, r-equity, whose receiver it held without ever
draining, so those rows accumulated for the length of the run and were dropped
unread. The reduce path had used a folded delivery all along. This moves the
single-run path onto it: the two f64 series land one summary row each at
finalize, the dense record is gated to its closed rows plus the true final row,
and the unread sink is no longer built. Retention on that path goes from
O(cycles) to O(trades).

Every reported number is unchanged. The replacement read is, line for line, what
`run_blueprint_member` already does, so it inherits that path's proof rather than
needing a new one; both equivalences it rests on were already pinned green
(a folded series summary equals the post-run fold's fields; the gated record
equals the full record through the R reduction, including a trade still open at
the window end). The `cli_fixed_cost` fingerprint, which hashes the exec record
line with only the build sha blanked, is unmoved.

The delivery parameter is renamed `fold_series`. Its old name described the
sweep/reduce run mode, which the single-run path is not; the new one names what
the flag does to the sinks, which is the only framing true at every call site —
the two param-space probes request the folded shape while consuming nothing at
all. Rust has no named arguments and every site passes the flag positionally, so
the rename reached no caller.

Two properties that were load-bearing but unguarded are now pinned:

- `crates/aura-runner/tests/folded_wrap_retention.rs` asserts that the folded
  wrap retains at most one record row per closed trade plus one, that each
  series sink retains at most its summary row, and that the r-equity sink is
  unbound. Both assertions are identities that hold at any trade count, so the
  pin depends on no property of the driven price path. Demonstrated red under
  retained delivery: 50000 rows for 250 trades.
- `--trace` has been absent since the sugar retirement (#319) with nothing in
  the suite guarding its absence; this cycle rests a scope exclusion on that
  absence, so `exec_trace_flag_no_longer_parses` now guards it.

A third pin closes a gap the change made load-bearing: the suite pinned
`total_pips` and the R block on the exec path but neither `max_drawdown` nor
`bias_sign_flips`, the two values whose positional read this cycle rewrote.
Demonstrated red under a deliberate column slip.

Prose the change stranded is corrected across production comments, test
identifiers and four live ledger contracts; the superseded C08 clause moves
verbatim to its history sidecar. Three of those sites still described `--trace`
as reachable, one of them in the ledger.

Suite 1652 green, clippy clean, all five bench fingerprints OK.

closes #308
2026-07-27 23:10:45 +02:00
claude 2add97e52d fieldtest: trace-identity cycle — 4 examples, 1 bug / 1 spec-gap / 7 friction / 4 working
A source-blind consumer drove the shipped binary across recording and
re-recording, addressing a run after the fact, the description-edit authoring
loop, and a cold read of the documentation.

Both decisions this cycle made deliberately hold in every shape tested. A
parameter pinned on the command line to the value it already has gives the same
handle as not passing it, across one-at-a-time, both scalar kinds, and all at
once; the order of several `--override` flags never matters; a
description-only edit converges on the same directory. Eight invocations, three
directories, and the right three.

The field test also falsified two sentences this cycle wrote, both fixed here.

The contract clause claimed that two blueprints differing only in debug symbols
"compute bit-identically, so one address is the correct answer". They do compute
bit-identically — and are addressed separately, because `manifest.params`
records node-qualified names and the merged parameter vector is hashed. The
audit's substitution blanks those names inside the topology hash's slot; the
parameter vector re-admits them through a field it does not touch. Whether the
handle should follow the record or #171's name-blind projection is a genuine
fork between two established positions here, so the behaviour stands and the
clause now states what actually holds, naming #354 for the fork.

The authoring guide predicted two directories for a no-op `--override`, since
it listed "an `--override` value" among the splitting causes without mentioning
the merge. It now states the rule the cycle actually shipped — effective
parameterisation, not how it was supplied — and names the provenance exclusions
in the one place an author looks.

Routed: #354 (the rename fork), #355 (five friction items on the path from "a
run recorded something" to "I am looking at it" — handle discoverability,
`measure ic` refusal parity, asking for an identity without running, empty-state
answers, the README's command map), and the consumer-visible face of the
non-pruning class onto #352.

refs #311
2026-07-27 11:40:29 +02:00
claude 0d6d5b1324 audit: trace-identity cycle close — C29 honoured, code twins back in lockstep
The drift review found the run handle keying on the blueprint's *content* id,
which hashes `doc` fields and every other C23 debug symbol. C29's Id treatment
forbids a description influencing an identity, and the repository already
carried the right projection unused. The digest now substitutes
`blueprint_identity_json`'s debug-symbol-blind projection (#171) for
`topology_hash` in the hashed value. `manifest.topology_hash` itself is
untouched, so #343's reference semantics and the committed record-line
fingerprint both stand.

That projection blanks more than descriptions — the render name and node, role,
output, tap and gang names — while param openness stays identity-bearing. This
is correct rather than incidental: names are non-load-bearing debug symbols
(C8/C23), so blueprints differing only in them compute bit-identically and one
address is the right answer. The reach is now stated in C22 and in the digest's
own doc, together with the one observable consequence: two structurally
identical blueprints whose declared tap names differ share a directory while
writing differently named tap files, since the write path never prunes (#352).

Two code twins the cycle left stale are corrected. `name_gate`'s doc comment
still carried the C23 clause this cycle superseded and moved to its sidecar,
and the authoring guide claimed a same-identity re-run replaces the directory's
contents — it does not, for the same non-pruning reason.

Bench is report-only and all five fingerprints are unchanged. `campaign_sweep`
reports a peak-RSS NOTICE (+12.8%); the campaign leg is untouched by this cycle
and its fingerprint is stable, so nothing is ratified against it here.

Routed rather than fixed: #353 — naming authority for one flat namespace is
spelled in three crates and the single-run leg has no claim path — filed into
the recorded-stream-store milestone, where the container consolidates anyway; a
data-identity seam recorded on #320 (a run's identity carries the window, never
the data's content, so a corrected archive is a silent overwrite until that
store supplies a recording identity); and a second instance of the non-pruning
class on #352.

The cycle's spec and plan are removed — git-ignored working files, read by the
drift review before deletion.

refs #311
2026-07-27 11:21:22 +02:00
claude b18a695531 feat(aura-runner, aura-cli, aura-registry): a run's trace directory is keyed by its own identity
A single run recorded into `runs/traces/<render-name>/`, so a second run of the
same blueprint overwrote the first and could leave a stale tap file beside an
index that no longer listed it. The directory is now
`runs/traces/<render-name>-<id8>/`, where the id is a digest over the run's own
manifest: two runs differing in any identity-bearing input land in two
directories, two runs differing in nothing land in one.

The digest hashes the manifest with its two provenance fields removed and its
parameter vectors merged.

- `commit` (the aura binary's build sha) and `project.commit` (the project
  repo's HEAD plus a `-dirty` marker, re-derived on every invocation) record who
  built or checked out the code, not what the run was. The latter would have
  minted a fresh directory for any uncommitted file anywhere in the project
  worktree — the authoring loop's normal state.
- `params` and `defaults` are merged name-sorted before hashing: how a value was
  supplied is not what the run is, so a no-op `--override` no longer splits one
  run across two directories. The two vectors are disjoint by construction.

`project.dylib_sha256` stays in. It is what the C13 hot-reload comparison
varies — an unchanged blueprint against a rebuilt node crate — which the
campaign leg structurally cannot express, since its axes vary params,
instrument and window, never code.

The manifest is now assembled before the tap bind rather than after the run.
Every input it needs was already resolved at that point, and the one value both
keys the directory and enters the report, so a handle can never name one
identity while the record describes another.

`aura chart <render-name>` no longer names a directory; it now lists the trace
handles beginning with that name instead of refusing blankly.
`TraceStoreError::NameTaken` stops prescribing `--trace`, retired in #319.

C18, C22, C23 and C27 record the new identity, each superseded clause moved
verbatim to its history sidecar; C23 gains its first.

closes #311
2026-07-27 10:52:56 +02:00
claude e759b89b0e docs(guide): spell out the two-token chart form
Field-test spec gap: the tap example said the series is "chartable by its
name", which reads as \`aura chart spread\` — that refuses, because no run is
named after a tap. The handle addresses the run, \`--tap\` selects within it;
both tokens are now shown, with the failing reading named so it is not
re-derived.

refs #309
2026-07-26 23:44:02 +02:00
claude a09aaadcbd fieldtest: trace-handle cycle — 5 examples, 3 bugs / 4 friction / 2 spec-gaps / 4 working
Source-blind field test of the handle echo and the rewritten chart refusal
(range 9636b00..9f87e5a), driven from the public interface only: the built
binary's help and refusals, the authoring guide, the glossary, the ledger.
Replayable via c309_0_run_all.sh, which rebuilds its lab from scratch and
captures stdout/stderr per step.

The chain the cycle promised holds: take trace_name off a run's stdout, feed
it to chart and to measure ic, never once list the trace directory. Both legs
report the handle identically — including the measurement leg the audit
caught shipping without a pin — and a tap-free run emits no key at all,
consistently across stdout and the stored record. The family-id arm proved
its worth: the intuitive cut of a family id yields a REAL but wrong handle
(another run's data), and the arm refuses to make it.

Findings routed: the three bugs fixed in-cycle RED-first (51ac155 and its
successor); the friction and spec-gap items filed as a collective issue.

refs #309
2026-07-26 23:43:39 +02:00
claude ede26bb231 fix(aura-cli, docs): fieldtest bugs — a refusal that misdirects, a handle denied, a stale signature
RED-first fixes for the three bugs the source-blind field test caught. Two
are the same failure the whole cycle keeps circling: prose that tells a
caller something the code does not do.

- The chart not-found refusal prescribed `aura exec --tap <NODE.FIELD>=<FOLD>`.
  The selector takes a declared tap NAME, so following the refusal verbatim
  earns a second refusal ("the tap plan names 'sub.value', but the blueprint
  declares no such tap"), and the flag is not needed at all — a plain run
  records every declared tap. This sentence predates the cycle and was carried
  over verbatim while the rest of the refusal was corrected; the field test
  ran it as written, which is exactly why it ran it.
- `aura measure ic <family-handle>` answered "no recorded run '…' under
  runs/traces" for a handle that IS recorded — the value the campaign run
  printed, that `chart` renders, whose directory exists. A family holds
  members rather than one series, so IC is measured over a member. The
  refusal now says that and lists the member keys, which also closes the
  friction that nothing on any text surface prints them: reaching a member
  previously meant the very `ls runs/traces/` this cycle set out to retire.
- C28's #297 note still gave the entry points' signature as the retired
  `(_, Vec<String>)` pair in the present tense, contradicting C27 one contract
  over. The close commit claimed lockstep; this makes it true.

Both code fixes have tests that fail without them. The field test also found
four friction items and two spec gaps, filed rather than fixed here.

refs #309
2026-07-26 23:43:23 +02:00
claude 9f87e5a583 audit: trace-handle cycle close — refusal parity, measurement pin, ledger lockstep
Architect review over 9636b00..9221bcd. What holds, confirmed against the
diff rather than the commit body: no stored record shape moved (neither
`aura-engine/src/report.rs` nor the registry compat mirror appears in the
change at all, and the byte pins pass unedited); the handle travels the
beside-the-report route C27/#297 established for the unbound-tap names, with
the library still printing nothing; trace enumeration landed inside
`aura-registry` where C22 puts trace file I/O; no C14 exit class moved, both
refusal arms falling through to the same exit 1.

Two high drift items, both fixed here:

- The corrected refusal claimed a campaign run prints its handle as
  `trace_name` on stdout. It prints `campaign_run.trace_name` — the campaign
  leg wraps its record, as the glossary states outright — so the chain the
  refusal advertised returns null there. This is the third instance of one
  error class in this cycle: a false claim inside the very prose meant to
  stop misdirecting callers. Grounding caught the first two, review the
  third.
- The measurement leg shipped without the handle pin the spec asked for. All
  three new pins drove the strategy path, so the measurement leg emitted a
  handle nothing asserted. It has its own test now, on the shared bind pair
  the two legs cannot drift across.

Ledger and vocabulary brought into lockstep: C27's current state records the
`RunOutcome` carrier that replaced the pair; the glossary's tap entry names
the single-run handle and warns that a family id is not one; the authoring
guide's own single-run tap example now says the name is printed rather than
guessed. The suggestion line gained the `aura:` prefix every other stderr
line carries.

Bench: all five fingerprints OK — the decisive signal, since they hang on the
record bytes this cycle was required not to move. Timing deltas are load
noise (loadavg 8.7 under concurrent agents), not regressions.

refs #309
2026-07-26 23:22:17 +02:00
claude 9221bcd167 feat(aura-runner, aura-cli): a run reports the trace handle it recorded under
A single run persisted its taps and then said nothing about where: the only
way to learn the directory name was `ls runs/traces/`, and the chart intake's
not-found refusal pointed at "the handle a sweep/walk-forward/campaign run
printed" — two verbs retired with #319, and a single run printed no handle at
all. A caller holding a family id from a families listing was stuck: that id
is not a trace handle, and nothing said so.

The handle now rides BESIDE the report, not inside it. Both declared-tap entry
points return `RunOutcome { report, skipped, trace_name }` in place of the
`(report, skipped)` pair, and the CLI composes report + handle into one stdout
object through a `serde(flatten)` wrapper. `RunReport`, `MeasurementReport`,
`RunManifest` and the registry's compat mirror are untouched, so no stored
record shape moves and a tap-free run's line stays byte-identical.

Placement is the load-bearing decision, and it follows a precedent rather than
inventing one: the report is the durable C18 run record — its manifest states
what the run *was* — while a trace directory is where its output went. The
project settled this exact question one cycle earlier for the sibling value,
where the unbound-tap names ride beside the report so the CLI, not the
library, prints the note (C27/#297). An embedding host gets the handle as a
value, never as text to parse back.

The chart refusal gains a second arm. A family id
(`{campaign8}-{strategy_ordinal}-{instrument}-w..-r..-s..-{run}`) is
recognised syntactically — the trailing segment shape plus an 8-hex head, the
form `derive_trace_name` mints — and answered with the campaign's REAL
recorded handles, filtered to those `chart` would accept, suggesting one only
when exactly one matches. It never derives a handle by truncating the id: the
id's second segment counts strategies while the handle's counts runs, and one
campaign run mints family ids at several strategy ordinals whose traces all
live under that single run's handle. `TraceStore::names()` supplies the
enumeration, keeping trace file I/O in the store where C22 puts it.

Verification caught three errors worth recording. The first spec draft claimed
the handle was the id's leading pair — refuted by a green test where a run-0
campaign mints ordinal-1 ids. The second draft's replacement refusal said a
campaign run "prints one handle per family"; it prints one per run. Review
then found the `NotFound` filter untested — deleting it left the suite green —
so the case now has a test that fails without it.

closes #309
2026-07-26 23:10:24 +02:00
claude 9636b00314 fieldtest: milestone safe-to-embed — 4 examples, 4 bugs / 4 friction / 2 spec-gaps / 6 working
Source-blind milestone fieldtest of the embedding promise (range
024e865..32f6be8): a path-dep World program driving refusals as values
and a live in-process tap, reproduce-as-data with both guard classes and
a tampered-metric divergence probe, 22 CLI exit-class probes derived
from the docs beforehand, and the stale-dylib handshake via the nodes-new
scaffold plus a rev-pinned external crate. Replayable via m4_0_run_all.sh.

Findings: 2 code bugs fixed in-cycle RED-first (99e9300 — the library
exposes-neither panic was the one real promise breach), 2 doc bugs fixed
in the same commit, 4 friction items bundled as #349, 2 spec gaps
ratified into C30 (rev+lockfile clause; the scaffold's absolute path also
in #349 — the committed lab-nodes fixture is relativized), 6 working.

refs #296
2026-07-26 20:06:18 +02:00
claude 99e93000c5 fix(aura-runner, aura-cli): milestone-fieldtest bugs — library exposes-neither guard, override class, doc truth
RED-first fixes for the two code bugs the safe-to-embed milestone
fieldtest caught: (1) run_signal_r panicked (exit 101) on a blueprint
exposing neither bias nor a declared tap — the CLI pre-validated but the
library seam, the very surface the milestone promises is kill-free, did
not; the guard now refuses class 2 with the CLI's prose family before
wrap_r (run_measurement has no wrap seam and no hole). (2) The override
unknown-param refusal exited 1 through the one inline site the #297
adjudication missed; it and its wrapped-retired sibling are class 2 now,
three existing pins re-pinned with the adjudication comment.

Doc truth from the same fieldtest: the guide's campaign no-data class
corrected (contained cell faults exit 3, not 1), TapPlanError's rustdoc
no longer advertises the retired exit-1 register, and C30 records that
rev + the embedding's committed lockfile are the reproducible-build
contract (branch-referenced engine deps resolve at lock time).

refs #296
2026-07-26 20:06:07 +02:00
claude 32f6be8552 audit: M4 cycle close — drift fixes, bench ratified clean
Architect review over 024e865..HEAD (issues #296/#348/#297/#299). Holds
confirmed: zero process::exit in the assembly crate with the milestone
promise pinned in-process; C1/C18 record shapes untouched with guard and
stamp sharing one label constructor; the C30/#348 re-arm transition-safe
with no stale references. Drift resolved in this commit: the C14 contract
now records the context-borne reproduce guard classes (#299), C30 carries
the source-level-identity limit in the contract itself (not only in
build.rs), the guide's exit-class prose no longer names the retired run
verb, C28 durably enumerates the deliberate print residuum, and the
pip_or_refuse doc names reproduce (not 'the CLI shell') as the from_choice
home.

Bench: all 5 fingerprints OK (engine/ingest/campaign_sweep/campaign_heavy/
cli_fixed_cost); metric deltas within load noise on a busy box (loadavg
12 warning), report-only, no baseline moved.
2026-07-26 19:31:56 +02:00
claude 58d5345a75 feat(aura-runner, aura-cli): reproduce returns its report; mismatch guard replaces silent DIVERGED
reproduce_family returns Result<ReproduceReport, RunnerError> — divergence
is a result the embedding caller reads (all_identical()), never an error;
the CLI renders the per-member lines and summary byte-identically and owns
the exit (1 on divergence, no stderr line). The library's print block and
the empty-message refusal are gone; exit_on_runner_error always prints
(no empty producer remains).

reproduce_family_in gains the identity/pip guard, firing before any store
fetch: an instrument mismatch (incl. synthetic-for-real) and a broker/pip
mismatch — forward-built label compare against the stored broker string
(RunManifest carries no pip field; the mint's label constructors became
shared pub(crate) helpers, so guard and stamp cannot drift). The class is
context-borne: the explicit-source seam refuses 2 (caller-named source),
the derived simple path refuses 1 (data drift), same prose. A window
guard is deliberately absent — no reproduce path takes a caller window.
In-process tests pin the promise: divergence returns Ok, drift refuses
class 1, the host survives. Fork minutes: issues/299#issuecomment-4875,
-4877, -4879.

closes #299
2026-07-26 19:23:44 +02:00
claude fd303de94d feat(aura-runner, aura-cli): RunnerError propagation — the assembly crate stops killing the host
All 14 process::exit sites in aura-runner (member x8, measure x5,
translate x1) convert to RunnerError { exit_code, message } propagation;
run_signal_r / run_measurement return Result tuples carrying the
skipped-tap names beside the C18 reports (record shapes untouched), and
the CLI owns printing via the shared exit_on_runner_error arm (the
dispatch_reproduce pattern — reproduce now uses the same helper).
cost_knob becomes fallible: the sweep worker maps it into the cell's
MemberFault (closing the one hole in 'a worker never process-exits'),
the persist path keeps its string channel. The skipped-tap note is
CLI-printed from returned data, byte-identical.

The one deliberate behaviour change is the minuted C14 adjudication:
refusals in the content of what argv named (binding, synthetic mismatch,
compile, tap-bind content, exposes-neither) move exit 1 -> 2; the
boundary is FORM vs VALUE — an out-of-domain override value keeps the
runtime class via the panic containment, matching the campaign leg.
Environment/data/IO refusals stay 1 (TapPlanError::exit_class is the
single source). Named re-pins in exec.rs, run_refuses_unrunnable_
blueprint.rs, tap_recording.rs, graph_construct.rs; new library-level
test pins the milestone promise (a refusal returns in-process, the
embedding host survives). Prose is byte-identical everywhere.

Docs in lockstep: C28 deferred block closed (14 sites, hole closed),
C14 partition + form-vs-value boundary, C27 note emission, guide exit
classes. Fork minutes: issues/297#issuecomment-4868 and -4873.

closes #297
2026-07-26 18:33:05 +02:00
claude 57c56ee4e2 feat(aura-core, aura-runner): re-arm the ABI handshake — source fingerprint replaces the frozen version stamp
The load seam's aura-core stamp becomes AURA_CORE_FINGERPRINT: an FNV-1a
64 hash over aura-core's sorted src/**/*.rs (prefix-free records: path,
NUL, u64-LE content length, contents), emitted by build.rs beside the
rustc stamp and baked into the descriptor by aura_project!. validate_c_tier
compares fingerprints ('aura-core build'); a stale dylib — same rustc,
different aura-core sources — is refused instead of trusted at the
Rust-ABI tier. RED-first: the headline test pins that the frozen crate
version is never again the host stamp (plus a version-bump-proof twin and
an aura-core shape pin: 16 hex, never CARGO_PKG_VERSION).

Descriptor field renamed aura_core_version -> aura_core_fingerprint
(engine-internal; field names are not ABI, order is). Documented accepted
C30 limit: source-level identity only — the consuming build's lockfile/
features stay outside the stamp. C13/C30 prose aligned.

closes #348
2026-07-26 16:41:27 +02:00
claude d87f534e85 docs(design): C30 — stability discipline: artifacts stable, code movable
Decide #296: no Rust-API stability before 1.0 (no semver, no shims; the
workspace version communicates nothing). Two consumer classes with two
mechanisms — native node crates ride the engine checkout as path deps
(what the scaffolder already emits), external embeddings pin a rev. The
compatibility promise attaches to the artifact plane (C18/C29/invariant
8), never to Rust API, with an erosion guard against 'stable API' claims.

The skeptic pass surfaced that a frozen 0.1.0 disarms the cdylib
handshake's CORE_VERSION refusal; the contract commits the load seam to a
build-identity fingerprint instead (transition tracked as #348).
Decision minutes: #296 (options a-e, rationale).

closes #296
2026-07-26 16:15:03 +02:00
claude 024e8652c0 fix(aura-cli, docs): fieldtest bugs — the non-literal override prose + the guide's family-id shape
- parse_override_tokens distinguishes the grammar fault (no '=' / no
  dotted path — prose unchanged) from a shape-valid token whose VALUE
  fails the scalar-literal parse: the refusal now names the value and
  the literal requirement (styled after the kind-mismatch sibling)
  instead of falsely claiming a NODE.PARAM=VALUE violation. RED-first;
  exit stays 2 on both branches.
- the authoring guide's multi-regime family ids corrected to the
  executor's real shape (infix -r{k} after the window segment, -r0
  explicit on the default regime), and §1's name-op paragraph no
  longer claims naming dissolves same-name trace overwriting (that
  trace-dir question is tracked on #311, not resolved here).
2026-07-26 14:25:26 +02:00
claude 7aac09d49e fieldtest: harvest 2026-07-26 — 4 examples, 2 bugs / 4 friction / 1 spec-gap / 4 working
Source-blind per-cycle fieldtest over the sweep's four axes: the
declared-tap discovery loop closes in three commands without provoked
refusals; fixed{distance} is campaign-reachable with honest R
compression beside the vol regime; the C14 exit partition branches
reliably across sixteen probes; reference-semantics hashes agree
between record line and trace index across bare/no-op/substantive
overrides. Findings routed: both bugs fixed in-cycle (next commit),
frictions bundled as #345, the trace-overwrite spec gap consolidated
onto #311, the guide-rot instance evidenced on #344.
2026-07-26 14:25:26 +02:00
claude f108291b7f audit: harvest 2026-07-26 cycle close — intake enumeration completed, ledger notes, stale comments
Architect drift review (scope 77ad046..HEAD): What holds — the #343
reference-semantics hash is code-backed on both output surfaces
(record line + trace index), the RiskRegime::Fixed stamp/re-derive
lockstep is complete both ways, and both new guide examples build
against the binary. Drift resolved in this commit: C24's
root-name-gated intake enumeration gains the introspect --taps FILE
branch plus its missing per-site pin (byte-identical refusal to the
register sibling — the #331 class-enumeration lesson applied); C27
gains the #337 realization note (positive discovery view closes the
recovery-only gap); C12's inline-hash sentence updated to the topo
parameter; five stale dispatch_run comments reworded to name
exec_blueprint_leg as the surviving owner.

Remaining drift routed: the unpinned guide op-scripts are a durable
debt class, filed as #344 (idea) rather than swept here.

Regression gate: aura-bench all five fingerprints OK (report-only
deltas within noise on a loaded box; no baseline move, nothing to
ratify).

refs #337
refs #343
2026-07-26 14:03:09 +02:00
claude 567f98b4e5 fix(aura-runner, aura-cli): the reference hash reaches the run before persistence
Harvest sweep, review re-check fix.

The prior fix corrected only the stdout record line: run_signal_r
persists the manifest to runs/traces/<name>/index.json (bound.finish)
BEFORE the post-hoc overwrite ran, so an overridden run's trace store
still carried the reopened hash — diverging from stdout in exactly the
case the revised C24/C18 clause describes. run_signal_r now takes
topo: Option<&str> (None = inline computation as before; Some = the
caller's reference-semantics hash, the run_blueprint_member precedent)
so record line and trace index read the one hash built before any
persistence; exec's override branch passes the base document's id and
the post-run mutation is gone. RED-first: the new trace-store pin
failed against the divergent state.

refs #343
2026-07-26 13:47:58 +02:00
claude ef24f06547 fix(aura-cli, ledger): review fixes — override stamps the base hash (#343 revised), prose corrections
Harvest sweep, whole-diff review fix block.

The review refuted the #343 ratification's factual premise: the
campaign leg does NOT mint a reopened member's hash — runner.rs passes
the stored strategy_id as topo even for reopened members (reference
semantics). Decision revised on the issue (fix, not ratify): exec's
override run now stamps the LOADED base document's content id
(computed before reopen_all; RED-first — the inverted pin failed on
the old reopened-hash behaviour), keeping topology_hash
store-resolvable and identical in meaning across both legs; the
variation lives in manifest.params. C24/C12/C18 rewritten to the
corrected reading.

Also per review: C24's retired build-free-introspection sentence
updated (old wording archived in the history sidecar, #339 item 4);
C14's partition key precisely scoped to the routing seam, naming the
legs' differing content-validation exits honestly; measure.rs's false
unreachability comment corrected; --taps added to the guide's
introspect cheat-sheet and pinned on the content-id path; the
zero-trade-cell note routed through the diag macro; doc-comment,
issue-ref, and gate-name corrections; the Fixed-regime NaN branch
pinned.

refs #339
refs #342
refs #343
2026-07-26 13:37:01 +02:00
claude 2e532bce00 docs(ledger), fix(aura-cli): override-hash ratified, exec exit partition pinned, cost-wiring example
Harvest sweep, batch 6 of 6.

- #343 ratified (per the decision minuted on the issue): an override IS
  a topology-parameter variation — the executed run's topology_hash is
  the reopened topology's own, deliberately not required to resolve in
  the store; the manifest's params map plus the base document
  reconstruct the variant. C24 carries the full clause, C12/C18
  cross-reference it; pinned by a bare-vs-noop-override hash test
  (distinct, and stable across runs).
- #342 item 5: C14 pins exec's unresolved-target exit partition on the
  existing usage/runtime line — file-content faults (not-JSON,
  op-script, wrong-kind) exit 2, missing-state faults (unreadable
  non-id target, unregistered content id) exit 1. One sanctioned
  realignment: the wrong-kind refusal moves 1 → 2 to join its
  file-content siblings (RED-first on the updated pin); the
  unregistered-id row gains its missing sibling pin.
- #341 item 4: the authoring guide gains a worked cost-wiring op-script
  (ConstantCost → CostSum via PositionManagement geometry, built and
  verified), honest about being a wiring reference — production cost
  models still come from a campaign document's cost block (C10).

closes #341
closes #342
closes #343
2026-07-26 13:05:36 +02:00
claude 74281842b8 feat(aura-cli, aura-engine): use-aware unwired introspection; gang-rule prose; LinComb discovery
Harvest sweep, batch 5 of 6.

- introspect --unwired resolves use refs through the registry store:
  the parse+resolve phase is extracted from graph build's path into the
  shared parse_and_resolve_ops, so a use-bearing document introspects
  identically to how it builds (fetch, C29 gate, label resolution) —
  retiring both the hard miss and the 'content id' mislabeling of
  by-name refs. The engine's subgraph closure contract is unchanged
  (store-freedom binds the engine, not the CLI caller).
- ganging a spliced instance's member path refuses with the rule
  (OpError::GangOfSplicedInstance) instead of the typo-shaped
  no-such-param prose; the leaf-typo case keeps its UnknownParam shape
  under a new sibling pin.
- the --node pending note names the follow-up moves on the current
  surface (build, then introspect --params / --unwired), and the
  authoring guide gains a worked LinComb op-script example
  (args-then-bind order, term[i]/weights[i], verified by building it).

refs #339
refs #341
2026-07-26 12:47:33 +02:00
claude d2b0cdf64c feat(aura-cli, aura-engine): zero-trade cell + synthetic-smoke notes; pending-builder serialize fence
Harvest sweep, batch 4 of 6.

- present_campaign's cell loop gains the non-walk-forward sibling of
  the #313 note: a cell whose families are all non-wf and whose every
  report traded zero times gets 'aura: note: ... metrics are vacuous,
  not a break-even result' on stderr (emit-independent; wf cells keep
  their window note, gate-truncated cells stay silent).
- exec's single-run bias leg (always RunData::Synthetic — a direct
  blueprint exec never binds real data) notes on a zero-trade run that
  the built-in 18-cycle stream is a smoke fixture and points at a
  campaign document's data section for a meaningful run.
- blueprint_serde refuses to serialize a still-pending arg-bearing
  builder (SerializeError::PendingBuilder naming node type + missing
  args + the try_args remedy) instead of emitting an args-free v1
  document whose fault only surfaces at load as MissingArg — the
  write-side twin of the load-path fence; only the Rust GraphBuilder
  path can reach this state.

closes #324
refs #341
2026-07-26 12:26:27 +02:00
claude 2b692a71e3 feat(aura-research, aura-runner): RiskRegime::Fixed — the fixed stop is campaign-reachable
Harvest sweep, batch 3 of 6 (second half).

The third externally-tagged variant ('{"fixed":{"distance":..}}',
additive — stored Vol/VolTf documents keep their bytes): doc-tier
validation mirrors FixedStop::new's distance>0 assert (NaN refused)
so the assert is unreachable from a document; stop_rule_for_regime
binds the shipped FixedStop composite; run_blueprint_member stamps
stop_distance beside the vol knobs' precedent and
stop_rule_from_params re-derives Fixed from it (stamps are mutually
exclusive; the pre-#233 no-knob fallback is unchanged). BadRegime
prose widened to name the distance rule. C10's risk-axis paragraph
updates from 'additive when needed' to shipped; glossary gains the
fixed variant.

closes #338
2026-07-26 12:10:43 +02:00
claude c39f5e4762 feat(aura-cli, aura-engine): graph introspect --taps — positive declared-tap discovery
Harvest sweep, batch 3 of 6 (first half).

Composite::declared_taps() walks the blueprint depth-first collecting
(tap name, source wire, column kind), names bare at every depth —
mirroring inline_composite's hoist, where a tap's name IS the flat
graph's load-bearing identifier — and bounds-total over invalid wires
(compile's validate_wiring stays the real gate). The CLI view loads
like --params (FILE gates the authored root name, a content id does
not), prints one 'name  node.field  Kind' row per tap, and renders the
tap-less case as a stderr note with empty stdout, exit 0.

Closes the recovery-only discovery loop: since #333 the only way to
learn a blueprint's declared tap names was provoking the undeclared-tap
refusal roster.

closes #337
2026-07-26 12:10:43 +02:00
claude 521459dd50 feat(aura-cli, aura-runner): exec routing-seam refusals + by-name unbound-role prose
Harvest sweep, batch 2 of 6.

- exec's file-target classification is now a four-way shape peek
  (campaign / blueprint / op-script / wrong-kind): a valid-JSON op-script
  array refuses at the routing seam with a build-first pointer ('aura
  graph build') instead of the blueprint leg's false 'not valid JSON'
  claim, and a kind-bearing non-campaign document names the found kind
  plus exec's two executable classes. Exit-code classes unchanged
  (op-script usage-class 2, wrong-kind runtime-class 1).
- the bare-tap measurement leg renders CompileError::UnboundRootRole by
  the signal's own root-role NAME (captured pre-consumption), retiring
  the raw Debug-form leak; every other variant keeps the total fallback.

refs #342
refs #339
2026-07-26 11:47:21 +02:00
claude 6b6086fdef fix(aura-cli): unknown-key refusal cites the op index; two diagnostics hints
Harvest sweep, batch 1 of 6.

- graph build's unknown-key refusal now parses the op-list element-by-
  element and attributes any per-element parse fault as 'op N (kind):',
  immune to serde's deny-unknown-fields token position landing on the
  following element (RED-first: the misattribution was pinned failing
  before the fix).
- the UnsupportedVersion refusal names the full accepted range
  ('versions 1..=2'), not just the ceiling.
- a bare unresolved type id inside a data-only project now carries the
  same Rust-escalation pointer as the namespaced form ('aura nodes new');
  outside any project a bare id stays hint-free (a std-name typo is the
  likelier cause there).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

closes #331
refs #328, #311

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

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

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

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

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

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

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

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

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

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

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

Gate verdict: green — all three boundary mechanisms deliver the
promise; genuinely-new logic is turned away toward Rust. Findings
routed: fold-summary read-back absorbed into #309, cross-run tap-file
lifecycle evidence onto #311, bare-refusal escalation-pointer parity
onto #341 (refs #309, refs #311, refs #341).
2026-07-24 23:43:53 +02:00
387 changed files with 19740 additions and 10306 deletions
+28 -29
View File
@@ -31,47 +31,46 @@ Invoke it as `aura <command> …` (examples below use the plain name).
- **Blueprint** — a serialized signal graph as data: a param-generic
`price → bias` node graph. A blueprint file (`blueprint.json`) is the unit a
downstream consumer loads and drives verbs over.
downstream consumer loads and executes or varies.
- **Open vs. closed** — a blueprint with unbound (free) numeric knobs is *open*;
one with every knob bound is *closed*. Different verbs want different states
(see each verb below).
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
in a content-addressed store and can be listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
one with every knob bound is *closed*. A plain `exec` wants closed; naming a
knob as a campaign axis (or an `exec --override`) is what reopens it.
- **Family** — the set of runs a campaign document's process pipeline produces
over a blueprint (a sweep grid, a Monte-Carlo seed set, a walk-forward window
sequence). Families are persisted in a content-addressed store and can be
listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
bound with a comma-separated value list. A **gang** fuses several sibling
knobs into one axis (one value drives all members).
## Running & orchestrating a loaded blueprint
## Executing a document
These verbs all take the **blueprint file as their first positional argument**.
Most drive a family of runs over it; `graph` renders its structure so a mis-wire
is visible before a run. `walkforward`, `mc`, and `generalize` share
`sweep`'s generic grammar — the blueprint positional plus `--real` and a
repeatable `--axis <name>=<csv>` (`generalize` takes `--real <SYM1,SYM2,…>`, at
least two instruments, with a single value per axis) — see `--help`.
`aura exec <target>` (#319) is the one executor verb: `<target>` is either a
loaded **blueprint** file (a single synthetic run) or a **campaign document**
(a `.json` file or its registered 64-hex content id — instruments × windows ×
strategy × param axes × process, see `docs/authoring-guide.md` §3). `graph`
renders a blueprint's structure so a mis-wire is visible before a run.
| Command | Purpose |
|---|---|
| `aura run <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or use `sweep`. |
| `aura exec <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or vary it as a campaign axis. |
| `aura exec <bp.json> --override <node.param>=<value>` | Reopen one **bound** param for this single execution only (the value is recorded raw in the manifest); repeatable. |
| `aura exec <bp.json> --tap <name>=<fold>` | Subscribe a declared tap to a fold (`record`/`count`/`sum`/`mean`/`min`/`max`/`first`/`last`) for this run; repeatable. |
| `aura graph <bp.json>` | **Render** the blueprint's structure as an interactive HTML DAG so a mis-wire is visible before a run. Omit the file to render the built-in sample; a named-but-unreadable file is a usage error. |
| `aura sweep <bp.json> --list-axes` | **Discover** the blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`. Run this first to learn the axis names. |
| `aura sweep <bp.json> --axis <name>=<csv> [--axis …]` | Run a **grid family** over the named axes and persist it. |
| `aura mc <bp.json> --seeds <n>` | Run a **synthetic Monte-Carlo family** of `n` seeded realizations. Wants a **closed** blueprint (the inverse of sweep). |
| `aura mc <bp.json> --real <sym> --axis <name>=<csv> [--axis …]` | Run a **Monte-Carlo R-bootstrap campaign** over recorded data — driven through the same generated-campaign pipeline as `sweep`; `--block-len`/`--resamples`/`--seed` tune the bootstrap. |
| `aura walkforward <bp.json> --axis <name>=<csv> [--axis …]` | Run an **in-sample-refit walk-forward family** — rolling optimize + out-of-sample test across windows, stitched into one verdict + parameter stability. `--select` chooses the per-window objective. |
| `aura graph introspect --params <bp.json\|id>` | **Discover** a blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`, bound params trailing `default=<value>`. Run this first to learn the axis names for a campaign document. |
| `aura exec <campaign.json\|id> [--parallel-instruments <n>]` | Execute a **campaign document** — a grid family, gates, walk-forward, Monte-Carlo, and cross-instrument generalization per its process document, one cell per (strategy, instrument, window). |
| `aura runs families` | List every persisted family (id, kind, member count). |
| `aura runs family <id> [rank <metric>]` | List one family's members, optionally ranked best-first by an R metric (e.g. `sqn_normalized`, `expectancy_r`). |
| `aura reproduce <family-id>` | Re-derive every member of a persisted family from the content-addressed store and check it is bit-identical (the C18/C1 determinism guarantee). |
**Important contract — every open knob is mandatory.** On `sweep` and
`walkforward`, the knobs enumerated by `--list-axes` are *all* required: you must
supply an `--axis` for each open knob. There is no default value — pin a knob you
don't want to vary with a single-value axis (`--axis name=4`). Omitting one is a
clean error naming the missing knob, not a silent default.
**Important contract — every open knob is mandatory on a campaign axis.** A
campaign document's `strategies[].axes` must name every open knob
`graph introspect --params` lists: there is no default value — pin a knob you
don't want to vary with a single-value axis (`{"kind":"I64","values":[4]}`).
Omitting one is a clean error naming the missing knob, not a silent default.
Use `aura <cmd> --help` for the full data-window (`--real`/`--from`/`--to`),
naming (`--name`), and selection (`--select`) flags.
Use `aura exec --help` for the exact flag grammar, `docs/authoring-guide.md`
for the campaign document shape (data window, naming, presentation/emit).
## Authoring & introspecting topology
@@ -111,11 +110,11 @@ Each element is one op, tagged by `"op"`. Node params are bound with the **typed
```
Piping this document into `aura graph build` emits a `blueprint.json` you can
then feed to `run` / `sweep` / `mc` / `walkforward` above:
then feed to `exec` above:
```sh
aura graph build < crossover.ops.json > crossover.bp.json
aura sweep crossover.bp.json --list-axes
aura graph introspect --params crossover.bp.json
```
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
@@ -1,16 +1,16 @@
{
"surface": "cli_fixed_cost",
"metrics": {
"help_ms": 1.5046899999999999,
"run_ms": 3.63305
"help_ms": 1.487171,
"run_ms": 3.376192
},
"fingerprint": "run_line_fnv=6bb0d796f760d140",
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "9c7f60b",
"date": "2026-07-23"
"commit": "10570b7",
"date": "2026-07-25"
}
+7 -20
View File
@@ -95,31 +95,18 @@ fn blueprint_stems(dir: &Path) -> Vec<String> {
.unwrap_or_default()
}
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
/// E2E `seed_blueprint` pattern) and return its content id.
/// Seed one blueprint into the scratch store via `graph register` (#319: the
/// surviving registration surface) and return its content id.
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
let before = blueprint_stems(dir);
let (out, code) = run_in(
bin,
dir,
&[
"sweep",
file,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
name,
],
)?;
let (out, code) = run_in(bin, dir, &["graph", "register", file, "--name", name])?;
if code != Some(0) {
return Err(format!("seed sweep failed ({code:?}): {out}"));
return Err(format!("seed register failed ({code:?}): {out}"));
}
blueprint_stems(dir)
.into_iter()
.find(|s| !before.contains(s))
.ok_or_else(|| "seed sweep must store a new blueprint".to_string())
.ok_or_else(|| "seed register must store a new blueprint".to_string())
}
fn register_process(bin: &Path, dir: &Path, file: &str, doc: &str) -> Result<String, String> {
@@ -261,10 +248,10 @@ pub fn winner_fingerprint(stdout: &str) -> Result<String, String> {
fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
let timed: TimedChild = run_timed(bin, &["campaign", "run", &doc], &scratch.0)?;
let timed: TimedChild = run_timed(bin, &["exec", &doc], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!(
"campaign run exited {:?}\nstdout: {}\nstderr: {}",
"aura exec exited {:?}\nstdout: {}\nstderr: {}",
timed.exit, timed.stdout, timed.stderr
));
}
+6 -6
View File
@@ -1,6 +1,6 @@
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
//! computed) and a minimal data-only project run whose fingerprint is the
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per
//! computed) and a minimal data-only project exec whose fingerprint is the
//! FNV-1a hash of the exec's single stdout JSON line. Fresh scratch project per
//! repetition, so store counters start identical.
use super::{median, RepOutcome};
@@ -11,7 +11,7 @@ use std::collections::BTreeMap;
use std::path::Path;
use std::time::Instant;
/// Fingerprint of an `aura run` record line, invariant across rebuilds: the
/// Fingerprint of an `aura exec` record line, invariant across rebuilds: the
/// line's `manifest.commit` is the aura binary's compile-time build sha
/// (crates/aura-cli/build.rs — the invariant-8 audit trail), so hashing the
/// raw line would flip the fingerprint on EVERY new commit and report a
@@ -59,9 +59,9 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
let mut run_walls = Vec::new();
let mut first_line = None;
for _ in 0..RUN_SAMPLES {
let timed = run_timed(bin, &["run", "bench_sma_a.json"], &scratch.0)?;
let timed = run_timed(bin, &["exec", "bench_sma_a.json"], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!("aura run exited {:?}: {}", timed.exit, timed.stdout));
return Err(format!("aura exec exited {:?}: {}", timed.exit, timed.stdout));
}
run_walls.push(timed.wall_s * 1000.0);
if first_line.is_none() {
@@ -70,7 +70,7 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
.stdout
.lines()
.next()
.ok_or("aura run must print its JSON record line")?
.ok_or("aura exec must print its JSON record line")?
.to_string(),
);
}
+2 -2
View File
@@ -105,7 +105,7 @@ mod tests {
/// `run_reps` composed with the real campaign sweep surface — a fresh
/// scratch project, a freshly seeded blueprint, and a genuine
/// `aura campaign run` child process spawned per repetition (not an
/// `aura exec` child process spawned per repetition (not an
/// in-process shortcut). Protects that two independently built-and-run
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
/// from the child's stdout — C1's determinism carried through the
@@ -136,7 +136,7 @@ mod tests {
/// `run_reps` composed with the real CLI fixed-cost surface — a genuine
/// `aura --help` spawn-floor wall plus a fresh scratch project and a real
/// `aura run` child process per repetition (not an in-process shortcut).
/// `aura exec` child process per repetition (not an in-process shortcut).
/// Protects that two independent scratch runs agree on the run-line FNV
/// fingerprint extracted from the child's stdout, and that the metrics
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
+101 -55
View File
@@ -1,4 +1,5 @@
//! `aura campaign run` — the driver that turns a stored campaign document into
//! `aura exec`'s campaign legs (#319; formerly `aura campaign run`) — the
//! driver that turns a stored campaign document into
//! a realized run-set (#198). The execution *semantics* (preflight, cell loop,
//! stage sequencing, selection, registry writes) live in `aura-campaign`; the
//! [`MemberRunner`](aura_campaign::MemberRunner) implementation over the shipped loaded-blueprint machinery
@@ -27,7 +28,7 @@ use aura_engine::FamilySelection;
use aura_registry::CampaignRunRecord;
use aura_research::{
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign,
validate_process, CampaignDoc, DocRef,
validate_process, Axis, CampaignDoc, DocRef,
};
use aura_runner::axes::is_content_id;
@@ -108,27 +109,17 @@ struct CampaignRunLine<'a> {
campaign_run: &'a CampaignRunRecord,
}
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
/// member/selection lines + the always-on final `campaign_run` record line).
/// `MemberLinesOnly` is dissolved-verb sugar: the generated document's
/// `emit: ["family_table"]` already limits emission to member lines; the mode
/// additionally suppresses the final record line so the verb's stdout
/// contract is reproduced byte-for-byte. The record append is identical in
/// both modes — presentation changes, the record does not.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum RunPresentation {
Full,
MemberLinesOnly,
}
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
/// `aura exec <target>` (campaign legs): resolve, gate, execute, emit. Every `Err`
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok`
/// carries the failed-cell count (#272) so the caller can exit 3 on a
/// completed-with-failures run via `exit_on_campaign_result`.
/// completed-with-failures run via `exit_on_campaign_result`. `overrides`
/// (#319 Task 4, the `exec --override` campaign leg) is threaded through
/// unchanged to `run_campaign_returning`, the one injection seam.
pub(crate) fn run_campaign(
target: &str,
env: &Env,
parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<usize, String> {
// Project gate FIRST: nothing (not even the file-sugar registration)
// touches a store outside a project.
@@ -162,7 +153,7 @@ pub(crate) fn run_campaign(
));
};
run_campaign_by_id(&campaign_id, env, RunPresentation::Full, parallel_instruments)
run_campaign_by_id(&campaign_id, env, parallel_instruments, overrides)
}
/// An executed campaign plus the context its presenter and the dissolved-verb
@@ -181,20 +172,15 @@ pub(crate) struct CampaignRun {
/// The one campaign executor path from a resolved content id onward: fetch
/// the stored canonical bytes by id (so file addressing and id addressing
/// produce the same realization by construction) and re-run the intrinsic
/// tier on them, execute, and emit per `presentation`. Shared by
/// `run_campaign` (`RunPresentation::Full`) and the dissolved-verb sugar path
/// (`verb_sugar::run_sweep_sugar`, `RunPresentation::MemberLinesOnly`) — no
/// project gate here: the sugar path must work project-less exactly as the
/// inline verb it replaces did (store mechanics run against `env.registry()`
/// in both cases).
/// tier on them, execute, and emit. Called by `run_campaign`.
pub(crate) fn run_campaign_by_id(
campaign_id: &str,
env: &Env,
presentation: RunPresentation,
parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<usize, String> {
let run = run_campaign_returning(campaign_id, env, parallel_instruments)?;
present_campaign(run, presentation, env)
let run = run_campaign_returning(campaign_id, env, parallel_instruments, overrides)?;
present_campaign(run, env)
}
/// The one campaign executor path from a resolved content id up to (not
@@ -204,18 +190,32 @@ pub(crate) fn run_campaign_by_id(
/// outcome bundled with the context the presenter needs. Shared by
/// `run_campaign_by_id` (which then presents) and a forthcoming dissolved-verb
/// sugar path that reads the outcome and self-prints (lands in a later task,
/// alongside the generalize translator's own runner).
/// alongside the generalize translator's own runner). `overrides` (#319
/// Task 4) is injected into the parsed document AFTER intrinsic validation
/// and BEFORE the referential gate — the stored bytes and the recorded
/// `campaign_id` stay those of the ORIGINAL document; only the executed
/// run-set changes, its audit record being the raw member manifests.
///
/// One deliberate exception to "every refusal is an `Err`" (review Minor-4):
/// the injection loop's axis-collision check exits directly with code 2
/// instead of returning `Err`. That collision is a USAGE-class fault (C14) —
/// `--override` naming a path the document already declares as an axis is a
/// malformed invocation, exactly like `--override`'s own malformed-token
/// refusal (`parse_override_tokens`, exit 2) — not a RUNTIME-class refusal
/// (missing environment/recorded state, exit 1), which is what this fn's
/// `Result<_, String>` return otherwise carries end to end.
pub(crate) fn run_campaign_returning(
campaign_id: &str,
env: &Env,
parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<CampaignRun, String> {
let registry = env.registry();
let campaign_text = registry
.get_campaign(campaign_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?;
let campaign = parse_campaign(&campaign_text)
let mut campaign = parse_campaign(&campaign_text)
.map_err(|e| doc_error_prose("stored campaign document", &e))?;
let faults = validate_campaign(&campaign);
if !faults.is_empty() {
@@ -225,6 +225,25 @@ pub(crate) fn run_campaign_returning(
));
}
// --override NODE.PARAM=VALUE (#246 residue, #319 Task 4): inject as a
// single-value axis over the named bound param into EVERY strategy
// entry. Collision with a document-declared axis refuses (an override
// overrides a bound value, never an axis); an unknown path then falls
// out of `validate_campaign_refs` below with its existing did-you-mean
// prose — no `RefFault` change, no registry change.
for (path, val) in overrides {
for entry in &mut campaign.strategies {
if entry.axes.contains_key(path) {
eprintln!(
"aura: exec: --override `{path}` collides with a declared axis \
of the campaign; an override overrides a bound value, never an axis"
);
std::process::exit(2);
}
entry.axes.insert(path.clone(), single_value_axis(*val));
}
}
// Referential gate: zero faults or refuse (the campaign-validate seam).
let resolve = |t: &str| env.resolve(t);
let ref_faults = registry
@@ -295,15 +314,11 @@ pub(crate) fn run_campaign_returning(
Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() })
}
/// Emit an executed campaign's results per `presentation`: the zero-survivor
/// stderr notes, the emit-gated family/selection lines, the always-on record
/// line (Full only), then trace persistence. Split out of `run_campaign_by_id`
/// so the dissolved-verb sugar can consume the outcome without this stdout tail.
fn present_campaign(
run: CampaignRun,
presentation: RunPresentation,
env: &Env,
) -> Result<usize, String> {
/// Emit an executed campaign's results: the zero-survivor stderr notes, the
/// emit-gated family/selection lines, the always-on record line, then trace
/// persistence. Split out of `run_campaign_by_id` so a forthcoming caller
/// could consume the outcome without this stdout tail.
fn present_campaign(run: CampaignRun, env: &Env) -> Result<usize, String> {
let CampaignRun { outcome, campaign, strategies, server } = run;
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
@@ -355,6 +370,32 @@ fn present_campaign(
let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
for cell_out in &outcome.cells {
// #313 zero-trade note (#319 Task 5, migrated from the retiring
// `dispatch_walkforward`/`run_walkforward_sugar` paths): every campaign
// cell's `std::walk_forward` family, if any, gets the same shared
// `note_zero_trade_windows` producer over its OOS members' trade
// counts — independent of `emit` (a stderr diagnostic, not gated
// stdout presentation).
if let Some(wf) = cell_out.families.iter().find(|f| f.block == "std::walk_forward") {
crate::diag::note_zero_trade_windows(
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
);
} else if !cell_out.families.is_empty() {
// #324 (re-scoped): the non-walk-forward sibling — a cell whose
// families are ALL non-`std::walk_forward` (grid sweep, MC
// bootstrap, …) has no OOS-window vocabulary, so without a note
// here a cell that never traded is indistinguishable from a
// break-even one. Over every report across every family this
// cell ran (a cell with zero families, e.g. gate-truncated,
// emits nothing new here).
let trades: Vec<u64> = cell_out
.families
.iter()
.flat_map(|f| f.reports.iter())
.map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades))
.collect();
crate::diag::note_zero_trade_cell(trades.into_iter());
}
if emit_family {
for fam in &cell_out.families {
for report in &fam.reports {
@@ -381,23 +422,21 @@ fn present_campaign(
}
}
}
if presentation == RunPresentation::Full {
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
eprintln!(
"aura: campaign run {} recorded: {} cells{}",
outcome.record.run,
outcome.record.cells.len(),
if failed == 0 {
String::new()
} else {
format!(", {failed} failed ({})", fail_labels.join(", "))
}
);
}
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
eprintln!(
"aura: campaign run {} recorded: {} cells{}",
outcome.record.run,
outcome.record.cells.len(),
if failed == 0 {
String::new()
} else {
format!(", {failed} failed ({})", fail_labels.join(", "))
}
);
// Trace persistence runs LAST, after the always-on record line (0109):
// stdout stays data-pure (the record line is the wire contract) and the
@@ -417,6 +456,13 @@ fn present_campaign(
Ok(failed)
}
/// Build the single-value `Axis` an `exec --override` injects (#319 Task 4):
/// exactly the shape the document form `{"kind": "I64", "values": [v]}`
/// deserializes to — `Scalar::kind` names the tag, one value in the list.
fn single_value_axis(v: Scalar) -> Axis {
Axis { kind: v.kind(), values: vec![v] }
}
/// #272: the fault-kind label the failed-cell summary line names — the same
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
+67 -6
View File
@@ -32,11 +32,11 @@ fn zero_trade_note_text(n_windows: usize) -> String {
}
}
/// 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.
/// The #313 zero-trade note (#319 Task 5: migrated onto the campaign
/// walk-forward leg in `campaign_run.rs`, the surviving executor path) so
/// the wording AND the condition live in exactly one place. Takes the
/// per-window trade counts; a no-op unless there is at least one window and
/// every one of them traded zero times.
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
let n_windows = window_trades.len();
if n_windows > 0 && window_trades.all(|n| n == 0) {
@@ -44,9 +44,54 @@ pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<
}
}
/// #324 (re-scoped): the non-walk-forward sibling of `note_zero_trade_windows`
/// — a campaign cell whose families are ALL non-walk-forward (grid sweep, MC
/// bootstrap, …) has no per-window vocabulary to note through, yet a
/// consumer still cannot tell "0.0 = break-even" from "0.0 = never traded"
/// without this. Pure over the count so the wording is unit-pinned (mirrors
/// `zero_trade_note_text`'s singular/plural split, #313).
fn zero_trade_cell_note_text(n_reports: usize) -> String {
if n_reports == 1 {
"the cell's one report recorded zero trades; its metrics are vacuous, not a break-even \
result"
.to_string()
} else {
format!(
"the cell's {n_reports} reports all recorded zero trades; its metrics are vacuous, \
not a break-even result"
)
}
}
/// See `zero_trade_cell_note_text`. Takes the per-report trade counts across
/// every family the cell ran; a no-op unless there is at least one report
/// and every one of them traded zero times. Called from the campaign cell
/// loop's non-walk-forward branch (`campaign_run.rs`), independent of `emit`.
pub(crate) fn note_zero_trade_cell(mut report_trades: impl ExactSizeIterator<Item = u64>) {
let n_reports = report_trades.len();
if n_reports > 0 && report_trades.all(|n| n == 0) {
note!("{}", zero_trade_cell_note_text(n_reports));
}
}
/// #324 (comment 4501): `exec <blueprint.json>`'s single-run signal/bias leg
/// always drives the built-in synthetic stream (`RunData::Synthetic`, no
/// direct-blueprint exec ever binds real data — only a campaign document's
/// `data` section does) — a tiny `n_cycles`-bar smoke fixture, not market
/// data. Zero trades on it is expected, not a broken strategy; a caller that
/// validates against it needs telling so. Unconditional — the caller gates
/// on the zero-trade condition before calling.
pub(crate) fn note_synthetic_smoke_zero_trades(n_cycles: usize) {
note!(
"the built-in synthetic stream ({n_cycles} cycles) is a small smoke fixture; zero \
trades is expected here — bind real data via a campaign document's `data` section \
(`aura exec <campaign.json>`) for a meaningful run"
);
}
#[cfg(test)]
mod tests {
use super::zero_trade_note_text;
use super::{zero_trade_cell_note_text, zero_trade_note_text};
#[test]
/// #278 fieldtest friction: the single-window form must read as
@@ -62,4 +107,20 @@ mod tests {
"all 3 walk-forward windows recorded zero trades"
);
}
#[test]
/// #324: the cell-level sibling pluralizes the same way and names the
/// vacuous-metrics property, not just the raw zero count.
fn zero_trade_cell_note_pluralizes() {
assert_eq!(
zero_trade_cell_note_text(1),
"the cell's one report recorded zero trades; its metrics are vacuous, not a \
break-even result"
);
assert_eq!(
zero_trade_cell_note_text(4),
"the cell's 4 reports all recorded zero trades; its metrics are vacuous, not a \
break-even result"
);
}
}
+329 -111
View File
@@ -8,10 +8,11 @@ use std::collections::BTreeMap;
use std::path::Path;
use aura_engine::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, ArgOpError,
BindOpError, BlueprintDoc, 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;
use crate::research_docs::resolve_id_prefix;
@@ -21,9 +22,10 @@ use crate::research_docs::resolve_id_prefix;
#[cfg(test)]
use aura_vocabulary::std_vocabulary;
/// The op-list reference `aura graph build --help` appends (#323): the ten
/// op kinds with their fields and one worked element each. Lives beside
/// [`OpDoc`] so a new op variant is one screen away from its help line.
/// 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
@@ -49,6 +51,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o
{"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>"#;
@@ -110,6 +115,9 @@ enum OpDoc {
#[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
@@ -142,21 +150,23 @@ impl OpDoc {
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`).
/// Infallible, context-free conversion. Neither production caller
/// (`composite_from_str`'s / `introspect_unwired`'s shared
/// [`parse_and_resolve_ops`] phase, #339 item 4 harvest) reaches the
/// `OpDoc::Use` arm below with an unresolved ref: both intercept every
/// `Op::Use` op and route it through `resolve_use_op` (registry fetch +
/// C29 gate + full-vocabulary resolve) BEFORE ever calling `Op::from` —
/// only the remaining, `use`-free op kinds reach this conversion as-is.
/// The `OpDoc::Use` arm stays for match exhaustiveness (a bare,
/// registry-free conversion, `ref_id` verbatim off the `UseRef`); a
/// hypothetical direct caller would still get one, but every op-script
/// entry point in this file resolves first.
fn from(d: OpDoc) -> Op {
match d {
OpDoc::Source { role, kind } => Op::Source { role, kind },
@@ -181,6 +191,7 @@ impl From<OpDoc> for Op {
name,
bind: bind.into_iter().collect(),
},
OpDoc::Name { name } => Op::Name { name },
}
}
}
@@ -245,20 +256,42 @@ fn format_op_error(e: &OpError) -> String {
format!("gang: `{node}.{param}` is already ganged")
}
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::GangOfSplicedInstance { node, member } => format!(
"gang: {node}.{member} is a spliced instance's member — ganging a used instance's member params is not yet supported"
),
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.
// #317, extended #339 item 4 harvest: BOTH CLI entry points
// (`composite_from_str`'s and `introspect_unwired`'s shared
// `parse_and_resolve_ops` phase) resolve and fetch every `use` op
// before replay/apply, so neither ever constructs an `Op::Use` whose
// `ref_id` is anything but an already-resolved content id — this
// arm is unreachable from the CLI surface. It stays reachable only
// against a bare `GraphSession`/`replay` caller that hands `Op::Use`
// a `ref_id` its own `subgraph` closure doesn't resolve (an engine-
// level API misuse, not a document-authoring fault).
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
@@ -382,14 +415,43 @@ fn resolve_use_op(
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). 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}"))?;
/// [`parse_and_resolve_ops`]'s success payload: the parsed `Op`s, their
/// by-index kind labels (`op N (kind): cause` attribution), and the id -> json
/// cache the caller's own `subgraph` closure reads.
type ResolvedOps = (Vec<Op>, Vec<String>, std::collections::HashMap<String, String>);
/// Parse a JSON op-list document into `Op`s, resolving every `use` op through
/// the registry (fetch, C29-gate, full-vocabulary resolve) HERE, before either
/// caller ever builds a session (#317, extended #339 item 4 harvest): the
/// shared first phase of `composite_from_str` (`graph build`'s real path) AND
/// `introspect_unwired`'s build-free path, so a `use`-bearing document
/// resolves identically under both — introspection no longer treats a
/// resolvable ref as a hard miss. A resolution/doc-gate fault is attributed
/// exactly like any other per-op construction fault (same `op N (kind):
/// cause` shape).
fn parse_and_resolve_ops(doc: &str, env: &aura_runner::project::Env) -> Result<ResolvedOps, String> {
// #336: deserialize element-by-element off the untyped `Value` array, not in
// one `Vec<OpDoc>` shot. For an internally-tagged enum, serde_json's
// `deny_unknown_fields` fault fires only once the parser has consumed the
// WHOLE offending object — its reported line/column lands on the start of
// the FOLLOWING element, misattributing the fault in longer op-lists. The
// op-list's own index is immune to that token-position quirk, so every
// per-element parse fault is wrapped with it directly.
let raw: Vec<serde_json::Value> =
serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let mut docs: Vec<OpDoc> = Vec::with_capacity(raw.len());
for (idx, v) in raw.into_iter().enumerate() {
// Read the op-kind tag straight off the still-untyped value: a parse
// fault (e.g. an unknown key) means `OpDoc` never materializes, so
// `OpDoc::kind_label` isn't available yet, but the raw "op" string is
// the same lowercase label it would produce for every kind but `use`.
let kind = v.get("op").and_then(serde_json::Value::as_str).map(str::to_string);
let parsed: OpDoc = serde_json::from_value(v).map_err(|e| match &kind {
Some(k) => format!("op {idx} ({k}): {e}"),
None => format!("op {idx}: {e}"),
})?;
docs.push(parsed);
}
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());
@@ -403,6 +465,15 @@ fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Comp
};
ops.push(op);
}
Ok((ops, labels, cache))
}
/// 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: &aura_runner::project::Env) -> Result<Composite, String> {
let (ops, labels, cache) = parse_and_resolve_ops(doc, env)?;
// 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
@@ -480,7 +551,15 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
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");
// #341 item 3 harvest: name the follow-up moves on the CURRENT
// surface, not just the fact that ports/params are absent — a
// consumer's first port guess is otherwise a coin flip, and only
// trial refusals surface the real names.
out.push_str(
" note ports and params form at construction; args are required — build \
the op-script, then `aura graph introspect --params <bp.json>` shows the open knobs and \
`--unwired` on a partial document shows the unfilled slots\n",
);
}
for port in &schema.inputs {
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
@@ -495,17 +574,29 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
}
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize).
/// op-list document, by-identifier (applies the ops, does NOT finalize). A
/// `use` op resolves through the registry via the SAME [`parse_and_resolve_ops`]
/// phase `composite_from_str` uses (#339 item 4 harvest: this path used to pass
/// `&|_| None` as the subgraph resolver, so a use-bearing document always
/// missed with `OpError::UnknownSubgraph` — mislabeling a by-name ref as a
/// "content id" in the bargain); the real store resolver threads in cleanly
/// because `unwired()` is read straight off the (unfinished) session,
/// unaffected by the `use` resolution happening one phase earlier.
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 (ops, labels, cache) = parse_and_resolve_ops(doc, env)?;
let resolver = |t: &str| env.resolve(t);
// #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)))?;
let subgraph = |ref_id: &str| {
cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok())
};
let mut session = GraphSession::new("introspect", &resolver, &subgraph);
for (i, op) in ops.into_iter().enumerate() {
session.apply(op).map_err(|e| {
let cause = format_op_error(&e);
match labels.get(i) {
Some(kind) => format!("op {i} ({kind}): {cause}"),
None => format!("op {i}: {cause}"),
}
})?;
}
let mut out = String::new();
for (slot, kind) in session.unwired() {
@@ -542,10 +633,11 @@ fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, Stri
}
/// `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).
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` /
/// `--taps <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: &aura_runner::project::Env) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
@@ -553,10 +645,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
+ cmd.folds as usize
+ cmd.registered as usize
+ cmd.params.is_some() as usize
+ cmd.taps.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 | --folds | --registered | --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> | --taps <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
);
std::process::exit(2);
}
@@ -614,10 +707,13 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
}
}
} 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 `exec --override`'s own resolution
// (`override_paths`, member.rs) resolves against (#328: the wrapped
// `<blueprint>.<node>.<param>` form was retired — there is exactly
// one axis namespace now, so the two surfaces agree line-for-line,
// see `params_lines`'s own doc comment).
match params_lines(target, env) {
Ok(s) => print!("{s}"),
Err(m) => {
@@ -625,6 +721,18 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
std::process::exit(1);
}
}
} else if let Some(target) = cmd.taps.as_deref() {
// --taps <FILE|ID> (#337): the positive declared-tap discovery view —
// #333 shipped only the refusal roster (`bind_tap_plan`'s
// `UnknownTap`), so provoking that refusal was the only way to learn
// a blueprint's declared tap names.
match taps_lines(target, env) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
} else {
// --content-id [FILE] / --identity-id (combinable): one build, then each
// requested id on its own line, content id first. With a FILE value the
@@ -635,8 +743,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
// The content id (#158) is the SHA256 of the same `blueprint_to_json`
// bytes `graph build` emits; the identity id (#171) the SHA256 of the
// debug-name-blind `blueprint_identity_json` form — both via the one
// shared `crate::content_id` primitive `topology_hash` also uses, so all
// surfaces agree by construction.
// shared `crate::content_id` primitive, the same hash the run
// manifest's `topology_hash` uses (computed inline in
// `aura_runner::member`'s `run_signal_r`/`run_blueprint_member` — #319
// Task 9 retired the CLI-side `topology_hash` wrapper this comment
// used to name), so all surfaces agree by construction.
let composite = match cmd.content_id.as_ref() {
Some(Some(file)) => {
let text = match std::fs::read_to_string(file) {
@@ -646,7 +757,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
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}");
@@ -694,14 +805,18 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
/// Phrase a blueprint-document `LoadError` as prose (Debug-leak-free; the
/// engine error types are `Display`-free by convention — this is the CLI's
/// presentation layer, like `format_op_error`). `pub(crate)`: shared by the
/// `graph build` path (below) and `aura run`'s loaded-blueprint branch
/// `graph build` path (below) and `aura exec`'s blueprint leg
/// (main.rs) — env-agnostic on purpose, so both callers get identical wording
/// (#184).
pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
match e {
LoadError::Json(err) => format!("blueprint document is not valid JSON: {err}"),
LoadError::UnsupportedVersion { found, supported } => {
format!("blueprint format_version {found} is unsupported (this build reads {supported})")
// #341 item 2: name the full accepted range, not just the ceiling — the
// loader's own floor is the fixed `1` of its `1..=BLUEPRINT_FORMAT_VERSION`
// check (`blueprint_serde::blueprint_from_json`), never carried in the
// error itself.
format!("blueprint format_version {found} is unsupported (this build reads versions 1..={supported})")
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
@@ -742,28 +857,38 @@ fn gang_fault_prose(e: &CompileError) -> String {
}
}
/// (#185/#241/#244) When an unresolved type id LOOKS project-namespaced
/// (`::`), the likeliest causes are environmental, tiered: outside any
/// project the hint points at the missing `Aura.toml`; inside a data-only
/// project it points at the missing node crate. Bare std ids (no `::`) and
/// invocations with a loaded node crate get no hint (the vocabulary error
/// carries the message). Shared core: both the blueprint LOAD path
/// (`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.
/// (#185/#241/#244/#341 item 5) When an unresolved type id LOOKS project-
/// namespaced (`::`), the likeliest causes are environmental, tiered: outside
/// any project the hint points at the missing `Aura.toml`; inside a data-only
/// project it points at the missing node crate. Outside any project, a BARE id
/// still gets no hint — at least as likely a plain typo of a std name as an
/// escalation miss, and "no Aura.toml" would mislead a std-typo author. Inside
/// a data-only project, though, a bare id gets the SAME escalation pointer as
/// a namespaced one (#341 item 5): the consumer who wrote a bare name is the
/// one least likely to know yet that new logic needs a namespace at all, so
/// withholding the hint from exactly them was backwards. A project with a
/// loaded node crate gets no hint either way (the vocabulary error carries the
/// message; an unresolved type there is a genuine miss, not an escalation
/// gap). Shared core: both the blueprint LOAD path (`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: &aura_runner::project::Env) -> Option<String> {
if !type_id.contains("::") {
return None;
}
let namespaced = type_id.contains("::");
match env.provenance() {
None => Some(
None if namespaced => Some(
"type id looks project-namespaced but no Aura.toml was found — run inside a project directory"
.to_string(),
),
Some(p) if p.namespace.is_none() => Some(
None => None,
Some(p) if p.namespace.is_none() && namespaced => Some(
"type id looks project-namespaced but this project binds no node crate — attach one with `aura nodes new <name>`"
.to_string(),
),
Some(p) if p.namespace.is_none() => Some(
"new logic is authored in Rust — this project binds no node crate yet; attach one with `aura nodes new <name>`"
.to_string(),
),
Some(_) => None,
}
}
@@ -777,45 +902,22 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::projec
}
}
/// Validate a blueprint-slot document — the `sweep`/`walkforward`/`mc`
/// loaded-blueprint branches' dispatch-boundary check (#210 c0110 fieldtest
/// finding: the sweep slot used to print the raw `{e:?}` Debug leak). Single-
/// sourced here so the three dual-grammar subcommands cannot diverge (#184's
/// convention, extended). Two refusals, checked in order:
///
/// 1. The document parses as JSON but is an op-script ARRAY (#157), not a
/// built blueprint envelope — refused with a targeted hint pointing at
/// `aura graph build`, since `blueprint_from_json` would otherwise surface
/// a confusing internal serde shape mismatch.
/// 2. `blueprint_from_json` fails to load the envelope — phrased house-style
/// via [`blueprint_load_prose`] (+ [`unresolved_namespace_hint`] where it
/// applies), never the raw `LoadError` Debug form.
///
/// `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: &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 \
'aura graph build' first"
.to_string(),
);
}
blueprint_from_json(doc, &|t| env.resolve(t)).map(|_| ()).map_err(|e| {
let mut msg = blueprint_load_prose(&e);
if let Some(hint) = unresolved_namespace_hint(&e, env) {
msg.push_str("");
msg.push_str(&hint);
}
msg
})
}
/// #196: build a `Composite` from a document that is EITHER a #155 blueprint
/// 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.
///
/// **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}"))?;
@@ -833,12 +935,76 @@ pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) ->
}
}
/// 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 exception — the one direct `gate_authored_root_name` caller
/// (main.rs).** One fresh-FILE intake reaches the same unsanitized
/// `traces/<name>/` seam without going through this wrapper, because it
/// 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:
/// - `exec <blueprint.json>` (`exec_blueprint_leg`, #319): envelope-only
/// grammar (no op-script fallback), feeds `signal.name()` into
/// `run_signal_r`/`run_measurement` -> `bind_tap_plan` ->
/// `TraceStore::begin_run`.
///
/// (The quintet's other direct callers — the now-retired `dispatch_run`,
/// `validate_and_register_axes`, `run_blueprint_sweep`/`_walkforward`/`_mc`,
/// `list_blueprint_axes` — none of these functions survives in the tree
/// today; all were retired together with the quintet, #319 Task 8.
/// `exec_blueprint_leg` above is the only direct caller left.)
///
/// This shares 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 `exec_blueprint_leg`'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 `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: &aura_runner::project::Env) -> Result<String, String> {
/// `exec`'s campaign-target resolution uses, so the two FILE-or-id surfaces
/// 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
@@ -848,11 +1014,12 @@ fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Resu
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 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()),
};
@@ -864,17 +1031,54 @@ fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Resu
/// 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.
/// 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 retired `aura sweep
/// --list-axes`'s own bound pass (`list_blueprint_axes`, main.rs, gone with
/// the #319 sugar retirement), 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 introspect --taps <FILE|ID>` (#337): one row per declared tap
/// — `<name> <node>.<field> <kind:?>` — the positive discovery view #333's
/// refusal roster (`bind_tap_plan`'s `UnknownTap`) deliberately deferred:
/// before this, the only way to learn a blueprint's declared tap names was
/// provoking that refusal. Loads exactly like `--params` (FILE gates the
/// authored root name, a store content id does not, C29). A tap-less
/// blueprint prints nothing to stdout — nothing was declared to list — plus a
/// stderr note (the `bind_tap_plan` unbound-this-run note's own "aura: note:"
/// convention), exit 0: a listing, not a fault.
fn taps_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
use std::fmt::Write as _;
let (text, is_file) = resolve_blueprint_text(target, env)?;
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
let taps = composite.declared_taps();
if taps.is_empty() {
crate::diag::note!("{target} declares no taps");
return Ok(String::new());
}
let mut out = String::new();
for (name, wire, kind) in taps {
let _ = writeln!(out, "{name} {wire} {kind:?}");
}
Ok(out)
}
@@ -908,7 +1112,9 @@ fn register_blueprint(
.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();
@@ -1002,6 +1208,18 @@ mod tests {
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
}
/// Property (#341 item 2): a v3 document's `UnsupportedVersion` refusal names
/// the FULL supported range, not just the ceiling — the pre-fix prose named
/// only `supported` ("this build reads 2"), leaving a reader to guess whether
/// 0/1 are also refused.
#[test]
fn blueprint_load_prose_unsupported_version_names_the_full_range() {
let e = LoadError::UnsupportedVersion { found: 3, supported: 2 };
let msg = super::blueprint_load_prose(&e);
assert!(msg.contains("1..=2"), "names the full supported range 1..=2: {msg}");
assert!(msg.contains('3'), "still names the found version: {msg}");
}
#[test]
fn build_from_str_reports_unconnected_slot_at_finalize() {
// sub.rhs left unwired -> the holistic 0-arm fires at the finalize step.
+1136 -2057
View File
File diff suppressed because it is too large Load Diff
+44 -29
View File
@@ -65,7 +65,7 @@ impl DocIntrospectCmd {
/// Parse `--parallel-instruments` in domain terms: clap's built-in
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
/// zero for non-zero type") at exactly the moment a user needs guidance.
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
pub(crate) fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
"must be a whole number of at least 1 — it bounds how many distinct \
instruments are resident in parallel"
@@ -161,7 +161,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
}
DocFault::BadRegime { index } => {
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
format!("risk[{index}]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0")
}
DocFault::BadCost { index } => {
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
@@ -411,19 +411,6 @@ pub enum CampaignSub {
Introspect(DocIntrospectCmd),
/// Register a valid campaign document into the store under the runs root.
Register { file: PathBuf },
/// Execute a stored campaign into a realized run-set (a .json file is
/// register-then-run sugar; the canonical address is the content id).
Run {
target: String,
/// Bound on distinct instruments resident in parallel (the RAM
/// lever; 1 reproduces the sequential loop's footprint).
#[arg(
long,
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
value_parser = parse_parallel_instruments,
)]
parallel_instruments: std::num::NonZeroUsize,
},
/// List stored campaign realizations, or dump one campaign's records
/// (the bare store lines, not the run-emit wrapper).
Runs { campaign: Option<String>, run: Option<usize> },
@@ -462,8 +449,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
RefFault::StrategyUnloadable { id, error } => {
format!("strategy {id} cannot be loaded: {error}")
}
RefFault::AxisNotInParamSpace { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
if let Some(candidate) = raw_candidate {
msg.push_str(&format!(
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
));
}
msg
}
RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
@@ -536,18 +529,7 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
Ok(())
}
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
/// via `exit_on_campaign_result`), so it is pulled out of the unified
/// `Result<(), String>` match the other four subcommands still share.
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
target,
env,
*parallel_instruments,
));
return;
}
let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => {
@@ -557,7 +539,6 @@ 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 {
eprintln!("aura: {m}");
@@ -761,6 +742,7 @@ mod tests {
ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
}),
ref_fault_prose(&RefFault::AxisKindMismatch {
strategy: "9f3a".into(),
@@ -781,6 +763,39 @@ mod tests {
}
}
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
/// and verbatim, exactly when `raw_candidate` is present — the document-
/// side refusal seam's mirror of the sweep `--axis` intake translation
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
/// `axis names are raw node.param paths` clause pinned there.
#[test]
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "graph.fast.length".into(),
raw_candidate: Some("fast.length".into()),
});
assert_eq!(
msg,
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
axis names are raw node.param paths — did you mean \"fast.length\"?"
);
}
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
/// leading segment yields no param-space hit, or the axis has no leading
/// segment to strip at all) renders today's prose unchanged — no
/// speculative suggestion.
#[test]
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
});
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
}
#[test]
/// #194 (the prefix trap): a doc ref that carries the display `content:`
/// prefix (a copy-paste from register/introspect output) is bare-only in
+15 -12
View File
@@ -231,11 +231,13 @@ const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data
This directory is an aura project: blueprints + research documents over the
std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
- Run: `aura run blueprints/signal.json` (the starter is closed — all
params bound; bound values are defaults — any `--axis` may override them
(#246))
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
- Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream
— the starter is closed; all params bound; bound values are defaults —
`--override NODE.PARAM=VALUE` may override one for this run (#246))
- Campaign: `aura exec <campaign.json>` executes a registered campaign
document (file or content id) — the multi-cell/axis surface
- Axes: `aura graph introspect --params blueprints/signal.json` lists the
open + bound-overridable axes, RAW `<node>.<param>` names (#328)
- Native nodes: when the project needs its first project-specific node,
`aura nodes new <name>` scaffolds a node crate beside this project and
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
@@ -245,9 +247,9 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
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`.
- Data plane: process/campaign documents are authored directly with
`aura process` / `aura campaign`, growing one from a bare `{}` via
`aura campaign introspect --unwired`.
"#;
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
@@ -458,11 +460,12 @@ mod tests {
}
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("demo-lab"));
// The CLAUDE.md sweep quickstart targets the closed starter itself
// with the rendered blueprint-name axis prefix (#246: a bound param
// is a default an axis overrides).
// The CLAUDE.md quickstart targets the closed starter itself through
// the surviving surface (#319): exec for both document classes, the
// #246 override residue, and raw-namespace axis discovery (#328).
assert!(claude.contains("blueprints/signal.json"));
assert!(claude.contains("demo_lab_signal.fast.length"));
assert!(claude.contains("--override NODE.PARAM=VALUE"));
assert!(claude.contains("graph introspect --params"));
}
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
File diff suppressed because it is too large Load Diff
+113 -26
View File
@@ -5,47 +5,136 @@
//! handled cleanly, never a panic.
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
mod common;
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// A fresh, unique working directory so the spawned `aura sweep` writes its
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, out.status.code())
}
fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(&p, text).expect("write doc");
p
}
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Seed one blueprint into the built demo project's store via `aura graph
/// register` and return its content id — copied verbatim from
/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep`
/// side-effect seeding this used to ride played no role in the returned
/// content id, a pure function of the loaded blueprint's canonical bytes;
/// `name` labels the registered id, mirroring the old per-call sweep family
/// name).
fn seed_blueprint(dir: &Path, name: &str) -> String {
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
assert_eq!(code, Some(0), "seed register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Register `doc` as a process document in the project store; returns its
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
write_doc(dir, file, doc);
let (out, code) = run_code_in(dir, &["process", "register", file]);
assert_eq!(code, Some(0), "process register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` —
/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same
/// shape, which yields exactly four family members (the four-line stream
/// this test's property needs).
fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "broken-pipe",
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = window.0,
to = window.1,
)
}
/// Property: a family-emitting command whose stdout reader closes early exits on
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken
/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON
/// lines via `println!`; when the reader closes stdout after the first line, the
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at
/// startup, so that write returns EPIPE and `println!` panics rather than the
/// process terminating quietly — exactly the behaviour a CLI must not have. This
/// is the contract: any aura subcommand can be piped into `| head` / `| less` /
/// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
/// when the reader closes stdout after the first line, the next write hits a
/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
/// write returns EPIPE and `println!` panics rather than the process
/// terminating quietly — exactly the behaviour a CLI must not have. This is
/// the contract: any aura subcommand can be piped into `| head` / `| less` /
/// a UI pane that goes away, and it must finish quietly, not panic.
///
/// Autonomous: constructs its own early-closing reader inline (no `head`, no
/// shell, no fixture files) by spawning the binary with a piped stdout and
/// dropping the read handle after a single short read, which closes the pipe's
/// read end and makes the writer's next write hit EPIPE.
#[test]
fn family_emitting_command_survives_early_reader_close() {
let cwd = temp_cwd("broken-pipe-sweep");
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir),
ScratchPath::File(dir.join("broken-pipe.process.json")),
ScratchPath::File(dir.join("broken-pipe.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "broken-pipe-seed");
let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC);
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
write_doc(&dir, "broken-pipe.campaign.json", &doc);
let mut child = Command::new(BIN)
.arg("sweep")
.current_dir(&cwd)
.args(["exec", "broken-pipe.campaign.json"])
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura sweep");
.expect("spawn aura exec");
// Read just enough to guarantee the writer is mid-stream, then drop the read
// handle. Dropping it closes the read end of the pipe; the next `println!`
@@ -63,9 +152,7 @@ fn family_emitting_command_survives_early_reader_close() {
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
let status = child.wait().expect("reap aura sweep");
let _ = std::fs::remove_dir_all(&cwd);
let status = child.wait().expect("reap aura exec");
// The defect: stderr carries the broken-pipe panic.
assert!(
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -1,7 +1,9 @@
//! Shared fixture helpers for the `aura-cli` end-to-end test binaries
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`), all of which build
//! and drive the `tests/fixtures/demo-project` fixture through the real
//! `aura` binary (#223).
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and —
//! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`,
//! `graph_construct.rs`), all of which build and drive the
//! `tests/fixtures/demo-project` fixture through the real `aura` binary
//! (#223).
//!
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
//! a unique tempdir per test, wired to the shared, once-built fixture crate
@@ -9,10 +11,10 @@
//! no two tests ever race on the same `runs/` store — libtest's default
//! thread parallelism applies without a serializing lock (#250).
//!
//! Each of the three test binaries compiles this file as its own `mod
//! common`, so an item unused by one binary trips that binary's `-D
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below
//! rather than deleting a helper just because one binary doesn't need it.
//! Each consuming test binary compiles this file as its own `mod common`, so
//! an item unused by one binary trips that binary's `-D warnings` `dead_code`
//! lint; see the `#[allow(dead_code)]` markers below rather than deleting a
//! helper just because one binary doesn't need it.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
@@ -182,7 +184,7 @@ impl Drop for ScratchGuard {
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
/// archive instead of streaming the 6.6 GB host archive (#250).
#[allow(dead_code)]
mod synthetic_data {
pub mod synthetic_data {
use std::io::Write;
use std::path::Path;
File diff suppressed because it is too large Load Diff
+638 -85
View File
@@ -5,6 +5,9 @@
use std::io::Write;
use std::process::{Command, Stdio};
mod common;
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// Run `aura <args>` with `stdin_doc` piped in; return (stdout, stderr, success).
@@ -88,6 +91,43 @@ const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
{"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.
@@ -242,7 +282,11 @@ fn graph_build_bad_tz_is_exit_1_naming_the_arg() {
/// #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.
/// discovery surface an author reads before writing the `args` object. The
/// note itself names the follow-up moves on the current surface (#341 item 3
/// harvest): `graph build` + `introspect --params` for the open knobs,
/// `--unwired` for a partial document's unfilled slots — so a consumer's
/// first port/param guess isn't a coin flip.
#[test]
fn graph_introspect_node_session_lists_arg_rows() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "Session"], "");
@@ -254,6 +298,14 @@ fn graph_introspect_node_session_lists_arg_rows() {
stdout.contains("ports and params form at construction"),
"shows the pending note: {stdout}"
);
assert!(
stdout.contains("aura graph introspect --params <bp.json>") && stdout.contains("open knobs"),
"the note names the params discovery follow-up: {stdout}"
);
assert!(
stdout.contains("--unwired") && stdout.contains("unfilled slots"),
"the note names the unwired-slots follow-up: {stdout}"
);
}
#[test]
@@ -442,6 +494,26 @@ fn graph_build_rejects_an_unknown_op_field() {
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
}
/// Property (#336): the unknown-key refusal cites the OFFENDING element's own op
/// index, not serde's `deny_unknown_fields` token position, which (for an
/// internally-tagged enum) surfaces only once the parser has consumed the whole
/// object and lands on the START OF THE FOLLOWING element. With the typo'd key at
/// index 1 (of 3), a line/column-based message would mislead toward index 2;
/// this pins the op-list's own coordinate instead.
#[test]
fn graph_build_unknown_op_field_names_the_offending_op_index() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"Const","params":{"value":{"F64":1.0}}},
{"op":"expose","from":"const.value","as":"bias"}
]"#;
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("op 1"), "names the offending element's own op index (1): {stderr}");
assert!(!stderr.contains("op 2"), "must not attribute the fault to the following element: {stderr}");
assert!(stderr.contains("params"), "still 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
@@ -660,16 +732,43 @@ fn register_refuses_undescribed_composites_end_to_end() {
/// 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, #319 retirement residue): `graph introspect --params`
/// prints the raw axis namespace — one `{name}:{kind:?}` line per open param,
/// then each bound param with its default (`list_blueprint_axes`, main.rs;
/// `params_lines`, this crate). This test used to also pin `aura sweep
/// --list-axes` as a byte-identical SECOND discovery surface over the same
/// namespace (guarding the two independently-dispatched surfaces against
/// drift); `sweep` is retired, so only `graph introspect --params`'s own
/// literal output survives here — see `graph_params_lists_raw_axis_namespace`
/// above for the sibling pin over the same fixture.
#[test]
fn graph_params_prints_raw_axis_names() {
let dir = temp_cwd("params-raw-axis-names");
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}");
assert_eq!(
params_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"
);
}
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
@@ -719,7 +818,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
@@ -736,7 +838,97 @@ 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 (#337): `--taps <FILE>` is the positive declared-tap discovery
/// view — before this, the only way to learn a blueprint's declared tap names
/// was provoking #333's undeclared-tap refusal roster. One row per declared
/// tap: name, source wire (`node.field`), column kind, in declaration order.
#[test]
fn graph_introspect_taps_lists_declared_taps_wire_and_kind() {
let dir = temp_cwd("taps-listed");
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"tap","from":"fast.value","as":"fast_ma"},
{"op":"tap","from":"sub.value","as":"spread"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (build_out, build_err, build_code) = run_in_stdin(&dir, &["graph", "build"], ops);
assert_eq!(build_code, Some(0), "graph build: {build_out} {build_err}");
let bp = dir.join("tapped.json");
std::fs::write(&bp, &build_out).expect("write built blueprint");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--taps", bp.to_str().unwrap()]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "fast_ma fast.value F64\nspread sub.value F64\n",
"one row per declared tap: name, source wire, column kind"
);
}
/// Property (#337 follow-up, harvest audit item 8): `--taps <ID>` resolves
/// through the project store exactly like `--params <ID>` already does
/// (`graph_params_by_content_id_matches_params_by_file`'s sibling) — register
/// a tap-bearing blueprint, then introspect its declared taps by content id;
/// the store path carries no `FILE`'s root-name gate (C29).
#[test]
fn graph_introspect_taps_by_content_id_matches_by_file() {
let dir = temp_cwd("taps-by-id");
let ops = r#"[
{"op":"doc","text":"fast/slow spread, tapped, exposed as 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":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"tap","from":"fast.value","as":"fast_ma"},
{"op":"tap","from":"sub.value","as":"spread"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (build_out, build_err, build_code) = run_in_stdin(&dir, &["graph", "build"], ops);
assert_eq!(build_code, Some(0), "graph build: {build_out} {build_err}");
let bp = dir.join("tapped.json");
std::fs::write(&bp, &build_out).expect("write built blueprint");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", bp.to_str().unwrap()]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let (by_id_out, by_id_err, by_id_code) =
run_in(&dir, &["graph", "introspect", "--taps", &id]);
assert_eq!(by_id_code, Some(0), "stdout: {by_id_out} stderr: {by_id_err}");
assert_eq!(
by_id_out, "fast_ma fast.value F64\nspread sub.value F64\n",
"same declared-tap rows resolved through the store by content id"
);
}
/// Property (#337): a blueprint with no declared taps prints nothing to
/// stdout (nothing was declared to list) plus a one-line stderr note — a
/// listing, not a fault, exit 0.
#[test]
fn graph_introspect_taps_reports_no_taps_on_stderr_when_none_declared() {
let dir = temp_cwd("taps-empty");
let bp = fixture("r_sma_open.json");
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--taps", &bp]);
assert_eq!(code, Some(0), "a tap-less blueprint is a listing, not a fault: {stderr}");
assert_eq!(stdout, "", "no declared taps to list: {stdout}");
assert!(
stderr.contains("declares no taps"),
"stderr carries the one-line note: {stderr}"
);
}
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
@@ -895,6 +1087,146 @@ fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
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");
}
/// (#331 spec test 10's missing sibling, harvest audit item 1): `introspect
/// --taps <FILE>` (#337) reads its FILE branch through the same
/// `composite_from_authored_text` choke point as `register` and `introspect
/// --content-id <FILE>` (C24's name-op clause), so a hand-crafted envelope
/// with a shape-violating root name refuses there too, with the identical
/// `name_gate_fault_prose` text — the per-site pin the C24 enumeration's
/// addition of `--taps` to its gated-intake list otherwise leaves unpinned.
#[test]
fn graph_introspect_taps_refuses_a_hand_crafted_envelope_with_a_bad_root_name() {
let dir = temp_cwd("taps-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}");
let (out2, err2, code2) =
run_in(&dir, &["graph", "introspect", "--taps", file.to_str().unwrap()]);
assert_eq!(code2, Some(1), "--taps <FILE> refuses too: {out2} {err2}");
assert_eq!(err2, err, "--taps shares the identical fault prose with register/--content-id");
}
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
/// introspect --params` accepts an op-script exactly as it accepts a blueprint
/// envelope — the raw axis namespace it prints for the op-script equals the one it
@@ -920,6 +1252,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
@@ -965,7 +1326,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
@@ -983,8 +1347,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"
);
}
@@ -1021,12 +1385,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");
@@ -1034,18 +1403,91 @@ 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"
);
}
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Register `doc` as a process document in `dir`'s project store; returns its
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
fn register_process_doc(dir: &std::path::Path, file: &str, doc: &str) -> String {
std::fs::write(dir.join(file), doc).expect("write process doc");
let (out, err, code) = run_in(dir, &["process", "register", file]);
assert_eq!(code, Some(0), "process register failed: {out} {err}");
out.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy campaign document over `bp_id`/`proc_id`, with a raw
/// `axes_json` object literal spliced in verbatim — a campaign document
/// refuses an empty `axes` map ("axes is empty"), so a "closed" comparison
/// cell (nothing to vary) passes a single-value axis over one of its OWN
/// already-bound params at its own default (a no-op reopen, #246: every
/// bound param is an equally re-openable axis). #319: campaigns are `exec`'s
/// surviving surface for binding a genuinely OPEN param to a value —
/// `--override` only reopens an already-BOUND one
/// (`aura_runner::member::override_paths` skips a name already in the open
/// space), so the gang-axis parity tests below run both the closed example
/// and its open-plus-axis twin through this SAME campaign machinery, over
/// the same synthetic archive window, keeping their metrics directly
/// comparable.
fn one_cell_campaign_doc(bp_id: &str, proc_id: &str, window: (i64, i64), axes_json: &str) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "gang-axis-vehicle",
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, "axes": {{{axes_json}}} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = window.0,
to = window.1,
)
}
/// Execs a one-cell campaign document (written to `dir.join(file)`) and
/// returns its single member's `report.metrics`.
fn one_cell_campaign_metrics(dir: &std::path::Path, file: &str) -> serde_json::Value {
let (out, err, code) = run_in(dir, &["exec", file]);
assert_eq!(code, Some(0), "campaign exec failed: {out} {err}");
let line = out
.lines()
.find(|l| l.starts_with(r#"{"family_id":"#))
.unwrap_or_else(|| panic!("no member line: {out} {err}"));
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
v["report"]["metrics"].clone()
}
/// Property (#61 + #159 cut 2): binding the shipped open example's ganged
/// `channel_length` axis through the REAL `aura sweep` CLI pipeline (argv ->
/// `parse_axes` -> `compile_with_cells`'s gang expansion) reproduces the exact
/// metrics of running the shipped CLOSED example, whose `channel_hi`/
/// `channel_lo` are separately hardwired to the same value. The equivalence
/// between a gang and its member-bound twin is already pinned at the builder/
/// engine level (aura-engine's `gang_e2e.rs`, and this crate's
/// `channel_length` axis through a REAL campaign axis (#319: the surviving
/// surface — `compile_with_cells`'s gang expansion, same as the retired
/// `aura sweep --axis`) reproduces the exact metrics of running the shipped
/// CLOSED example, whose `channel_hi`/`channel_lo` are separately hardwired
/// to the same value. Both legs run as one-cell campaigns over the same
/// synthetic archive window, so the comparison is data-source-agnostic (only
/// the axis-vs-hardwire binding differs). The equivalence between a gang and
/// its member-bound twin is already pinned at the builder/engine level
/// (aura-engine's `gang_e2e.rs`, and this crate's
/// `r_breakout_example_loaded_runs_identically_to_the_carved_signal`, which
/// calls `run_signal_r` directly); this instead drives the actual public
/// binary end-to-end on the actual shipped files, so a regression in the
@@ -1055,25 +1497,53 @@ fn open_r_breakout_fixture_lists_its_axis_namespace() {
/// equivalence still holds.
#[test]
fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-breakout-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_breakout.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
let closed: serde_json::Value =
serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir),
ScratchPath::File(dir.join("breakout-gang.process.json")),
ScratchPath::File(dir.join("breakout-gang-closed.campaign.json")),
ScratchPath::File(dir.join("breakout-gang-open.campaign.json")),
]);
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"],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
assert_eq!(lines.len(), 1, "one grid point for a single-value axis: {sweep_stdout}");
let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");
let (reg_closed_out, reg_closed_err, reg_closed_code) =
run_in(&dir, &["graph", "register", &example("r_breakout.json")]);
assert_eq!(reg_closed_code, Some(0), "register closed: {reg_closed_out} {reg_closed_err}");
let closed_id = registered_id(&reg_closed_out);
let (reg_open_out, reg_open_err, reg_open_code) =
run_in(&dir, &["graph", "register", &fixture("r_breakout_open.json")]);
assert_eq!(reg_open_code, Some(0), "register open: {reg_open_out} {reg_open_err}");
let open_id = registered_id(&reg_open_out);
let proc_id = register_process_doc(&dir, "breakout-gang.process.json", SWEEP_ONLY_PROCESS_DOC);
// The shared SYMA window used throughout the suite's campaign vehicles
// (inside `fresh_project_with_data`'s Jan-Aug 2024 synthetic archive).
let window = (1709251200000, 1719791999999);
std::fs::write(
dir.join("breakout-gang-closed.campaign.json"),
// No-op axis: `delay.lag` reopened at its own bound default (1).
one_cell_campaign_doc(&closed_id, &proc_id, window, r#""delay.lag": { "kind": "I64", "values": [1] }"#),
)
.expect("write closed campaign doc");
std::fs::write(
dir.join("breakout-gang-open.campaign.json"),
one_cell_campaign_doc(
&open_id,
&proc_id,
window,
r#""channel_length": { "kind": "I64", "values": [3] }"#,
),
)
.expect("write open campaign doc");
let closed_metrics = one_cell_campaign_metrics(&dir, "breakout-gang-closed.campaign.json");
let open_metrics = one_cell_campaign_metrics(&dir, "breakout-gang-open.campaign.json");
assert_eq!(
member["report"]["metrics"], closed["metrics"],
"channel_length=3, bound via the real sweep CLI, must fan out to both channel_hi \
open_metrics, closed_metrics,
"channel_length=3, bound via the campaign axis, must fan out to both channel_hi \
and channel_lo identically to the closed example's hardwired channel=3"
);
}
@@ -1086,7 +1556,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`)
@@ -1103,39 +1577,69 @@ fn open_r_meanrev_fixture_lists_its_axis_namespace() {
);
}
/// Property (#61 + #159 cut 3): a sweep over TWO axes at once — the ganged
/// `window` knob (fusing `mean_window.length`/`var_window.length`) alongside
/// the independent, un-ganged `band.factor` knob — binds both correctly
/// through the real CLI grid, matching the shipped closed example's hardwired
/// window=3/band=2.0. This is a distinct risk from the r_breakout gang test
/// (a lone gang axis): a mis-scoped gang-expansion map that shifts sibling
/// param positions could silently mis-bind the co-present un-ganged axis
/// instead (or vice-versa) even though each axis alone still resolves.
/// Property (#61 + #159 cut 3): a campaign over TWO axes at once (#319: the
/// surviving surface for what `aura sweep --axis` used to do inline) — the
/// ganged `window` knob (fusing `mean_window.length`/`var_window.length`)
/// alongside the independent, un-ganged `band.factor` knob — binds both
/// correctly through the real CLI grid, matching the shipped closed
/// example's hardwired window=3/band=2.0. This is a distinct risk from the
/// r_breakout gang test (a lone gang axis): a mis-scoped gang-expansion map
/// that shifts sibling param positions could silently mis-bind the
/// co-present un-ganged axis instead (or vice-versa) even though each axis
/// alone still resolves. Both legs run as one-cell campaigns over the same
/// synthetic archive window, so the comparison is data-source-agnostic.
#[test]
fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-meanrev-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_meanrev.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
let closed: serde_json::Value =
serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir),
ScratchPath::File(dir.join("meanrev-gang.process.json")),
ScratchPath::File(dir.join("meanrev-gang-closed.campaign.json")),
ScratchPath::File(dir.join("meanrev-gang-open.campaign.json")),
]);
let sweep_dir = temp_cwd("r-meanrev-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&[
"sweep", &fixture("r_meanrev_open.json"),
"--axis", "r_meanrev_signal.window=3",
"--axis", "r_meanrev_signal.band.factor=2.0",
],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
assert_eq!(lines.len(), 1, "one grid point for two single-value axes: {sweep_stdout}");
let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");
let (reg_closed_out, reg_closed_err, reg_closed_code) =
run_in(&dir, &["graph", "register", &example("r_meanrev.json")]);
assert_eq!(reg_closed_code, Some(0), "register closed: {reg_closed_out} {reg_closed_err}");
let closed_id = registered_id(&reg_closed_out);
let (reg_open_out, reg_open_err, reg_open_code) =
run_in(&dir, &["graph", "register", &fixture("r_meanrev_open.json")]);
assert_eq!(reg_open_code, Some(0), "register open: {reg_open_out} {reg_open_err}");
let open_id = registered_id(&reg_open_out);
let proc_id = register_process_doc(&dir, "meanrev-gang.process.json", SWEEP_ONLY_PROCESS_DOC);
let window = (1709251200000, 1719791999999);
std::fs::write(
dir.join("meanrev-gang-closed.campaign.json"),
// No-op axis: `mean_window.length` reopened at its own bound default (3).
one_cell_campaign_doc(
&closed_id,
&proc_id,
window,
r#""mean_window.length": { "kind": "I64", "values": [3] }"#,
),
)
.expect("write closed campaign doc");
std::fs::write(
dir.join("meanrev-gang-open.campaign.json"),
one_cell_campaign_doc(
&open_id,
&proc_id,
window,
r#""window": { "kind": "I64", "values": [3] }, "band.factor": { "kind": "F64", "values": [2.0] }"#,
),
)
.expect("write open campaign doc");
let closed_metrics = one_cell_campaign_metrics(&dir, "meanrev-gang-closed.campaign.json");
let open_metrics = one_cell_campaign_metrics(&dir, "meanrev-gang-open.campaign.json");
assert_eq!(
member["report"]["metrics"], closed["metrics"],
"window=3 (ganged) + band.factor=2.0 (plain), bound via the real sweep CLI, must \
open_metrics, closed_metrics,
"window=3 (ganged) + band.factor=2.0 (plain), bound via the campaign axes, must \
match the closed example's hardwired window=3/band=2.0"
);
}
@@ -1539,6 +2043,25 @@ fn graph_build_hints_attach_for_namespaced_ids_in_a_data_only_project() {
);
}
/// Property (#341 item 5): the BARE counterpart of the test above — the
/// consumer least likely to know yet that new logic is Rust (and therefore
/// least likely to namespace their type) is the one who most needs the
/// escalation pointer, not the one least likely to get it. Same data-only
/// project, same unresolved-type refusal, no `::` in the type id this time.
#[test]
fn graph_build_hints_attach_for_a_bare_unresolved_type_in_a_data_only_project() {
let dir = temp_cwd("graph-build-dataonly-bare-hint");
std::fs::write(dir.join("Aura.toml"), "").expect("write bare Aura.toml");
let ops = r#"[{"op":"add","type":"ThirdCandle","name":"n"}]"#;
let (stdout, stderr, code) = run_in_stdin(&dir, &["graph", "build"], ops);
assert_ne!(code, Some(0), "an unresolvable bare type refuses: {stderr}");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("aura nodes new"),
"the bare-form refusal now carries the same escalation pointer as the namespaced one: {stderr}"
);
}
/// Property (#284): the `tap` op-script op, driven through the real `aura
/// graph build` process (not an in-crate call to `build_from_str`), resolves
/// a name-addressed interior wire (`"fast.value"`, session node 0 / output
@@ -1607,12 +2130,17 @@ fn tap_authored_via_op_script_runs_and_persists_the_series() {
let bp_path = dir.join("tapped.json");
std::fs::write(&bp_path, &blueprint_json).expect("write built blueprint");
let (_stdout, stderr2, code2) = run_in(&dir, &["run", bp_path.to_str().unwrap()]);
let (stdout2, stderr2, code2) = run_in(&dir, &["exec", bp_path.to_str().unwrap()]);
assert_eq!(code2, Some(0), "aura run over the op-authored tap: {stderr2}");
// `aura graph build` always names the root composite "graph" (`composite_from_str`),
// so the trace store's run-name directory is fixed.
let trace_path = dir.join("runs/traces/graph/slow_ma.json");
// `aura graph build` always names the root composite "graph"
// (`composite_from_str`), but #311 keys the directory by the RUN's identity,
// so the render name is only its prefix — read the handle off the record.
let record: serde_json::Value =
serde_json::from_str(stdout2.trim()).expect("stdout is one JSON record line");
let handle = record["trace_name"].as_str().expect("a recording run prints its trace_name");
assert!(handle.starts_with("graph-"), "the render name stays the readable prefix: {handle}");
let trace_path = dir.join("runs/traces").join(handle).join("slow_ma.json");
let trace_text = std::fs::read_to_string(&trace_path)
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
@@ -1744,6 +2272,30 @@ fn graph_build_use_resolves_a_label_and_echoes_the_id() {
);
}
/// `graph introspect --unwired` on a use-bearing document (#339 item 4
/// harvest): the build-free introspection path resolves `use` refs through
/// the store exactly like `graph build` does now, so a document splicing a
/// registered pattern lists the open slots reaching THROUGH the splice (the
/// pattern's own open input role, surfaced as `trend.x`) alongside an
/// ordinary leaf node's open slot — instead of a hard `UnknownSubgraph` miss.
#[test]
fn graph_introspect_unwired_lists_open_slots_through_a_use_splice() {
let dir = temp_cwd("unwired-use-splice");
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":"add","type":"SMA","name":"solo"},
{"op":"use","ref":{"name":"smooth"},"name":"trend"}
]"#;
let (stdout, stderr, code) = run_in_stdin(&dir, &["graph", "introspect", "--unwired"], consumer);
assert_eq!(code, Some(0), "a use-bearing document introspects: {stderr}");
assert!(stdout.contains("trend.x"), "the splice's own open role surfaces through: {stdout}");
assert!(stdout.contains("solo.series"), "the leaf node's own open slot still lists: {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]
@@ -1849,10 +2401,10 @@ fn graph_build_use_docless_source_refuses_with_the_c29_shape_exit_1() {
/// 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.
/// `aura graph introspect --params` 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");
@@ -1887,11 +2439,11 @@ fn graph_build_use_end_to_end_axes() {
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}");
run_in(&dir, &["graph", "introspect", "--params", consumer_bp.to_str().unwrap()]);
assert_eq!(axes_code, Some(0), "graph introspect --params: {axes_out} {axes_err}");
assert_eq!(
axes_out, "graph.trend.sma.length:I64\n",
"the spliced instance's open param surfaces path-qualified, bare (unbound) form"
axes_out, "trend.sma.length:I64\n",
"the spliced instance's open param surfaces path-qualified, bare (unbound) RAW form (#328)"
);
}
@@ -1914,10 +2466,9 @@ fn graph_build_accepts_an_open_input_pattern() {
/// 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.
/// `CompileError::UnboundRootRole` at bootstrap — rendered by role NAME
/// (#339 item 3), not the raw flat index the earlier form left as a
/// follow-up.
#[test]
fn running_an_open_blueprint_refuses_at_bootstrap() {
let doc = r#"[
@@ -1931,11 +2482,13 @@ fn running_an_open_blueprint_refuses_at_bootstrap() {
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}");
let (stdout, stderr, code) = run_in(&dir, &["exec", bp.to_str().unwrap()]);
// C14 class 2: fault in argv-named content (#297)
assert_eq!(code, Some(2), "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}"
stderr.contains("root role \"price\" is unbound"),
"the runnability gate (compile), unchanged, now names the role, not a raw index: {stderr}"
);
assert!(!stderr.contains("UnboundRootRole"), "Debug leak: {stderr}");
}
+20 -25
View File
@@ -1,7 +1,7 @@
//! 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
//! End-to-end pins for the self-description surfaces (#315, #323, #319): the
//! help opens with the two-layer concept, `exec` names both document classes
//! it executes, every introspection roster carries per-entry meanings, and
//! `graph build --help` carries the op-list reference. Driven over the
//! built binary — the zero-setup surface a reader without repo access gets.
use std::process::Command;
@@ -26,7 +26,10 @@ fn run(args: &[&str]) -> (String, String, bool) {
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("the one executor over both document classes"),
"names exec + the document classes: {out}"
);
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
@@ -34,31 +37,22 @@ fn top_level_help_opens_with_the_two_layer_concept() {
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.
/// #319: `exec --help` names both document classes it accepts — the sugar
/// verbs' retirement leaves `exec` as the one executor over a campaign
/// document (file or content id) or a signal blueprint (single run).
#[test]
fn 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}");
fn exec_help_names_both_document_classes() {
let (out, err, ok) = run(&["exec", "--help"]);
assert!(ok, "aura exec --help failed: {err}");
assert!(out.contains("campaign"), "names the campaign document class: {out}");
assert!(out.contains("blueprint"), "names the blueprint document class: {out}");
assert!(out.contains("single run"), "names the blueprint leg's single-run shape: {out}");
}
/// #323: `graph build --help` carries the op-list reference — the op kinds
/// and fields are learnable from the binary, not only from serde refusals.
/// #317: the `use` op joins the roster (nine -> ten).
/// #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"]);
@@ -74,6 +68,7 @@ fn graph_build_help_carries_the_op_reference() {
r#"{"op":"gang""#,
r#"{"op":"doc""#,
r#"{"op":"use""#,
r#"{"op":"name""#,
] {
assert!(out.contains(op), "op reference carries {op}: {out}");
}
+73 -12
View File
@@ -31,15 +31,28 @@ fn two_tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
}
fn run_measurement(cwd: &Path) {
/// Run the two-tap measurement blueprint and return the trace handle it
/// printed. #311: the trace directory is keyed by the run's own identity, so
/// every `measure ic` argv below addresses the run by the handle it reported —
/// which is also this file's read-back pin for the analysis consumer.
fn run_measurement(cwd: &Path) -> String {
let bp = cwd.join("measurement.json");
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
let out = Command::new(BIN)
.args(["run", bp.to_str().unwrap()])
.args(["exec", bp.to_str().unwrap()])
.current_dir(cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
let handle = v["trace_name"]
.as_str()
.expect("a recording run prints its trace_name")
.to_string();
assert!(handle.starts_with("sma_signal-"), "the render name stays the prefix: {handle}");
handle
}
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
@@ -51,11 +64,17 @@ fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
#[test]
fn measure_ic_emits_a_well_formed_report() {
let cwd = temp_cwd("wellformed");
run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &[]);
let handle = run_measurement(&cwd);
let out = measure_ic(&cwd, &handle, &[]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
assert_eq!(r["run"], "sma_signal");
// #311: the handle the run printed reaches that run's data — the analysis
// consumer's own round trip through the identity-keyed store.
assert_eq!(
r["run"].as_str(),
Some(handle.as_str()),
"the report addresses the run by the handle the run itself printed"
);
assert_eq!(r["signal_tap"], "signal");
assert_eq!(r["price_tap"], "price");
assert_eq!(r["horizon"], 1);
@@ -71,9 +90,9 @@ fn measure_ic_emits_a_well_formed_report() {
#[test]
fn measure_ic_is_deterministic() {
let cwd = temp_cwd("determinism");
run_measurement(&cwd);
let a = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
let b = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
let handle = run_measurement(&cwd);
let a = measure_ic(&cwd, &handle, &["--seed", "9"]);
let b = measure_ic(&cwd, &handle, &["--seed", "9"]);
assert!(a.status.success() && b.status.success());
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
}
@@ -97,8 +116,8 @@ fn measure_ic_unknown_run_errors() {
#[test]
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
let cwd = temp_cwd("oversizedhorizon");
run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &["--horizon", "100000000"]);
let handle = run_measurement(&cwd);
let out = measure_ic(&cwd, &handle, &["--horizon", "100000000"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
assert_eq!(r["horizon"], 100_000_000);
@@ -110,9 +129,9 @@ fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
#[test]
fn measure_ic_missing_tap_errors() {
let cwd = temp_cwd("missingtap");
run_measurement(&cwd);
let handle = run_measurement(&cwd);
let out = Command::new(BIN)
.args(["measure", "ic", "sma_signal", "--signal", "nope", "--price", "price"])
.args(["measure", "ic", &handle, "--signal", "nope", "--price", "price"])
.current_dir(&cwd)
.output()
.expect("spawn");
@@ -123,3 +142,45 @@ fn measure_ic_missing_tap_errors() {
String::from_utf8_lossy(&out.stderr)
);
}
/// #309 fieldtest: a campaign's trace handle is exactly what the run printed
/// and what `chart` accepts, so denying that it exists at all is false. A
/// family holds members, not one series — say so, and name the members, since
/// no other text surface lists them.
#[test]
fn measure_ic_on_a_family_handle_names_its_members_instead_of_denying_the_handle() {
let cwd = temp_cwd("measure-ic-family");
for member in ["c0/m0", "c0/m1"] {
let dir = cwd.join("runs/traces/fam1234-0").join(member);
std::fs::create_dir_all(&dir).expect("fabricate a family fan-out");
// A minimally valid member index: the manifest shape the store reads
// back, with no taps (the refusal needs the member KEY, not series).
std::fs::write(
dir.join("index.json"),
r#"{"manifest":{"commit":"fieldtest","params":[],"defaults":[],"window":[0,0],"seed":0,"broker":"sim"},"taps":[]}"#,
)
.expect("write member index");
}
let out = std::process::Command::new(BIN)
.args(["measure", "ic", "fam1234-0", "--signal", "signal", "--price", "price"])
.current_dir(&cwd)
.output()
.expect("spawn measure ic");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("no recorded run 'fam1234-0'"),
"the handle exists as a family; denying it outright is false: {stderr}"
);
assert!(
stderr.contains("family"),
"the refusal must say the handle names a family: {stderr}"
);
assert!(
stderr.contains("c0/m0") && stderr.contains("c0/m1"),
"the refusal must name the member keys, the only text route to them: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
+92 -31
View File
@@ -1,8 +1,9 @@
//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture
//! project once (cargo, same toolchain, path-dep on this workspace's
//! aura-core), then drives the aura binary from inside the fixture dir.
//! `aura run` persists nothing without --trace, so the tracked fixture stays
//! clean (target/, Cargo.lock, runs/ are fixture-gitignored).
//! `aura exec` persists only the taps a blueprint declares (C27) and the
//! fixture blueprints declare none, so the tracked fixture stays clean
//! (target/, Cargo.lock, runs/ are fixture-gitignored).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@@ -38,13 +39,13 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
#[test]
fn project_run_resolves_demo_node_and_is_bit_identical() {
let dir = built_project();
let a = aura(&["run", "demo_signal.json"], dir);
let a = aura(&["exec", "demo_signal.json"], dir);
assert!(
a.status.success(),
"run failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "demo_signal.json"], dir);
let b = aura(&["exec", "demo_signal.json"], dir);
assert!(b.status.success());
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
}
@@ -52,7 +53,7 @@ fn project_run_resolves_demo_node_and_is_bit_identical() {
#[test]
fn project_run_manifest_carries_provenance() {
let dir = built_project();
let out = aura(&["run", "demo_signal.json"], dir);
let out = aura(&["exec", "demo_signal.json"], dir);
assert!(out.status.success());
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("run report is JSON");
@@ -83,7 +84,7 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
let bp = fixture_dir().join("demo_signal.json");
let out = aura(&["run", bp.to_str().unwrap()], cwd);
let out = aura(&["exec", bp.to_str().unwrap()], cwd);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
@@ -104,31 +105,91 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
/// subdirectory. This is the property that makes `[paths]` project-relative
/// rather than shell-relative (C17).
///
/// #319 vehicle note: the synthetic in-process family builder this test used
/// to drive via bare `sweep --axis` (no `--real`) retired with the quintet —
/// no surviving CLI surface produces a family record without touching a real
/// archive (`exec`'s campaign leg always resolves against `DataServer`), so
/// the vehicle is now a one-cell campaign over the `fresh_project_with_data`
/// synthetic `SYMA` archive (mirrors `exec.rs`'s `seed_blueprint` +
/// `campaign_doc_json_for` recipe), executed from a project subdirectory.
#[test]
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
let dir = built_project();
let sub = dir.join("src");
let (dir, _fixture) = common::fresh_project_with_data();
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).expect("create invocation subdirectory");
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let closed_bp =
format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let reg_out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["graph", "register", &closed_bp, "--name", "proj-anchor-seed"])
.current_dir(&dir)
.output()
.expect("spawn aura graph register");
assert!(reg_out.status.success(), "register failed: {}", String::from_utf8_lossy(&reg_out.stderr));
let reg_text = String::from_utf8_lossy(&reg_out.stdout);
let bp_id = reg_text
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.to_string();
let process_doc = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
std::fs::write(dir.join("anchor.process.json"), process_doc).expect("write process doc");
let proc_out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "register", "anchor.process.json"])
.current_dir(&dir)
.output()
.expect("spawn aura process register");
assert!(proc_out.status.success(), "process register failed: {}", String::from_utf8_lossy(&proc_out.stderr));
let proc_text = String::from_utf8_lossy(&proc_out.stdout);
let proc_id = proc_text
.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string();
// The established `SYMA` window (2024-03..06, fully inside its 2024-01..08
// span) other #319 vehicle campaigns use.
let campaign_doc = format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "run-seam",
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#
);
std::fs::write(dir.join("anchor.campaign.json"), &campaign_doc).expect("write campaign doc");
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"proj-anchor",
])
.args(["exec", "../anchor.campaign.json"])
.current_dir(&sub)
.output()
.expect("spawn aura sweep");
.expect("spawn aura exec");
assert!(
out.status.success(),
"sweep failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
@@ -162,7 +223,7 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
"badcharter fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
let run = aura(&["exec", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
@@ -191,7 +252,7 @@ fn undescribed_vocabulary_entry_refuses_end_to_end() {
"undescribed fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
let run = aura(&["exec", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
@@ -229,7 +290,7 @@ fn restated_name_vocabulary_entry_refuses_end_to_end() {
"restated fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
let run = aura(&["exec", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
@@ -287,7 +348,7 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
set_mtime(&dylib, DYLIB_MTIME);
set_mtime(&src, SOURCE_MTIME);
let out = aura(&["run", "demo_signal.json"], dir);
let out = aura(&["exec", "demo_signal.json"], dir);
// Restore sane mtimes before asserting, so a failed assertion does not
// leave the shared fixture wedged "stale" (or with a future mtime that
@@ -351,7 +412,7 @@ fn nested_nodes_dir() -> PathBuf {
fn data_only_project_runs_and_stamps_commit_only_provenance() {
let dir = dataonly_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "signal.json"])
.args(["exec", "signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -387,7 +448,7 @@ fn data_only_project_runs_and_stamps_commit_only_provenance() {
fn multi_crate_pointer_refuses_end_to_end() {
let dir = multicrate_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "x.json"])
.args(["exec", "x.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -427,7 +488,7 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
String::from_utf8_lossy(&build.stderr)
);
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "pointer_signal.json"])
.args(["exec", "pointer_signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -455,7 +516,7 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
let bp = tmp.join("bp.json");
std::fs::write(&bp, r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"nosuch::Node"}}],"edges":[],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}],"source":"F64"}],"output":[{"node":0,"field":0,"name":"bias"}]}}"#).unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", bp.to_str().unwrap()])
.args(["exec", bp.to_str().unwrap()])
.current_dir(&tmp)
.output()
.expect("spawn aura");
@@ -479,7 +540,7 @@ fn missing_artifact_refuses_with_build_hint() {
"[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
)
.unwrap();
let out = aura(&["run", "x.json"], &tmp);
let out = aura(&["exec", "x.json"], &tmp);
assert_eq!(out.status.code(), Some(1), "runtime refusal");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cargo build"), "build hint present: {err}");
+130 -39
View File
@@ -1,11 +1,12 @@
//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction
//! — plus the refusal contract. Scaffolded projects live in per-test temp
//! dirs.
//! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the
//! refusal contract. Scaffolded projects live in per-test temp dirs.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
mod common;
fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
@@ -61,17 +62,17 @@ fn init_and_commit(dir: &Path) {
///
/// Documented deviation from the task text: the plan's wording asks this
/// headline to also assert that the run "writes a manifest under `x/runs/`".
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the
/// trace store — it only prints the `RunReport` to stdout; only
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241
/// split this task's file list does not touch (giving `run` a store write
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new`
/// and this test file). There is thus no on-disk manifest for THIS verb to
/// assert against; the assertion below proves that absence directly instead
/// of only asserting it in prose, and the persisted-store property the plan
/// wanted is covered by `data_only_project_sweeps_without_any_build` below,
/// which does assert `proj.join("runs").exists()`.
/// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r`
/// (main.rs): a single-blueprint `aura exec` never touches the trace store —
/// it only prints the `RunReport` to stdout; only exec's campaign leg
/// persists (`env.trace_store()`), a pre-#241 split this task's file list
/// does not touch (giving the blueprint leg a store write would widen scope
/// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test
/// file). There is thus no on-disk manifest for THIS leg to assert against;
/// the assertion below proves that absence directly instead of only
/// asserting it in prose, and the persisted-store property the plan wanted
/// is covered by `data_only_project_execs_a_campaign_without_any_build`
/// below, which does assert `proj.join("runs").exists()`.
#[test]
fn new_scaffolds_and_runs_the_data_only_loop() {
let base = tmp("loop");
@@ -102,13 +103,13 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml");
assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs");
let a = aura(&["run", "blueprints/signal.json"], &proj);
let a = aura(&["exec", "blueprints/signal.json"], &proj);
assert!(
a.status.success(),
"run failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "blueprints/signal.json"], &proj);
let b = aura(&["exec", "blueprints/signal.json"], &proj);
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
let p = &v["manifest"]["project"];
@@ -116,50 +117,140 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
assert!(
!proj.join("runs").exists(),
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \
the task text's \"writes a manifest under x/runs/\" does not hold for this verb"
"a plain single-blueprint `aura exec` must not persist a trace store (only exec's \
campaign leg does) — the task text's \"writes a manifest under x/runs/\" does not \
hold for this leg"
);
let _ = std::fs::remove_dir_all(&base);
}
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's
/// gate) with zero build step — using the scaffolded (closed) starter
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises.
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Writes a tiny, self-contained January-2024 `SYMA` archive into
/// `<proj>/data` and points the scaffolded `Aura.toml`'s `[paths]` table at
/// it. #319: `exec`'s campaign leg (the surviving surface for what `aura
/// sweep --axis` used to do inline) needs a registered instrument, unlike a
/// plain blueprint run — this keeps the scaffolder's "zero host setup"
/// headline intact (no real archive mount) by minting the archive straight
/// into the scaffold instead.
fn seed_synthetic_archive(proj: &Path) {
let data_dir = proj.join("data");
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
common::synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 1));
let toml_path = proj.join("Aura.toml");
let toml = std::fs::read_to_string(&toml_path).expect("read scaffolded Aura.toml");
let toml = toml.replacen("runs = \"runs\"\n", "runs = \"runs\"\ndata = \"data\"\n", 1);
std::fs::write(&toml_path, toml).expect("point Aura.toml at the synthetic archive");
}
/// The id extracted from a `registered <kind> <id> (<path>)` line (#194: bare
/// id, no `content:` prefix — tolerant of the pre-#194 prefix all the same).
fn registered_id(stdout: &str, kind: &str) -> String {
let needle = format!("registered {kind} ");
stdout
.lines()
.find(|l| l.starts_with(&needle))
.unwrap_or_else(|| panic!("no \"{needle}\" line: {stdout}"))
.trim_start_matches(&needle)
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, one-axis campaign document over `bp_id`/`proc_id` — the
/// surviving surface for what `aura sweep --axis` used to do inline over the
/// scaffolded starter's bound `fast.length` (#246: a bound param is a default
/// an axis overrides). Windowed fully inside `seed_synthetic_archive`'s
/// January-2024 `SYMA` archive.
fn one_axis_campaign_doc(bp_id: &str, proc_id: &str, axis_name: &str, axis_values: &str) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "quickstart",
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745599999 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "{axis_name}": {{ "kind": "I64", "values": [{axis_values}] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 1,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#
)
}
/// #241 headline: a fresh data-only project executes a small campaign — #319's
/// surviving surface for the retired `aura sweep` — with zero build step,
/// using the scaffolded (closed) starter blueprint and the exact axis the
/// scaffolded CLAUDE.md advertises. Registering the blueprint straight (`aura
/// graph register`, not a sweep side-effect) and seeding a tiny synthetic
/// archive (`seed_synthetic_archive`) keeps the loop free of any host data
/// mount, matching the pre-retirement headline.
///
/// #246: the closed starter IS the sweep target — a bound param is a default
/// an axis overrides.
/// #246: the closed starter IS the campaign axis target — a bound param is a
/// default an axis overrides.
#[test]
fn data_only_project_sweeps_without_any_build() {
fn data_only_project_execs_a_campaign_without_any_build() {
let base = tmp("sweep");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
&proj,
);
seed_synthetic_archive(&proj);
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let bp_id = registered_id(&String::from_utf8_lossy(&reg.stdout), "blueprint");
std::fs::write(proj.join("quickstart.process.json"), SWEEP_ONLY_PROCESS_DOC)
.expect("write process doc");
let preg = aura(&["process", "register", "quickstart.process.json"], &proj);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4");
std::fs::write(proj.join("quickstart.campaign.json"), &doc).expect("write campaign doc");
let out = aura(&["exec", "quickstart.campaign.json"], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(proj.join("runs").exists(), "store must land inside the project");
let _ = std::fs::remove_dir_all(&base);
}
/// Property (#246): the scaffold's sweep quickstart genuinely re-opens the
/// Property (#246): the scaffold's campaign quickstart genuinely re-opens the
/// bound `fast.length` param — the persisted family has exactly one member
/// per axis value, not a single collapsed member. A silent regression where
/// the bound-param override is dropped (member always built from the
/// blueprint's own default) would still exit 0 and still create a `runs`
/// store, so `data_only_project_sweeps_without_any_build` above cannot catch
/// it; only the member count can.
/// store, so `data_only_project_execs_a_campaign_without_any_build` above
/// cannot catch it; only the member count can.
#[test]
fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
fn data_only_project_campaign_over_the_starter_opens_one_member_per_axis_value() {
let base = tmp("sweep-members");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
&proj,
);
seed_synthetic_archive(&proj);
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let bp_id = registered_id(&String::from_utf8_lossy(&reg.stdout), "blueprint");
std::fs::write(proj.join("members.process.json"), SWEEP_ONLY_PROCESS_DOC)
.expect("write process doc");
let preg = aura(&["process", "register", "members.process.json"], &proj);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4, 8");
std::fs::write(proj.join("members.campaign.json"), &doc).expect("write campaign doc");
let out = aura(&["exec", "members.campaign.json"], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let fams = aura(&["runs", "families"], &proj);
assert!(fams.status.success(), "runs families exit: {:?}", fams.status);
@@ -301,10 +392,10 @@ fn new_outside_a_work_tree_leaves_a_resolvable_head() {
// ...so the first quickstart run stamps a commit into the manifest, not
// an empty provenance block.
let run = aura(&["run", "blueprints/signal.json"], &proj);
let run = aura(&["exec", "blueprints/signal.json"], &proj);
assert!(
run.status.success(),
"run failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&run.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
+81 -43
View File
@@ -149,72 +149,110 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>))
}
/// Acceptance box 1 (#235): a project-authored node with an OPEN param is
/// discoverable as a sweep axis, sweeps over real data into a persisted family,
/// and that family reproduces bit-identically — the defining research loop of a
/// genuine external project ("sweep my OWN node's knob"), proven end to end.
/// discoverable as an axis, execs over real data (a campaign — #319: the
/// surviving surface for what `aura sweep --axis` used to do inline) into a
/// persisted family, and that family reproduces bit-identically — the
/// defining research loop of a genuine external project ("vary my OWN node's
/// knob"), proven end to end.
#[test]
fn project_node_open_param_sweeps_and_reproduces() {
let (dir, _g) = fresh_project();
// (a) `--list-axes` discovers the PROJECT node's own open knob, followed by
// the fixture's three BOUND params as `default=`-lines (#246: every bound
// param is an equally re-openable `--axis`, listed regardless of how many
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
// no data, so the discoverability half runs on every host. The wrapped
// name is `<blueprint>.<node>.<param>`.
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
// (a) `graph introspect --params` discovers the PROJECT node's own open
// knob, followed by the fixture's three BOUND params as `default=`-lines
// (#246: every bound param is an equally re-openable axis, listed
// regardless of how many knobs happen to be open alongside it). NOT
// gated: enumerating axes needs no data, so the discoverability half runs
// on every host. The name is RAW `<node>.<param>` (#328: the blueprint
// name stays out of axis paths).
let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]);
assert!(
axes.status.success(),
"--list-axes stderr: {}",
"graph introspect --params stderr: {}",
String::from_utf8_lossy(&axes.stderr)
);
assert_eq!(
String::from_utf8_lossy(&axes.stdout),
"scaled_signal.gain.factor:F64\n\
scaled_signal.fast.length:I64 default=2\n\
scaled_signal.slow.length:I64 default=4\n\
scaled_signal.bias.scale:F64 default=0.5\n",
"gain.factor:F64\n\
fast.length:I64 default=2\n\
slow.length:I64 default=4\n\
bias.scale:F64 default=0.5\n",
"the project node's own OPEN param is the one open axis, alongside the \
fixture's bound defaults; stderr: {}",
String::from_utf8_lossy(&axes.stderr)
);
// The real-data sweep/reproduce legs are archive-gated.
// The real-data campaign/reproduce legs are archive-gated.
if !local_data_present() {
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
return;
}
// (b) sweep the project node's knob over real GER40 data -> two members.
let sweep = aura_in(
dir,
&[
"sweep",
"blueprints/scaled_open.json",
"--real",
"GER40",
"--from",
GER40_FROM_MS,
"--to",
GER40_TO_MS,
"--axis",
"scaled_signal.gain.factor=0.5,1.0",
"--name",
"knob",
],
// (b) exec a campaign over the project node's knob, real GER40 data ->
// two members. Register the blueprint straight (not a sweep side-effect)
// and a minimal sweep-only process, then reference both by content id.
let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let reg_out = String::from_utf8_lossy(&reg.stdout).into_owned();
let bp_id = reg_out
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("blueprint id")
.to_string();
std::fs::write(dir.join("knob-reproduce.process.json"), SWEEP_ONLY_PROCESS)
.expect("write process doc");
let preg = aura_in(dir, &["process", "register", "knob-reproduce.process.json"]);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let preg_out = String::from_utf8_lossy(&preg.stdout).into_owned();
let proc_id = preg_out
.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("process id")
.to_string();
let campaign = format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "knob-reproduce",
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
"axes": {{ "gain.factor": {{ "kind": "F64", "values": [0.5, 1.0] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = GER40_FROM_MS,
to = GER40_TO_MS,
bp = bp_id,
proc = proc_id,
);
std::fs::write(dir.join("knob-reproduce.campaign.json"), &campaign).expect("write campaign doc");
let exec = aura_in(dir, &["exec", "knob-reproduce.campaign.json"]);
assert!(
sweep.status.success(),
"sweep stderr: {}",
String::from_utf8_lossy(&sweep.stderr)
exec.status.success(),
"exec stderr: {}",
String::from_utf8_lossy(&exec.stderr)
);
let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().collect();
let stdout = String::from_utf8_lossy(&exec.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().filter(|l| l.starts_with(r#"{"family_id":"#)).collect();
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
// Each member's manifest carries the swept PROJECT-node param binding under
// its wrapped name — the load-bearing proof that it is MY node's knob that
// varied across the family, not some incidental std axis.
// Each member's manifest carries the swept PROJECT-node param binding
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
// real/campaign route's own `manifest.params` mints raw too now) — the
// load-bearing proof that it is MY node's knob that varied across the
// family, not some incidental std axis.
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
assert_eq!(
@@ -227,7 +265,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
.expect("manifest.params is an array");
let bound = params
.iter()
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
.find(|p| p[0].as_str() == Some("gain.factor"))
.and_then(|p| p[1]["F64"].as_f64());
assert_eq!(
bound,
@@ -322,7 +360,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
);
std::fs::write(dir.join("knob.campaign.json"), &campaign).expect("write campaign doc");
let run = aura_in(dir, &["campaign", "run", "knob.campaign.json"]);
let run = aura_in(dir, &["exec", "knob.campaign.json"]);
let out = format!(
"{}{}",
String::from_utf8_lossy(&run.stdout),
+137 -132
View File
@@ -319,34 +319,9 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
]);
// Seed one real, content-addressed blueprint into the project's own
// store via a real sweep over its bound params, which the axes reopen
// as overridable defaults (#246) (mirrors `project_load.rs`'s anchor
// test).
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (sweep_out, sweep_code) = run_code_in(
&dir,
&[
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"campaign-ref-seed",
],
);
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
.expect("blueprints dir")
.next()
.expect("one stored blueprint")
.expect("dir entry")
.path()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned();
// store via `aura graph register` (#319 — the retired `sweep`
// side-effect seeding this used to ride).
let bp_id = seed_blueprint(&dir, "campaign-ref-seed");
// Register a valid AND executable process into the same project store:
// this test's OK campaign must pass all three validate tiers, and
@@ -377,10 +352,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,
@@ -440,32 +416,8 @@ fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit
]);
// Seed one real, content-addressed blueprint (mirrors the content_id
// sibling test above).
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (sweep_out, sweep_code) = run_code_in(
&dir,
&[
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"campaign-identity-seed",
],
);
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
.expect("blueprints dir")
.next()
.expect("one stored blueprint")
.expect("dir entry")
.path()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned();
// sibling test above; #319 — the retired `sweep` side-effect seeding).
let bp_id = seed_blueprint(&dir, "campaign-identity-seed");
let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json"));
// The identity id of the SAME stored bytes — `--content-id FILE
@@ -710,7 +662,7 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() {
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"),
"stdout/stderr: {out}"
);
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
@@ -1240,7 +1192,7 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
assert!(
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"),
"stdout/stderr: {out}"
);
assert!(
@@ -1277,35 +1229,26 @@ fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
);
}
/// Seed one blueprint (its params bound, reopened by the sweep axes per
/// #246) into the built demo project's store via a real sweep and return
/// its content id (the referential test's recipe).
/// Seed one blueprint into the built demo project's store via `aura graph
/// register` and return its content id (the referential test's recipe).
/// `#319` retired the `sweep`-side-effect seeding this used to ride (its
/// `--axis`/reopen mechanics played no role in the returned content id,
/// which is a pure function of the loaded blueprint's canonical bytes);
/// `name` labels the registered id for readability/uniqueness across call
/// sites, mirroring the old per-call sweep family name.
fn seed_blueprint(dir: &Path, name: &str) -> String {
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (out, code) = run_code_in(
dir,
&[
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
name,
],
);
assert_eq!(code, Some(0), "seed sweep failed: {out}");
std::fs::read_dir(dir.join("runs").join("blueprints"))
.expect("blueprints dir")
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
assert_eq!(code, Some(0), "seed register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("one stored blueprint")
.expect("dir entry")
.path()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Register `doc` as a process document in the project store; returns its id.
@@ -1493,7 +1436,7 @@ const WF_PROCESS_DOC: &str = r#"{
fn campaign_run_outside_project_refuses() {
let dir = temp_cwd("campaign-run-outside-project");
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
let (out, code) = run_code_in(&dir, &["campaign", "run", "c.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "c.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
assert!(
@@ -1507,7 +1450,7 @@ fn campaign_run_outside_project_refuses() {
#[test]
fn campaign_run_bogus_target_refuses() {
let (dir, _fixture) = fresh_project();
let (out, code) = run_code_in(&dir, &["campaign", "run", "no-such-target"]);
let (out, code) = run_code_in(&dir, &["exec", "no-such-target"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"),
@@ -1520,7 +1463,7 @@ fn campaign_run_bogus_target_refuses() {
fn campaign_run_unknown_id_refuses() {
let (dir, _fixture) = fresh_project();
let id = "0".repeat(64);
let (out, code) = run_code_in(&dir, &["campaign", "run", &id]);
let (out, code) = run_code_in(&dir, &["exec", &id]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains(&format!("no campaign {id} in the project store")),
@@ -1638,7 +1581,7 @@ fn campaign_run_refuses_mc_before_walk_forward() {
let bp_id = seed_blueprint(&dir, "campaign-run-mcwf-seed");
let proc_id = register_process_doc(&dir, "mcwf.process.json", MC_BEFORE_WF_PROCESS_DOC);
write_doc(&dir, "mcwf.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "mcwf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "mcwf.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
assert!(
@@ -1688,7 +1631,7 @@ fn campaign_run_refuses_a_non_terminal_selection_free_sweep() {
let proc_id =
register_process_doc(&dir, "selfree.process.json", SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC);
write_doc(&dir, "selfree.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "selfree.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "selfree.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains(
@@ -1802,7 +1745,7 @@ fn campaign_run_refuses_a_grid_stage_not_immediately_before_walk_forward() {
"gridgate.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridgate.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gridgate.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
assert!(
@@ -1855,7 +1798,7 @@ fn campaign_run_synthetic_e2e_grid_then_wf_persists_no_sweep_family() {
"",
),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridwf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gridwf.campaign.json"]);
assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out
@@ -1917,7 +1860,7 @@ fn campaign_run_synthetic_e2e_cell_order_is_document_order_under_parallel_instru
for k in ["1", "2"] {
let (out, code) = run_code_in(
&dir,
&["campaign", "run", "cellorder.campaign.json", "--parallel-instruments", k],
&["exec", "cellorder.campaign.json", "--parallel-instruments", k],
);
assert_eq!(code, Some(0), "K={k}: campaign run failed: {out}");
let line = out
@@ -1978,7 +1921,7 @@ fn campaign_run_synthetic_e2e_parallel_instruments_contains_a_real_per_cell_faul
let (out, code) = run_code_in(
&dir,
&["campaign", "run", "parfault.campaign.json", "--parallel-instruments", "2"],
&["exec", "parfault.campaign.json", "--parallel-instruments", "2"],
);
assert_eq!(code, Some(3), "one contained fault, one success: {out}");
let line = out
@@ -2033,7 +1976,7 @@ fn campaign_run_synthetic_e2e_gate_emptied_cell_carries_the_note_marker() {
write_doc(&dir, "gateempty.campaign.json", &doc);
let (out, code) =
run_code_in(&dir, &["campaign", "run", "gateempty.campaign.json"]);
run_code_in(&dir, &["exec", "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 "),
@@ -2065,7 +2008,7 @@ fn campaign_run_refuses_single_instrument_generalize() {
let bp_id = seed_blueprint(&dir, "campaign-run-gen1-seed");
let proc_id = register_process_doc(&dir, "gen1.process.json", GENERALIZE_PROCESS_DOC);
write_doc(&dir, "gen1.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "gen1.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gen1.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("std::generalize needs at least 2"),
@@ -2126,7 +2069,7 @@ fn campaign_run_refuses_generalize_non_r_metric() {
}}"#
);
write_doc(&dir, "genpip.campaign.json", &campaign_doc);
let (out, code) = run_code_in(&dir, &["campaign", "run", "genpip.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "genpip.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
// #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable
// R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is
@@ -2190,7 +2133,7 @@ fn process_validate_permits_wf_after_mc_but_campaign_run_refuses_it() {
let bp_id = seed_blueprint(&dir, "campaign-run-wfmc-seed");
let proc_id = register_process_doc(&dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
write_doc(&dir, "wfmc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "wfmc.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "wfmc.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("std::walk_forward cannot follow std::monte_carlo"),
@@ -2399,7 +2342,7 @@ fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() {
let bp_id = seed_blueprint(&dir, "campaign-run-zeromc-seed");
let proc_id = register_process_doc(&dir, "zeromc.process.json", ZERO_RESAMPLES_MC_PROCESS_DOC);
write_doc(&dir, "zeromc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "zeromc.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "zeromc.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("process stage 1: monte_carlo resamples must be > 0"),
@@ -2434,7 +2377,7 @@ fn campaign_run_refuses_unknown_tap_at_validate() {
"badtap.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "badtap.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "badtap.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
assert!(
@@ -2477,7 +2420,7 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() {
"taps.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "taps.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "taps.campaign.json"]);
assert_eq!(code, Some(3), "stdout/stderr: {out}");
assert!(
!out.contains("unknown tap"),
@@ -2547,7 +2490,7 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
"\"family_table\", \"selection_report\"",
),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "wf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "wf.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -2724,7 +2667,7 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_pooled_oos_bootstrap() {
// Both runs share the store (the cost-e2e sibling pattern) — the record
// line is read from each run's own stdout, so no isolation is needed.
let pooled = |doc: &str| -> serde_json::Value {
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
let (out, code) = run_code_in(&dir, &["exec", doc]);
assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out
.lines()
@@ -2801,7 +2744,7 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap() {
write_doc(&dir, "net_ps.campaign.json", &with_cost);
let per_survivor = |doc: &str| -> Vec<(u64, serde_json::Value)> {
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
let (out, code) = run_code_in(&dir, &["exec", doc]);
assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out
.lines()
@@ -2863,7 +2806,7 @@ fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() {
(1709251200000, 1717199999999),
);
write_doc(&dir, "percost.campaign.json", &doc);
let (out, code) = run_code_in(&dir, &["campaign", "run", "percost.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "percost.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
// Member emit lines carry report.manifest; group the stamped resolved
@@ -2924,7 +2867,7 @@ fn campaign_run_synthetic_e2e_vol_tf_regime_realizes_and_stamps() {
);
assert_ne!(with_risk, base, "replacen must actually match the seed field");
write_doc(&dir, "voltf.campaign.json", &with_risk);
let (out, code) = run_code_in(&dir, &["campaign", "run", "voltf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "voltf.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
let record_line = out
@@ -3049,7 +2992,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
write_doc(&dir, "close.campaign.json", &base);
write_doc(&dir, "open.campaign.json", &rebound);
let (out_close, code_close) = run_code_in(&dir, &["campaign", "run", "close.campaign.json"]);
let (out_close, code_close) = run_code_in(&dir, &["exec", "close.campaign.json"]);
if code_close == Some(3)
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
{
@@ -3057,7 +3000,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
return;
}
assert_eq!(code_close, Some(0), "stdout/stderr: {out_close}");
let (out_open, code_open) = run_code_in(&dir, &["campaign", "run", "open.campaign.json"]);
let (out_open, code_open) = run_code_in(&dir, &["exec", "open.campaign.json"]);
assert_eq!(code_open, Some(0), "stdout/stderr: {out_open}");
let first_member = |out: &str| -> serde_json::Value {
@@ -3112,7 +3055,7 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
);
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "regime.campaign.json", &with_regime);
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "regime.campaign.json"]);
if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
@@ -3143,6 +3086,68 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
}
}
/// Property (#338, the `campaign_run_real_e2e_non_default_regime_stamps_its_own_stop`
/// precedent above, `Fixed` edition): a campaign document's `RiskRegime::Fixed`
/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually
/// stamped into each emitted member's manifest as `stop_distance` — making the
/// shipped `FixedStop` composite campaign-reachable end to end, not merely
/// intrinsically valid.
#[test]
fn campaign_run_real_e2e_fixed_regime_stamps_its_own_distance() {
let (dir, _fixture) = fresh_project();
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("fixed_regime.process.json")),
ScratchPath::File(dir.join("fixed_regime.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "campaign-run-fixed-regime-seed");
let proc_id = register_process_doc(&dir, "fixed_regime.process.json", SWEEP_ONLY_PROCESS_DOC);
let base = campaign_doc_json(
&bp_id,
&proc_id,
(1725148800000, 1727740799999),
"",
"\"family_table\"",
);
let with_regime = base.replacen(
"\"seed\": 7,",
"\"seed\": 7,\n \"risk\": [ { \"fixed\": { \"distance\": 12.0 } } ],",
1,
);
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "fixed_regime.campaign.json", &with_regime);
let (out, code) = run_code_in(&dir, &["exec", "fixed_regime.campaign.json"]);
if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{
eprintln!("skip: no local GER40 data for the fixed-regime campaign e2e");
return;
}
assert_eq!(code, Some(0), "stdout/stderr: {out}");
let member_lines: Vec<&str> =
out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect();
assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}");
for line in &member_lines {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
let params = v["report"]["manifest"]["params"]
.as_array()
.expect("manifest.params is an array");
let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name));
assert_eq!(
get("stop_distance").and_then(|p| p[1]["F64"].as_f64()),
Some(12.0),
"the campaign's own fixed distance is stamped, not the R_SMA vol-stop default: {line}"
);
assert!(
get("stop_length").is_none() && get("stop_k").is_none(),
"a Fixed stop stamps no vol knobs: {line}"
);
}
}
/// Property (#210 T3/T4 — per-cell regime resolution, not a shared last-write):
/// a campaign with TWO distinct non-default risk regimes stamps EACH regime's
/// OWN stop into ITS OWN cell's members, never the other regime's values and
@@ -3182,7 +3187,7 @@ fn campaign_run_real_e2e_two_regimes_each_stamp_their_own_stop() {
);
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "tworegimes.campaign.json", &with_regimes);
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "tworegimes.campaign.json"]);
if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
@@ -3269,7 +3274,7 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
);
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "regime-persist.campaign.json", &with_regime);
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "regime-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3315,9 +3320,9 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
/// trace-persist re-run must not false-fail the C1 drift alarm, exercised
/// through the REAL production path — `campaign run` -> `run_campaign_returning`
/// -> `CliMemberRunner` (net-mode member run) -> `persist_campaign_traces`
/// (non-reduce re-run binding a `CostLeg` via `cost_nodes_for`) — not the
/// (retained-delivery re-run binding a `CostLeg` via `cost_nodes_for`) — not the
/// hand-mirrored arithmetic pinned by
/// `persist_side_nonreduce_rerun_matches_reduce_mode_net_metrics_under_cost` in
/// `persist_side_retained_rerun_matches_folded_net_metrics_under_cost` in
/// `main.rs`'s unit tests. Mirrors the sibling non-default-regime e2e above
/// (same #219-class divergence risk, cost edition): a costed campaign
/// document, `persist_taps` non-empty so the re-run actually executes, must
@@ -3349,7 +3354,7 @@ fn campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "cost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "cost-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "cost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3444,7 +3449,7 @@ fn campaign_run_real_e2e_net_r_equity_tap_persists_the_cost_adjusted_curve() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "net-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "net-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "net-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3525,7 +3530,7 @@ fn campaign_run_real_e2e_net_r_equity_tap_persists_the_cost_adjusted_curve() {
/// its `constant` sibling
/// (`campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm`):
/// `campaign run` -> `CliMemberRunner` (net-mode) -> `persist_campaign_traces`
/// (non-reduce re-run binding a `CostLeg`). The re-run must bind the identical
/// (retained-delivery re-run binding a `CostLeg`). The re-run must bind the identical
/// vol-slippage model the nominee ran under, so it must not false-fail the C1
/// drift alarm; the emitted member must stamp `cost[0].slip_vol_mult`; and the
/// vol-driven charge must actually move net off gross (non-vacuity — a
@@ -3558,7 +3563,7 @@ fn campaign_run_real_e2e_vol_slippage_cost_persists_without_drift_alarm() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "volcost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "volcost-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "volcost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3647,7 +3652,7 @@ fn campaign_persist_two_regimes_land_in_distinct_trace_dirs() {
);
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "tworegimes-persist.campaign.json", &with_regimes);
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "tworegimes-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3725,7 +3730,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
"",
),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "mixedtap.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "mixedtap.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3841,7 +3846,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
// (a) the cost-less baseline: net == gross, and net_r_equity skips with
// the remedy notice while equity still persists.
let (out_gross, code_gross) = run_code_in(&dir, &["campaign", "run", "costless.campaign.json"]);
let (out_gross, code_gross) = run_code_in(&dir, &["exec", "costless.campaign.json"]);
if code_gross == Some(3)
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
{
@@ -3863,7 +3868,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
// (b) the costed run: gross identical member-for-member, net strictly
// below gross, no skip notice, net_r_equity persisted on disk.
let (out_net, code_net) = run_code_in(&dir, &["campaign", "run", "costnet.campaign.json"]);
let (out_net, code_net) = run_code_in(&dir, &["exec", "costnet.campaign.json"]);
assert_eq!(code_net, Some(0), "costed run: {out_net}");
assert!(
!out_net.contains("skipped"),
@@ -3928,7 +3933,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
/// charge or `vol_slippage`'s volatility-proxy-scaled charge) — nets a real
/// campaign member through the SAME production path as its `constant`/
/// `vol_slippage` siblings (`campaign run` -> `CliMemberRunner` (net-mode) ->
/// `persist_campaign_traces`, non-reduce re-run binding a `CostLeg`). The
/// `persist_campaign_traces`, retained-delivery re-run binding a `CostLeg`). The
/// re-run must bind the identical carry model the nominee ran under, so it
/// must not false-fail the C1 drift alarm; the emitted member must stamp
/// `cost[0].carry_per_cycle`; and the accrual must actually move at least one
@@ -3962,7 +3967,7 @@ fn campaign_run_real_e2e_carry_cost_persists_without_drift_alarm() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "carrycost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "carrycost-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "carrycost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -4034,7 +4039,7 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
"mc.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1725148800000, 1727740799999), "", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "mc.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "mc.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -4105,7 +4110,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
let proc_id = register_process_doc(&dir, "mc2.process.json", MC_PROCESS_DOC);
write_doc(&dir, "mc2.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (file_out, file_code) = run_code_in(&dir, &["campaign", "run", "mc2.campaign.json"]);
let (file_out, file_code) = run_code_in(&dir, &["exec", "mc2.campaign.json"]);
assert_eq!(file_code, Some(3), "stdout/stderr: {file_out}");
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
@@ -4120,7 +4125,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
.expect("id")
.trim_start_matches("content:")
.to_string();
let (id_out, id_code) = run_code_in(&dir, &["campaign", "run", &id]);
let (id_out, id_code) = run_code_in(&dir, &["exec", &id]);
assert_eq!(id_code, Some(3), "stdout/stderr: {id_out}");
let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line");
@@ -4168,8 +4173,8 @@ fn campaign_run_tolerates_content_prefix_on_target() {
.trim_start_matches("content:")
.to_string();
let (bare_out, _) = run_code_in(&dir, &["campaign", "run", &id]);
let (pfx_out, _) = run_code_in(&dir, &["campaign", "run", &format!("content:{id}")]);
let (bare_out, _) = run_code_in(&dir, &["exec", &id]);
let (pfx_out, _) = run_code_in(&dir, &["exec", &format!("content:{id}")]);
let pfx_line = pfx_out.lines().find(|l| l.starts_with("aura: ")).expect("prefixed refusal line");
assert!(
@@ -4221,7 +4226,7 @@ fn campaign_run_accepts_a_graph_register_seeded_strategy() {
let proc_id = register_process_doc(&dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
write_doc(&dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "onramp.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "onramp.campaign.json"]);
assert_eq!(code, Some(3), "stdout/stderr: {out}");
assert!(
out.contains("no recorded geometry") || out.contains("no data for instrument"),
@@ -4250,7 +4255,7 @@ fn campaign_run_invalid_file_refuses_before_touching_store() {
let bad_path = write_doc(&dir, "bad.campaign.json", &bad);
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone()), ScratchPath::File(bad_path)]);
let (out, code) = run_code_in(&dir, &["campaign", "run", "bad.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "bad.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("aura: campaign document invalid:"),
@@ -4303,7 +4308,7 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
}}"#,
);
write_doc(&dir, "gap.campaign.json", &doc);
let (out, code) = run_code_in(&dir, &["campaign", "run", "gap.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gap.campaign.json"]);
assert_eq!(code, Some(3), "stdout/stderr: {out}");
let record_line = out
@@ -4345,7 +4350,7 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
/// clap refuses it as a usage error (exit 2) before any target resolution.
#[test]
fn campaign_run_rejects_a_zero_parallel_instruments_bound() {
let (out, code) = run_code(&["campaign", "run", "unused", "--parallel-instruments", "0"]);
let (out, code) = run_code(&["exec", "unused", "--parallel-instruments", "0"]);
assert_eq!(code, Some(2), "clap usage error expected, got: {out}");
// Domain prose, not clap's raw NonZeroUsize wording ("number would be
// zero for non-zero type") — the fieldtest friction finding.
+106 -5
View File
@@ -15,6 +15,19 @@ fn temp_cwd(name: &str) -> std::path::PathBuf {
dir
}
/// The `trace_name` handle a recording run printed on its stdout record line.
/// #311: the trace directory is `<render-name>-<id8>`, keyed by the run's own
/// identity, so the path is derived from this value rather than hard-coded.
fn trace_handle(stdout: &[u8]) -> String {
let line = String::from_utf8_lossy(stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
v["trace_name"]
.as_str()
.expect("a recording run prints its trace_name")
.to_string()
}
/// The shipped `examples/r_sma.json`, turned MEASUREMENT-shaped: one declared tap
/// on node 0 ("fast", SMA(2)) field 0, and its `bias` output removed. Same closed
/// topology (`sma_signal`), so it runs on the built-in synthetic stream.
@@ -34,7 +47,7 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
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")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -49,7 +62,8 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
assert_eq!(report["manifest"]["broker"], "measurement");
// The tapped series is persisted through the trace store.
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
let trace_path =
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json");
let trace_text = std::fs::read_to_string(&trace_path)
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
@@ -65,14 +79,16 @@ fn measurement_run_honours_the_tap_selector() {
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"])
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace_text = std::fs::read_to_string(
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("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");
@@ -81,3 +97,88 @@ fn measurement_run_honours_the_tap_selector() {
let expected = (1.0097_f64 + 1.0092) / 2.0;
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
}
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
/// normalizes there) instead of refusing at the same root-name choke point
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
/// property that actually matters — the escaped directory is never created.
#[test]
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
let cwd = temp_cwd("bad-root-name");
let mut v: serde_json::Value =
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
v["blueprint"]["name"] = serde_json::json!("../x");
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
let bp_path = cwd.join("bad-root-name.json");
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(
out.status.code(),
Some(2),
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(r#"blueprint name "../x" is invalid"#),
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
);
assert!(
out.stdout.is_empty(),
"a refused run must not print a report: {:?}",
String::from_utf8_lossy(&out.stdout)
);
// The property that actually matters: the escaped write target (one level
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
// was never created — the gate fires before `begin_run` ever touches disk.
assert!(
!cwd.join("runs/x").exists(),
"the escaped trace directory must never be created"
);
assert!(
!cwd.join("runs/traces").exists(),
"no trace directory of any shape is written on a refused run"
);
}
/// #309: the measurement leg reports its trace handle exactly as the strategy
/// leg does — both bind through one shared tap-plan pair, so a handle present
/// on one and absent on the other would mean the seam had drifted.
#[test]
fn measurement_run_reports_its_trace_handle_on_stdout() {
let cwd = temp_cwd("measurement-trace-handle");
let bp_path = cwd.join("measurement.json");
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout);
let report: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON object");
let handle = report["trace_name"].as_str().expect("trace_name is present and a string");
assert!(
cwd.join("runs/traces").join(handle).join("fast_tap.json").exists(),
"the reported handle must name the directory the tapped series landed in; got {handle}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
@@ -1,8 +1,8 @@
//! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the
//! established binding-refusal register — never a process panic. Both cases
//! are user mistakes on the hand-authored single-run path, so each deserves a
//! diagnosis, not a stack trace.
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 2, the
//! argv-named blueprint-content class (C14) — never a process panic. Both
//! cases are user mistakes on the hand-authored single-run path, so each
//! deserves a diagnosis, not a stack trace.
//!
//! Two independent unrunnable shapes, each pinned as its own minimal,
//! self-contained blueprint (inlined here — no shared fixture file):
@@ -38,7 +38,7 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
std::fs::write(&bp_path, bp_json).expect("write blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -51,16 +51,18 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
!stderr.to_lowercase().contains("panic"),
"{what}: expected a clean refusal, got a PANIC:\n{stderr}"
);
// The clean-refusal register: `aura: <msg>` on stderr, exit 1 — exactly
// what the binding refusals in `run_signal_r` already emit.
// The clean-refusal register: `aura: <msg>` on stderr — the prefix every
// refusal class (C14) shares regardless of exit code.
assert!(
stderr.contains("aura:"),
"{what}: refusal must carry the `aura:` prefix:\n{stderr}"
);
// C14 class 2: fault in argv-named content (#297) — both callers of this
// helper name a compile-time / exposed-output content fault.
assert_eq!(
out.status.code(),
Some(1),
"{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}"
Some(2),
"{what}: a refusal exits 2 (blueprint-content fault, not 101 panic, not 1 env/data fault):\n{stderr}"
);
}
+346 -32
View File
@@ -41,6 +41,20 @@ fn tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
}
/// The `trace_name` handle a recording run printed on its stdout record line —
/// the address of the directory its taps landed in. #311: that directory is
/// `<render-name>-<id8>`, keyed by the run's own identity, so no test may
/// hard-code it; every trace path in this file is derived from this value.
fn trace_handle(stdout: &[u8]) -> String {
let line = String::from_utf8_lossy(stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
v["trace_name"]
.as_str()
.expect("a recording run prints its trace_name")
.to_string()
}
/// Property: on the single-run path (`aura run <blueprint.json>` over the
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
/// per-cycle series is persisted through the trace store — the persisted
@@ -53,13 +67,14 @@ fn single_run_persists_a_declared_tap_series() {
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")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
let trace_path =
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json");
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
});
@@ -100,7 +115,7 @@ fn tap_free_run_writes_no_trace_store() {
let cwd = temp_cwd("tap-free-writes-nothing");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(BIN)
.args(["run", &bp])
.args(["exec", &bp])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -152,12 +167,14 @@ fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
// C14 class 2: fault in argv-named content (#297)
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("dup") && stderr.contains("more than once"),
@@ -178,12 +195,14 @@ fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
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")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
// C14 class 1: environment fault (#297)
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
@@ -196,6 +215,15 @@ fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
/// 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).
///
/// #311: the run directory's name is the run's own identity and cannot be known
/// in advance, so the premise is set up by RUNNING first — the identity property
/// itself guarantees that a second, byte-identical invocation targets the same
/// directory. Emptying it and dropping its write bit between the two runs
/// reproduces the original premise exactly (an existing, unwritable run dir)
/// without hard-coding a name. Making the store ROOT unwritable instead would
/// move the failure to `begin_run`, i.e. into the pre-run arm its sibling at
/// `an_unwritable_store_root_exits_through_the_tap_trace_register` already pins.
#[cfg(unix)]
#[test]
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
@@ -204,28 +232,44 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
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");
let exec = || {
Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run")
};
let first = exec();
assert!(first.status.success(), "stderr: {}", String::from_utf8_lossy(&first.stderr));
let run_dir = cwd.join("runs/traces").join(trace_handle(&first.stdout));
for entry in std::fs::read_dir(&run_dir).expect("read the run dir") {
std::fs::remove_file(entry.expect("dir entry").path()).expect("empty the 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");
let out = exec();
// 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");
// C14 class 1: environment fault (#297)
assert_eq!(out.status.code(), Some(1));
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");
assert_eq!(
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
1,
"the identical re-run targeted the same directory rather than minting a second"
);
}
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
@@ -239,19 +283,20 @@ fn run_tap_selector_persists_a_fold_summary_row() {
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"])
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
let run_dir = cwd.join("runs/traces").join(trace_handle(&out.stdout));
let index_text =
std::fs::read_to_string(run_dir.join("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_text =
std::fs::read_to_string(run_dir.join("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");
@@ -275,8 +320,8 @@ fn run_explicit_record_plan_matches_the_record_all_default() {
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"];
let run = |cwd: &std::path::Path, extra: &[&str]| -> std::path::PathBuf {
let mut args = vec!["exec", "two_tap.json"];
args.extend_from_slice(extra);
let out = Command::new(BIN)
.args(&args)
@@ -284,21 +329,28 @@ fn run_explicit_record_plan_matches_the_record_all_default() {
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
cwd.join("runs/traces").join(trace_handle(&out.stdout))
};
run(&cwd_a, &[]);
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
let dir_a = run(&cwd_a, &[]);
let dir_b = run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
// #311: the tap PLAN is not identity-bearing — it selects what is written,
// not what the run IS — so both invocations mint the same handle.
assert_eq!(
dir_a.file_name(),
dir_b.file_name(),
"the same blueprint under the same inputs is one identity either way"
);
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");
let a = std::fs::read(dir_a.join(file)).expect("default trace");
let b = std::fs::read(dir_b.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");
let taps_of = |dir: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(dir.join("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));
assert_eq!(taps_of(&dir_a), taps_of(&dir_b));
}
/// #310: an unknown fold label is refused through the registry's
@@ -309,11 +361,13 @@ fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
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"])
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unknown fold label must refuse");
// C14 class 2: fault in argv-named content (#297)
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("medain") && stderr.contains("mean"),
@@ -334,11 +388,13 @@ fn run_tap_selector_refuses_an_undeclared_tap() {
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"])
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an undeclared tap must refuse");
// C14 class 2: fault in argv-named content (#297)
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
assert!(
@@ -359,7 +415,7 @@ fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
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"])
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -384,7 +440,7 @@ fn run_with_no_tap_flag_emits_no_unbound_note() {
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")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -407,7 +463,7 @@ fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
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")];
let mut full = vec!["exec", bp_path.to_str().expect("utf-8 path")];
full.extend(args);
let out = Command::new(BIN)
.args(&full)
@@ -420,3 +476,261 @@ fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
assert!(!cwd.join("runs").exists());
}
}
/// #309: a run that records traces names the handle they landed under, on the
/// data plane — so a caller can chain `exec` into `chart` without knowing the
/// blueprint's render name. The tapped fixture's root composite is named
/// `sma_signal`, which is exactly the directory the existing
/// `single_run_persists_a_declared_tap_series` reads its trace back from.
#[test]
fn recording_run_reports_its_trace_handle_on_stdout() {
let cwd = temp_cwd("trace-handle-echo");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(line.trim()).expect("stdout is one JSON object");
let handle = v["trace_name"].as_str().expect("trace_name is present and a string");
// #311: the handle is the run's own IDENTITY, not its render name alone —
// the render name is the readable prefix, the 8-hex tail the run identity.
// (The next assertion, that the handle names the directory the traces
// landed in, is name-agnostic and unchanged.)
assert!(
handle.starts_with("sma_signal-"),
"the handle carries the run's render name as its prefix; got {handle}"
);
assert_eq!(
handle.len(),
"sma_signal-".len() + 8,
"the identity suffix is exactly 8 hex chars; got {handle}"
);
assert!(
cwd.join("runs/traces").join(handle).join("fast_tap.json").exists(),
"the reported handle must name the directory the traces landed in; got {handle}"
);
}
/// #309: the handle is appended, never interleaved — the report's own key
/// order and bytes survive the flattening wrapper untouched. This is the first
/// pin of `serde(flatten)` byte behaviour in this workspace; assert bytes.
///
/// #311: the handle's own spelling (`<render-name>-<id8>`) is pinned elsewhere
/// (`recording_run_reports_its_trace_handle_on_stdout`); this test only pins the
/// BYTE POSITION — that whatever the handle is, it is the last key, appended
/// after every other field, never interleaved — so it reads the handle back off
/// the same line rather than hard-coding its value.
#[test]
fn trace_handle_is_appended_as_the_last_key() {
let cwd = temp_cwd("trace-handle-bytes");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
let handle = trace_handle(out.stdout.as_slice());
let expected_tail = format!(",\"trace_name\":\"{handle}\"}}");
assert!(
line.ends_with(&expected_tail),
"the handle must be the last key, appended before the closing brace: {line}"
);
}
/// #309: a tap-free run records nothing, so it reports no handle — the
/// wrapper must not emit an empty key. (The line's key order and grouping are
/// pinned separately, in `exec.rs`; this asserts only the absent key.)
#[test]
fn tap_free_run_line_carries_no_trace_handle() {
let cwd = temp_cwd("trace-handle-absent");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(BIN)
.args(["exec", &bp])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(
!line.contains("trace_name"),
"a run that recorded nothing must emit no trace_name key at all: {line}"
);
}
/// #311 headline: two runs of ONE blueprint that differ in an identity-bearing
/// input (an `--override` value that actually changes the bound parameter,
/// `fast.length=2` vs `fast.length=3`) land in TWO trace directories — the
/// first run's tapped series is still on disk, byte-unchanged, after the
/// second run completes. Before #311, both runs minted `traces/sma_signal/`
/// and the second overwrote the first, losing the earlier series
/// unrecoverably.
#[test]
fn two_runs_of_one_blueprint_with_different_overrides_keep_both_traces() {
let cwd = temp_cwd("two-overrides-two-traces");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run = |value: &str| -> String {
let ov = format!("fast.length={value}");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--override", &ov])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
trace_handle(&out.stdout)
};
let handle_a = run("2");
let series_a = std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
.expect("the first run's tapped series");
let handle_b = run("3");
assert_ne!(
handle_a, handle_b,
"two runs differing in an identity-bearing input must land in two directories"
);
let series_b = std::fs::read(cwd.join("runs/traces").join(&handle_b).join("fast_tap.json"))
.expect("the second run's tapped series");
assert_ne!(series_a, series_b, "SMA(2) and SMA(3) are different series");
assert_eq!(
std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
.expect("the first run's series survives the second run"),
series_a,
"the earlier run's trace must survive the later run untouched"
);
for handle in [&handle_a, &handle_b] {
assert!(
cwd.join("runs/traces").join(handle).join("index.json").is_file(),
"each run closes its own index: {handle}"
);
}
}
/// #311 acceptance criterion 2: two runs identical in every identity-bearing
/// input write ONE directory and print ONE handle. Identity keying separates
/// runs that differ; it does not mint a directory per invocation.
#[test]
fn two_identical_runs_share_one_trace_directory() {
let cwd = temp_cwd("identical-runs-one-dir");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run = || -> String {
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
trace_handle(&out.stdout)
};
let first = run();
let second = run();
assert_eq!(first, second, "same inputs, same identity, same handle");
assert!(
first.starts_with("sma_signal-") && first.len() == "sma_signal-".len() + 8,
"the render name is the readable prefix, the 8-hex tail the identity: {first}"
);
assert_eq!(
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
1,
"an unchanged re-run must not grow the store"
);
}
/// #311 acceptance criterion 3 — and the pin for the two source-read properties
/// the `project.commit` exclusion rests on: the field carries a `-dirty` marker,
/// and it is re-derived on EVERY invocation (`aura-runner::project::project_commit`).
/// A run whose project worktree is dirty must still mint the handle it minted
/// with a clean worktree; otherwise editing any file in the project — including
/// the blueprint being worked on — would turn the store into an unbounded
/// directory generator.
#[test]
fn a_dirty_project_worktree_does_not_change_the_run_handle() {
let cwd = temp_cwd("provenance-does-not-churn");
std::fs::write(cwd.join("Aura.toml"), "# identity fixture: data-only project\n")
.expect("write Aura.toml");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
// Ignore the trace store the first `exec()` below creates — otherwise
// `runs/` itself would be an untracked path and `git status --porcelain`
// would already report the worktree dirty on the SECOND invocation
// regardless of `scratch.txt`, making that write inert rather than the
// thing that actually flips the marker.
std::fs::write(cwd.join(".gitignore"), "runs/\n").expect("write .gitignore");
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(&cwd)
.output()
.expect("run git");
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "test"]);
git(&["add", "-A"]);
git(&["commit", "-m", "fixture"]);
let exec = || -> (String, String) {
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
let commit = v["manifest"]["project"]["commit"]
.as_str()
.expect("a run inside a git-backed project stamps its repo HEAD")
.to_string();
(trace_handle(&out.stdout), commit)
};
let (clean_handle, clean_commit) = exec();
// `runs/` is gitignored (above), so this is the only untracked change —
// it, and nothing else, is what flips `git status --porcelain` for the
// second invocation below.
std::fs::write(cwd.join("scratch.txt"), "an uncommitted edit\n").expect("dirty the worktree");
let (dirty_handle, dirty_commit) = exec();
// The two source-read properties, pinned: the marker exists, and the field
// is re-derived per invocation rather than captured once.
assert!(
!clean_commit.ends_with("-dirty"),
"a committed worktree stamps a bare HEAD: {clean_commit}"
);
assert!(
dirty_commit.ends_with("-dirty"),
"an uncommitted file stamps the -dirty marker: {dirty_commit}"
);
assert_ne!(clean_commit, dirty_commit, "project.commit is re-derived on every invocation");
// ...and yet it is the same run.
assert_eq!(
clean_handle, dirty_handle,
"the project repo's HEAD/dirty state is provenance, not identity"
);
assert_eq!(
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
1,
"one identity, one directory"
);
}
+64 -4
View File
@@ -1,7 +1,37 @@
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the
//! project descriptor (src/project.rs) can stamp it. Runs per consuming build:
//! the host binary and a project cdylib each recompile aura-core under their
//! own toolchain, which is exactly what makes the two stamps comparable.
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION`, and a
//! deterministic fingerprint of aura-core's own sources into
//! `AURA_CORE_FINGERPRINT`, so the project descriptor (src/project.rs) can
//! stamp both. Runs per consuming build: the host binary and a project
//! cdylib each recompile aura-core under their own toolchain / from their own
//! checkout, which is exactly what makes the two stamps comparable (C30: the
//! load seam commits to a build-identity fingerprint, never the frozen crate
//! version).
use std::path::{Path, PathBuf};
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0100_0000_01b3;
fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 {
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
/// Recursively collect every `*.rs` file under `dir`.
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
fn main() {
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
@@ -13,4 +43,34 @@ fn main() {
"cargo:rustc-env=AURA_RUSTC_VERSION={}",
String::from_utf8_lossy(&out.stdout).trim()
);
// Deterministic staleness fingerprint: hash (relative path + contents) of
// every source file under src/, in sorted order. Not a security hash —
// FNV-1a is dependency-free and plenty for detecting a stale build. This
// is *source-level* identity only, an accepted C30 limit: the consuming
// build resolves its own lockfile and features, so shared-dependency
// types riding the Rust tier (e.g. chrono-tz's) are NOT guarded by this
// stamp — the seam refuses staleness of aura-core itself, not the full
// link graph.
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rs_files(&src_dir, &mut files);
files.sort();
let mut hash = FNV_OFFSET_BASIS;
for path in &files {
// The walk starts at src_dir, so every path strips; a silent fallback
// here would make the fingerprint checkout-dependent.
let rel = path.strip_prefix(&src_dir).expect("walked file lies outside src/");
let contents = std::fs::read(path).expect("read aura-core source file");
// Prefix-free record: path NUL length-prefixed contents (paths never
// contain NUL, the length pins the content boundary).
hash = fnv1a(rel.to_string_lossy().as_bytes(), hash);
hash = fnv1a(&[0], hash);
hash = fnv1a(&(contents.len() as u64).to_le_bytes(), hash);
hash = fnv1a(&contents, hash);
}
println!("cargo:rustc-env=AURA_CORE_FINGERPRINT={hash:016x}");
println!("cargo:rerun-if-changed=src");
}
+21 -8
View File
@@ -2,7 +2,7 @@
//!
//! A research project compiles to a cdylib exporting ONE symbol,
//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers:
//! a **C tier** (`magic`, `descriptor_version`, the version stamps, the
//! a **C tier** (`magic`, `descriptor_version`, the build stamps, the
//! namespace — all C-compatible field types) the host validates BEFORE
//! trusting anything, and a **Rust tier** (the vocabulary resolver and the
//! enumerable type-id list) the host touches only after both stamps match.
@@ -23,8 +23,11 @@ pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0";
/// The compiling rustc's `--version` line (via build.rs).
pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION");
/// This aura-core's crate version.
pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Build-identity fingerprint of aura-core's own sources (via build.rs),
/// per ledger contract C30: the load seam commits to a source-derived
/// fingerprint, never the frozen crate version (a fixed 0.1.0 under the
/// no-semver discipline would disarm the handshake it's meant to guard).
pub const CORE_FINGERPRINT: &str = env!("AURA_CORE_FINGERPRINT");
/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes.
/// No NUL convention needed; readable without trusting the Rust ABI.
@@ -63,7 +66,7 @@ pub struct ProjectDescriptor {
pub magic: u64,
pub descriptor_version: u32,
pub rustc_version: StrSlice,
pub aura_core_version: StrSlice,
pub aura_core_fingerprint: StrSlice,
pub namespace: StrSlice,
// ---- Rust tier: only after both stamps match ----
pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>,
@@ -86,8 +89,8 @@ macro_rules! aura_project {
rustc_version: $crate::project::StrSlice::new(
$crate::project::RUSTC_VERSION,
),
aura_core_version: $crate::project::StrSlice::new(
$crate::project::CORE_VERSION,
aura_core_fingerprint: $crate::project::StrSlice::new(
$crate::project::CORE_FINGERPRINT,
),
namespace: $crate::project::StrSlice::new($ns),
vocabulary: $vocab,
@@ -117,7 +120,17 @@ mod tests {
#[test]
fn stamps_are_baked_and_nonempty() {
assert!(RUSTC_VERSION.starts_with("rustc "));
assert!(!CORE_VERSION.is_empty());
assert!(!CORE_FINGERPRINT.is_empty());
}
/// C30/#348: the stamp is a source-derived hash, never a constant that
/// would disarm the load-seam refusal — pin its mechanical shape (the
/// 16-hex FNV-1a rendering) and that it is not the crate version.
#[test]
fn core_fingerprint_is_a_source_hash_not_the_crate_version() {
assert_eq!(CORE_FINGERPRINT.len(), 16);
assert!(CORE_FINGERPRINT.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(CORE_FINGERPRINT, env!("CARGO_PKG_VERSION"));
}
#[test]
@@ -152,7 +165,7 @@ mod tests {
assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION);
assert_eq!(unsafe { d.namespace.as_str() }, Some("t"));
assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION));
assert_eq!(unsafe { d.aura_core_version.as_str() }, Some(CORE_VERSION));
assert_eq!(unsafe { d.aura_core_fingerprint.as_str() }, Some(CORE_FINGERPRINT));
assert!((d.vocabulary)("nope").is_none());
assert_eq!((d.type_ids)(), &["t::A"]);
}
+150 -2
View File
@@ -195,6 +195,40 @@ 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 has an operational run-time role: it prefixes a
/// run's identity-keyed trace directory on disk unsanitized
/// (`traces/<name>-<id8>/`, #311; `crates/aura-registry/src/trace_store.rs`),
/// not the whole directory component as before #311. 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
@@ -329,6 +363,24 @@ impl Composite {
out
}
/// Every declared measurement tap across the whole blueprint, depth-first
/// through nested composites — the taps-as-data twin of `param_space()`
/// (#337, the positive discovery view #333's refusal roster deliberately
/// deferred). Each entry is `(tap name, source wire, column kind)`, the
/// wire rendered `<node>.<field>` in the LOCAL frame the tap was declared
/// in. Unlike a param's path, neither the tap's own name nor its wire's
/// node identifier is prefixed by an enclosing composite: a tap hoists
/// BARE to the flat graph (`inline_composite` keeps `tap.name` verbatim),
/// so this view renders exactly the load-bearing name `compile` keeps.
/// Bounds-total like `derive_signature`: a structurally-invalid wire (out
/// of a producer's output arity) yields no row rather than a panic — the
/// real gate is `compile`'s own `resolve_tap_wire`.
pub fn declared_taps(&self) -> Vec<(String, String, ScalarKind)> {
let mut out = Vec::new();
collect_taps(self, &mut out);
out
}
/// Re-open ONE bound param at its `param_space()`-style path (#246): the
/// param returns to the open surface at the slot `collect_params` order
/// dictates; its bound value is forgotten by this value (the authored
@@ -542,7 +594,8 @@ pub enum ReopenError {
/// One entry of the aggregated BOUND param surface (#246): the path-qualified
/// twin of [`ParamSpec`] carrying the bound value — the default a sweep axis
/// may override, and what `--list-axes` renders as `default=`.
/// may override, and what `aura graph introspect --params` renders as
/// `default=`.
#[derive(Debug, Clone, PartialEq)]
pub struct BoundSpec {
pub name: String,
@@ -1098,6 +1151,31 @@ fn collect_params(items: &[BlueprintNode], gangs: &[Gang], prefix: &str, out: &m
}
}
/// Recursive walk for `Composite::declared_taps` (#337): this level's OWN
/// declared taps first (resolved against this level's OWN `nodes` — a tap's
/// `from` wire is always local to the frame it was declared in), then
/// recurse into every nested composite's own tap list. No prefix threading
/// (unlike `collect_params`): a tap's name and its wire's node identifier
/// stay bare at every depth, mirroring `inline_composite`'s own hoist.
fn collect_taps(c: &Composite, out: &mut Vec<(String, String, ScalarKind)>) {
for tap in &c.taps {
if let Some(item) = c.nodes.get(tap.from.node) {
let node_name = match item {
BlueprintNode::Primitive(b) => b.node_name(),
BlueprintNode::Composite(inner) => inner.name().to_string(),
};
if let Some(field) = item.signature().output.get(tap.from.field) {
out.push((tap.name.clone(), format!("{node_name}.{}", field.name), field.kind));
}
}
}
for item in &c.nodes {
if let BlueprintNode::Composite(inner) = item {
collect_taps(inner, out);
}
}
}
/// Recursive mutable walk for `Composite::reopen` (#246): mirrors
/// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns
/// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns
@@ -1519,6 +1597,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]
@@ -3331,7 +3422,8 @@ mod tests {
}
/// #246: bound_param_space mirrors param_space's path qualification and
/// carries the bound value (the default the CLI renders in --list-axes).
/// carries the bound value (the default the CLI renders in
/// `aura graph introspect --params`).
#[test]
fn bound_param_space_is_path_qualified_with_values() {
use aura_strategy::Bias;
@@ -3907,4 +3999,60 @@ mod tests {
// the inner Sub lowered to flat node 0; the hoisted tap points at it
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "inner_d".into(), node: 0, field: 0 }]);
}
/// The build-free discovery twin (#337) of the compile-time hoist above:
/// `declared_taps()` finds the SAME nested-composite tap without ever
/// compiling — its wire is rendered in the LOCAL frame it was declared in
/// (`sub.value`, the un-named Sub's default node name + its own output
/// field), never composite-path-prefixed (unlike a param's path).
#[test]
fn declared_taps_finds_a_nested_composites_tap_without_compiling() {
let inner = Composite::new(
"inner",
vec![Sub::builder().into()],
vec![],
vec![Role {
name: "x".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
source: None,
}],
vec![OutField { node: 0, field: 0, name: "o".into() }],
)
.with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]);
let root = Composite::new(
"root",
vec![BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "a".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
assert_eq!(
root.declared_taps(),
vec![("inner_d".to_string(), "sub.value".to_string(), ScalarKind::F64)]
);
}
/// Bounds-total (mirrors `derive_signature`): a tap wire naming a field
/// beyond its producer's output arity yields no row rather than a panic —
/// the real gate stays `compile`'s own `TapWireOutOfRange`.
#[test]
fn declared_taps_is_bounds_total_over_an_out_of_range_wire() {
let bp = Composite::new(
"m",
vec![Sub::builder().into()],
vec![],
vec![Role {
name: "a".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![],
)
.with_taps(vec![Tap { name: "bad".into(), from: TapWire { node: 0, field: 9 } }]);
assert_eq!(bp.declared_taps(), Vec::new(), "an out-of-range wire yields no row, not a panic");
}
}
+90 -13
View File
@@ -103,9 +103,33 @@ pub struct ArgData {
#[derive(Debug)]
pub enum SerializeError {
Json(serde_json::Error),
/// #341: an arg-bearing (`PrimitiveBuilder::pending`) node reached the
/// serialize seam still unconfigured. Only the Rust `GraphBuilder`/
/// `Composite::new` path can build this state — the data path
/// (`add_node`) always runs `try_args` first — but emitting it as an
/// args-free v1 document would produce a document `blueprint_from_json`
/// can only refuse as `LoadError::BadArg(ArgOpError::MissingArg(..))` at
/// LOAD time; refusing here instead names the fault at its origin.
/// `missing_args` is the pending recipe's full declared `ArgSpec` list
/// (none configured yet, so every declared arg is missing).
PendingBuilder { node_type: String, missing_args: Vec<String> },
}
fn project(c: &Composite) -> CompositeData {
impl std::fmt::Display for SerializeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SerializeError::Json(e) => write!(f, "{e}"),
SerializeError::PendingBuilder { node_type, missing_args } => write!(
f,
"node \"{node_type}\" is an unconfigured arg-bearing builder (missing arg(s): \
{}); call try_args to configure it before it enters a graph",
missing_args.join(", ")
),
}
}
}
fn project(c: &Composite) -> Result<CompositeData, SerializeError> {
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
// bound-param canonicalization below): each gang's members ascending
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
@@ -116,21 +140,30 @@ fn project(c: &Composite) -> CompositeData {
}
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
CompositeData {
Ok(CompositeData {
name: c.name().to_string(),
doc: c.doc().map(str::to_string),
nodes: c.nodes().iter().map(project_node).collect(),
nodes: c.nodes().iter().map(project_node).collect::<Result<Vec<_>, _>>()?,
edges: c.edges().to_vec(),
input_roles: c.input_roles().to_vec(),
output: c.output().to_vec(),
taps: c.taps().to_vec(),
gangs,
}
})
}
fn project_node(n: &BlueprintNode) -> NodeData {
fn project_node(n: &BlueprintNode) -> Result<NodeData, SerializeError> {
match n {
BlueprintNode::Primitive(b) => {
// #341: refuse a still-pending arg-bearing recipe outright — an
// args-free v1 document built from it would only surface its
// fault at load (`LoadError::BadArg(MissingArg)`), never here.
if b.is_pending() {
return Err(SerializeError::PendingBuilder {
node_type: b.label(),
missing_args: b.arg_specs().iter().map(|s| s.name.to_string()).collect(),
});
}
// Canonical: bound params in ascending original-slot order, mirroring
// the loader's re-bind canonicalization, so serialization is
// bind-order-independent (a precondition for content-addressing, #158).
@@ -147,14 +180,14 @@ fn project_node(n: &BlueprintNode) -> NodeData {
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
NodeData::Primitive(PrimitiveData {
Ok(NodeData::Primitive(PrimitiveData {
type_id: b.label(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}))
}
BlueprintNode::Composite(c) => NodeData::Composite(project(c)),
BlueprintNode::Composite(c) => Ok(NodeData::Composite(project(c)?)),
}
}
@@ -180,10 +213,10 @@ fn document_version(b: &CompositeData) -> u32 {
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
let blueprint = project(c);
fn build_doc(c: &Composite) -> Result<BlueprintDoc, SerializeError> {
let blueprint = project(c)?;
let format_version = document_version(&blueprint);
BlueprintDoc { format_version, blueprint }
Ok(BlueprintDoc { format_version, blueprint })
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
@@ -194,7 +227,7 @@ fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
serialize_doc(&build_doc(c))
serialize_doc(&build_doc(c)?)
}
/// The identity-canonical form (#171): the canonical document with every
@@ -207,7 +240,7 @@ pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
/// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
let mut doc = build_doc(c);
let mut doc = build_doc(c)?;
strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc)
}
@@ -1017,4 +1050,48 @@ mod tests {
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
/// #341 (refuse at the serialize seam, not just at load): a Rust-built
/// graph holding a still-`Session::builder()`-pending node (no
/// `try_args` applied) refuses to serialize outright — the twin of
/// `loading_arg_bearing_type_without_args_refuses` at the OTHER end of
/// the wire, the fault named at its origin instead of surfacing only
/// once the emitted document is reloaded. Only the Rust `GraphBuilder`
/// path can build this state; `add_node`'s data path always runs
/// `try_args` first (fenced separately).
#[test]
fn project_node_refuses_a_pending_arg_bearing_builder() {
let mut gb = crate::GraphBuilder::new("has_pending");
gb.add(Session::builder());
let composite = gb.build().expect("an unwired single-node graph builds");
let err = blueprint_to_json(&composite).expect_err("a pending builder must not serialize");
assert!(
err.to_string().contains("try_args"),
"the refusal prose hints at the fix: {err}"
);
match err {
SerializeError::PendingBuilder { node_type, missing_args } => {
assert_eq!(node_type, "Session", "names the pending node's type: {missing_args:?}");
assert_eq!(
missing_args,
vec!["tz".to_string(), "open".to_string()],
"names every declared arg as missing (none configured yet)"
);
}
SerializeError::Json(e) => panic!("wrong error variant: {e:?}"),
}
}
/// The fenced happy path (#341, negative twin): a `try_args`-configured
/// `Session` (the SAME recipe, just no longer pending) serializes fine —
/// `session_arg_fixture`'s existing round-trip coverage
/// (`args_round_trip_preserves_pairs_and_version_2`) already proves
/// this; re-asserted here beside the refusal for the reader who lands on
/// one and wants the other in view.
#[test]
fn project_node_serializes_a_configured_arg_bearing_builder() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
assert!(blueprint_to_json(&c).is_ok(), "a configured builder serializes");
}
}
+170 -19
View File
@@ -16,8 +16,8 @@ use std::collections::{HashMap, HashSet};
use aura_core::{ArgOpError, BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use crate::blueprint::{
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
Gang, GangMember, OutField, Role, Tap, TapWire,
check_param_namespace_injective, edge_kind_check, name_gate, validate_wiring, BlueprintNode,
Composite, Gang, GangMember, NameGateFault, OutField, Role, Tap, TapWire,
};
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
use crate::harness::{Edge, Target};
@@ -64,6 +64,8 @@ pub enum Op {
/// `bind` is applied path-qualified (e.g. `"sma.length"`), after the
/// splice.
Use { ref_id: String, name: Option<String>, bind: Vec<(String, Scalar)> },
/// Set the composite's render name (#331). At most once per script.
Name { name: String },
}
/// A per-op construction fault, by-identifier so the cause names the op.
@@ -114,6 +116,14 @@ pub enum OpError {
AlreadyGanged { node: String, param: String },
/// A gang needs at least two members.
GangArity { gang: String },
/// A gang member path resolves onto a spliced instance (`Op::Use`'s
/// `Composite` node), not a primitive (#339 item 1 harvest): a gang
/// fuses a PRIMITIVE's raw `(name, pos)` param slot, and an instance's
/// params are nested/path-qualified, so ganging a used instance's
/// member params is unsupported today. Distinct from
/// `BadParam::UnknownParam` so the refusal names the rule instead of
/// reading like a typo hint on the leaf case.
GangOfSplicedInstance { node: String, member: String },
/// A holistic finalize fault (totality / injectivity / unbound root role),
/// wrapping the unchanged engine gate's `CompileError`.
Incomplete(CompileError),
@@ -124,6 +134,13 @@ pub enum OpError {
/// resolves and fetches before replay, refusing there on a miss), but
/// total: a bare `replay`/`GraphSession` caller can still hit it.
UnknownSubgraph { ref_id: String },
/// A second `name` op — the render name is declared at most once (mirrors
/// `DuplicateDoc`).
DuplicateName,
/// A `name` op's `name` (or, at the CLI's blueprint-envelope intake, an
/// envelope's root name) failed the deterministic `name_gate` shape check
/// (#331). Carries the offending name and the shape fault detail.
BadName { name: String, fault: NameGateFault },
}
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
@@ -137,7 +154,11 @@ pub struct GraphSession<'v> {
/// The engine stays store-free — CLI-side resolution (label/prefix,
/// C29 doc gate, the store fetch itself) happens at DTO conversion,
/// before replay; this closure is a pure lookup into an already-fetched
/// id->`Composite` cache. Build-free introspection paths pass `&|_| None`.
/// id->`Composite` cache. The engine places no obligation on a caller
/// either way: a build-free CLI path MAY still resolve `use` refs
/// through the store first and pass a real cache-backed closure here
/// (#339 item 4 harvest: `introspect --unwired` now does); a truly
/// resolver-less caller (a bare `replay`, most tests) passes `&|_| None`.
subgraph: &'v dyn Fn(&str) -> Option<Composite>,
nodes: Vec<BlueprintNode>,
schemas: Vec<NodeSchema>,
@@ -153,6 +174,11 @@ pub struct GraphSession<'v> {
coverage: HashMap<(usize, usize), usize>,
gangs: Vec<Gang>,
doc: Option<String>,
/// At-most-once guard for `Op::Name` (#331), mirroring `doc`'s own
/// uniqueness posture but tracked separately (`name` itself is always
/// populated, seeded from `::new`'s `name` argument, so it cannot double
/// as its own "was it authored" flag).
name_authored: bool,
}
impl<'v> GraphSession<'v> {
@@ -182,6 +208,7 @@ impl<'v> GraphSession<'v> {
coverage: HashMap::new(),
gangs: Vec::new(),
doc: None,
name_authored: false,
}
}
@@ -205,9 +232,29 @@ impl<'v> GraphSession<'v> {
Ok(())
}
Op::Use { ref_id, name, bind } => self.use_subgraph(ref_id, name, bind),
Op::Name { name } => self.set_name(name),
}
}
/// `Op::Name` (#331): set the composite's render name — the op-script's
/// data-borne twin of `replay`'s seeded default name. Guarded by (a)
/// at-most-once (mirrors the `doc` op's uniqueness guard just above) and
/// (b) the shared deterministic shape gate (`name_gate`) — the SAME gate
/// the CLI's blueprint-envelope intake applies to a hand-edited
/// envelope's root name (the other data-borne birth route, skeptic
/// finding minuted on #331).
fn set_name(&mut self, name: String) -> Result<(), OpError> {
if self.name_authored {
return Err(OpError::DuplicateName);
}
if let Err(fault) = name_gate(&name) {
return Err(OpError::BadName { name, fault });
}
self.name = name;
self.name_authored = true;
Ok(())
}
/// `Op::Use` (#317): fetch a registered blueprint through the injected
/// `subgraph` resolver, rename it to the instance identifier, apply its
/// path-qualified `bind` entries, and push it as an ordinary
@@ -455,12 +502,11 @@ impl<'v> GraphSession<'v> {
// Composite, not just a Primitive — reachable now, not a
// defect. A gang fuses a PRIMITIVE's raw (name, pos) param
// slot; an instance has no such flat slot (its params are
// nested/path-qualified), so this is the ordinary
// no-such-open-param refusal, not a panic.
return Err(OpError::BadParam {
node: node_name,
err: BindOpError::UnknownParam(param_name),
});
// nested/path-qualified). #339 item 1 harvest: name the
// rule here instead of falling into the ordinary
// no-such-open-param shape, which reads as a typo hint on
// an otherwise correct member path.
return Err(OpError::GangOfSplicedInstance { node: node_name, member: param_name });
};
let hits: Vec<usize> = b
.params()
@@ -1002,6 +1048,72 @@ mod tests {
);
}
// ---- Op::Name (#331) -----------------------------------------------
/// A `name` op sets the finished composite's render name — the property
/// the whole op exists for.
#[test]
fn name_op_sets_the_finished_composite_name() {
let mut s = scaffold();
s.apply(Op::Name { name: "x".into() }).unwrap();
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] })
.unwrap();
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
let c = s.finish().expect("finishes");
assert_eq!(c.name(), "x");
}
/// Byte-compat (#331 AC1): a script that never authors a `name` op keeps
/// the seed name `replay`/`GraphSession::new` was given — the CLI's own
/// seed stays `"graph"`, unchanged by this op's addition.
#[test]
fn no_name_op_keeps_the_seeded_replay_name() {
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["a.series".into()] },
Op::Expose { from: "a.value".into(), as_name: "out".into() },
];
let c = super::replay("graph", ops, &std_vocabulary, &|_: &str| None).expect("replays");
assert_eq!(c.name(), "graph");
}
/// A second `name` op is a fault, not a silent last-wins overwrite —
/// mirrors the `doc` op's own at-most-once guard.
#[test]
fn duplicate_name_op_is_refused() {
let mut s = session("g");
s.apply(Op::Name { name: "x".into() }).unwrap();
assert_eq!(
s.apply(Op::Name { name: "y".into() }),
Err(OpError::DuplicateName)
);
}
/// Shape violations (#331): empty, containing a path separator, and the
/// dot segments all fault at the op via the shared `name_gate`.
#[test]
fn name_op_shape_violations_are_refused() {
use crate::blueprint::NameGateFault;
let mut s = session("g");
assert_eq!(
s.apply(Op::Name { name: "".into() }),
Err(OpError::BadName { name: "".into(), fault: NameGateFault::Empty })
);
let mut s = session("g");
assert_eq!(
s.apply(Op::Name { name: "a/b".into() }),
Err(OpError::BadName { name: "a/b".into(), fault: NameGateFault::ContainsSeparator })
);
let mut s = session("g");
assert_eq!(
s.apply(Op::Name { name: "..".into() }),
Err(OpError::BadName { name: "..".into(), fault: NameGateFault::DotSegment })
);
}
#[test]
fn finish_reports_incomplete_on_unwired_slot() {
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm
@@ -1501,6 +1613,30 @@ mod tests {
);
}
/// #331 ratifying pin (spec test 5): `use_subgraph`'s instance-identifier
/// default (`name: None` -> the fetched composite's own `name()`,
/// `construction.rs:221-230` — code-inspected, previously untested since
/// every other test in this section passes an explicit instance name)
/// actually reaches the session's identifier namespace: `use_fixture()`'s
/// own name is "fixture", so with no instance name the spliced ports
/// resolve under "fixture.*", not some other placeholder.
#[test]
fn use_op_with_no_name_defaults_instance_identifier_to_the_fetched_composites_name() {
let subgraph = |_: &str| Some(use_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
assert_eq!(
s.apply(Op::Use { ref_id: "fixture-id".into(), name: None, bind: vec![] }),
Ok(())
);
s.apply(Op::Feed { role: "price".into(), into: vec!["fixture.price".into()] }).unwrap();
assert_eq!(
s.apply(Op::Expose { from: "fixture.out".into(), as_name: "bias".into() }),
Ok(()),
"the spliced instance resolves under \"fixture\" — the fetched composite's own name"
);
}
/// A post-splice `bind` path with no matching open param anywhere in the
/// instance faults through the existing bind-fault shape (`BadParam`),
/// naming the instance and the qualified within-instance path (#317).
@@ -1575,12 +1711,14 @@ mod tests {
);
}
/// Documented residue pin (#317 audit): a `gang` op cannot fuse a spliced
/// instance's member param — a gang fuses a PRIMITIVE's raw `(name, pos)`
/// slot, and an instance's params are nested/path-qualified. The refusal
/// is the ordinary no-such-open-param shape on the instance node, so the
/// authoring-guide/C24 claim "the gang op refuses a composite instance's
/// member path" stays an observed fact, not prose.
/// Documented residue pin (#317 audit, prose updated #339 item 1
/// harvest): a `gang` op cannot fuse a spliced instance's member param —
/// a gang fuses a PRIMITIVE's raw `(name, pos)` slot, and an instance's
/// params are nested/path-qualified. The refusal now names the rule
/// (`OpError::GangOfSplicedInstance`) instead of the bare
/// no-such-open-param shape, so it reads as "instance-member ganging is
/// unsupported", not as a typo hint — matching the authoring-guide/C24
/// claim "the gang op refuses a composite instance's member path".
#[test]
fn gang_of_a_spliced_instance_member_path_refuses() {
let subgraph = |_: &str| Some(use_fixture());
@@ -1594,10 +1732,23 @@ mod tests {
as_name: "fused".into(),
into: vec!["gate.sma.length".into(), "solo.length".into()],
}),
Err(OpError::BadParam {
node: "gate".into(),
err: BindOpError::UnknownParam("sma.length".into()),
})
Err(OpError::GangOfSplicedInstance { node: "gate".into(), member: "sma.length".into() })
);
}
/// Sibling pin to `gang_of_a_spliced_instance_member_path_refuses`: the
/// plain-typo case — a `gang` member naming a param that never existed
/// on a PRIMITIVE (leaf) node — stays the ordinary no-such-open-param
/// shape (`BindOpError::UnknownParam`), unaffected by the instance-path
/// rule above (#339 item 1 harvest).
#[test]
fn gang_of_a_leaf_node_with_a_wrong_param_name_refuses_as_unknown_param() {
let mut s = session("g");
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] }).unwrap();
assert_eq!(
s.apply(Op::Gang { as_name: "typo".into(), into: vec!["a.lenght".into(), "b.length".into()] }),
Err(OpError::BadParam { node: "a".into(), err: BindOpError::UnknownParam("lenght".into()) })
);
}
+3 -2
View File
@@ -52,8 +52,9 @@ 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, ArgData, BlueprintDoc,
+120 -5
View File
@@ -421,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
@@ -523,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
@@ -2251,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
+5
View File
@@ -31,6 +31,11 @@ pub enum FamilyKind {
Sweep,
MonteCarlo,
WalkForward,
/// Dead machinery since the #319 sugar retirement (C18): no producer
/// mints a merged cross-instrument family anymore — the campaign-run
/// record's `generalizations[]` carries the data. The variant stays for
/// store read-back of historically minted families (never retroactively
/// invalidated, C29).
CrossInstrument,
}
+81 -1
View File
@@ -158,6 +158,25 @@ impl TraceStore {
NameKind::NotFound
}
/// Every top-level directory entry in the store, sorted. This enumerates,
/// it does not classify: an interrupted run leaves a directory with no
/// `index.json`, which appears here but is `NotFound` to
/// [`TraceStore::name_kind`] — a caller that needs chartable names must
/// filter. A store directory that does not exist or cannot be read yields
/// none rather than an error: enumeration is a diagnostic aid, never a
/// run-blocking step, so callers must not read emptiness as "nothing
/// recorded".
pub fn names(&self) -> Vec<String> {
let Ok(entries) = fs::read_dir(&self.dir) else { return Vec::new() };
let mut out: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
out.sort();
out
}
/// Read every member of a family. Two on-disk shapes are resolved: the
/// depth-1 campaign layout (`<name>/<key>/index.json`, `key` = the immediate
/// subdir name) and the depth-2 #224 sweep/walk-forward fan-out
@@ -476,7 +495,16 @@ impl fmt::Display for TraceStoreError {
NameKind::Family => "family",
NameKind::NotFound => "name",
};
write!(f, "'{name}' already used as a {kind}; pick another --trace name")
// #311: no verb has carried a trace-naming flag since #319 —
// a single run's handle is its own identity digest and a
// campaign's is `derive_trace_name`'s, so the only remedy left
// is freeing the directory.
write!(
f,
"'{name}' already used as a {kind}; both a single run's handle and a \
campaign's are machine-minted, so free the name by moving or deleting \
runs/traces/{name}"
)
}
}
}
@@ -575,6 +603,37 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
/// `names()` enumerates top-level directories, sorted, and nothing else:
/// a loose file in the store root is not a handle, and an index-less
/// directory (an interrupted run) IS listed even though `name_kind` calls
/// it absent — the split this method's callers must filter on.
#[test]
fn names_lists_sorted_directories_only() {
let root = temp_traces_root("names");
let store = TraceStore::open(&root);
store.write("zeta", &sample_manifest(), &sample_taps()).expect("write zeta");
store.write("alpha", &sample_manifest(), &sample_taps()).expect("write alpha");
// An interrupted run: directory created, no index written.
store.begin_run("halfway").expect("begin halfway");
// A loose file beside the handles must not be listed.
fs::write(root.join("traces").join("stray.json"), "{}").expect("write stray file");
assert_eq!(store.names(), vec!["alpha", "halfway", "zeta"]);
assert_eq!(store.name_kind("halfway"), NameKind::NotFound, "listed, but not chartable");
let _ = fs::remove_dir_all(&root);
}
/// An absent store root is not an error for enumeration — callers use it
/// on a diagnostic path where a refusal is already being printed.
#[test]
fn names_on_an_absent_store_is_empty() {
let root = temp_traces_root("names-absent");
let store = TraceStore::open(&root);
assert!(store.names().is_empty());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn name_kind_classifies_run_family_and_absent() {
let root = temp_traces_root("namekind");
@@ -656,6 +715,27 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
/// #311: the refusal's remedy must not send the reader to a flag that does
/// not exist — no verb has carried `--trace` since #319. Both handles are
/// machine-minted; freeing the directory is the only remedy left.
#[test]
fn name_taken_refusal_prescribes_freeing_the_directory_not_a_retired_flag() {
let root = temp_traces_root("nametaken-prose");
let store = TraceStore::open(&root);
store.write("asrun", &sample_manifest(), &sample_taps()).expect("write run");
let msg = store
.ensure_name_free("asrun", WriteKind::Family)
.expect_err("cross-kind reuse is refused")
.to_string();
assert!(msg.contains("already used as a run"), "the existing kind is named: {msg}");
assert!(!msg.contains("-trace"), "no verb carries a trace-naming flag since #319: {msg}");
assert!(
msg.contains("runs/traces/asrun"),
"the remedy names the directory to free: {msg}"
);
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
+96 -8
View File
@@ -513,16 +513,17 @@ pub struct Axis {
}
/// A protective-stop regime: a serializable, content-addressable mirror of the
/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is
/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive
/// variant (it runs as a composite today but is not yet campaign-reachable).
/// Externally tagged so adding `Fixed` is additive — no content-id churn on
/// stored `Vol` regimes.
/// runtime `StopRule` structural axis (C10/C20). `Fixed{distance}` (#338) makes
/// the shipped `FixedStop` composite campaign-reachable, beside the vol-stop
/// family — `distance` is the same price-unit knob `FixedStop`'s own `distance`
/// param carries. Externally tagged so each variant was additive on arrival —
/// no content-id churn on stored `Vol`/`VolTf` regimes.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime {
Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, length: i64, k: f64 },
Fixed { distance: f64 },
}
/// One component of the campaign's cost model (#234): a closed, additive
@@ -973,6 +974,14 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
faults.push(DocFault::BadRegime { index: i });
}
}
RiskRegime::Fixed { distance } => {
// Mirrors `FixedStop::new`'s own `distance > 0.0` assert
// (`aura-strategy`) — refused gracefully here instead of
// reaching that assert and panicking.
if *distance <= 0.0 || distance.is_nan() {
faults.push(DocFault::BadRegime { index: i });
}
}
}
}
for (i, c) in doc.cost.iter().enumerate() {
@@ -1309,14 +1318,31 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
slots.push(open("seed", "required, non-negative integer"));
}
if v.get("presentation").is_none() {
slots.push(open(
match v.get("presentation") {
None => slots.push(open(
"presentation",
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
));
)),
Some(p) => {
if p.get("persist_taps").and_then(|x| x.as_array()).is_none() {
slots.push(open(
"presentation.persist_taps",
format!(
"required, list of: {}",
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
));
}
if p.get("emit").and_then(|x| x.as_array()).is_none() {
slots.push(open(
"presentation.emit",
format!("required, list of: {}", emit_vocabulary().join(" | ")),
));
}
}
}
Ok(slots)
}
@@ -1649,6 +1675,18 @@ mod tests {
assert_eq!(back, r);
}
/// #338: the third variant's wire form follows the same externally-tagged
/// convention as `vol`/`vol_tf` — `fixed` snake_case tag, one named field
/// (the `FixedStop` composite's own `distance` param).
#[test]
fn risk_regime_round_trips_as_externally_tagged_fixed() {
let r = RiskRegime::Fixed { distance: 10.0 };
let j = serde_json::to_string(&r).unwrap();
assert_eq!(j, r#"{"fixed":{"distance":10.0}}"#);
let back: RiskRegime = serde_json::from_str(&j).unwrap();
assert_eq!(back, r);
}
#[test]
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
@@ -1707,6 +1745,26 @@ mod tests {
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
}
/// #338 (harvest audit item 11 extends the NaN branch): a non-positive OR
/// NaN fixed-stop distance must be refused as a graceful
/// `DocFault::BadRegime` at the doc tier — otherwise it reaches
/// `FixedStop::new`'s `distance > 0.0` assert and panics instead of a
/// validation error (the `validate_campaign_flags_non_positive_period_minutes_regime`
/// precedent above, `Fixed` edition).
#[test]
fn validate_campaign_flags_non_positive_fixed_distance_regime() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.risk = vec![
RiskRegime::Fixed { distance: 10.0 }, // 0: valid
RiskRegime::Fixed { distance: 0.0 }, // 1: distance <= 0
RiskRegime::Fixed { distance: f64::NAN }, // 2: NaN (unreachable via JSON; defensive)
];
let faults = validate_campaign(&doc);
assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}");
assert!(faults.contains(&DocFault::BadRegime { index: 2 }), "{faults:?}");
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
}
#[test]
fn validate_campaign_accepts_a_valid_risk_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
@@ -2294,6 +2352,36 @@ mod tests {
);
}
/// #319 fieldtest bug 1: a present-but-empty `presentation: {}` must drill
/// down into its two required sub-slots exactly as a present-but-empty
/// `data: {}` drills into `instruments`/`windows` — the walker must not
/// stop at "the section exists" without checking what's inside it.
#[test]
fn presentation_drills_into_an_empty_section_like_data_does() {
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": { "content_id": "9f3a" },
"axes": { "slow": { "kind": "I64", "values": [10] } } } ],
"process": { "ref": { "content_id": "4e2d" } },
"seed": 1,
"presentation": {} }"#;
let slots = open_slots_campaign(draft).unwrap();
assert!(
!slots.iter().any(|s| s.path == "presentation"),
"an empty-but-present presentation is not the same open slot as an absent one: {slots:?}"
);
assert!(
slots.iter().any(|s| s.path == "presentation.persist_taps"
&& s.hint == "required, list of: equity | exposure | r_equity | net_r_equity"),
"presentation.persist_taps must be named as its own open slot: {slots:?}"
);
assert!(
slots.iter().any(|s| s.path == "presentation.emit"
&& s.hint == "required, list of: family_table | selection_report"),
"presentation.emit must be named as its own open slot: {slots:?}"
);
}
/// #256 fork B: the fieldless enumerate-only stage round-trips through
/// the schema-strict parser, and any slot on it is refused (its slot
/// list is empty, so the generic unknown-slot check covers every key).
+117
View File
@@ -45,6 +45,76 @@ 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 (`aura graph introspect --params`'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.
@@ -139,6 +209,53 @@ mod tests {
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)
+10 -105
View File
@@ -1,8 +1,8 @@
//! Coverage reporting: the single interior-gap-month walk, shared by a
//! campaign cell's coverage annotation
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s
//! archive-wide report — two independent walk implementations before this
//! module existed (#295 dedup).
//! 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 {
@@ -18,11 +18,11 @@ pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) {
/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
/// is a flat list an aggregate counts directly. `months` is assumed sorted
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk
/// implementation (#295): both `DefaultMemberRunner::window_coverage`
/// (a swept cell's own window) and [`data_coverage_report`] (the full
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead
/// of maintaining independent copies.
/// ([`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);
@@ -40,68 +40,6 @@ pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<S
out
}
/// A Unix-ms window that decodes (via `unix_ms_to_year_month`) to year/month
/// bounds — 0001-01 and 9999-01, exact epoch-ms for those UTC instants, not
/// an approximation — comfortably outside any realistic archive month, so an
/// unbounded, archive-wide [`interior_gap_months`] walk never ms-filters out
/// a real gap. Safely inside `chrono`'s valid calendar range, so the
/// conversion inside `interior_gap_months` never panics.
pub const FULL_ARCHIVE_WINDOW_MS: (i64, i64) = (-62_135_596_800_000, 253_370_764_800_000);
/// `aura data coverage <SYMBOL>`'s pure report body (#264): render `symbol`'s
/// coverage report from its sorted `(year, month)` file list — one `span:`
/// line framing the first/last present month, then either a `no gaps` line
/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a
/// ten-month hole is one line, not ten). `Err` exactly when `months` is
/// empty — no archive file exists for `symbol` at all (the caller's "unknown
/// symbol" refusal, stderr + exit 1). The gap detection itself is
/// [`interior_gap_months`] over the full archive span
/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into
/// contiguous ranges is presentation-only regrouping, done here since it
/// stays byte-identical to the pre-dedup report.
pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result<Vec<String>, String> {
let Some(&first) = months.first() else {
return Err(format!("no archive files found for symbol \"{symbol}\""));
};
let last = *months.last().expect("non-empty checked above");
let mut lines =
vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))];
let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS);
if gap_months.is_empty() {
lines.push(format!("{symbol} no gaps"));
} else {
for (from, to) in collapse_contiguous_year_months(&gap_months) {
lines.push(format!("{symbol} missing: {from}..{to}"));
}
}
Ok(lines)
}
/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into
/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to
/// one pair, not ten. Presentation-only regrouping: the gap WALK itself
/// already ran in `interior_gap_months`.
fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for m in months {
let ym = parse_year_month(m);
match out.last_mut() {
Some((_, to)) if next_year_month(parse_year_month(to)) == ym => {
*to = m.clone();
}
_ => out.push((m.clone(), m.clone())),
}
}
out
}
/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape
/// [`interior_gap_months`] always emits.
fn parse_year_month(s: &str) -> (u16, u8) {
let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM");
(y.parse().expect("YYYY digits"), m.parse().expect("MM digits"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -125,37 +63,4 @@ mod tests {
"both interior gap months must be named, in order"
);
}
/// An unknown symbol (no archive files at all — an empty month list)
/// refuses rather than reporting a bogus empty-span coverage (#264): the
/// caller eprintln's the message and exits 1.
#[test]
fn data_coverage_report_refuses_an_unknown_symbol() {
let err = data_coverage_report("GHOST", &[]).unwrap_err();
assert!(err.contains("GHOST"), "names the unknown symbol: {err}");
}
/// A symbol with a fully contiguous file index reports its span plus an
/// explicit `no gaps` line — never a bare span with no gap-status line at
/// all, which would leave "no gaps" indistinguishable from "gaps not yet
/// checked" (#264).
#[test]
fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() {
let months = [(2024, 1), (2024, 2), (2024, 3)];
let lines = data_coverage_report("SYMA", &months).expect("known symbol");
assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]);
}
/// The Copper failure shape itself: one interior gap collapses to a single
/// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not
/// one line per missing month (#264).
#[test]
fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() {
let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)];
let lines = data_coverage_report("GAPSYM", &months).expect("known symbol");
assert_eq!(
lines,
vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"]
);
}
}
+52 -690
View File
@@ -1,60 +1,24 @@
//! 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).
//! The blueprint sweep / walk-forward / Monte-Carlo family builders that used
//! to live here are retired (#319 — the research-verb quintet's own
//! machinery; the campaign document path, `aura-campaign::exec`, is the
//! surviving family-execution surface, with its own independent axis/window/
//! winner-selection machinery). What remains is the shared [`DataSource`]
//! data provider — the synthetic/real price-source abstraction
//! `aura_runner::reproduce` drives its own bit-identical re-derivation
//! over — and its synthetic stream primitives (`showcase_prices`,
//! `walkforward_prices`, `walkforward_window_source`, `synthetic_walk_sources`).
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
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 aura_core::{Scalar, Timestamp};
use aura_engine::{window_of, SyntheticSpec, VecSource};
use aura_backtest::{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::member::{no_real_data, pip_or_refuse, probe_window, 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),
}
use crate::RunnerError;
/// 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
@@ -103,19 +67,18 @@ pub enum DataChoice {
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).
/// The source provider `aura_runner::reproduce` threads through its
/// re-derivation: 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
/// series: the full-window consumers (`full_window` / `run_sources`) draw the
/// 18-bar `showcase_prices()`, while the windowed consumers
/// (`windowed_sources` / `wf_window_sizes`) 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.
/// faces never reach one consumer (full-window vs. windowed reproduction), so
/// the split is invisible per call site but real across the type.
pub enum DataSource {
Synthetic,
Real {
@@ -128,19 +91,20 @@ pub enum DataSource {
}
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 {
/// Build a provider from a parsed choice, or refuse (#297: returned, not
/// printed) 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) -> Result<DataSource, RunnerError> {
match choice {
DataChoice::Synthetic => DataSource::Synthetic,
DataChoice::Synthetic => Ok(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);
let pip = pip_or_refuse(&server, &symbol, env)?;
if !server.has_symbol(&symbol) {
no_real_data(&symbol, env);
return Err(no_real_data(&symbol, env));
}
DataSource::Real { server, symbol, from_ms, to_ms, pip }
Ok(DataSource::Real { server, symbol, from_ms, to_ms, pip })
}
}
}
@@ -155,11 +119,11 @@ impl DataSource {
/// 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) {
pub fn full_window(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> {
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")
Ok(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)
@@ -173,11 +137,11 @@ impl DataSource {
/// 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) {
pub fn wf_full_span(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> {
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")
Ok(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)
@@ -188,12 +152,14 @@ impl DataSource {
/// 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>> {
pub fn run_sources(
&self, env: &Env, fields: &[aura_ingest::M1Field],
) -> Result<Vec<Box<dyn aura_engine::Source>>, RunnerError> {
match self {
DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))],
DataSource::Synthetic => Ok(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))
.ok_or_else(|| no_real_data(symbol, env))
}
}
}
@@ -203,12 +169,12 @@ impl DataSource {
/// 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>> {
) -> Result<Vec<Box<dyn aura_engine::Source>>, RunnerError> {
match self {
DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))],
DataSource::Synthetic => Ok(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))
.ok_or_else(|| no_real_data(symbol, env))
}
}
}
@@ -223,618 +189,14 @@ impl DataSource {
}
}
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
/// originate on these paths and the instrument context is genuinely inert.
/// Named once so every such call site states its intent by reference instead
/// of repeating the justifying comment.
const NO_INSTRUMENT_CONTEXT: &str = "";
/// The winner-selection objective for walk-forward's per-window IS refit — the
/// deflation-aware SQN variant (#144 default). Named once so `select_winner`
/// and `blueprint_walkforward_family` (the only two family-builder-side call
/// sites) cannot drift apart on the token; the CLI shell's campaign-sugar
/// bridge keeps its own copy of this token (main.rs `WINNER_SELECTION_METRIC`)
/// since it is not itself a family builder.
const WINNER_SELECTION_METRIC: &str = "sqn_normalized";
/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward
/// winner selection. Recorded on each winner's manifest (so `overfit_probability`
/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The
/// resample count and block length are the shared `aura_registry::DEFLATION_*`.
const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
/// Renders a [`BindError`] as one-line prose in `member::override_paths`' sibling
/// register above (#247) — never the raw Rust `Debug` struct name
/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep
/// terminal actually raises past `override_paths`' own pre-flight (which
/// already rejects an unresolvable axis name as prose before either call
/// site below ever reaches the terminal). `UnknownKnob` already carries a
/// fully-prosed message string (wrapped from `override_paths`' own
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
/// the walkforward path's rejection reaches stderr as bare prose too.
pub fn render_bind_error(e: &BindError) -> String {
match e {
BindError::MissingKnob(name) => format!(
"axis {name}: an open param with no axis and no bound default — \
bind it with `--axis {name}=<value>` see `aura sweep <bp> --list-axes`"
),
BindError::KindMismatch { knob, expected, got } => format!(
"axis {knob}: expected {expected:?}, supplied {got:?} — \
see `aura sweep <bp> --list-axes`"
),
BindError::UnknownKnob(msg) => msg.clone(),
BindError::DuplicateBinding(name) => format!(
"axis {name}: bound twice — each param takes exactly one axis — \
see `aura sweep <bp> --list-axes`"
),
BindError::EmptyAxis(name) => format!(
"axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`"
),
BindError::EmptyRange(name) => format!(
"axis {name}: the named range is empty — give it at least one value"
),
// A blueprint defect, not an axis usage error: the point passed name
// resolution but its bootstrap failed. Prose frame with the compile
// detail explicitly labelled as internal — per-variant prose for
// CompileError belongs to the graph-build surface, not this boundary.
BindError::Compile(e) => format!(
"the resolved axis point failed to bootstrap — the blueprint is \
defective at this point, re-validate it with `aura graph build` \
(internal detail: {e:?})"
),
}
}
/// Resolve the in-sample winner under the chosen selection objective. `Argmax`
/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid
/// surface — it needs the grid lattice, so a sweep with no lattice (a future random
/// walk-forward producer) is refused rather than silently argmaxed. The metric is
/// always known at the call sites, so a metric error is unreachable (`expect`); the
/// only fallible outcome is the plateau-without-lattice refusal, returned as
/// `Err(message)` for the caller to print and exit 2.
pub fn select_winner(
family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>,
) -> Result<(SweepPoint, FamilySelection), String> {
match select {
Selection::Argmax => Ok(optimize_deflated(
family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED,
).expect("walk-forward metrics are known")),
Selection::Plateau(mode) => match lattice {
Some(lens) => Ok(optimize_plateau(family, lens, metric, mode)
.expect("walk-forward metrics are known")),
None => Err(
"--select plateau requires a grid sweep; a random sweep has no parameter lattice"
.to_string(),
),
},
}
}
/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal
/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all
/// run BEFORE this closure is invoked per grid point, so its body never influences the
/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to
/// satisfy the terminal, at O(1) cost per point instead of a full member run. Reused
/// (#278) as the discarded slot value for a member run [`catch_member_panic`]
/// contains: the caller always resolves the captured fault and exits before this
/// value is ever rendered or persisted, so its content is equally irrelevant there.
fn axis_grid_probe_report() -> RunReport {
RunReport {
manifest: RunManifest {
commit: String::new(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "wf-axis-preflight-placeholder".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
}
}
/// The LOWEST enumeration index's captured member-panic message, found by
/// walking `family.points` — guaranteed by `assemble_sweep`'s own
/// `collect()` to be in grid-enumeration order regardless of which worker
/// finished first (C1) — for the first point whose params match a captured
/// fault. Thread-order-independent by construction (mirrors the lowest-
/// index-fault convention `aura-campaign::exec::run_members` establishes for
/// the identical #272 seam), without needing a separate numeric-index
/// side-channel: the returned family already carries the points in order.
fn lowest_point_fault(family: &SweepFamily, faults: Mutex<Vec<(Vec<Cell>, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.points
.iter()
.find_map(|pt| captured.iter().find(|(p, _)| *p == pt.params).map(|(_, m)| m.clone()))
}
/// The LOWEST seed's captured member-panic message (#278), found by walking
/// `family.draws` — `monte_carlo`'s own contract guarantees seed-**input**
/// order regardless of thread completion (C1) — for the first draw whose
/// seed matches a captured fault. Thread-order-independent by construction,
/// mirroring [`lowest_point_fault`]'s and `lowest_window_fault`'s identical
/// convention one level over (per-point sweep / per-window walk-forward).
fn lowest_seed_fault(family: &McFamily, faults: Mutex<Vec<(u64, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.draws
.iter()
.find_map(|d| captured.iter().find(|(s, _)| *s == d.seed).map(|(_, m)| m.clone()))
}
/// Render + exit(3) on a contained member panic (#278: the deliberate
/// failed-cells exit, C14, never the raw 101 an uncontained panic would
/// yield) — reuses `aura_campaign::member_fault_prose`'s established
/// "a member panicked: …" wording rather than re-wording it, and the
/// `aura: warning: ` class marker (#278 decision) the CLI's own `diag`
/// macro would emit, hand-written here since that macro is presentation-
/// crate-private (#295 boundary) and this fires from `aura-runner`.
fn exit_on_member_panic(msg: &str) -> ! {
eprintln!("aura: warning: {}", member_fault_prose(&MemberFault::Panic(msg.to_string())));
std::process::exit(3);
}
/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any
/// member (#253). Reuses the SAME strict, erroring axis-name check
/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two
/// paths cannot drift to differently-worded rejections) to derive the #246 override
/// set, then drives the sweep terminal's own `resolve_axes`/arity/kind checks
/// (`SweepBinder::sweep_with_lattice`) with [`axis_grid_probe_report`] standing in for
/// the run closure — no data access, no sim engine tick. Axis resolution is
/// window-agnostic, so the caller derives or passes no window at all.
fn validate_axis_grid(
doc: &str, axes: &[(String, Vec<Scalar>)], raw_space: &[ParamSpec], probe_signal: &Composite,
env: &Env,
) -> Result<(), BindError> {
let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ())
}
/// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it
/// keeps the shape of the retired `r_sma_sweep_family` demo builder (#159), with three
/// deviations. (1) The signal source is
/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is
/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3)
/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs):
/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a
/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message
/// string — a named error, never a panic. An axis naming a bound param re-opens it
/// (#246: bound value = default); an axis matching neither space is refused by
/// `override_paths` before any run. Every member manifest carries the shared
/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired
/// mirror's default (no-trace) arm.
pub fn blueprint_sweep_family(
doc: &str,
axes: &[(String, Vec<Scalar>)],
data: &DataSource,
env: &Env,
) -> Result<SweepFamily, String> {
// Identity + binding read the AUTHORED doc, raw (no override re-open):
// topology and the resolved role plan are properties of the document, not
// of any one sweep's axis choices.
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at
// `aura_research`.
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// Strict binding resolution (name defaults — the verb path carries no
// campaign overrides): the family's open plan and wrap plan in one value.
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
}
let pip = data.pip_size();
let window = data.full_window(env);
// The un-reopened wrapped OPEN space (#246): derives the override set (which
// named axes re-open a bound param) before the real, reopened probe is built —
// probe and per-member reloads must re-open identically so points resolve
// against one space. A name matching neither space is the error here.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
// The doc is parse-validated at the dispatch boundary (with file-path context),
// so every reload here is infallible: the builder has a single error contract —
// the `BindError` returned by the sweep terminal — and no hidden process exit.
// Member reloads re-open the SAME override set derived above, so every member
// resolves its axes against the identical (reopened) param space the probe used.
let reload = |d: &str| {
reopen_all(
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
&overrides,
)
};
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
// sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked.
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
// otherwise unwind straight through this sweep to an uncaught exit 101 —
// contained the same way the real/campaign path contains it, via
// `catch_member_panic` + a per-point capture, resolved to the lowest
// enumeration index's message after the sweep joins (thread-order-
// independent, C1).
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let family = binder
.sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs.
match catch_member_panic(|| {
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})
// render the sweep terminal's BindError to prose (#247), the fn's String error
// contract — never the raw Debug struct.
.map_err(|e| render_bind_error(&e))?;
if let Some(msg) = lowest_point_fault(&family, faults) {
exit_on_member_panic(&msg);
}
Ok(family)
}
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
/// `[from,to]` — the windowed, lattice-carrying twin of
/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select
/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the
/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis
/// naming a bound param re-opens it (#246: bound value = default, same
/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is
/// its walk-forward in-sample twin); an axis matching neither space is refused
/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired
/// "fully bound; nothing to sweep" refusal) before any member runs.
///
/// The returned `Option<String>` (#278) is this fn's OWN resolved member-panic
/// capture: this fn's nested sweep runs inside `blueprint_walkforward_family`'s
/// per-window parallel closure, so a contained member panic here must NOT
/// `eprintln!`+`exit` on the spot (that would race across windows, the exact
/// `#177` duplicated-rejection class the dispatch-boundary axis pre-flight
/// above already avoids) — captured internally, resolved to the lowest-index
/// message via [`lowest_point_fault`] before returning, for the caller to push
/// onto its own per-window fault list only after every window has joined.
pub fn blueprint_sweep_over(
doc: &str, axes: &[(String, Vec<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
env: &Env, binding: &ResolvedBinding,
) -> Result<(SweepFamily, Vec<usize>, Option<String>), BindError> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let pip = data.pip_size();
let probe_signal = reload(doc);
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set (which named axes re-open a bound param) before
// the reopened probe is built — probe and per-member reloads must re-open
// identically so points resolve against one space, exactly like
// `blueprint_sweep_family`.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let (family, lattice) = binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty in-sample window");
match catch_member_panic(|| {
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})?;
let fault = lowest_point_fault(&family, faults);
Ok((family, lattice, fault))
}
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
/// blueprint. The reduce-mode member
/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching
/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides`
/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and
/// resolved `space`/`params` against — the OOS reload must re-open it too, or a
/// bound-param axis's winner point (kind-checked against the REOPENED space) fails
/// `bootstrap_with_cells`'s arity check against this still-closed reload.
#[allow(clippy::too_many_arguments)]
pub fn run_oos_blueprint(
doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp,
topo: &str, data: &DataSource, env: &Env, binding: &ResolvedBinding,
overrides: &[String],
) -> (Vec<(Timestamp, f64)>, RunReport) {
let reload = reopen_all(
blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
overrides,
);
let pip = data.pip_size();
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty out-of-sample window");
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT);
(Vec::new(), report)
}
/// A discarded placeholder [`WindowRun`] for a window whose IS refit or OOS
/// run was contained after a member panic (#278): `chosen_params` matches
/// `space`'s arity with inert zero cells (the `assemble_walk_forward` arity
/// invariant), `oos_equity` is empty (an empty segment leaves the stitched
/// curve unbroken, same convention as a genuinely equity-less window), and
/// the report reuses [`axis_grid_probe_report`]'s zero-cost placeholder.
/// Never rendered: the caller always resolves the captured window fault and
/// exits before touching a faulted window's run.
fn placeholder_window_run(space: &[ParamSpec]) -> WindowRun {
WindowRun {
chosen_params: space.iter().map(|_| Cell::from_i64(0)).collect(),
oos_equity: Vec::new(),
oos_report: axis_grid_probe_report(),
}
}
/// The LOWEST roll-order window's captured member-panic message (#278),
/// found by walking `result.windows` — guaranteed by `assemble_walk_forward`
/// to be in roll order regardless of thread completion (C1) — for the first
/// window whose bounds match a captured fault. Thread-order-independent by
/// construction, mirroring [`lowest_point_fault`]'s identical convention one
/// level down (per-point within one window's IS sweep) and
/// `aura-campaign::exec`'s own lowest-index walk-forward fault attribution.
fn lowest_window_fault(result: &WalkForwardResult, faults: Mutex<Vec<(WindowBounds, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
result
.windows
.iter()
.find_map(|w| captured.iter().find(|(b, _)| *b == w.bounds).map(|(_, m)| m.clone()))
}
/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the
/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the
/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`;
/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors
/// (a bad `--axis`) `exit(2)` with the sweep terminal's message.
pub fn blueprint_walkforward_family(
doc: &str, axes: &[(String, Vec<Scalar>)], data: &DataSource, select: Selection,
env: &Env,
) -> WalkForwardResult {
let span = data.wf_full_span(env);
let (is_len, oos_len, step) = data.wf_window_sizes();
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
Ok(r) => r,
Err(e) => {
eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}");
std::process::exit(2);
}
};
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set ONCE for the whole family — every per-window
// sweep AND the OOS reload re-open the SAME set, mirroring
// `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant
// (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis
// matching neither space is simply not an override here — the strict check + its
// established error message stay single-sourced in `blueprint_sweep_over`'s own
// pre-flight call below, so this derivation cannot double-validate with a
// differently-worded rejection.
let axis_names: Vec<String> = axes.iter().map(|(n, _)| n.clone()).collect();
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal);
let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space();
// Strict binding resolution, once per family; refusal is the established
// `aura: ` + exit-1 register (the roller's usage refusals stay exit 2).
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(probe_signal.name(), &binding));
std::process::exit(1);
}
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
// (which resolves its axes a single time before any member runs). `walk_forward` fans
// the per-window closure out across the windows in parallel, so a `BindError` raised
// *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one
// exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without
// running a single member — no IS window (or a second roller) is needed here at all.
if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) {
eprintln!("aura: {}", render_bind_error(&e));
std::process::exit(2);
}
// #278: `blueprint_sweep_over`'s inner sweep and `run_oos_blueprint`'s single
// member run both drive `run_blueprint_member` bare, so either could
// otherwise unwind an uncaught member-compile panic straight through this
// window closure. Both are contained (`blueprint_sweep_over` resolves its
// own capture and returns it; `run_oos_blueprint` via `catch_member_panic`
// directly) and captured PER WINDOW here rather than printed on the spot —
// this closure runs across windows in parallel (`walk_forward`), so an
// inline `eprintln!`+`exit` would race the same way the axis pre-flight
// above was written to avoid (#177). The lowest roll-order window's fault
// is resolved once, after every window has joined.
let window_faults: Mutex<Vec<(WindowBounds, String)>> = Mutex::new(Vec::new());
let result = walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice, is_fault) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
.expect("axes validated in the dispatch-boundary pre-flight");
if let Some(msg) = is_fault {
window_faults.lock().expect("fault capture lock").push((w, msg));
return placeholder_window_run(&space);
}
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
match catch_member_panic(|| {
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides)
}) {
Ok((oos_equity, mut oos_report)) => {
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
}
Err(msg) => {
window_faults.lock().expect("fault capture lock").push((w, msg));
placeholder_window_run(&space)
}
}
});
if let Some(msg) = lowest_window_fault(&result, window_faults) {
exit_on_member_panic(&msg);
}
result
}
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s
/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the
/// `aura mc <blueprint.json>` persist path AND the reproduce MonteCarlo branch, so the
/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded
/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades.
/// A fresh seeded synthetic price walk for one Monte-Carlo draw (a distinct
/// realization per seed). A FIXED `SyntheticSpec` shared by the reproduce
/// MonteCarlo branch (`aura_runner::reproduce`, its only remaining
/// production caller — #319 retired the family builder that used to mint
/// such a family), 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)
}
+317 -4
View File
@@ -30,13 +30,326 @@ pub use tap_plan::{
};
pub use tap_recorder::TapRecorder;
/// What a declared-tap entry point returns: the run's record plus the two
/// values that ride *beside* it rather than inside it. The report is the
/// durable C18 record; these are per-invocation facts the shell renders
/// (C27/#297 — the library never prints).
///
/// `Debug` is load-bearing, not decoration: a caller asserting a refusal with
/// `Result::unwrap_err` needs the `Ok` side to be `Debug`, which the
/// `(report, skipped)` tuple this type replaced satisfied implicitly.
#[derive(Debug)]
pub struct RunOutcome<R> {
/// The run record itself.
pub report: R,
/// Declared taps the plan left unbound this run; the CLI prints the note.
pub skipped: Vec<String>,
/// The trace-store handle the run's taps landed under, when it recorded
/// any; `None` when the plan persisted nothing.
pub trace_name: Option<String>,
}
/// 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).
/// itself. The shell (`aura-cli`'s `exit_on_runner_error`, shared by the
/// dispatch arms) maps it back to the stderr bytes + exit code; the class
/// follows the C14 partition — argv-named content 2, environment/data/IO 1
/// (#295/#297).
#[derive(Debug)]
pub struct RunnerError {
pub exit_code: i32,
pub message: String,
}
/// The 8-hex identity of a run: a digest over its manifest with the two
/// provenance fields removed and `params`/`defaults` merged.
///
/// Both removed fields record *who built or checked out the code*, not what the
/// run was. `commit` is the aura binary's own build sha (`crates/aura-cli/build.rs`),
/// so it moves whenever the engine's checkout moves. `project.commit` is the
/// project repository's HEAD plus a `-dirty` marker, and — unlike the former —
/// it is re-evaluated on every invocation (`project.rs::project_commit`), so any
/// uncommitted file in the project worktree, including the blueprint being
/// edited, would mint a fresh directory. Including either would make an
/// identity-keyed store an unbounded directory generator.
///
/// `project.dylib_sha256` is deliberately kept: it is the node crate's own
/// content hash, which is what the C13 hot-reload comparison varies.
///
/// **Removing the keys, not blanking them, is load-bearing.**
/// `ProjectProvenance.commit` is `skip_serializing_if = "Option::is_none"`, so
/// the key is *absent* — not null — whenever the project's HEAD is not
/// derivable. Blanking would canonicalise one run to `{"project":{"commit":null}}`
/// and an otherwise-identical run to `{"project":{}}`: two digests for two runs
/// that differ in nothing identity-bearing. Removal collapses both to the same
/// bytes.
///
/// **`params` and `defaults` are merged into one name-sorted sequence, not
/// hashed as the two separate vectors the manifest carries.** The partition
/// between them records *how a value was supplied* — reopened by an axis vs.
/// held at its bound default (`RunManifest.defaults`' own doc comment,
/// `crates/aura-engine/src/report.rs`) — which is exactly the same kind of
/// provenance-not-identity distinction the two removed fields above are
/// removed for. Two runs whose bound parameterisation is identical but whose
/// values happen to be partitioned differently between the vectors (e.g. one
/// run leaves `fast.length` at its default of `2`, another passes
/// `--override fast.length=2` — a no-op override) compute the exact same
/// signal and must land in the same trace directory. The merge is total and
/// lossless *because* the two vectors are disjoint by construction — a bound
/// param is either varied (`params`) or held (`defaults`), never both — so no
/// name collision can silently drop or shadow a value.
///
/// serde_json's map is sorted, so re-serialising a parsed value yields
/// deterministic bytes — the same technique aura-bench uses for its record-line
/// fingerprint (`crates/aura-bench/src/surfaces/fixed_cost.rs`).
///
/// **`topology_hash` is not hashed as-is — `identity_hash` stands in for it
/// (2026-07-27 audit fix, C29).** `manifest.topology_hash` is a **content** id
/// (`content_id_of(blueprint_to_json(..))`, #158/C24): the canonical bytes it
/// hashes include a blueprint's `doc` field — an authored one-line rationale,
/// C29 — and every other C23 debug symbol (render name, instance names, bound
/// param/role/output/tap/gang names). A description-only (or name-only) edit
/// to an otherwise bit-identical blueprint therefore changes `topology_hash`,
/// which would mint a fresh trace directory for what is, by C29's own Id
/// treatment ("description fields … are blanked for the identity id"), the
/// SAME run — a description influencing an identity id is exactly what C29
/// forbids. The caller passes `identity_hash` — the #171
/// `blueprint_identity_json` projection (debug-symbol-blind by construction)
/// — and this function substitutes it for whatever `manifest.topology_hash`
/// carries in the hashed value; `manifest.topology_hash` itself is untouched
/// (it keeps its own #343 reference-semantics content id — the reproduction
/// store still keys on it byte-exact). **The blanking is wholesale, not
/// description-only:** `identity_hash` is blind to the render name and every
/// node/role/output/tap/gang name too, because none of these carry run
/// semantics (a bound param's *openness* stays identity-bearing) — two
/// blueprints identical in every load-bearing respect but differing only in
/// such names correctly compute the same `identity_hash`, one directory, not
/// two. Both mint sites
/// (`aura-runner::member::run_signal_r`, `aura-runner::measure::run_measurement`)
/// compute `identity_hash` from the SAME blueprint reference they already hash
/// for `topology_hash`, so the two can never drift.
pub fn run_identity_digest(manifest: &aura_engine::RunManifest, identity_hash: &str) -> String {
use aura_engine::Scalar;
use sha2::{Digest, Sha256};
let mut merged: Vec<(String, Scalar)> = manifest
.params
.iter()
.chain(manifest.defaults.iter())
.cloned()
.collect();
merged.sort_by(|a, b| a.0.cmp(&b.0));
let mut v = serde_json::to_value(manifest).expect("a run manifest serializes");
if let Some(obj) = v.as_object_mut() {
obj.remove("commit");
if let Some(project) = obj.get_mut("project").and_then(|p| p.as_object_mut()) {
project.remove("commit");
}
obj.remove("defaults");
obj.insert(
"params".to_string(),
serde_json::to_value(&merged).expect("the merged param vec serializes"),
);
obj.insert(
"topology_hash".to_string(),
serde_json::Value::String(identity_hash.to_string()),
);
}
let canonical = serde_json::to_string(&v).expect("re-serializing a parsed value cannot fail");
let hex = format!("{:x}", Sha256::digest(canonical.as_bytes()));
hex[..8].to_string()
}
#[cfg(test)]
mod tests {
use super::run_identity_digest;
use aura_engine::{ProjectProvenance, RunManifest, Scalar, Timestamp};
/// A fixed `identity_hash` stand-in used by every case below that does not
/// itself vary the blueprint's identity projection — held equal across both
/// calls being compared so the case tests exactly the field it names, not an
/// incidental `identity_hash` difference. Shaped like a real SHA-256 hex
/// digest (64 lowercase hex chars) but not a real hash of anything.
fn identity_hash() -> String {
"beef".repeat(16)
}
/// A manifest with every field populated the way a real single run stamps
/// them — the base each case below perturbs by exactly one field.
fn manifest() -> RunManifest {
RunManifest {
commit: "aabbccdd".to_string(),
params: vec![("fast.length".to_string(), Scalar::I64(2))],
defaults: vec![("bias.scale".to_string(), Scalar::F64(0.5))],
window: (Timestamp(1), Timestamp(18)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: Some("0f1e2d3c".to_string()),
project: Some(ProjectProvenance {
namespace: None,
dylib_sha256: None,
commit: Some("deadbeef".to_string()),
}),
}
}
#[test]
fn run_identity_digest_is_eight_lowercase_hex() {
let d = run_identity_digest(&manifest(), &identity_hash());
assert_eq!(d.len(), 8, "the handle suffix is 8 hex chars: {d}");
assert!(
d.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"lowercase hex only: {d}"
);
}
#[test]
fn run_identity_digest_ignores_the_binary_build_sha() {
let a = manifest();
let mut b = manifest();
b.commit = "0123456789abcdef".to_string();
assert_eq!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"manifest.commit is the binary's build provenance, not the run's identity"
);
}
#[test]
fn run_identity_digest_ignores_the_project_head_and_its_dirty_marker() {
let a = manifest();
let mut b = manifest();
b.project.as_mut().expect("base manifest carries provenance").commit =
Some("deadbeef-dirty".to_string());
assert_eq!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"project.commit is re-derived per invocation; editing the worktree is not a new run"
);
}
/// The key-presence case that removal (rather than blanking) exists for: an
/// absent `project.commit` is a MISSING key, not `null`
/// (`skip_serializing_if`), so blanking would split one identity in two.
#[test]
fn run_identity_digest_collapses_an_absent_project_commit_onto_a_present_one() {
let a = manifest();
let mut b = manifest();
b.project.as_mut().expect("base manifest carries provenance").commit = None;
assert_eq!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"a derivable HEAD and an underivable one are the same run"
);
}
#[test]
fn run_identity_digest_separates_two_param_settings() {
let a = manifest();
let mut b = manifest();
b.params = vec![("fast.length".to_string(), Scalar::I64(3))];
assert_ne!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"params are identity-bearing — this is the headline case"
);
}
/// A no-op `--override` that pins a param to the value it already defaults
/// to moves the pair from `defaults` to `params` without changing the
/// effective parameterisation — the digest must not tell the two runs
/// apart. This is the union-not-partition property: same name/value pairs,
/// different vector each rides in.
#[test]
fn run_identity_digest_ignores_which_vector_a_param_rides_in() {
let a = manifest(); // params: [fast.length=2], defaults: [bias.scale=0.5]
let mut b = manifest();
b.params = vec![];
b.defaults = vec![
("fast.length".to_string(), Scalar::I64(2)),
("bias.scale".to_string(), Scalar::F64(0.5)),
];
assert_eq!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"same effective params, different params/defaults partition — same run"
);
}
/// The C13 discriminator stays IN: a reloaded node crate with different
/// bytes is a different run.
#[test]
fn run_identity_digest_keeps_the_project_dylib_hash() {
let a = manifest();
let mut b = manifest();
b.project.as_mut().expect("base manifest carries provenance").dylib_sha256 =
Some("ff00".to_string());
assert_ne!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"dylib_sha256 is exactly what C13 hot-reload varies"
);
}
/// C29 audit fix (2026-07-27): `topology_hash` is a CONTENT id — it hashes
/// a blueprint's `doc` field (and every other C23 debug symbol) along with
/// its structure — so a description-only edit changes `manifest.
/// topology_hash` even though the run is otherwise bit-identical. The
/// digest must not tell these two manifests apart: it hashes the caller's
/// `identity_hash`, not `manifest.topology_hash`, so two manifests whose
/// `topology_hash` fields differ (as a real doc-only edit would produce)
/// digest the same as long as the SAME `identity_hash` is supplied — which
/// is exactly what `blueprint_identity_json` (doc-blind) computes for both.
#[test]
fn run_identity_digest_ignores_topology_hash_content_id_when_identity_hash_agrees() {
let a = manifest(); // topology_hash: "0f1e2d3c"
let mut b = manifest();
b.topology_hash = Some("ffffffff".to_string()); // as if a doc-only edit changed the content id
assert_eq!(
run_identity_digest(&a, &identity_hash()),
run_identity_digest(&b, &identity_hash()),
"a description-only edit changes topology_hash (a content id, C29) but not \
the blueprint's identity projection the digest must not tell these apart"
);
}
/// The absolute property every other case in this module only tests
/// relationally: ONE fully-populated manifest (every field set, including
/// a `project` with `namespace`, `dylib_sha256` AND `commit` all present)
/// plus one fixed `identity_hash` yields this ONE specific 8-hex value —
/// stably, across builds and processes, since the whole trace store's
/// addressing rests on it.
///
/// If this literal ever needs to change, that change is NOT a casual
/// update: it means `run_identity_digest`'s canonicalisation shifted
/// (e.g. a workspace-wide serde_json `preserve_order` feature unification
/// would turn `Map::remove` into a swap-remove and reorder keys), and
/// every previously recorded trace directory has been silently
/// re-addressed. Update it only with that consequence understood.
#[test]
fn run_identity_digest_is_stable_for_a_fixed_manifest() {
let m = RunManifest {
commit: "aabbccdd".to_string(),
params: vec![("fast.length".to_string(), Scalar::I64(2))],
defaults: vec![("bias.scale".to_string(), Scalar::F64(0.5))],
window: (Timestamp(1), Timestamp(18)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: Some("0f1e2d3c".to_string()),
project: Some(ProjectProvenance {
namespace: Some("quadriga".to_string()),
dylib_sha256: Some("ff00".to_string()),
commit: Some("deadbeef".to_string()),
}),
};
assert_eq!(
run_identity_digest(&m, &"c0ffee00".repeat(8)),
"8cfcc5fc",
"the digest for this fixed manifest must be stable across builds and processes"
);
}
}
+92 -38
View File
@@ -10,11 +10,12 @@
use std::collections::BTreeMap;
use aura_core::{Scalar, Timestamp};
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
use aura_engine::{CompileError, Composite, Harness, MeasurementReport, RunManifest};
use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData};
use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, RunData};
use crate::project::Env;
use crate::tap_plan::{bind_tap_plan, TapPlan};
use crate::RunnerError;
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit`
@@ -51,68 +52,116 @@ pub fn measurement_manifest(
}
}
/// #339 item 3 (a #317 follow-up): `CompileError::UnboundRootRole { role }`
/// carries a flat root-role index — meaningless to a caller who authored a
/// NAMED open role (`{"op":"input","role":"price"}`). `role_names` is
/// `signal.input_roles()`'s own names, read before `compile_with_params`
/// consumes `signal` (mirrors `member::compile_error_prose`'s pre-consumption
/// `names` capture for its `ParamKindMismatch` prose). Every other
/// `CompileError` variant keeps the existing Debug fallback deliberately —
/// they ARE reachable on this direct-compile path (a hand-authored
/// measurement envelope with an out-of-range declared-tap wire reaches
/// `TapWireOutOfRange` here exactly as `run_signal_r`'s own compile call
/// does, see `run_refuses_unrunnable_blueprint.rs`), so the fallback stays
/// total rather than partial; only `UnboundRootRole` gets dedicated prose
/// above.
fn compile_error_prose(e: &CompileError, role_names: &[String]) -> String {
let CompileError::UnboundRootRole { role } = e else {
return format!("this blueprint does not compile to a runnable harness: {e:?}");
};
let name = role_names.get(*role).map(String::as_str).unwrap_or("<unknown>");
format!(
"this blueprint does not compile to a runnable harness: root role \"{name}\" is \
unbound it is declared open (an `input` role) but there is no enclosing graph \
to wire it when run standalone"
)
}
/// 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
/// No broker, no risk executor, no equity/exposure/r sinks at all — the R leg is
/// absent here rather than merely folded, which is what distinguishes this path
/// now that `run_signal_r` retains per trade rather than per cycle. 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 {
) -> Result<crate::RunOutcome<MeasurementReport>, RunnerError> {
// 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`.
// `aura_research`. `identity_hash` is the sibling #171 projection over the
// SAME `signal`, feeding `run_identity_digest` in place of `topology_hash`
// (2026-07-27, C29) — see that function's doc comment.
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 identity_hash = aura_research::content_id_of(
&aura_engine::blueprint_identity_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed
// #311 (mirrors run_signal_r): the render name is the readable prefix; the
// directory is minted from the manifest below.
let render_name = signal.name().to_string();
// C14 class 2: fault in argv-named content (#297)
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
.map_err(|m| RunnerError { exit_code: 2, message: m })?;
// C14 class 2: fault in argv-named content (#297)
if matches!(data, RunData::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding));
std::process::exit(1);
return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) });
}
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
let defaults = wrapped_bound_defaults(&signal);
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
// #339 item 3: `signal`'s own root-role names, captured before
// `compile_with_params` consumes it below — the bare-tap path compiles
// the signal DIRECTLY (unlike the bias/strategy arm's `wrap_r` nesting),
// so an unbound open root role surfaces here as `CompileError::
// UnboundRootRole { role }`, a flat index with no name attached at the
// engine boundary. `role_names` lets `compile_error_prose` resolve it.
let role_names: Vec<String> = signal.input_roles().iter().map(|r| r.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");
// C14 class 2: fault in argv-named content (#297)
let mut flat = signal.compile_with_params(params)
.map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &role_names) })?;
// #311 (mirrors run_signal_r): assemble the manifest BEFORE the bind and
// key the trace directory by the run's own identity. Every input this
// needs — `topo` (above), `defaults` (above), `window` (from
// `resolve_run_data`) — is already resolved by this point; `names` is
// untouched by the compile call, which passes `role_names`.
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();
let run_name =
format!("{}-{}", render_name, crate::run_identity_digest(&manifest, &identity_hash));
// Bind each declared tap per the plan's subscription (mirrors
// run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror).
// C14 class 2: fault in argv-named content (#297)
let bound = bind_tap_plan(&mut flat, plan, env, &run_name)
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
let trace_name = bound.trace_name().map(str::to_string);
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");
// 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 }
let skipped = bound.skipped().to_vec();
bound.finish(&manifest)
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
Ok(crate::RunOutcome {
report: MeasurementReport { manifest, taps: tap_names },
skipped,
trace_name,
})
}
#[cfg(test)]
@@ -165,11 +214,15 @@ mod tests {
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);
let outcome = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan)
.expect("measurement run succeeds");
// #311: the run reports the identity-keyed directory it wrote into.
let handle = outcome.trace_name.clone().expect("a recording run reports its handle");
let report = outcome.report;
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"),
root.join("runs").join("traces").join(&handle).join("fast_tap.json"),
)
.expect("one-row fold trace persisted");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
@@ -193,7 +246,8 @@ mod tests {
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());
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all())
.expect("measurement run succeeds").report;
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
+57 -11
View File
@@ -10,7 +10,7 @@
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,
CORE_FINGERPRINT, ProjectDescriptor, RUSTC_VERSION, StrSlice,
};
use aura_engine::ProjectProvenance;
use aura_registry::{Registry, TraceStore};
@@ -378,7 +378,7 @@ fn validate_c_tier(
magic: u64,
descriptor_version: u32,
rustc_version: StrSlice,
aura_core_version: StrSlice,
aura_core_fingerprint: StrSlice,
namespace: StrSlice,
dylib_path: &Path,
) -> Result<String, ProjectError> {
@@ -401,13 +401,13 @@ fn validate_c_tier(
host: RUSTC_VERSION.to_string(),
});
}
let dylib_core = unsafe { aura_core_version.as_str() }
let dylib_core = unsafe { aura_core_fingerprint.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_core != CORE_VERSION {
if dylib_core != CORE_FINGERPRINT {
return Err(ProjectError::Incompatible {
what: "aura-core version",
what: "aura-core build",
dylib: dylib_core.to_string(),
host: CORE_VERSION.to_string(),
host: CORE_FINGERPRINT.to_string(),
});
}
unsafe { namespace.as_str() }
@@ -580,13 +580,13 @@ fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectErro
let magic = unsafe { (*desc_ptr).magic };
let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
let rustc_version = unsafe { (*desc_ptr).rustc_version };
let aura_core_version = unsafe { (*desc_ptr).aura_core_version };
let aura_core_fingerprint = unsafe { (*desc_ptr).aura_core_fingerprint };
let namespace_stamp = unsafe { (*desc_ptr).namespace };
let namespace = validate_c_tier(
magic,
descriptor_version,
rustc_version,
aura_core_version,
aura_core_fingerprint,
namespace_stamp,
&dylib_path,
)?;
@@ -806,7 +806,7 @@ mod tests {
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
StrSlice::new(CORE_VERSION),
StrSlice::new(CORE_FINGERPRINT),
)
}
@@ -857,7 +857,7 @@ mod tests {
}
#[test]
fn validate_c_tier_rejects_aura_core_version_mismatch() {
fn validate_c_tier_rejects_aura_core_build_mismatch() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_core = StrSlice::new("9.9.9-fake");
@@ -865,10 +865,56 @@ mod tests {
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "aura-core version", .. }
ProjectError::Incompatible { what: "aura-core build", .. }
));
}
/// #348 / C30: the aura-core stamp compared at the load seam is a
/// build-identity fingerprint of aura-core's *sources*, not the frozen
/// crate version. Under C30's no-semver discipline the workspace version
/// is pinned at "0.1.0", so every stale pre-transition dylib stamps
/// exactly that string — a host whose own stamp were still the crate
/// version would wave any of them through (the disarmed handshake this
/// pin re-arms). The host side of the comparison must be a
/// source-derived fingerprint (never the frozen version string), the
/// refusal must echo both stamps, and its prose must name the rebuild
/// fix. The accept side — identical stamps still load — stays pinned by
/// `validate_c_tier_accepts_matching_stamps` above.
#[test]
fn validate_c_tier_rejects_the_frozen_crate_version_as_aura_core_stamp() {
let path = PathBuf::from("/tmp/x.so");
// What every stale, pre-fingerprint dylib carries as its aura-core
// stamp: the frozen workspace crate version.
let stale_stamp = StrSlice::new("0.1.0");
let err = validate_c_tier(
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
stale_stamp,
StrSlice::new("demo"),
&path,
)
.unwrap_err();
let ProjectError::Incompatible { dylib, host, .. } = &err else {
panic!("expected Incompatible, got: {err}");
};
assert_eq!(dylib.as_str(), "0.1.0", "the refusal echoes the dylib's stale stamp");
assert_ne!(
host.as_str(),
"0.1.0",
"the host's stamp is a source-derived fingerprint, not the frozen crate version"
);
// Version-bump-proof twin of the literal above: whatever the crate
// version becomes, it must never be the host stamp again.
assert_ne!(
host.as_str(),
env!("CARGO_PKG_VERSION"),
"the host's stamp reverted to the crate version"
);
let msg = err.to_string();
assert!(msg.contains("rebuild"), "the refusal names the fix: {msg}");
}
/// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
/// than dereferencing it, same as the standalone `StrSlice::as_str` test
/// in aura-core — here exercised through the loader's own refusal path.
+248 -31
View File
@@ -1,14 +1,18 @@
//! 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).
//! Both outcomes here are values, not process control flow: success returns a
//! [`ReproduceReport`] the caller inspects (`ReproduceReport::all_identical`);
//! a refusal returns a [`crate::RunnerError`] — neither ends the process. The
//! shell dispatch arm (`aura-cli`'s `dispatch_reproduce`) is the single place
//! that renders the report and prints a refusal's message to stderr, calling
//! `std::process::exit` on its code, keeping the observable stderr/exit bytes
//! unchanged (C18); a library embedder (a World program) reads the returned
//! report directly instead.
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;
@@ -22,10 +26,18 @@ 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).
#[derive(Debug)]
pub struct ReproduceReport {
pub outcomes: Vec<(String, bool)>,
}
impl ReproduceReport {
/// True iff every member reproduced bit-identically (C1).
pub fn all_identical(&self) -> bool {
self.outcomes.iter().all(|(_, identical)| *identical)
}
}
/// 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
@@ -67,11 +79,35 @@ pub fn load_family(reg: &Registry, id: &str) -> Result<Family, RunnerError> {
/// 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).
///
/// Refusal contract: an identity/pip mismatch (below) refuses class 2 — this is the
/// explicit-source seam, so a caller-given `data` that contradicts the member
/// manifests is argv-named content (C14). `reproduce_family`, the simple/derived
/// path, hits the same guard at class 1 instead (Fork 6, #299: on that path `data`
/// is derived FROM the family's own manifest, so a mismatch there is data drift, not
/// a caller error). A WINDOW guard is deliberately absent: no reproduce path takes a
/// caller window (WF and real-sweep re-runs load stored.manifest.window; MonteCarlo
/// derives its walk from manifest.seed; synthetic uses the full window).
pub fn reproduce_family_in(
reg: &Registry,
id: &str,
data: &DataSource,
env: &Env,
) -> Result<ReproduceReport, RunnerError> {
reproduce_family_with_class(reg, id, data, env, 2)
}
/// The shared reproduce loop (#299 Fork 6): `mismatch_class` is the exit code the
/// identity/pip guard refuses with — context-borne, not a property of the guard
/// itself. `reproduce_family_in` (explicit-source seam) passes 2; `reproduce_family`
/// (derived-source seam) passes 1. Guard messages are identical either way; only the
/// class varies.
fn reproduce_family_with_class(
reg: &Registry,
id: &str,
data: &DataSource,
env: &Env,
mismatch_class: i32,
) -> Result<ReproduceReport, RunnerError> {
let family = load_family(reg, id)?;
let pip = data.pip_size();
@@ -82,6 +118,52 @@ pub fn reproduce_family_in(
exit_code: 1,
message: "family member has no topology_hash; not a generated run".to_string(),
})?;
// Identity/geometry guard (#299): a source whose identity or pip
// contradicts the member manifests makes divergence mechanical. The
// class is context-borne (Fork 6, see this fn's doc): caller error on
// the explicit-source seam, data drift on the derived seam.
if let Some(recorded) = stored.manifest.instrument.as_deref() {
match data {
DataSource::Synthetic => {
return Err(RunnerError {
exit_code: mismatch_class,
message: format!(
"reproduce source mismatch: family member was recorded over instrument '{recorded}', the source is synthetic"
),
});
}
DataSource::Real { symbol, .. } if symbol != recorded => {
return Err(RunnerError {
exit_code: mismatch_class,
message: format!(
"reproduce source mismatch: family member was recorded over instrument '{recorded}', the source names '{symbol}'"
),
});
}
_ => {}
}
}
// pip has no manifest field of its own: forward-build the label the
// FAMILY MINT stamps for this pip (the r_sma/risk-executor variant —
// `run_blueprint_member` unconditionally stamps that label) and
// compare against the recorded one. The plain-arm branch below
// tolerates a stored record shaped like a single-run stamp (legacy or
// otherwise), not a variant any live re-run of THIS family would
// itself produce.
let expected = if stored.manifest.broker.contains("+risk-executor") {
crate::member::r_sma_broker_label(pip)
} else {
crate::member::sim_optimal_broker_label(pip)
};
if expected != stored.manifest.broker {
return Err(RunnerError {
exit_code: mismatch_class,
message: format!(
"reproduce broker/pip mismatch: manifest recorded '{}', the source's pip renders '{expected}'",
stored.manifest.broker
),
});
}
let doc = reg
.get_blueprint(&hash)
.map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })?
@@ -133,6 +215,9 @@ pub fn reproduce_family_in(
.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).
// Deliberately class 1, unchanged by #297: prose identical to the
// now-class-2 `run_signal_r` twins, but a stored artifact being
// reproduced is not argv-named content (C14 partition).
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() {
@@ -142,7 +227,31 @@ pub fn reproduce_family_in(
});
}
let space = crate::member::wrap_r(reload()?, tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding, None).param_space();
let point = point_from_params(&space, &stored.manifest.params)
// #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
@@ -174,7 +283,7 @@ pub fn reproduce_family_in(
// 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 s = data.windowed_sources(from, to, env, &binding.columns())?;
let w = window_of(&s).expect("non-empty OOS window");
(s, w)
}
@@ -187,9 +296,11 @@ pub fn reproduce_family_in(
_ => match data {
DataSource::Real { .. } => {
let (from, to) = stored.manifest.window;
(data.windowed_sources(from, to, env, &binding.columns()), (from, to))
(data.windowed_sources(from, to, env, &binding.columns())?, (from, to))
}
DataSource::Synthetic => {
(data.run_sources(env, &binding.columns())?, data.full_window(env)?)
}
DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)),
},
};
let rerun = run_blueprint_member(
@@ -209,7 +320,8 @@ pub fn reproduce_family_in(
// 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(""),
);
)
.expect("re-run cost specs are stamp-derived scalars; the instrument-inert fallback never misses");
outcomes.push((label, rerun.metrics == stored.metrics));
}
Ok(ReproduceReport { outcomes })
@@ -222,13 +334,17 @@ pub fn reproduce_family_in(
/// 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> {
/// Returns the report as a value (#299): divergence is data the caller inspects
/// (`ReproduceReport::all_identical`), not a process exit — this library never
/// prints and never exits. The CLI's `dispatch_reproduce` renders the report and
/// owns the exit; a library embedder (a World program) reads `report.outcomes`
/// directly.
///
/// This is the derived-source seam (Fork 6, #299): `data` is reconstructed FROM
/// the family's own manifest, not caller-given, so the identity/pip guard inside
/// the shared loop refuses class 1 here (data drift within the stored family),
/// not class 2 (`reproduce_family_in`'s explicit-source class — see its doc).
pub fn reproduce_family(id: &str, env: &Env) -> Result<ReproduceReport, RunnerError> {
let reg = env.registry();
let family = load_family(&reg, id)?;
// Reconstruct the DataSource the family was minted over: `None` instrument
@@ -238,21 +354,10 @@ pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> {
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)
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(())
reproduce_family_with_class(&reg, id, &data, env, 1)
}
#[cfg(test)]
@@ -262,7 +367,9 @@ mod tests {
use aura_engine::RunManifest;
use aura_registry::{FamilyKind, Registry};
use super::load_family;
use super::{load_family, reproduce_family_in, ReproduceReport};
use crate::family::DataSource;
use crate::project::Env;
/// A registry over a fresh per-test directory (the #258 tag-keyed pattern:
/// fixed name under the build-tree tmp anchor, pre-create wipe).
@@ -293,6 +400,116 @@ mod tests {
}
}
/// A persisted member carrying a `topology_hash` (#299 guard tests): the
/// identity/pip guards fire BEFORE the blueprint fetch, so a seeded record
/// needs a `topology_hash` to clear the earlier "not a generated run"
/// check, but the hash itself never resolves against the store.
fn stamped_report(instrument: Option<&str>, broker: &str) -> RunReport {
RunReport {
manifest: RunManifest {
topology_hash: Some("deadbeef".to_string()),
instrument: instrument.map(str::to_string),
broker: broker.to_string(),
..minimal_report().manifest
},
..minimal_report()
}
}
/// The instrument guard (#299): a family member recorded over a real
/// instrument refuses class 2 — before any blueprint fetch — when the
/// caller-given source is `Synthetic`, naming the recorded instrument in
/// the message.
#[test]
fn reproduce_family_in_refuses_synthetic_source_for_a_real_instrument_member() {
let reg = temp_registry("guard-synthetic-for-real");
reg.append_family(
"f",
FamilyKind::Sweep,
&[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")],
)
.expect("append family");
let err = reproduce_family_in(&reg, "f", &DataSource::Synthetic, &Env::std())
.expect_err("a synthetic source must not silently reproduce a real-instrument member");
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("GER40"), "message: {}", err.message);
assert!(err.message.contains("synthetic"), "message: {}", err.message);
}
/// The instrument guard's Real-vs-Real twin: a caller-given `Real` source
/// naming a DIFFERENT symbol than the member's recorded instrument refuses
/// class 2, naming both sides.
#[test]
fn reproduce_family_in_refuses_a_real_source_naming_the_wrong_symbol() {
let reg = temp_registry("guard-wrong-symbol");
reg.append_family(
"f",
FamilyKind::Sweep,
&[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")],
)
.expect("append family");
// A hand-built `DataSource::Real` fixture: `DataServer::new` merely
// scans its base path (a no-op on a missing directory), so the guard
// — which never touches the server — is exercised without any real
// archive on disk.
let server = std::sync::Arc::new(data_server::DataServer::new("/nonexistent-aura-test-path"));
let data = DataSource::Real {
server,
symbol: "US500".to_string(),
from_ms: None,
to_ms: None,
pip: 1.0,
};
let err = reproduce_family_in(&reg, "f", &data, &Env::std())
.expect_err("a real source naming the wrong symbol must not silently reproduce");
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("GER40"), "message: {}", err.message);
assert!(err.message.contains("US500"), "message: {}", err.message);
}
/// The broker/pip guard (#299): the recorded manifest carries no pip
/// field of its own — the pip survives only inside the broker label — so
/// the guard forward-builds the label the family mint stamps for this pip
/// (tolerating a plain-shape stored record generically, not asserting a
/// live re-run would itself produce it — #299) and refuses
/// class 2 on a label mismatch, naming both labels.
#[test]
fn reproduce_family_in_refuses_a_pip_mismatch_via_the_broker_label() {
let reg = temp_registry("guard-pip-mismatch");
// Recorded under a pip the actual synthetic run (0.0001, `SYNTHETIC_PIP_SIZE`)
// does not match: the label the re-run would forward-build diverges from
// the stored one, so the guard must fire before any re-run.
reg.append_family(
"f",
FamilyKind::Sweep,
&[stamped_report(None, "sim-optimal(pip_size=0.0002)")],
)
.expect("append family");
let err = reproduce_family_in(&reg, "f", &DataSource::Synthetic, &Env::std())
.expect_err("a pip mismatch forward-built via the broker label must refuse");
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("sim-optimal(pip_size=0.0002)"), "message: {}", err.message);
assert!(err.message.contains("sim-optimal(pip_size=0.0001)"), "message: {}", err.message);
}
/// `all_identical` is true iff every outcome reproduced bit-identically.
#[test]
fn all_identical_reflects_every_outcome() {
let all_ok = ReproduceReport {
outcomes: vec![("a".to_string(), true), ("b".to_string(), true)],
};
assert!(all_ok.all_identical());
let one_diverged = ReproduceReport {
outcomes: vec![("a".to_string(), true), ("b".to_string(), false)],
};
assert!(!one_diverged.all_identical());
}
/// 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
+54 -16
View File
@@ -54,7 +54,7 @@ pub fn render_value(v: &Scalar) -> String {
}
/// The shipped harness/data binding seam for `aura_campaign::execute`: members
/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode
/// run through the shipped loaded-blueprint machinery (`wrap_r` folded-delivery
/// 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
@@ -116,6 +116,21 @@ impl MemberRunner for DefaultMemberRunner<'_> {
.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,
@@ -173,7 +188,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
let mut report = run_blueprint_member(
signal,
&point,
&space,
&manifest_space,
sources,
(from, to),
0,
@@ -184,7 +199,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
&binding,
&self.cost,
&cell.instrument,
);
)?;
report.manifest.instrument = Some(cell.instrument.clone());
Ok(report)
}
@@ -354,7 +369,7 @@ pub fn cell_member_fanout(cell_out: &CellOutcome) -> Vec<(Option<String>, &RunRe
/// 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`,
/// once under retained delivery 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'
@@ -483,14 +498,35 @@ pub fn persist_campaign_traces(
})?;
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
// Re-run the member under retained delivery: the SAME member the
// executor ran (same wrapped space, same params, same window,
// seed-free real data), mirroring `DefaultMemberRunner::run_member`
// with the series fold off so the per-cycle tap streams exist. The window
// is the member report's own `manifest.window` — already
// epoch-ns (`run_blueprint_member` stamped the post-seam
// bounds), so no second ms->ns crossing here.
let point = point_from_params(&space, &member_report.manifest.params)?;
//
// #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!(
@@ -543,11 +579,13 @@ pub fn persist_campaign_traces(
// 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 cost_leg = if campaign.cost.is_empty() {
None
} else {
let nodes = cost_nodes_for(&campaign.cost, &cell_rec.instrument)
.map_err(|f| f.message())?;
Some(CostLeg { nodes, 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)
@@ -571,8 +609,8 @@ pub fn persist_campaign_traces(
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
// member. The folded delivery shares its arithmetic with this
// retained-side 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));
+49 -6
View File
@@ -217,8 +217,9 @@ impl FoldRegistry {
}
/// 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`.
/// store fault. Entry points wrap every variant into a returned
/// `RunnerError` (#297) — prose via `Display`, class via `exit_class`
/// (content faults 2, store I/O 1, the C14 partition).
pub enum TapPlanError {
/// The plan names a tap the blueprint does not declare.
UnknownTap { name: String, declared: Vec<String> },
@@ -285,6 +286,25 @@ impl From<TraceStoreError> for TapPlanError {
}
}
impl TapPlanError {
/// C14 partition, wired into `run_signal_r`/`run_measurement`'s
/// `bind_tap_plan` `.map_err` (#297 Fork 1/2): faults in the content of
/// what argv named are class 2; environment faults (store I/O) are
/// class 1.
pub fn exit_class(&self) -> i32 {
match self {
TapPlanError::Store(_) => 1,
TapPlanError::UnknownTap { .. }
| TapPlanError::UnknownLabel { .. }
| TapPlanError::KindMismatch { .. }
| TapPlanError::UnknownParam { .. }
| TapPlanError::MissingParam { .. }
| TapPlanError::ParamKind { .. }
| TapPlanError::Bind(_) => 2,
}
}
}
/// 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.
@@ -348,6 +368,15 @@ pub struct BoundTaps {
persisted: Vec<String>,
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
/// Declared taps that resolved to no subscription this run (#297): the
/// caller-printed "unbound" note migrates to the CLI, this is the data
/// it prints from.
skipped: Vec<String>,
/// The trace-store handle this run's taps landed under — `Some` exactly
/// when the plan persisted something and `begin_run` was called. Rides
/// beside the report like `skipped` (#297): the CLI prints it, the
/// library hands it back as a value.
trace_name: Option<String>,
}
impl BoundTaps {
@@ -356,6 +385,17 @@ impl BoundTaps {
&self.declared
}
/// The recorded trace handle, or `None` when this run persisted nothing.
pub fn trace_name(&self) -> Option<&str> {
self.trace_name.as_deref()
}
/// Declared taps that resolved to no subscription this run (#297) — the
/// data the caller's "unbound" note prints from.
pub fn skipped(&self) -> &[String] {
&self.skipped
}
/// 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
@@ -424,6 +464,7 @@ pub fn bind_tap_plan(
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
}
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
let mut skipped: Vec<String> = 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) {
@@ -438,11 +479,11 @@ pub fn bind_tap_plan(
// (`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.
// The name is recorded here and the note is CLI-printed from
// the returned `skipped` names (the runner→CLI print
// migration, #297) — this module no longer emits it.
None => {
eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name);
skipped.push(tap.name.clone());
continue;
}
},
@@ -476,6 +517,8 @@ pub fn bind_tap_plan(
persisted: Vec::new(),
rows: Vec::new(),
outcomes: Vec::new(),
skipped,
trace_name: if persists { Some(run_name.to_string()) } else { None },
};
for (name, kind, sub) in resolved {
let node: Box<dyn Node> = match sub {
+112 -24
View File
@@ -4,10 +4,11 @@
//! 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.
//! `stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance` <->
//! [`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};
@@ -23,11 +24,14 @@ pub const R_SMA_STOP_LENGTH: i64 = 3;
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
/// (`stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance`, stamped by
/// `run_blueprint_member`): if `stop_period_minutes` is present alongside
/// `stop_length`/`stop_k`, this re-derives `VolTf`; `stop_length`/`stop_k`
/// alone re-derives `Vol`; `stop_distance` alone (#338) re-derives `Fixed` —
/// the three stamps are mutually exclusive (`run_blueprint_member` stamps
/// exactly one arm's knobs per member). Falls back 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 {
@@ -35,11 +39,13 @@ pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
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)) => {
let distance = params.iter().find(|(n, _)| n == "stop_distance").map(|(_, s)| s.as_f64());
match (period_minutes, length, k, distance) {
(Some(period_minutes), Some(length), Some(k), _) => {
StopRule::VolTf { period_minutes, length, k }
}
(None, Some(length), Some(k)) => StopRule::Vol { length, k },
(None, Some(length), Some(k), _) => StopRule::Vol { length, k },
(None, None, None, Some(distance)) => StopRule::Fixed(distance),
_ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
}
}
@@ -94,6 +100,7 @@ pub fn cost_specs_from_params(
/// 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).
/// `Fixed { distance }` (#338) binds the shipped `FixedStop` composite.
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 },
@@ -103,6 +110,7 @@ pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_c
Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => {
aura_composites::StopRule::VolTf { period_minutes, length, k }
}
Some(aura_research::RiskRegime::Fixed { distance }) => aura_composites::StopRule::Fixed(distance),
}
}
@@ -113,12 +121,15 @@ pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_c
/// 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> {
pub fn cost_nodes_for(
specs: &[aura_research::CostSpec],
instrument: &str,
) -> Result<Vec<PrimitiveBuilder>, CostKnobFault> {
specs
.iter()
.map(|s| {
let (knob, v) = cost_knob(s, instrument);
match s {
let (knob, v) = cost_knob(s, instrument)?;
Ok(match s {
aura_research::CostSpec::Constant { .. } => {
ConstantCost::builder().bind(knob, Scalar::f64(v))
}
@@ -128,29 +139,47 @@ pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Ve
aura_research::CostSpec::Carry { .. } => {
CarryCost::builder().bind(knob, Scalar::f64(v))
}
}
})
})
.collect()
}
/// The fault `cost_knob` refuses with: the document named an instrument the
/// value map has no entry for (unreachable after intrinsic validation —
/// kept as a loud refusal, never a silent 0-charge).
#[derive(Debug)]
pub struct CostKnobFault {
pub knob: &'static str,
pub instrument: String,
}
impl CostKnobFault {
/// The exact prose the retired exit register printed (byte-identical).
pub fn message(&self) -> String {
format!("cost {}: no entry for instrument {}", self.knob, self.instrument)
}
}
/// 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) {
pub fn cost_knob(
spec: &aura_research::CostSpec,
instrument: &str,
) -> Result<(&'static str, f64), CostKnobFault> {
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(|| {
match value.resolve(instrument) {
Some(v) => Ok((knob, v)),
// 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)
None => Err(CostKnobFault { knob, instrument: instrument.to_string() }),
}
}
#[cfg(test)]
@@ -178,6 +207,15 @@ mod tests {
assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 });
}
#[test]
/// #338 round-trip: a manifest carrying ONLY `stop_distance` (no vol
/// knobs) re-derives `StopRule::Fixed` — the `stop_rule_from_params_round_trips_the_vol_tf_stamp`
/// precedent above, `Fixed` edition.
fn stop_rule_from_params_round_trips_the_fixed_distance_stamp() {
let fixed = vec![("stop_distance".to_string(), Scalar::f64(10.0))];
assert_eq!(stop_rule_from_params(&fixed), StopRule::Fixed(10.0));
}
#[test]
/// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to
/// `StopRule::VolTf` field-for-field — the resolve-side half of the
@@ -193,6 +231,17 @@ mod tests {
);
}
#[test]
/// #338: `stop_rule_for_regime` binds `RiskRegime::Fixed` to
/// `StopRule::Fixed` — the resolve-side half of the write/resolve pair the
/// manifest round-trip test above covers from the stamp side (the
/// `stop_rule_for_regime_binds_vol_tf_field_for_field` precedent, `Fixed`
/// edition).
fn stop_rule_for_regime_binds_fixed_distance() {
let regime = Some(aura_research::RiskRegime::Fixed { distance: 10.0 });
assert_eq!(stop_rule_for_regime(regime), aura_composites::StopRule::Fixed(10.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
@@ -212,14 +261,53 @@ mod tests {
},
],
"GER40",
);
)
.expect("every knob resolves against the GER40-keyed fixture");
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");
assert!(
cost_nodes_for(&[], "GER40").expect("empty model resolves").is_empty(),
"an empty model binds no nodes"
);
}
#[test]
/// Review fix (#297 fork 3): `CostKnobFault::message()` is the exact prose
/// the retired exit register printed byte-identically — pinned against a
/// concrete fixture so a future edit to the format string is caught here,
/// not only at the call sites that propagate it as `MemberFault::Bind`.
fn cost_knob_fault_message_is_byte_pinned() {
let fault = CostKnobFault { knob: "cost_per_trade", instrument: "XYZ".to_string() };
assert_eq!(fault.message(), "cost cost_per_trade: no entry for instrument XYZ");
}
#[test]
/// Review fix (#297 fork 3): `cost_knob`/`cost_nodes_for` refuse (never
/// silently charge 0) when a `CostSpec`'s per-instrument map lacks the
/// requested instrument — the `PerInstrument` counterpart of
/// `cost_nodes_for_maps_each_component_to_its_bound_builder`'s all-resolve
/// case above, exercising the `None` arm of `CostValue::resolve`.
fn cost_knob_and_cost_nodes_for_refuse_an_uncovered_instrument() {
let spec = aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::PerInstrument(std::collections::BTreeMap::from([(
"GER40".to_string(),
2.0,
)])),
};
let err = cost_knob(&spec, "EURUSD").expect_err("EURUSD is not in the map");
assert_eq!(err.knob, "cost_per_trade");
assert_eq!(err.instrument, "EURUSD");
let err = match cost_nodes_for(std::slice::from_ref(&spec), "EURUSD") {
Err(f) => f,
Ok(_) => panic!("cost_nodes_for must propagate the same fault"),
};
assert_eq!(err.knob, "cost_per_trade");
assert_eq!(err.instrument, "EURUSD");
}
#[test]
@@ -0,0 +1,142 @@
//! The folded wrap retains one record row per closed trade, not one per cycle,
//! and binds no sink whose rows nothing reads.
//!
//! Both assertions are identities that hold at any trade count, so this pin
//! depends on no property of the driven price path. Under retained delivery the
//! record sink keeps one row per warm cycle and the series sinks one row each,
//! so both identities fail loudly at any trade rate below one close per cycle —
//! that is where the discrimination comes from.
use aura_backtest::summarize_r;
use aura_composites::StopRule;
use aura_core::{Scalar, Timestamp};
use aura_engine::{blueprint_from_json, Harness, Source, VecSource};
use aura_runner::binding::probe_binding;
use aura_runner::member::{key_supply, wrap_r, SYNTHETIC_PIP_SIZE};
use aura_runner::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH};
use aura_vocabulary::std_vocabulary;
use std::sync::mpsc;
const CYCLES: usize = 50_000;
/// A deterministic triangular oscillation around 100.0. No RNG: the same series
/// every run. The period is long enough that the vol stop warms and positions
/// both open and close, so the gated sink is exercised rather than trivially
/// empty — but nothing in the assertions depends on how often that happens.
fn prices(n: usize) -> Vec<(Timestamp, Scalar)> {
(0..n)
.map(|i| {
let phase = (i % 400) as f64;
let leg = if phase < 200.0 { phase } else { 400.0 - phase };
(Timestamp(i as i64 + 1), Scalar::f64(100.0 + leg * 0.05))
})
.collect()
}
#[test]
fn folded_wrap_retains_rows_per_trade_not_per_cycle() {
let signal = blueprint_from_json(
include_str!("../../aura-cli/examples/r_sma.json"),
&|t| std_vocabulary(t),
)
.expect("the shipped r_sma example parses");
let binding = probe_binding(signal.input_roles());
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();
let wrapped = wrap_r(
signal,
tx_eq,
tx_ex,
tx_r,
tx_req,
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
true,
SYNTHETIC_PIP_SIZE,
&binding,
None,
);
let flat = wrapped.compile_with_params(&[]).expect("the wrap compiles bound");
let mut h = Harness::bootstrap(flat).expect("the wrap bootstraps");
let sources: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(prices(CYCLES)))];
h.run_bound(key_supply(&binding, sources)).expect("sources key-match the binding");
let record: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let equity_rows = rx_eq.try_iter().count();
let exposure_rows = rx_ex.try_iter().count();
let req_rows = rx_req.try_iter().count();
// `n_trades` counts TRADES, not rows — closed rows plus one when a position
// is still open on the last retained row. Feeding it more rows does not make
// it larger, which is what keeps the identity below non-vacuous.
let n_trades = summarize_r(&record, &[]).n_trades;
assert!(
record.len() as u64 <= n_trades + 1,
"record retention exceeded one row per trade: {} rows for {n_trades} trades \
over {CYCLES} cycles",
record.len()
);
assert!(
equity_rows <= 1 && exposure_rows <= 1,
"a folded series sink retained more than its summary row: eq={equity_rows} ex={exposure_rows}"
);
assert_eq!(
req_rows, 0,
"the r-equity sink is bound under folded delivery, retaining {req_rows} unread rows"
);
}
/// A run with no warm cycle reports zeros — and reaches them by the folded
/// route: the series sink emits its summary row at finalize unconditionally,
/// carrying the fold's defaults. The gated record sink, having no last row to
/// flush, emits nothing at all.
#[test]
fn folded_wrap_reports_zeros_when_no_cycle_warms() {
let signal = blueprint_from_json(
include_str!("../../aura-cli/examples/r_sma.json"),
&|t| std_vocabulary(t),
)
.expect("the shipped r_sma example parses");
let binding = probe_binding(signal.input_roles());
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();
let wrapped = wrap_r(
signal,
tx_eq,
tx_ex,
tx_r,
tx_req,
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
true,
SYNTHETIC_PIP_SIZE,
&binding,
None,
);
let flat = wrapped.compile_with_params(&[]).expect("the wrap compiles bound");
let mut h = Harness::bootstrap(flat).expect("the wrap bootstraps");
// No bars at all: nothing warms, nothing evaluates.
let sources: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(Vec::new()))];
h.run_bound(key_supply(&binding, sources)).expect("sources key-match the binding");
let eq: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let record: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
assert_eq!(eq.len(), 1, "the series sink emits its summary row even unwarmed");
assert_eq!(ex.len(), 1, "the series sink emits its summary row even unwarmed");
assert_eq!(record.len(), 0, "the gated sink has no last row to flush");
assert_eq!(rx_req.try_iter().count(), 0, "the r-equity sink is not bound");
// The zeros the entry point would report, read exactly as it reads them.
assert_eq!(eq[0].1[0].as_f64(), 0.0, "total_pips");
assert_eq!(eq[0].1[1].as_f64(), 0.0, "max_drawdown");
assert_eq!(ex[0].1[2].as_i64(), 0, "bias_sign_flips");
}
@@ -185,7 +185,6 @@ fn the_shell_defines_no_domain_modules_and_no_lib_target() {
"render.rs",
"research_docs.rs",
"scaffold.rs",
"verb_sugar.rs",
],
"C28 (#295): the shell holds argv/dispatch, argv->document translation, \
and presentation only a new module needs a library home"
+237 -46
View File
@@ -10,7 +10,7 @@ JSON artifact kinds you author headlessly along that arc:
through `aura process validate|introspect|register|show`;
3. a **campaign document** (role 6b) — experiment intent (instruments ×
windows × strategy × axes × process) through `aura campaign
validate|introspect|register|show|run|runs`.
validate|introspect|register|runs|show`, executed by `aura exec` (#319).
Each section below is a worked, verified example — every command shown was
run against this repo and the output is transcribed, not invented. The *why*
@@ -39,9 +39,11 @@ the same shape every node in `aura-std` already follows.
scaffolds a **data-only** project (`docs/project-layout.md`, "A project
repo (two tiers)") — a strategy over the std vocabulary is a
blueprint/campaign document, not Rust: the scaffold ships **one** closed
`signal.json` starter that serves both verbs`aura run` uses its bound
values as-is, `aura sweep --axis <bp>.fast.length=2,4,8` overrides them
(bound = default, not fixed); `--list-axes` lists the open knobs and the
`signal.json` starter that serves both legs of `exec` (#319) — `aura exec
signal.json` uses its bound values as-is, `aura exec signal.json --override
fast.length=8` reopens one for that run, and a campaign document's
`strategies[].axes` reopens one across a family (bound = default, not
fixed); `aura graph introspect --params` lists the open knobs and the
bound defaults alike, so every override target is discoverable.
- **A project-specific *native* node type** — the moment §0's three-part
pattern is actually needed — lives in an attached **node crate**:
@@ -227,10 +229,11 @@ $ aura graph build < smacross.json > blueprint.json
Nodes are referenced by an **identifier** (given by `add`, see below); ports
are dotted `<identifier>.<port>` on both sides of a wire.
### The ten ops
### The eleven ops
| op | JSON shape | does |
|---|---|---|
| `name` | `{"op":"name","name":<str>}` | set the composite's **render name**, script-level and at-most-once (a second `name` op refuses: `a script names its blueprint at most once`). The name must be a single path segment (non-empty, no `/`, `\`, `.` or `..`) since it prefixes a trace directory (`traces/<name>-<id8>/`, #311) at run time. Omitting the op keeps the CLI's own default, `"graph"`. |
| `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). |
@@ -239,9 +242,41 @@ are dotted `<identifier>.<port>` on both sides of a wire.
| `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura run` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a sweep leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura exec` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a campaign member run leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
| `gang` | `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` | Fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space and `as` replaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound). |
Omitting `name` is fine for a single strategy, but it has two live
consequences once a project has more than one: every unnamed store document
is indistinguishable by name (`"graph"` again), and two `use`-splices of
unnamed blueprints collide on the same default instance identifier (`graph`)
inside the same composing script. A one-line `name` op dissolves both.
The third consequence that used to sit here — every unnamed strategy's tap
recording landing in one shared `traces/graph/` directory, where a later run
overwrote an earlier one's trace — is **resolved** (#311), and no longer
depends on naming at all: a run's trace directory is keyed by the run's own
identity (`traces/<name>-<id8>/`, a digest over its manifest), so two runs
that differ in any identity-bearing input — a different topology, a data
window, a seed, or an `--override` that changes a value — land in two
directories, and two runs that differ in nothing land in the same one.
What counts as differing is the run's *effective* parameterisation, not how
you supplied it: the digest merges the manifest's `params` and `defaults`
name-sorted, so pinning a parameter on the command line to the value it
already has gives you the same directory as not passing it at all, and the
order of several `--override` flags never matters. What the digest does not
see is provenance — the aura binary's build sha, and your project repo's
HEAD and dirty state — so rebuilding aura or leaving a file uncommitted
never re-addresses a run.
That directory's
*contents* are not wholesale-replaced on the second write, though: the
declared tap *plan* (which of a blueprint's taps this invocation subscribes)
is not itself identity-bearing, so a same-identity pair that persists a
different tap subset can strand an earlier tap file beside the later run's
own — tracked as #352. The render name survives as the directory's readable
prefix, and `aura chart <name>` lists the runs recorded under it.
Value forms are the typed-tag representations used everywhere in this
family of artifacts:
@@ -274,9 +309,10 @@ corpus's own example, verified below; byte-identical to the on-disk
A `bind` in an `add` op pins that param to a value and removes it from the
**open** param space (`--params`): a bound param is a **default** (#246) — a
run uses it as-is, while any campaign axis or `aura sweep --axis` naming it
re-opens it for that family and binds it per cell. `--list-axes` lists it
after the open knobs as `<name>:<KIND> default=<value>`. `bind` is for a
plain `exec` uses it as-is, while a campaign axis naming it re-opens it for
that family (bound per cell) and `exec`'s own `--override NODE.PARAM=VALUE`
(#319) re-opens it for that one execution. `graph introspect --params` lists
it after the open knobs as `<name>:<KIND> default=<value>`. `bind` is for a
value the strategy carries by default; leave a param unbound, as all three
are here, to make binding it mandatory for every sweep.
@@ -284,7 +320,7 @@ A param therefore has three states: **open** (an axis every sweep MUST bind),
**bound** (a default any axis MAY override, #246), and **ganged** (open, but
fused with its siblings under ONE public knob declared by a `gang` op; the
member addresses are unbindable and only the gang's own single-segment name —
wrapped like any knob, e.g. `graph.channel_length` — appears in `--list-axes`).
e.g. `channel_length` — appears in `graph introspect --params`).
### Worked example: declaring a measurement tap
@@ -313,13 +349,101 @@ bias scaling) under the name `spread`:
The built blueprint carries a `taps` array naming the resolved wire
(`{"name":"spread","from":{"node":<sub's index>,"field":0}}` — the name is
addressed, the index is resolved *for* you). A single `aura run <blueprint>`
addressed, the index is resolved *for* you). A single `aura exec <blueprint>`
then constructs a recorder at that point and persists the `spread` series as a
`ColumnarTrace` in the run's trace store, chartable by its name. A tap is inert
in a sweep (no per-cell recorder) — it is a single-run observation surface.
`ColumnarTrace` in the run's trace store. The run prints the handle it wrote
under as `trace_name` on its stdout line, so it need not be guessed; chart it
with `aura chart <trace_name>` (all of the run's taps) or
`aura chart <trace_name> --tap spread` (just this one). Note the two tokens
are different things: the handle addresses the RUN, `--tap` selects a tap
WITHIN it — `aura chart spread` refuses, because no run is named `spread`. A tap is inert
in a campaign member run (no per-cell recorder) — it is a single-run
observation surface (or, per campaign, what a nominee's `persist_taps`
re-run records, §3).
(`sub.value` is tapped here purely to illustrate; any interior output field is
a legal tap source, and a producer needs no other consumer to be tapped.)
### Worked example: a `LinComb` op-script
`LinComb` is **arg-bearing** (#271): `graph introspect --node LinComb` shows
only the `arity` arg and the pending note until `args` supplies it — the
ports (`term[0]`, `term[1]`, …) and params (`weights[0]`, `weights[1]`, …)
form only once `arity` is real, one pair per term. A minimal weighted blend
of two sources:
```json
[
{"op": "source", "role": "a", "kind": "F64"},
{"op": "source", "role": "b", "kind": "F64"},
{"op": "add", "type": "LinComb", "name": "combo",
"args": {"arity": "2"}, "bind": {"weights[0]": {"F64": 0.7}}},
{"op": "feed", "role": "a", "into": ["combo.term[0]"]},
{"op": "feed", "role": "b", "into": ["combo.term[1]"]},
{"op": "expose", "from": "combo.value", "as": "blend"}
]
```
`args` fixes the arity BEFORE `bind`/`feed` can address `term[i]`/`weights[i]`
at all — the same closed-args-then-bind order every arg-bearing type follows
(§0/#271). `weights[0]` is bound to `0.7` here, so it becomes a default
(#246); the unbound `weights[1]` stays a required axis, appearing in `graph
introspect --params` as `combo.weights[1]:F64` (alongside
`combo.weights[0]:F64 default=0.7`).
### Worked example: wiring a cost model (`ConstantCost``CostSum`)
`CostSum`'s `cost[k].<field>` inputs (C10) are meaningless fed from a bare
source: they read the co-temporal record a real `PositionManagement` node
emits (`closed`, `open`, `entry_price`, `stop_price`). Every node in that
chain — `Bias`, `FixedStop`, `Sizer`, `PositionManagement`, `ConstantCost`,
`CostSum` — sits in the closed vocabulary, so the whole cost-model wiring is
honestly expressible by hand, one `add`/`connect` pair at a time, mirroring
the Rust `risk_executor` + `cost_graph` composite-builders (`aura-composites`)
field-for-field:
```json
[
{"op": "source", "role": "price", "kind": "F64"},
{"op": "source", "role": "signal", "kind": "F64"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
{"op": "feed", "role": "signal", "into": ["bias.signal"]},
{"op": "add", "type": "FixedStop", "name": "stop", "bind": {"distance": {"F64": 0.01}}},
{"op": "add", "type": "PositionManagement", "name": "pm"},
{"op": "feed", "role": "price", "into": ["stop.price", "pm.price"]},
{"op": "add", "type": "Sizer", "name": "sizer", "bind": {"risk_budget": {"F64": 1.0}}},
{"op": "connect", "from": "bias.bias", "to": "sizer.bias"},
{"op": "connect", "from": "stop.stop_distance", "to": "sizer.stop_distance"},
{"op": "connect", "from": "bias.bias", "to": "pm.bias"},
{"op": "connect", "from": "stop.stop_distance", "to": "pm.stop_distance"},
{"op": "connect", "from": "sizer.size", "to": "pm.size"},
{"op": "add", "type": "ConstantCost", "name": "cc", "bind": {"cost_per_trade": {"F64": 0.5}}},
{"op": "connect", "from": "pm.closed_this_cycle", "to": "cc.closed"},
{"op": "connect", "from": "pm.open", "to": "cc.open"},
{"op": "connect", "from": "pm.entry_price", "to": "cc.entry_price"},
{"op": "connect", "from": "pm.stop_price", "to": "cc.stop_price"},
{"op": "add", "type": "CostSum", "name": "costsum", "args": {"n_costs": "1"}},
{"op": "connect", "from": "cc.cost_in_r", "to": "costsum.cost[0].cost_in_r"},
{"op": "connect", "from": "cc.cum_cost_in_r", "to": "costsum.cost[0].cum_cost_in_r"},
{"op": "connect", "from": "cc.open_cost_in_r", "to": "costsum.cost[0].open_cost_in_r"},
{"op": "expose", "from": "pm.realized_r", "as": "realized_r"},
{"op": "expose", "from": "costsum.cost_in_r", "as": "cost_in_r"}
]
```
`CostSum`'s port names ARE the `cost[k].<field>` shape already
(`cost[0].cost_in_r`, …) — `cost_port` is baked into `CostSum::make`'s schema
itself (#271), no composite-level renaming needed; a second factor is
`args: {"n_costs": "2"}` plus a second cost-node block wired into
`cost[1].*`, and `CostSum` per-field-sums whatever arity it was given (#341
item 4). This closes the "CostSum reachable but pathless" gap: the ports
genuinely connect at the op-script level, using only closed-vocabulary
nodes. It is a standalone wiring reference, though, not the production
attachment path — a campaign strategy's real cost model still comes from
the campaign document's `cost:` block (C10, `cost_nodes_for`), wrapped
around the user's bias-only blueprint entirely in Rust at run time; a
user's op-script-authored blueprint never embeds `PositionManagement` /
cost nodes itself.
### Session anchoring: the `SessionFrankfurt` preset
To anchor logic to the trading session, the closed vocabulary ships a
@@ -344,6 +468,14 @@ separate in-session bool — `bars_since_open` alone is the contract. DST is
handled by `chrono-tz`, so the same local minute reads the same index in
summer (CEST) and winter (CET).
The index is derived from the clock (`ctx.now()`), never counted from trigger
firings — so the trigger's cadence only sets how *often* the node emits, not
what it emits. Feeding a denser stream (e.g. a raw m1 `price`) is equally
valid: the node fires once per input tick and reports the same
`period_minutes`-bar index throughout that bar. The once-per-bar wiring above
is the convention when you want exactly one emission per completed bar; it is
not a correctness requirement.
Wire it like any node —
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
@@ -394,6 +526,9 @@ SMA
param length:I64 (bind {"I64": <v>})
$ aura graph introspect --unwired < partial.json # open slots of a partial op-script
sub.rhs:F64
$ aura graph introspect --taps tapped.json # one row per declared tap: name, source wire, kind
fast_ma fast.value F64
spread sub.value F64
$ aura graph introspect --content-id smacross.json # SHA-256 of the canonical form
597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876dafba9a
$ aura graph introspect --content-id smacross.json --identity-id # + debug-name-blind identity id, combinable
@@ -405,23 +540,42 @@ slow.length:I64
bias.scale:F64
```
These printed names are the **raw param-space namespace**: op-script params
and a campaign document's `strategies[].axes` keys (§3) share this one raw
form. There is a third, *wrapped* surface: the dissolved `aura sweep
<blueprint> --axis` CLI (glossary `sweep`) accepts only the names `aura
sweep <blueprint> --list-axes` prints — the raw name prefixed with the
root-composite instance name, `graph.<param>` — never the raw form; the
campaign document the sweep sugar generates still stores the raw form
(#210):
These printed names are the **one** axis namespace (#328): op-script params
and a campaign document's `strategies[].axes` keys (§3) speak the same raw
`<node>.<param>` form as `graph introspect --params` prints (open params
bare, bound params trailing `default=<value>`) — every discovered name is
verbatim legal as a document axis key, bound params included (#246's
re-open contract), and as the token `exec --override NODE.PARAM=VALUE` (#319)
takes on the single-run leg:
```json
"strategies": [ { "ref": { "content_id": "…" },
"axes": { "fast.length": { "kind": "I64", "values": [3, 5, 7, 9] },
"bias.scale": { "kind": "F64", "values": [0.25, 0.5] } } } ]
```
A single execution reopens the same raw name directly, with no document at all:
```
raw (--params, campaign axes): fast.length
wrapped (--list-axes, --axis): graph.fast.length
$ aura exec smacross.json --override fast.length=5 # single run: raw works
```
The older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`,
what pre-#328 transcripts and the retired `--list-axes` output used to print)
is retired from the surface; naming it in a campaign document refuses with a
translation pointer to the raw candidate, the one remaining intake seam:
```
$ aura campaign validate wrapped.json # a document quoting an old transcript
aura: campaign references do not resolve:
strategy f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240:
axis "graph.fast.length" is not in the param space; axis names are raw
node.param paths — did you mean "fast.length"?
```
A ganged knob's raw address has one path segment less than a member address
would — it sits at the composite's own level, like a role name (e.g.
`channel_length`, not `channel_hi.length`) — and wraps identically (`graph.channel_length`).
`channel_length`, not `channel_hi.length`).
`--content-id`, `--identity-id`, `--params`, and `graph register` all accept
**either** shape: the raw op-script array or an already-built `#155`
@@ -671,9 +825,9 @@ campaign document valid (executable): pipeline shape and static guards pass
```
$ aura campaign register mra_3_campaign_full_v2.json
registered campaign 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd (…/runs/campaigns/42ed…json)
$ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd
{"family_id":"42edebd2-0-GER40-w0-s0-0","report":{...}}
{"family_id":"42edebd2-0-GER40-w0-s0-0-r1","report":{...}}
$ aura exec 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd
{"family_id":"42edebd2-0-GER40-w0-r0-s0-0","report":{...}}
{"family_id":"42edebd2-0-GER40-w0-r1-s0-0","report":{...}}
{"campaign_run":{"campaign":"42edebd2…","process":"cd9127…","run":0,"seed":42,
"cells":[{"strategy":"597d719b…","instrument":"GER40","window_ms":[...],
@@ -692,32 +846,69 @@ $ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b6
```
With two stop regimes every (instrument, window) cell runs twice — the four
cells above are the "4 cell(s)" the validate summary counted. The second
regime's family ids and trace dirs carry the `-r1` ordinal suffix (the
default/first regime stays unsuffixed), each cell record names its regime,
and generalization is keyed per regime — regimes are compared, never pooled.
cells above are the "4 cell(s)" the validate summary counted. The regime
ordinal is an INFIX segment, `-r{k}`, right after the window segment
(`{id8}-{ordinal}-{instrument}-w{k}-r{k}-s{stage}-{run}`) — the
default/first regime carries `-r0` explicitly, never left unmarked — each
cell record names its regime, and generalization is keyed per regime —
regimes are compared, never pooled.
`aura campaign run` is register-then-run sugar for a `.json` file, but the
`aura exec` (#319) is register-then-run sugar for a `.json` file, but the
canonical address is always the content id — running a bare file the first
time registers it implicitly. `aura campaign runs` lists stored
realizations; `aura campaign runs <id>` dumps the bare stored record(s) (not
the `{"campaign_run": …}` emit wrapper above). If `presentation.persist_taps`
is non-empty, the run also persists the named taps under
`runs/traces/<trace_name>/…`, chartable with `aura chart`. The
`sweep`/`walkforward --trace` analog persists every member's taps as a
family charted the same way, by the handle the run prints (members keyed
`<cell>/<member>`) — also by the `--trace <NAME>` you chose, when that name
uniquely names one recorded run (`aura chart <NAME>` resolves it against the
stored campaign documents; a name reused across runs refuses rather than
guessing which one you mean).
is non-empty, the run also persists every member's taps under
`runs/traces/<trace_name>/…` — the deterministic `{campaign8}-{run}` handle
the `campaign_run` record's `trace_name` field prints, never a user-chosen
name — chartable with `aura chart <trace_name>` (members keyed
`<cell>/<member>`); `aura chart` also resolves a bare campaign id/name
against the stored campaign documents when no literal trace directory
matches, refusing rather than guessing when the name is ambiguous across
recorded runs. A single run reports its handle the same way: the `trace_name` value on
its own stdout line, absent when the run recorded nothing. That handle is
`<render-name>-<id8>` (#311) — the render name plus an 8-hex digest over the
run's manifest, with the aura binary's build sha and the project repo's HEAD
excluded — so two runs of one blueprint that differ in an identity-bearing
input keep both traces, and `aura chart <render-name>` lists the handles
recorded under that name rather than charting one of them.
Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any
cell runs (invalid document, missing project, unresolvable strategy) exits 1;
a run that **completes with one or more failed cells** exits 3 — the run record
and every healthy cell persist, and the failed cells are named on stderr
(#272). A **gate-emptied cell** — a `std::gate` stage that filters out every
member — is a *successful* cell, not a failed one: the gate legitimately
answered "no survivors", so the run still exits 0, the cell's realization is
recorded as truncated at that stage, and an informational `aura:`-prefixed
note names the cell on stderr. Exit 3 is reserved for cells that could not be
evaluated (faults), never for an empty-but-valid result.
(#272). A directly-run blueprint (`aura exec <blueprint>.json`, no campaign)
has no cells at all: its own refusals — a binding mismatch, a synthetic-data
mismatch, a compile error, a tap-bind content fault (unknown fold/tap,
duplicate) — are the content of the argv-named file and exit 2 like a usage
error; only its environment refusals (a tap-trace store write failure)
exit 1. The no-data family (no local data, no data in the requested window,
no recorded geometry) belongs to the real-data paths: on `reproduce` it
exits 1, while inside a campaign the same fault is contained per cell
(#272) — the run completes and exits 3, naming the failed cells (#297;
the full partition lives in
[C14](design/contracts/c14-headless-two-faces.md)). `aura
reproduce`'s identity/pip guard (a stored member's recorded instrument or
broker/pip label contradicting the source it re-derives against) is
likewise class 1 on the plain verb — its source is derived from the
family's own manifest, so a mismatch there is data drift, not a caller
error (Fork 6, #299).
The class boundary is FORM vs VALUE, not "environment/data/IO vs everything
else": an `--override` value that is well-formed but out of the node's own
domain (e.g. a negative length) is not caught at the compile/bind seam above
— it panics inside the node's own constructor at bootstrap. `aura exec`
catches that panic at the dispatch boundary (`catch_member_panic`) and
renders it as a runtime-class refusal, exit 1 — the same class an
environment/data fault gets, and the same class the campaign leg gives the
identical value as a per-cell fault (#272). A form fault the surface detects
before the node ever runs (kind mismatch, unknown param, compile error)
stays class 2 argv-content; a well-formed value the node itself refuses in
its own domain is class 1, regardless of which leg hit it.
A **gate-emptied cell** — a `std::gate` stage that filters out every member —
is a *successful* cell, not a failed one: the gate legitimately answered "no
survivors", so the run still exits 0, the cell's realization is recorded as
truncated at that stage, and an informational `aura:`-prefixed note names the
cell on stderr. Exit 3 is reserved for cells that could not be evaluated
(faults), never for an empty-but-valid result.
+7
View File
@@ -201,6 +201,13 @@ the repo, not session memory, is the source of truth.
optional gated document `description`); descriptions never influence
execution or identity ids, and registered artifacts are never
retroactively invalidated.
- **[C30 — Stability discipline: artifacts are stable, code is movable](contracts/c30-stability-discipline.md)** —
No Rust-API stability before 1.0 (no semver, no shims): node crates ride
the engine checkout as path deps, external embeddings pin a rev, and the
cdylib handshake refuses non-identical aura-core builds (build-identity
stamp, #348). The compatibility promise attaches to the artifact plane
(C18/C29/invariant 8); no surface may claim "stable API" while this
contract stands.
---
@@ -23,3 +23,7 @@ never line-ordered. Duplicate campaign instruments are refused at both the
validate tier and the executor's preflight: the per-cell family name embeds
the raw instrument string, so uniqueness is what keeps concurrent appends from
racing one name's run-index assignment.
**Current-state cross-command-path example (superseded by the #319 sugar
retirement, 2026-07-25):** "e.g. a swept member's `sqn` vs the same cell
re-run under `aura generalize`".
+3 -2
View File
@@ -5,8 +5,9 @@ loop that reaches a unique state after each input tick. Same input (incl. seed)
→ bit-identical run. Two backtests are fully disjoint and run concurrently
without locking. The bit-identity is *per run*: one backtest of given (inputs,
seed) reproduces byte-for-byte. It does **not** extend to a *derived metric*
recomputed for the same params by two *different command paths* (e.g. a swept
member's `sqn` vs the same cell re-run under `aura generalize`), which may differ
recomputed for the same params by two *different command paths* (e.g. a
campaign cell's `sqn` within its sweep stage vs the same params re-run
through `exec`'s blueprint leg with `--override`, #319), which may differ
by floating-point reassociation (≤1 ULP) because the two paths accumulate the
same logical reduction in a different operation order (IEEE-754 non-associativity).
C1 governs the determinism of a single run, not the cross-command bit-identity of
@@ -171,3 +171,11 @@ entry left open: declared-tap consumers now move `(Timestamp, Cell)` (zero
per-cycle heap), and the `Recorder``Probe` rename is retired — `Recorder` keeps
its name on the legacy live/`--trace`/test-tap surface, whose migration onto the
subscriber shape is tracked as #308.
**Refinement (2026-07-27 — the single-run path folds too, #308).** `run_signal_r`
moved onto the folded delivery shape `run_blueprint_member` already used, which
narrowed `Recorder`'s surviving callers to the campaign trace writer and the
test-tap sites. The superseded clause read: "`GatedRecorder` and `SeriesReducer`
(`aura-std`) are the folding siblings of the per-cycle `Recorder`
(`aura-std/src/recorder.rs`), which survives for the live / `--trace` / test-tap
path." The `--trace` flag it named retired with the `run` verb (#319).
+4 -2
View File
@@ -96,8 +96,10 @@ of a zero-output node. The run loop (`aura-engine/src/harness.rs`) debug-asserts
topological order before the source loop, and the `finalize()` epilogue after it
drains — each pinned by its own once-per-run mirror test; `GatedRecorder` and
`SeriesReducer` (`aura-std`) are the folding siblings of the per-cycle `Recorder`
(`aura-std/src/recorder.rs`), which survives for the live / `--trace` / test-tap
path.
(`aura-std/src/recorder.rs`), which survives for the callers that read a
per-cycle series back: the campaign trace writer
(`aura-runner::runner::persist_campaign_traces`, whose retained-delivery re-run
builds the trace columns) and the test-tap sites.
`BlueprintNode::signature()` (`aura-engine/src/blueprint.rs`) answers uniformly for
both arms: a primitive returns its recipe's schema, a composite *derives* it via
+10 -8
View File
@@ -276,18 +276,20 @@ under it — every result is net, no second gross-labelled result kind.
`cost_nodes_for` (beside `stop_rule_for_regime`, `aura-runner/src/translate.rs`) is
the one doc→builder binding; `wrap_r` (`aura-runner/src/member.rs`) carries the
optional cost leg (cost_graph off the executor's four geometry outputs, the vol
proxy in production, a gated cost recorder in reduce mode as the `summarize_r` join
input, the `LinComb(4)` `net_r_equity` curve in trace mode). Both re-run sides
re-derive the model (`cost_specs_from_params`), so the C1 drift alarm compares like
with like; costed families reproduce bit-identically (incl. `Carry`).
proxy in production, a gated cost recorder under folded delivery as the
`summarize_r` join input, the `LinComb(4)` `net_r_equity` curve under
retained delivery). Both re-run sides re-derive the model
(`cost_specs_from_params`), so the C1 drift alarm compares like with like;
costed families reproduce bit-identically (incl. `Carry`).
`TapChannel::Net` (`aura-runner/src/runner.rs`) routes `net_r_equity` to persisted
curves; a cost-less doc requesting it keeps a remedy-naming skip notice. There are
no `--cost-*` run-path flags (removed #221/#234); cost travels in the document.
**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol}` axis is
realized at the campaign-document level as `CampaignDoc.risk: [RiskRegime]`
(`aura-research`, variants `Vol{length, k}` and `VolTf{period_minutes, length, k}`
(#262), the fixed-stop rule additive when needed) — a kept-separate matrix axis,
**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol, VolTf}`
axis is realized at the campaign-document level as `CampaignDoc.risk:
[RiskRegime]` (`aura-research`, variants `Vol{length, k}`,
`VolTf{period_minutes, length, k}` (#262), and `Fixed{distance}` (#338, binding
the shipped `FixedStop` composite)) — a kept-separate matrix axis,
peer of instruments and windows. The executor keys the nominee map by `(strategy,
window, regime)`, so `generalize` aggregates *within* a regime, never across.
Regimes are **compared** at presentation, never argmax-**selected** across (a
@@ -34,3 +34,15 @@ walk-forward families (axes 24 above) build their members over **one** shared
`Arc<DataServer>` (one `FileCache`), so a window is parsed once and every member's
`M1FieldSource` borrows the same cached `Arc<[M1Parsed]>` chunks
(`crates/aura-ingest/src/lib.rs:316`). The single-pass *parse* cost stays tracked as #95.
**Current-state bound-override + identity-anchor clause (superseded by the
#319 sugar retirement, 2026-07-25):** "so axis 1 (param-sweep) may name a
bound param — the family boundary re-opens it on the probe and on every
member reload, and the axis binds it per cell; `run`/`mc` still require
every param resolved (a truly open param refuses). Identity is untouched:
`content_id_of` (`crates/aura-research/src/lib.rs`) and `topology_hash`
(`crates/aura-cli/src/main.rs`) read the authored document, never a
re-opened probe, and each member manifest records its per-cell bindings
(#246, ratified 2026-07-12; the restriction this amends — axes bind only
open knobs — was an implementation consequence of `bind()` shrinking the
param surface, not a recorded decision)."
+19 -7
View File
@@ -5,13 +5,25 @@ RNG-seed) → deterministic run → metrics`. Parameters are typed, ranged, runt
values injected at graph build — no recompile per param-set; the optimizer sees a
generic vector of typed ranges. A **bound blueprint param is that param's
default**: "open" means *must be bound by an axis*, "bound" means *default,
overridable by an axis*, so axis 1 (param-sweep) may name a bound param — the
family boundary re-opens it on the probe and on every member reload, and the axis
binds it per cell; `run`/`mc` still require every param resolved (a truly open
param refuses). Identity is untouched: `content_id_of`
(`crates/aura-research/src/lib.rs`) and `topology_hash`
(`crates/aura-cli/src/main.rs`) read the authored document, never a re-opened
probe, and each member manifest records its per-cell bindings (#246, ratified
overridable by an axis*, so a campaign axis (param-sweep) may name a bound
param — the family boundary re-opens it on the probe and on every member
reload, and the axis binds it per cell; `exec`'s blueprint leg still requires
every param resolved (a truly open param refuses), and its own `--override
NODE.PARAM=VALUE` (#246 residue, #319) reopens exactly the named bound param
for that one execution — the single-member analogue of a campaign axis. The
reopened execution's `topology_hash` stays the base document's own — reference
semantics, not the reopened topology's (#343, revised; full clause in
[C24](c24-blueprint-data.md)'s "Reproduction identity"). Identity is untouched: `content_id_of` (`crates/aura-research/src/lib.rs`) is
the one hashing primitive, read directly off the authored document by
`aura_runner::member`'s `run_signal_r`/`run_blueprint_member` — where the
manifest's `topology_hash` is computed inline by default (`run_signal_r`'s
`topo: Option<&str>` parameter's `None` arm, every caller but one; #319 Task 9
retired the CLI-side wrapper that used to duplicate this one call), or
supplied by the caller as a ready reference-semantics hash (`Some`, #343
revised — `exec`'s override branch passes the loaded base document's own id,
computed before `reopen_all`, so the SAME value reaches both the record line
and the trace-store persistence) — never a re-opened probe either way, and
each member manifest records its per-cell bindings (#246, ratified
2026-07-12; the restriction this amends — axes bind only open knobs — was an
implementation consequence of `bind()` shrinking the param surface, not a recorded
decision). Raw data is shared read-only across sims via `Arc<[T]>` (the
@@ -22,7 +22,8 @@ A research project compiles to an external cdylib exporting one symbol,
`AURA_PROJECT`, a two-tier `#[repr(C)]` `ProjectDescriptor`
(`aura-core::project`, `crates/aura-core/src/project.rs`, emitted by the
`aura_project!` macro). A **C tier**`magic` (`AURAPROJ`),
`descriptor_version`, the rustc-version and aura-core-version stamps, the
`descriptor_version`, the rustc-version and aura-core build-fingerprint
stamps ([C30](c30-stability-discipline.md)), the
namespace, all C-compatible field types — is validated **before** any Rust-ABI
field is touched; a **Rust tier** — the vocabulary resolver
`fn(&str) -> Option<PrimitiveBuilder>` and the enumerable type-id list — is read
@@ -37,7 +38,7 @@ via `cargo metadata` (debug default, `--release` opt-in) and loads it
**load-and-hold** — the `Library` is leaked, never unloaded, so its `'static`
strings and fn-pointers stay valid (`aura-runner::project::load`,
`crates/aura-runner/src/project.rs`). `validate_c_tier` checks the four stamps
front-to-back; a mismatch of either version stamp refuses with exit 1 naming
front-to-back; a mismatch of either stamp refuses with exit 1 naming
both sides (`ProjectError::Incompatible`). The project vocabulary is
charter-checked at load (`check_charter`): non-empty namespace, every id
`<ns>::`-prefixed, no duplicate, no collision against the std vocabulary,
@@ -27,3 +27,27 @@ every hand-rolled usage line reads `Usage: aura <verb> …` (#179, cycle 0101);
refusal diagnostics stay unprefixed (diagnostics are not usage lines). The
machine-first help surface (JSON/manifest help, stdin op-scripts) stays on the
#157/C21 track, not this cycle (settled as human/GNU convention compliance).
**Current-state "Dual grammar" section (superseded by the #319 sugar
retirement, 2026-07-25):** "The four dual-grammar subcommands
(run/sweep/walkforward/mc) keep both grammars under one token via an
optional `[blueprint]` positional plus a post-parse `is_file()` dispatch; the
execution layer is unchanged (arg-plumbing via thin `*_from` adapters). The
machine-first help surface (JSON/manifest help, stdin op-scripts) is
deferred to the #157 / C21 track, distinct from this human/GNU-convention
compliance."
**Current-state "Single document grammar" section's first-draft routing
sentence (superseded by the #319 sugar retirement, 2026-07-25):** "one verb,
`exec <target>`, dispatches on `is_blueprint_file` (a first-positional
naming an existing `.json` file selects the loaded-blueprint leg; anything
else — a campaign file or a registered content id — the campaign leg)." This
undersold the routing by one layer: the code always also peeked the file's
top-level `"kind"` field (`is_campaign_document_file`) to tell a campaign
document ending in `.json` from a blueprint file — both being ordinary
readable `.json` files `is_blueprint_file` alone cannot distinguish — and,
after an independent review found a malformed campaign file was silently
misrouted to the blueprint leg's own JSON-parse error prose, gained a
neutral not-valid-JSON refusal ahead of the leg choice (review Minor-3,
2026-07-25's fix cycle). The current contract's "Single document grammar"
section names this real routing.
+101 -6
View File
@@ -59,12 +59,107 @@ caller branches on the failure class without parsing stderr.
in `crates/aura-cli/src/main.rs`, threaded from the run registry —
[C18](c18-registry.md)).
**Dual grammar.** The four dual-grammar subcommands (run/sweep/walkforward/mc)
keep both grammars under one token via an optional `[blueprint]` positional plus
a post-parse `is_file()` dispatch; the execution layer is unchanged (arg-plumbing
via thin `*_from` adapters). The machine-first help surface (JSON/manifest help,
stdin op-scripts) is deferred to the #157 / [C21](c21-world.md) track, distinct
from this human/GNU-convention compliance.
**`aura-runner`'s single-run refusals now honor this same partition (#297,
2026-07-26).** Before this cycle, every refusal inside `aura-runner`'s
single-run verb paths (`aura exec <blueprint.json>`, both the bias-output and
bare-measurement legs) exited 1 uniformly — a `std::process::exit(1)` baked
into the library itself, bypassing the partition above rather than
instantiating it. The #297 conversion turns every such site into a returned
`RunnerError { exit_code, message }` the CLI prints and exits on; the class
is now assigned by the same criterion as the rest of this partition — is the
fault in the content of what argv named, or in the environment the command
needs? The binding refusal, the synthetic/binding mismatch, the blueprint
compile error, and tap-bind content faults (unknown fold, unknown tap,
duplicate bind) are argv-named-content faults and exit 2; the no-local-data /
no-data-in-window / no-recorded-geometry refusals and a tap-trace store I/O
failure (`bound.finish`) are environment faults and stay at 1. `cost_knob`
(via `aura-runner::translate::cost_nodes_for`) has no single-run production
caller at all — its two production callers are both worker-side: reached
from inside a sweep worker (`run_blueprint_member`) it surfaces as a per-cell
`MemberFault::Bind` — never a process exit (the campaign contract
[C28](c28-stratification.md) already guaranteed); reached from
`persist_campaign_traces`'s C1 drift-alarm re-run it propagates as a `String`
through that function's existing channel, exiting 1 via the campaign run
summary (`campaign_run.rs`'s `?` after the run record is already persisted).
**The class-1 set is not only environment/data/IO.** It also covers the exec
blueprint leg's bootstrap domain-refusal path: an `--override` value that is
well-formed but out of a node's own domain (e.g. a negative length) is not
caught at the compile/bind seam above — it panics inside the node's own
constructor at bootstrap. `aura exec` catches that panic at the dispatch
boundary (`catch_member_panic`, `crates/aura-cli/src/main.rs`) and renders it
as a runtime-class refusal, exit 1 — the same class the campaign leg gives
the identical value as a per-cell fault (#272). The boundary is FORM vs
VALUE: a form fault the surface detects before the node ever runs (kind
mismatch, unknown param, compile error) is class 2 argv-content; a
well-formed value the node itself refuses in its own domain is class 1,
regardless of which leg hit it.
**The reproduce guard's classes are context-borne (#299, 2026-07-26).**
Reproducing a stored family refuses up front when the effective data
source's identity or pip/broker geometry contradicts the member manifests
(divergence would be mechanical — refusing beats reporting a false
`DIVERGED`). The refusal prose is one; the class is carried by who named
the source: the explicit-source library seam (`reproduce_family_in`)
refuses class 2 (the caller named it), while the plain verb's derived
path — the source is reconstructed from the manifest — refuses the same
drift as class 1 (environment/data: a re-ingested sidecar, a stored label
outside the known shapes; nobody named the faulting input).
**Single document grammar (#319, 2026-07-25).** The five dual-grammar
subcommands (run/sweep/walkforward/mc/generalize) that used to keep two
grammars under one token are retired; one verb, `exec <target>`, dispatches
on `is_blueprint_file` layered with a "kind"-field peek
(`classify_exec_document_file`): a first-positional naming an existing
`.json` file that parses as JSON and carries no top-level `"kind"` selects
the loaded-blueprint leg; a file carrying `"kind"`, or a target that is not a
readable `.json` file at all (a registered content id), selects the campaign
leg. A file that does not parse as JSON at all refuses neutrally (exit 2)
before either leg's own document-shape validation runs, rather than
silently choosing a leg (review Minor-3, 2026-07-25). The execution layer
this collapses onto is unchanged in kind (arg-plumbing via thin adapters).
The machine-first help surface (JSON/manifest help, stdin op-scripts) is
deferred to the #157 / [C21](c21-world.md) track, distinct from this
human/GNU-convention compliance.
**`exec`'s target-classification exit partition is pinned, precisely scoped
(#342 item 5).** The partition below governs exec's TARGET-CLASSIFICATION
refusals at the routing seam — the shape-peek (`classify_exec_document_file`)
that decides which leg, if any, a target reaches, strictly before either
leg's own document-shape validation ever runs. A target fault at this seam
splits along the same usage/runtime line the exit-code partition above
already draws, keyed on whether a *real, existing document's content* is
being judged (usage, exit 2 — the content of an argv-named file is itself
part of the argument) or the target simply fails to resolve to any recorded
state at all (runtime, exit 1 — the needed environment/recorded state is
missing). Missing-state faults (exit 1): a target naming no readable file and
no plausible content id (`'{target}' is neither a readable .json file nor a
64-hex content id`), and a well-formed-but-unregistered 64-hex content id
(`no campaign {id} in the project store`) — no file or store entry exists to
have content in either case. File-content faults at the routing seam (exit
2): a file that does not parse as JSON at all, a valid-JSON op-script array
(not a document exec can execute at all — it names the `graph build`
escalation instead), and a kind-bearing document whose `"kind"` is not
`"campaign"` (e.g. a process document handed to `exec`) — all three are a
real, readable file whose content the ROUTING seam itself cannot classify
into a leg. The wrong-kind case is the one realignment this pin made (#342
item 5): it shipped at exit 1 (mirroring the campaign leg's own missing-state
refusals) until this pass, despite being a routing-seam content fault like
its op-script and not-valid-JSON siblings, not a missing-state fault like the
file-vs-id cases — realigned to exit 2 to match its true class, the one
sanctioned behaviour change of this pin.
Once a target classifies as campaign or blueprint, it falls under that leg's
**own** established validation partition, not this one — and the two legs'
content-validation exits differ today, named honestly rather than harmonized:
a `kind:"campaign"` document whose *content* then fails the campaign
doc-tier gate (e.g. an empty axis) exits 1, runtime-class, per the
established campaign contract (pinned by
`campaign_run_invalid_file_refuses_before_touching_store`), while the
blueprint leg's own content faults (a blueprint envelope that fails to parse
or build) exit 2, usage-class. No behaviour changes here — only this pin's
scope is named precisely: it is the routing seam's partition, not a
uniform content-validation rule across both legs.
**Two artifact classes, two redundancy budgets** (#249, ratified 2026-07-13).
The data layer the programmatic face emits is a public interface read raw (by
@@ -271,3 +271,34 @@ and both callers are untouched; maintenance is lazy-only — put-time indexing
was rejected because it would need a roster-free doc-level identity function
whose equivalence to the loaded-composite path no green test ratifies (decision
log: #191 comments).
**Current-state `runs.jsonl`/`families.jsonl` CLI clauses (superseded by the
#319 sugar retirement, 2026-07-25):** "No live producer writes it today —
sweep / walk-forward / mc persist to the family store and `aura run` does not
persist — so the flat lib API is retained but selectively live: `rank_by`
backs `aura runs family … rank`, `optimize` backs walk-forward's in-sample
step (`aura-campaign` and the registry's own selection helper both call it),
while `append`/`load` remain public API with no in-tree caller (a latent
surface for external consumers)." … "CLI: `aura runs families`, `aura runs
family <id> [rank <metric>]`; `aura sweep`/`walkforward`/`mc` persist via
`append_family` with an optional `--name`."
**Current-state "Cross-instrument generalization" paragraph (superseded by
the #319 sugar retirement, 2026-07-25):** "`FamilyKind::CrossInstrument`:
`aura generalize` runs one candidate across an instrument list and persists
the M per-instrument runs via `append_family`, each member self-identifying
through `RunManifest.instrument`. The generalization score (worst-case R
floor + sign-agreement + per-instrument breakdown) is a **recomputable
aggregate** over those members, not a persisted family-level record."
**Current-state "The campaign executor" opening sentence (superseded by the
#319 sugar retirement, 2026-07-25):** "`aura campaign run <file|content-id>`
executes a campaign (a file is register-then-run sugar; the content id is
canonical): a zero-fault referential gate, then the process pipeline."
**Current-state "Single-run trace identity (#311)" clause (superseded
2026-07-27 — the audit that closed the #311 cycle found `topology_hash`
itself, a C24 content id, feeding the digest, in violation of C29's Id
treatment):** "What remains — the merged params, window, seed, broker,
instrument, `topology_hash`, and the project's `namespace`/`dylib_sha256` (the
C13 hot-reload discriminator) — is identity-bearing."
+77 -21
View File
@@ -39,9 +39,10 @@ by aura. The experiments-&-results plane is the run registry in `aura-registry`
- `runs.jsonl` — the append-only flat store, one `RunReport` per line
(`RunManifest` + `RunMetrics`), with a typed read-path (`load`) and best-first
ranking (`rank_by`/`optimize`). No live producer writes it today — sweep /
walk-forward / mc persist to the family store and `aura run` does not persist —
so the flat lib API is retained but selectively live: `rank_by` backs
ranking (`rank_by`/`optimize`). No live producer writes it today — the campaign
executor (`exec`'s campaign leg, `aura-campaign`, #319) persists to the family
store instead, and `exec`'s blueprint leg (single run) does not persist — so
the flat lib API is retained but selectively live: `rank_by` backs
`aura runs family … rank`, `optimize` backs walk-forward's in-sample step
(`aura-campaign` and the registry's own selection helper both call it), while
`append`/`load` remain public API with no in-tree caller (a latent surface for
@@ -49,14 +50,16 @@ by aura. The experiments-&-results plane is the run registry in `aura-registry`
- `families.jsonl` — the family store. A sweep / Monte-Carlo / walk-forward /
cross-instrument run persists as a *set of related records*, each a
`FamilyRunRecord` (a `RunReport` stamped with `family` + `run` + `kind` +
`ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`).
`group_families` re-derives a family from the stored links (re-listable /
rankable as a unit — C21). The user-facing `family_id = "{family}-{run}"` handle
is **derived** from the stored `family` name plus a per-name `run` index
(numeric max+1 — not a content hash, so re-running the same family mints a fresh
id). CLI: `aura runs families`, `aura runs family <id> [rank <metric>]`;
`aura sweep`/`walkforward`/`mc` persist via `append_family` with an optional
`--name`.
`ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`,
the last now dead — below). `group_families` re-derives a family from the
stored links (re-listable / rankable as a unit — C21). The user-facing
`family_id = "{family}-{run}"` handle is **derived** from the stored `family`
name plus a per-name `run` index (numeric max+1 — not a content hash, so
re-running the same family mints a fresh id). CLI: `aura runs families`,
`aura runs family <id> [rank <metric>]`; the campaign executor persists per-cell
sweep/walk-forward families via `append_family` under a deterministic,
campaign-derived name — no user-supplied `--name` (the retired quintet's
naming flag, #319).
- `campaign_runs.jsonl` — one thin `CampaignRunRecord` per campaign run (below),
over untouched family records.
- `blueprints/<hash>.json`, `processes/`, `campaigns/` — the content-addressed
@@ -109,12 +112,13 @@ the byte-exact `topology_hash` keeps every debug role untouched (introspection-o
the blueprint does not use leaves the id byte-stable. `--content-id` and
`--identity-id` are combinable.
**Cross-instrument generalization.** `FamilyKind::CrossInstrument`: `aura generalize`
runs one candidate across an instrument list and persists the M per-instrument runs
via `append_family`, each member self-identifying through `RunManifest.instrument`.
The generalization score (worst-case R floor + sign-agreement + per-instrument
breakdown) is a **recomputable aggregate** over those members, not a persisted
family-level record.
**Cross-instrument generalization (retired standalone family, #319).** The
`FamilyKind::CrossInstrument` variant is now dead: the standalone `aura
generalize` verb that used to run one candidate across an instrument list and
persist the M per-instrument runs via `append_family` is retired. The
surviving cross-instrument grading is `std::generalize`'s campaign-scope
computation (below, "Annotators are terminal") — a **recomputable aggregate**
over the cells' nominees, not a persisted family-level record.
**Research-artifact document stores.** `processes/` and `campaigns/` hold two
document types (C25 roles 5 / 6b): the **process document** (a named
@@ -133,7 +137,24 @@ show <content-id>` prints a registered document's canonical bytes (#300), so the
generate → retrieve → hand-extend → re-register loop needs no direct store
filesystem access.
**The campaign executor.** `aura campaign run <file|content-id>` executes a
**The bound-override coincidence (#246, ratified #328).** A campaign axis naming a
**bound** param of the referenced blueprint is deliberately accepted alongside one
naming an open param: `validate_campaign_refs` checks each axis's name against
`param_space()` **OR** `bound_param_space()` — the bound value stands as the
axis's default, and the axis's own values re-open it for that campaign. There is
no schema flag distinguishing the two cases (no `open`/`bound` marker on the axis
entry); name coincidence against one of the two namespaces IS the mechanism, and
the referenced blueprint is the single source of truth for which namespace a name
falls in. Pinned by
`referential_tier_accepts_a_kind_correct_axis_over_a_bound_param`
(`aura-registry/src/lib.rs`). The single-run analogue — `exec`'s own
`--override NODE.PARAM=VALUE` reopening a bound param for one execution — now
truthfully has the identical hash consequence (#343, revised): both legs
stamp the referenced/loaded document's own id, never the reopened topology's,
exactly as a reopened campaign member's has always been ([C24](c24-blueprint-data.md)'s
"Reproduction identity" carries the full clause).
**The campaign executor.** `aura exec <file|content-id>` (#319) executes a
campaign (a file is register-then-run sugar; the content id is canonical): a
zero-fault referential gate, then the process pipeline. The executable shape is
`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?`
@@ -154,8 +175,8 @@ carry `manifest.selection`), deflation nulls seeded from the doc's `seed` — th
realization is a pure function of doc + stores + data (C1). Harness/data binding
stays consumer-side behind the one-method **`MemberRunner`** seam (the shipped
implementation is `aura_runner::DefaultMemberRunner`; the CLI binds the loaded-
blueprint reduce convention with a unique suffix-join of raw axis names onto the
wrapped `param_space`). The `campaign_runs.jsonl` sibling records one thin
blueprint folded-delivery convention with a unique suffix-join of raw axis names
onto the wrapped `param_space`). The `campaign_runs.jsonl` sibling records one thin
`CampaignRunRecord` per run — campaign/process ids, seed, and per-cell realized
stage prefixes linking family ids, gate survivor ordinals, and sweep selections —
run-counted per campaign id. Zero survivors truncate a cell's realized prefix and
@@ -215,7 +236,7 @@ is a **closed vocabulary** of the wrap convention's four sink names
intrinsic `DocFault::UnknownTap` — the escalation for a new observable is a new
vocabulary entry or an authored blueprint sink, never an open node-path namespace).
Scope is the per-cell **nominee only**: after the pipeline settles the CLI re-runs
each nominee once in non-reduce mode (all four channels drained, windowed to the
each nominee once under retained delivery (all four channels drained, windowed to the
nominee manifest's own ns bounds) and **asserts metrics equality** against the
recorded nominee — the C1 drift alarm, a hard refusal on divergence (the reproduce
precedent, enforced). Traces land in the existing `TraceStore` as
@@ -230,6 +251,41 @@ unproducible-tap skip (`net_r_equity` needs a cost leg the campaign runner wires
of). Known debt: `aura chart` over the campaign family ROOT (cells spanning
instruments) is untested / semantically undefined — only per-cell read-back is pinned.
**Single-run trace identity (#311).** A single `aura exec` run's taps land under
`traces/<render-name>-<id8>/`. `id8` is the 8-hex head of a SHA-256 over the run's
own `RunManifest` with two keys *removed*`commit`, the aura binary's build sha,
and `project.commit`, the project repo's HEAD plus a `-dirty` marker — and
`params`/`defaults` *merged* into one name-sorted sequence. The two removed keys
answer *who built or checked out the code*, not *what the run was*, and the second
is re-derived on every invocation, so including either would make the store an
unbounded directory generator. `params` and `defaults` are merged because the
partition between them records *how* a bound value was supplied (varied vs. held
at its default, #246) rather than *what* the run's effective parameterisation is —
the same provenance-not-identity distinction, applied to a different pair of keys;
the merge is lossless since the two vectors are disjoint by construction. What
remains — the merged params, window, seed, broker, instrument, and the
project's `namespace`/`dylib_sha256` (the C13 hot-reload discriminator) — is
identity-bearing, with one substitution (2026-07-27, C29): `topology_hash` is
a **content** id (`content_id_of(blueprint_to_json(..))`, #158/C24) that also
hashes a blueprint's `doc` field and every other C23 debug symbol, so a
description-only edit would otherwise mint a fresh directory for a
bit-identical run — forbidden by C29's own Id treatment ("description fields
… are blanked for the identity id"). The digest hashes
`blueprint_identity_json`'s debug-symbol-blind projection (#171) in
`topology_hash`'s place instead; `manifest.topology_hash` itself is
untouched, keeping its own #343 reference-semantics content id. The two
provenance keys are removed rather than blanked
because `ProjectProvenance.commit` skips serializing when it is `None`: blanking
would canonicalise an absent key to `null` and split one identity in two.
`aura-runner::run_identity_digest` is the single implementation; both single-run
entry points assemble the manifest *before* the tap bind and reuse that one value
for the record, so a handle can never name a directory whose identity the stored
record does not describe. This is consistent with C18's plane split above:
`commit = identity` names plane (1), the code identity; a *run's* identity (plane
2) is its manifest minus provenance, with params/defaults collapsed to their
effective union. The campaign leg is unaffected — its handle is
`derive_trace_name`'s `{campaign8}-{run}`, already collision-free by construction.
**Identity-ref resolution is index-first (#191).** `find_blueprint_by_identity`
consults the persistent `blueprint_identity_index.jsonl` sidecar (identity id →
content id; a fixed-name sibling of the runs store, appended under the #276 lock)
@@ -34,3 +34,16 @@ scaffolding's last data weld — `wrap_r`'s hard-wired `price`←close role and
`M1Field::Close`-only open sites — is retired; a strategy's input roles now bind
archive columns by name (C26). `wrap_r`'s remaining R-scaffolding retirement
stays #159.]
**Current-state "Pre-C24 scaffolding retired" paragraph's family-builder
naming (superseded by the #319 sugar retirement, 2026-07-25):** "The family
builders are now generic and blueprint-driven: `blueprint_sweep_family`,
`blueprint_walkforward_family`, `blueprint_mc_family` (and
`blueprint_sweep_over`) in `crates/aura-runner/src/family.rs`." Those four
functions are themselves retired by #319 (2026-07-25): the campaign executor
(`aura_campaign::exec::execute`) builds every family now, driven by the
process document's stage pipeline; `family.rs` keeps only the synthetic-
stream helpers `aura_runner::reproduce` calls to re-derive a recorded family
(`showcase_prices`, `walkforward_prices`, `walkforward_window_source`,
`synthetic_walk_sources`, `DataSource`/`DataChoice`). The current contract's
"Pre-C24 scaffolding retired" paragraph names this real state.
+14 -7
View File
@@ -71,13 +71,20 @@ rather than a hand-enumerated menu. Persisted experiment *intent* is the campaig
document (C25/C18).
**Pre-C24 scaffolding retired.** The pre-C24 scaffolding — `HarnessKind` and the
per-strategy `*_sweep_family` functions — is **gone**. The family builders are now
generic and blueprint-driven: `blueprint_sweep_family`, `blueprint_walkforward_family`,
`blueprint_mc_family` (and `blueprint_sweep_over`) in
`crates/aura-runner/src/family.rs`. The R-evaluator scaffold `wrap_r`
(defined in `crates/aura-runner/src/member.rs`; imported and called from
`runner.rs`) survives; its last hard data weld — the
hard-wired `price`←close role and the `M1Field::Close`-only open sites — is retired
per-strategy `*_sweep_family` functions — is **gone**. The generic blueprint-driven
family builders that first replaced it (`blueprint_sweep_family`,
`blueprint_walkforward_family`, `blueprint_mc_family`, `blueprint_sweep_over`) are,
in turn, retired by the #319 sugar retirement (2026-07-25): the **campaign
executor** (`aura_campaign::exec::execute`) now builds every family, driven by
the process document's stage pipeline (`std::sweep`/`std::grid`,
`std::walk_forward`, `std::monte_carlo`). `crates/aura-runner/src/family.rs`
retains only the synthetic-stream helpers serving `aura_runner::reproduce`
(`showcase_prices`, `walkforward_prices`, `walkforward_window_source`,
`synthetic_walk_sources`, `DataSource`/`DataChoice`) — reproduction's own
re-derivation path, not a family-minting path. The R-evaluator scaffold
`wrap_r` (defined in `crates/aura-runner/src/member.rs`; imported and called
from `runner.rs`) survives; its last hard data weld — the hard-wired
`price`←close role and the `M1Field::Close`-only open sites — is retired
(a strategy's input roles now bind archive columns by name, C26), and `wrap_r`'s
remaining R-scaffolding retirement is tracked at #159.
@@ -135,3 +135,52 @@ the mean shows the net/duty-cycle level. Deferred refinements: rendering the min
envelope honestly as range bars / OHLC rather than a polyline (#112); a `--width`
budget flag and true intra-bucket min/max ordering for the non-default continuous
x-mode (#110).
**Current-state "Single run" clause (superseded 2026-07-27 by #311's
identity-keyed single-run trace directory):** "`aura exec <blueprint.json>`
(#319) persists every tap the blueprint declares to the trace store under the
run's own name, on both shapes: …"
**Current-state "Encoding / storage / rendering split (C14)" clause
(superseded 2026-07-27 by #311's identity-keyed single-run trace directory):**
"file I/O is `aura-registry::TraceStore`, persisting each run under
`runs/traces/<name>/` beside the run registry's `runs.jsonl`"
**Current-state "Single-run trace identity (#311)" clause (superseded
2026-07-27 — the audit that closed the #311 cycle found `topology_hash`
itself, a C24 content id, feeding the digest, in violation of C29's Id
treatment):** "Everything else the manifest carries is identity-bearing,
`project.dylib_sha256` included (it is what C13 hot-reload varies)."
**Current-state "Single run" + "Families" + "Newcomer" clauses (superseded by
the #319 sugar retirement, 2026-07-25):** "**Single run.** `aura run
<blueprint.json>` persists every tap the blueprint declares to the trace
store under the run's own name, on both shapes: a `bias`-output strategy
(`aura-runner::member::run_signal_r`, R-wrapped) and a bare measurement
blueprint with ≥1 declared tap but no `bias`
(`aura-runner::measure::run_measurement`). A tap-free run writes nothing to
`runs/` and its stdout stays byte-identical. The CLI `--trace <NAME>` flag is
**retired** on `run` (and on `mc`): it parses but is refused at dispatch
(exit 2) — naming a trace is the family / campaign path's job, not a per-run
flag.
**Families.** `aura sweep|walkforward --real <SYM> … --trace <NAME>`
persists *each member* via the campaign path: the dissolved verb is
translated into a content-addressed process + campaign document whose
`presentation.persist_taps` requests the tap vocabulary, run through the one
campaign executor; `aura-runner::runner::persist_campaign_traces` writes
each member under a depth-2 fan-out `runs/traces/<name>/<cell>/<member>/`.
Every written member is independently re-run once in non-reduce trace mode
over its own recorded window, and its re-derived metrics are asserted equal
to the recorded member's — a **C1 drift alarm** that refuses (exit 1) rather
than persist a silently-wrong trace. The **synthetic** sweep / walk-forward
path refuses `--trace` (exit 2); **Monte-Carlo is excluded from trace
persistence** (`mc --real` refuses `--name`/`--trace`, exit 2 — the
real-data R-bootstrap campaign itself runs, but records no per-member
family traces; the synthetic `--seeds` family's realization argument, C12,
does not carry over to one real series). `TraceStore::ensure_name_free`
makes name resolution a total function, refusing cross-kind reuse of one
name by both a run and a family." … (Newcomer, tail sentence) "so `aura run`
prints summary R-metrics to stdout but writes no on-disk trace — a
**chartable** trace today comes from a blueprint that declares taps (single
run) or from a `--real … --trace` family campaign."
+90 -28
View File
@@ -56,35 +56,97 @@ per fired cycle, tagged `ctx.now()`), matching a trace of timestamped events (C1
**Encoding / storage / rendering split (C14).** A drained tap is encoded as a
columnar (SoA, C7) `ColumnarTrace` — struct→JSON only — in `aura-engine`
(`report.rs`); file I/O is `aura-registry::TraceStore`, persisting each run under
`runs/traces/<name>/` beside the run registry's `runs.jsonl`; rendering is
`runs/traces/<handle>/` beside the run registry's `runs.jsonl``<handle>` is a
single run's own identity-keyed name (`<render-name>-<id8>`, #311) or a
campaign family's derived name, unchanged; rendering is
`aura-cli` (`render_chart_html` + the vendored `chart-viewer.js`). The engine stays
headless.
**Single run.** `aura run <blueprint.json>` persists every tap the blueprint
declares to the trace store under the run's own name, on both shapes: a
`bias`-output strategy (`aura-runner::member::run_signal_r`, R-wrapped) and a bare
measurement blueprint with ≥1 declared tap but no `bias`
(`aura-runner::measure::run_measurement`). A tap-free run writes nothing to `runs/`
and its stdout stays byte-identical. The CLI `--trace <NAME>` flag is **retired** on
`run` (and on `mc`): it parses but is refused at dispatch (exit 2) — naming a trace
is the family / campaign path's job, not a per-run flag.
**Single run.** `aura exec <blueprint.json>` (#319) persists every tap the
blueprint declares to the trace store under the run's own identity-keyed
handle — `<render-name>-<id8>`, #311 — on both shapes: a `bias`-output strategy (`aura-runner::member::run_signal_r`,
R-wrapped) and a bare measurement blueprint with ≥1 declared tap but no
`bias` (`aura-runner::measure::run_measurement`). A tap-free run writes
nothing to `runs/` and its stdout stays byte-identical. `exec` carries no
free-standing `--trace <NAME>` flag — naming a trace is the family / campaign
path's job, not a per-run flag (the repeatable `--tap TAP=FOLD` selector,
#310, only chooses which of the blueprint's own declared taps to subscribe,
never names the trace).
**Families.** `aura sweep|walkforward --real <SYM> … --trace <NAME>` persists *each
member* via the campaign path: the dissolved verb is translated into a
content-addressed process + campaign document whose `presentation.persist_taps`
requests the tap vocabulary, run through the one campaign executor;
`aura-runner::runner::persist_campaign_traces` writes each member under a depth-2
fan-out `runs/traces/<name>/<cell>/<member>/`. Every written member is
independently re-run once in non-reduce trace mode over its own recorded window,
and its re-derived metrics are asserted equal to the recorded member's — a **C1
drift alarm** that refuses (exit 1) rather than persist a silently-wrong trace. The
**synthetic** sweep / walk-forward path refuses `--trace` (exit 2);
**Monte-Carlo is excluded from trace persistence** (`mc --real` refuses
`--name`/`--trace`, exit 2 — the real-data R-bootstrap campaign itself runs, but
records no per-member family traces; the synthetic `--seeds` family's realization
argument, C12, does not carry over to one real series).
`TraceStore::ensure_name_free` makes name resolution a total function, refusing
cross-kind reuse of one name by both a run and a family.
A run that persists anything reports the handle it wrote under as
`trace_name` on its stdout line (#309) — appended to the record's own bytes,
absent entirely when the run recorded nothing — so the handle is read, never
guessed. The library hands it to an embedding caller as a value beside the
report, like the unbound-tap names (C27/#297); it is not part of the stored
record.
**Single-run trace identity (#311).** The handle is `<render-name>-<id8>`,
where `id8` is the 8-hex head of a SHA-256 over the run's own `RunManifest`
with two keys *removed*`commit` (the aura binary's build sha) and
`project.commit` (the project repo's HEAD plus a `-dirty` marker, re-derived
on every invocation) — and `params`/`defaults` *merged* into one name-sorted
sequence. The two removed keys are provenance — who built or checked out the
code — not what the run was; including either would make the store an
unbounded directory generator. The merge is the same distinction applied to a
different pair: `params`/`defaults` record *how* a bound value was supplied
(varied vs. held at its default, #246), not *what* the run's effective
parameterisation is, and the two vectors are disjoint by construction, so the
merge is lossless. Everything else the manifest carries is identity-bearing,
`project.dylib_sha256` included (it is what C13 hot-reload varies) —
**except** `topology_hash` (2026-07-27, C29): it is a **content** id
(`content_id_of(blueprint_to_json(..))`, #158/C24) that also hashes a
blueprint's `doc` field and every other C23 debug symbol, so a
description-only edit would otherwise mint a fresh directory for a
bit-identical run, which C29's own Id treatment forbids. The digest hashes
`blueprint_identity_json`'s debug-symbol-blind projection (#171) in
`topology_hash`'s place instead; `manifest.topology_hash` itself is
untouched. That projection blanks every C23 debug symbol, not descriptions
alone — the render name, and node, role, output, tap and gang names — while
param openness stays identity-bearing.
**What that does and does not reach.** The blanking applies to the
substituted hash only, not to the whole digest, so a debug symbol that also
appears in another manifest field still reaches the handle through that
field. Two consequences hold today, both observed:
- A *description-only* edit converges: same handle, one directory.
- A *node rename* does **not** converge, because `manifest.params` records
node-qualified names (`fast.length` vs `quick.length`) and the merged
parameter vector is hashed. Two blueprints differing only in node names
therefore compute bit-identically but are addressed separately. Whether
the handle should follow the record here (param names are part of what
the record describes) or follow #171's projection (param names are not
identity-bearing) is an open fork — #354; the render name likewise
survives, deliberately, as the handle's readable prefix.
One further observable consequence: two structurally identical blueprints
whose declared *tap names* differ share a directory while writing
differently named tap files, and the later run's `index.json` lists only its
own, since the write path never prunes (tracked as #352, unchanged here). The
manifest is assembled *before* the tap bind and the same value is reused for
the record, so the handle can never name a directory whose identity the
stored record does not describe. Consequences: two runs of one blueprint
differing in any identity-bearing input write two directories and neither
overwrites the other; two runs with the same effective parameterisation —
including a no-op `--override` that merely moves a value between `params` and
`defaults` — write one; and the bare render name is no longer a directory, so
`aura chart <render-name>` refuses with the handles recorded under that name
(`recorded_handles_with_prefix`, the same helper the family-id branch uses).
**Families.** A campaign document's `presentation.persist_taps` (the closed
tap vocabulary, C18/C27) requests traces for each cell's nominee, run through
`aura exec <campaign.json|id>` (#319 — a document's `axes` replace the
retired `sweep|walkforward --real <SYM> --trace <NAME>` grammar entirely;
there is no per-invocation `--trace`/`--name` flag left on any surface):
`aura-runner::runner::persist_campaign_traces` writes each member under a
depth-2 fan-out `runs/traces/{campaign8}-{run}/<cell>/<member>/`, the name
derived deterministically (`derive_trace_name`), never user-chosen. Every
written member is independently re-run once under retained delivery over
its own recorded window, and its re-derived metrics are asserted equal to
the recorded member's — a **C1 drift alarm** that refuses (exit 1) rather
than persist a silently-wrong trace. `TraceStore::ensure_name_free` makes
name resolution a total function, refusing cross-kind reuse of one name by
both a run and a family.
**Viewer.** `aura chart <name> [--tap <t>] [--panels]` classifies the name on disk
(`TraceStore::name_kind`: top-level `index.json` → a single Run; member subdirs → a
@@ -117,10 +179,10 @@ vocabulary (an SMA-cross → `Bias` strategy), `.gitignore`, and a project `CLAU
The engine also ships example blueprints under
`crates/aura-cli/examples/r_{sma,breakout,channel,meanrev}.json`. Honest gap against
the Guarantee's "populated trace immediately": these shipped blueprints are
*strategies* (a `bias` output, no declared taps), so `aura run` prints summary
*strategies* (a `bias` output, no declared taps), so `aura exec` prints summary
R-metrics to stdout but writes no on-disk trace — a **chartable** trace today comes
from a blueprint that declares taps (single run) or from a `--real … --trace` family
campaign.
from a blueprint that declares taps (single run) or from a campaign document
requesting `presentation.persist_taps`.
**Deferred.** Live sink streams *during* a run are not built — taps are
buffer-then-drain (collected, then written after the run), and there is no local
@@ -0,0 +1,11 @@
# C23 — Graph compilation and behaviour-preserving optimisation: history
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp
> and may be superseded; this file is NOT current truth and NOT a grounding
> surface. Current contract: [c23-graph-compilation.md](c23-graph-compilation.md).
**Render-name run-time-role clause (#331; superseded 2026-07-27 by #311's
identity-keyed single-run trace directory):** "…but at *run* time, outside
compilation, it keys the trace directory (`traces/<name>/`); that operational
role, not any load-bearing weight inside the flat graph, is exactly why the
authored-blueprint intakes shape-gate it."
@@ -9,7 +9,13 @@ entirely (there is no composite in the flat graph). The blueprint's field /
input-role *names* are **non-load-bearing** — the wiring resolves by index, and names
survive at most as **informative debug symbols** (exactly as `FieldSpec.name` already
is, C8), kept for tracing / rendering (C9 graph-as-data) but carrying no run
semantics. The flat graph is then the target of **behaviour-preserving optimisation**
semantics. (Reconciling with the root composite's own render name, #331: that
claim stands unweakened — the name never influences compilation, identity, or
execution semantics — but at *run* time, outside compilation, it prefixes the
trace directory (`traces/<name>-<id8>/`, #311 — the run's own identity digest
completes it); that operational role, not any load-bearing
weight inside the flat graph, is exactly why the authored-blueprint intakes
shape-gate it.) The flat graph is then the target of **behaviour-preserving optimisation**
— any transform that leaves every observable sink trace bit-identical (C1 is the
correctness invariant) — on two levels:
@@ -75,3 +81,5 @@ points (`compile`, `compile_with_params`, and the `bind` paths) as well as from
- [C9](c09-fractal-composition.md) — inlining vs runtime sub-engine.
- [C11](c11-sources-record-replay.md), [C12](c12-atomic-sim-unit.md) — recorded-stream sharing and the sweep family.
- [C19](c19-bootstrap.md) — the flat-graph representation this optimises.
> History: [c23-graph-compilation.history.md](c23-graph-compilation.history.md)
@@ -157,4 +157,100 @@ convert every deliberate template improvement into forced churn of the frozen
fixture — the same cross-purpose coupling that rules out regenerating the
fixture from the scaffolder.
**Current-state "Runs and families are built FROM blueprint-data" section
(superseded by the #319 sugar retirement, 2026-07-25):** "`aura run
<blueprint.json>` loads a serialized **signal** blueprint and emits a
`RunReport` **bit-identical** (C1) to its Rust-built twin; the run scaffolding
(sinks / broker / data) is supplied **at run**, not serialized. Beyond a
single run, the World constructs and orchestrates **families** of harnesses
from topology-data:
- `aura sweep <blueprint.json> --axis <name>=<csv>``FamilyKind::Sweep`. A
sweep needs an **open** blueprint (a fully-bound one has an empty
`param_space`).
- `aura mc <blueprint.json> --seeds N``FamilyKind::MonteCarlo`, each seed a
distinct synthetic price walk drawn disjoint-parallel through the engine
`monte_carlo` seam (invariant 1). MC binds no axis, so it needs a **closed**
blueprint (the sweep's distinction inverted); an open one returns a named
`Err`, rendered exit-2 at the builder boundary — no hidden exit in the pure
builder.
- `aura walkforward <blueprint.json> --axis <name>=<csv> [--select
argmax|plateau:mean|plateau:worst]` → `FamilyKind::WalkForward`:
re-optimizes the loaded blueprint's params over the `--axis` grid on each
24/12/12 IS window, selects the winner by `sqn_normalized`, runs it
out-of-sample. Reduce-mode members are R-measured (`oos_r` the meaningful
summary; stitched pip-equity empty, C10).
The family builders live in `aura-runner::family` (`blueprint_sweep_family` /
`blueprint_mc_family` / `blueprint_walkforward_family`). Every member manifest
carries the **shared** `topology_hash` (one signal topology, only params
vary; `member_key` distinguishes members), and each family stores its
blueprint(s) content-addressed so `aura reproduce` re-derives every member
bit-identically (`aura-runner::reproduce`, C18). The synthetic-walk DGP is
the MC machinery, not trader-grade statistics; a real-data block-bootstrap —
and retiring the `synthetic_walk_sources` `len:60`↔warm-up coupling — rides
#172."
**Current-state "Axis discovery" section (superseded by the #319 sugar
retirement, 2026-07-25):** "`aura sweep <blueprint.json> --list-axes` lists
a loaded blueprint's open sweepable knobs (one `<name>:<kind>` per line,
`param_space()` order) and exits; the printed names are exactly what
`--axis` binds. Every listed name is **mandatory** on `sweep` /
`walkforward` — the blueprint must be fully bound before it runs — so a
subset grid is refused with the missing knob named (`BindError::MissingKnob`,
`aura-engine`) and there is no default; pin a knob you do not want to vary
with a single-value axis. A single `blueprint_axis_probe` (`aura-runner`)
single-sources the wrapped probe for the sweep terminal, the MC closed-check,
and the listing, so **listed == swept by construction** (and stays so across
the harness retirement — the listing tracks whatever the sweep actually
resolves). One raw namespace, `<node>.<param>` (a splice path keeps its
interior path, e.g. `anchor.sess.period_minutes`), is the only user-facing
axis name (#328): `graph introspect --params` and `--list-axes` are
line-identical (open params bare, bound params with `default=`) and both
print exactly what `--axis` binds, on both sweep routes. The wrapped
`<blueprint>.<node>.<param>` form the probe still resolves against
internally is retired from the surface, with a translation refusal naming
the raw candidate on both intake seams (`--axis` and `campaign validate`).
`--trace` on `sweep` / `walkforward` writes per-member traces on the
real-data campaign path (depth-2 fan-out, chartable by the printed family
handle, #224); the synthetic path still refuses (`run` / `mc` refuse
`--trace` outright). The live trace-writer is the campaign
`presentation.persist_taps` (`persist_campaign_traces`,
`aura-runner::runner`)."
**Current-state `name`-op gated-intake clause (superseded by the #319 sugar
retirement, 2026-07-25):** "Gated by a shared shape check (non-empty, single
path segment, no `/`, `\`, `.` or `..`) applied at this op intake **plus
every CLI intake that reads an authored blueprint envelope from a file** —
`register`, `introspect --content-id <FILE>`, the bare graph-file viewer,
`run`, `introspect --params <FILE>`, `sweep --list-axes`, and each of
`sweep`/`walkforward`/`mc`/`generalize`'s file-reading entry points
(synthetic and `--real` alike) — one class of intake, one gate. The
refusal's core sentence (`name_gate_fault_prose`) is byte-uniform across
every one of these sites; only the leading context prefix varies — `run`
and the bare graph-file viewer prepend `aura: <path>:`, while `register`,
the sweep-axis family (`validate_and_register_axes`), and `introspect`
prepend bare `aura: `. Store read-back (`reproduce`, `use` resolution,
`introspect`/`--params` by content id) stays deliberately ungated — C29: a
registered artifact is never retroactively invalidated."
**[C26, 2026-07-10 (#231): the single-price data weld inside the surviving `wrap_r` scaffolding is retired — input roles bind archive columns by name; the wrapper's remaining R-scaffolding retirement stays #159.]** (From the C24 Forbids clause as of that date; the single-price weld was retired at #231/C26, but `wrap_r` itself survives — its full R-scaffolding retirement stays deferred, #159.)
---
> The disjoint-parallel synthetic
> price walks a sweep/MC family draws (`aura_runner::family`) are the family
> machinery, not trader-grade statistics; a real-data block-bootstrap — and
> retiring the `synthetic_walk_sources` `len:60`↔warm-up coupling — rides #172.
(superseded by the #319 sugar retirement, 2026-07-25 — audit-close correction:
no family-minting path draws synthetic walks after the family-builder
retirement; only `reproduce` consumes them)
**"Registered-blueprint splice" clause, build-free-introspection sentence
(amended by the 2026-07-26 harvest, #339 item 4):** "build-free introspection
paths pass `&|_| None`." (The CLI's introspection paths now resolve `use`
refs through the store exactly like `graph build` does, via the shared
`parse_and_resolve_ops` phase — retiring the earlier build-free resolver on
the introspection side; the engine's `subgraph` closure contract itself is
unchanged.)
+92 -46
View File
@@ -75,34 +75,36 @@ channel). `LinComb` / `CostSum` / `Session` — formerly listed here — entered
round-trippable set with #271's typed construction args (see the add-op `args`
clause and the data-driven `format_version` below).
### Runs and families are built FROM blueprint-data
### Runs and families are built FROM documents, via `exec` (#319, 2026-07-25)
`aura run <blueprint.json>` loads a serialized **signal** blueprint and emits a
`RunReport` **bit-identical** (C1) to its Rust-built twin; the run scaffolding (sinks
/ broker / data) is supplied **at run**, not serialized. Beyond a single run, the
World constructs and orchestrates **families** of harnesses from topology-data:
`aura exec <blueprint.json>` loads a serialized **signal** blueprint and runs it
once, emitting a `RunReport` **bit-identical** (C1) to its Rust-built twin (a
no-bias blueprint with ≥1 declared tap runs the measurement leg instead); the
run scaffolding (sinks / broker / data) is supplied **at run**, not serialized.
Beyond a single run, the World constructs and orchestrates **families** from a
**campaign document** (role 6b, [C18](c18-registry.md)/[C25](c25-role-model.md)):
`aura exec <campaign.json|id>` drives the document's `strategies[].axes` through
the `aura-campaign` executor (`aura_campaign::exec::execute`) — one member per
(strategy, instrument, window) cell, the process document's stage pipeline
(`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]?
[std::generalize]?`) deciding what each cell realizes (grid family, gated
survivors, walk-forward roll, MC bootstrap, cross-instrument generalization —
C18's cycle-0107/0108 realizations). The one deliberate argv residue,
`--override NODE.PARAM=VALUE`, reopens a bound param for the execution without
touching the document (blueprint leg: `override_paths` + `reopen_all`; campaign
leg: injected as a single-value axis ahead of `validate_campaign_refs`, refusing
a collision with a document-declared axis).
- `aura sweep <blueprint.json> --axis <name>=<csv>``FamilyKind::Sweep`. A sweep
needs an **open** blueprint (a fully-bound one has an empty `param_space`).
- `aura mc <blueprint.json> --seeds N``FamilyKind::MonteCarlo`, each seed a
distinct synthetic price walk drawn disjoint-parallel through the engine
`monte_carlo` seam (invariant 1). MC binds no axis, so it needs a **closed**
blueprint (the sweep's distinction inverted); an open one returns a named `Err`,
rendered exit-2 at the builder boundary — no hidden exit in the pure builder.
- `aura walkforward <blueprint.json> --axis <name>=<csv> [--select
argmax|plateau:mean|plateau:worst]` → `FamilyKind::WalkForward`: re-optimizes the
loaded blueprint's params over the `--axis` grid on each 24/12/12 IS window,
selects the winner by `sqn_normalized`, runs it out-of-sample. Reduce-mode members
are R-measured (`oos_r` the meaningful summary; stitched pip-equity empty, C10).
The family builders live in `aura-runner::family` (`blueprint_sweep_family` /
`blueprint_mc_family` / `blueprint_walkforward_family`). Every member manifest carries
the **shared** `topology_hash` (one signal topology, only params vary; `member_key`
distinguishes members), and each family stores its blueprint(s) content-addressed so
`aura reproduce` re-derives every member bit-identically (`aura-runner::reproduce`,
C18). The synthetic-walk DGP is the MC machinery, not trader-grade statistics; a
real-data block-bootstrap — and retiring the `synthetic_walk_sources` `len:60`↔warm-up
coupling — rides #172.
Every member manifest carries the **shared** `topology_hash` (one signal
topology, only params vary; `member_key` distinguishes members), and the
executor stores its blueprint(s) content-addressed so `aura reproduce`
re-derives every member bit-identically (C18). The synthetic price walks
(`aura_runner::family::synthetic_walk_sources`) survive the #319 retirement
only as `reproduce`'s re-derivation inputs for historically minted synthetic
families — no family-minting path draws them anymore (the campaign executor
is the one family builder, C20) — and they are not trader-grade statistics; a
real-data block-bootstrap — and retiring the `len:60`↔warm-up coupling —
rides #172.
### Reproduction identity
@@ -117,24 +119,44 @@ itself blueprint-data. The **identity id** (#171) is an additive sibling:
--identity-id`. The byte-exact content id keeps the store/reproduce roles; the
identity id is introspection-only until a dedup consumer exists.
**`topology_hash` carries reference semantics on every leg (#343, revised).**
`exec`'s `--override NODE.PARAM=VALUE` (above) reopens a bound param before
bootstrap, but the executed run's `topology_hash` stamps the **loaded (or
stored) base document's own content id** — computed before the reopen —
never a transient reopened variant, exactly as the campaign leg's #246
axis-over-bound-param path has always stamped the referenced strategy's id
for a reopened member ([C18](c18-registry.md)'s bound-override coincidence).
The manifest's `params` map carries the variation (the bound value still
moves from `defaults` to `params`, #249); `params` plus the base document
deterministically reconstruct the executed run, byte-identically —
reproduction identity, not the hash, is where the variation lives. A no-op
override (re-binding a param to its existing bound value) therefore changes
nothing about identity: the bare run's and the no-op-override run's
`topology_hash` are equal. An unresolved `topology_hash` (a file-target exec
never registered via `graph register`) is still the honest name of a real,
reconstructible topology, not a dangling reference — store-resolution
(`get_blueprint`) is a lookup convenience, never an identity guarantee.
### Axis discovery
`aura sweep <blueprint.json> --list-axes` lists a loaded blueprint's open sweepable
knobs (one `<name>:<kind>` per line, `param_space()` order) and exits; the printed
names are exactly what `--axis` binds. Every listed name is **mandatory** on `sweep` /
`walkforward` — the blueprint must be fully bound before it runs — so a subset grid is
refused with the missing knob named (`BindError::MissingKnob`, `aura-engine`) and
there is no default; pin a knob you do not want to vary with a single-value axis. A
single `blueprint_axis_probe` (`aura-runner`) single-sources the wrapped probe for the
sweep terminal, the MC closed-check, and the listing, so **listed == swept by
construction** (and stays so across the harness retirement — the listing tracks
whatever the sweep actually resolves). The names are prefixed by the current wrapping
(`sma_signal.fast.length`, the nested-composite prefix), which is why discovery lives
on the sweep verb (it owns the wrapping), not `graph introspect`. `--trace` on
`sweep` / `walkforward` writes per-member traces on the real-data campaign path
(depth-2 fan-out, chartable by the printed family handle, #224); the synthetic path
still refuses (`run` / `mc` refuse `--trace` outright). The live trace-writer is the
campaign `presentation.persist_taps` (`persist_campaign_traces`, `aura-runner::runner`).
`aura graph introspect --params <blueprint.json|id>` (#328) is the one
axis-discovery surface: it lists a loaded blueprint's open knobs (one
`<name>:<kind>` per line, `param_space()` order) followed by its bound knobs
(`<name>:<kind> default=<value>`) — the campaign-axis namespace
`validate_campaign_refs` checks a campaign document's `strategies[].axes`
against. Every open name a campaign document's `axes` varies is **mandatory**:
a subset is refused with the missing knob named (`BindError::MissingKnob`,
`aura-engine`) and there is no default; pin a knob you do not want to vary with
a single-value axis. One raw namespace, `<node>.<param>` (a splice path keeps
its interior path, e.g. `anchor.sess.period_minutes`), is the only user-facing
axis name (#328) — the wrapped `<blueprint>.<node>.<param>` form is retired
from the surface, with a did-you-mean refusal naming the raw candidate at the
one remaining intake seam, `campaign validate` (#319 retired the standalone
`--axis`/`--list-axes` CLI flags this used to also gate). Per-member traces
are the campaign document's `presentation.persist_taps` (closed vocabulary,
C18 cycle-0109) or, on `exec`'s blueprint leg, the repeatable `--tap
TAP=FOLD` selector (#310) over a blueprint's own declared taps (C27); the
live trace-writer is `persist_campaign_traces` (`aura-runner::runner`).
### The construction service (op-script)
@@ -176,7 +198,12 @@ it ever reaches the session, so a doc-less fetched entry cannot enter a NEW
composition — a **backstop**: the register verb already gates both input forms, so
this fires only for store content written before C29 or through the raw in-crate
path), and the resolution echo — happens CLI-side, at DTO conversion, before
replay; build-free introspection paths pass `&|_| None`. The echo
replay; the CLI's introspection paths resolve `use` refs through the store
exactly like `graph build` does (`parse_and_resolve_ops`, the shared first
phase both callers run — #339 item 4 harvest retired the earlier build-free
`&|_| None` resolver on the introspection side). The engine's closure
contract above is unchanged: only the CLI-side caller supplied for
introspection now always threads a real store-backed resolver. The echo
(`aura: note: use "<instance>": <label-or-prefix> -> <full id>`) is the existing
`aura: note:` benign-diagnostic marker (C14) — a new instance of the existing
class, not a new one, so the exit-code/marker taxonomy is unchanged.
@@ -191,8 +218,27 @@ rejected at construction.
The op-script is a JSON **array of ops**, each object internally tagged by `"op"`,
replayed in order; nodes are referenced **by identifier**, ports as dotted
`<identifier>.<port>`. The ten verbs:
`<identifier>.<port>`. The eleven verbs:
- `name``{"op":"name","name":<str>}` — set the composite's render name
(#331), script-level and at-most-once (a second refuses); omitted, the
built composite keeps the CLI's own seed default (`"graph"`). Gated by a
shared shape check (non-empty, single path segment, no `/`, `\`, `.` or
`..`) applied at this op intake **plus every CLI intake that reads an
authored blueprint envelope from a file** — `graph register`, `introspect
--content-id <FILE>`, the bare graph-file viewer, `introspect --params
<FILE>`'s file branch, `introspect --taps <FILE>`'s file branch (#337,
same `composite_from_authored_text` route), and `exec`'s blueprint leg
(#319; its narrower, envelope-only grammar shares the same
`gate_authored_root_name` call directly rather than through the
shape-discriminating wrapper) — one
class of intake, one gate. The refusal's core sentence
(`name_gate_fault_prose`) is byte-uniform across every one of these
sites; only the leading context prefix varies — `exec` and the bare
graph-file viewer prepend `aura: <path>:`, while `register` and
`introspect` prepend bare `aura: `. Store read-back (`reproduce`, `use`
resolution, `introspect`/`--params` by content id) stays deliberately
ungated — C29: a registered artifact is never retroactively invalidated.
- `source``{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
source role producing a base column of `kind`.
- `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred
@@ -223,8 +269,8 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
recorded observation point (a `Composite.taps` entry, C27), not a boundary output.
Name-addressed like every other op (no raw index); tap names are their own
namespace (a duplicate refuses). The `finish` gate threads op-declared taps into
the built `Composite` (`.with_taps`); a single `aura run` records each, a sweep
leaves them inert.
the built `Composite` (`.with_taps`); a single `aura exec` (#319) records
each, a campaign member run leaves them inert.
- `gang``{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}`
— fuse two or more sibling params into ONE public knob: the member addresses
leave the sweepable param space and `as` replaces them; the bound or swept
@@ -0,0 +1,10 @@
# C25 — The role model: nine authoring roles, cut by artifact + surface + iteration cost: history
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp and may be superseded; this file is NOT current truth and NOT a grounding surface. Current contract: [c25-role-model.md](c25-role-model.md).
**Current-state executor-verb-set sentence (owner-minuted 2026-07-21 on #295,
delivered by #300; superseded by the #319 sugar retirement, 2026-07-25):**
"The executor verb set is settled: `run`, the four thin per-verb generators,
and the document verbs validate/introspect/register/show/run/runs — `show`
being #300's read-back addition. The verbs' per-verb identity is re-ratified
(#300 F8 — reduction, not grammar collapse)."
+10 -6
View File
@@ -74,12 +74,14 @@ present but faceless: no addressed verb families yet. Role homes in the project
layout and docs-by-role are open (#192).
**Document-first completion** is the resolved direction on the control-surface
amendment (owner-minuted 2026-07-21 on #295), delivered by #300. The executor verb
set is settled: `run`, the four thin per-verb generators, and the document verbs
validate/introspect/register/show/run/runs — `show` being #300's read-back
addition. The verbs' per-verb identity is re-ratified (#300 F8 — reduction, not
grammar collapse). A typed-protocol host or MCP face remains demand-driven and
unbuilt.
amendment (owner-minuted 2026-07-21 on #295), delivered by #300. The executor
surface is settled again, one reduction further (#319, 2026-07-25): a single
`exec <target>` verb runs both document classes — a campaign (file or content
id) and a signal blueprint (single run) — over the document verbs
validate/introspect/register/show/runs (`show` #300's read-back addition); the
one argv residue is `--override NODE.PARAM=VALUE`, a per-execution bound-param
reopen threading through both `exec` legs. A typed-protocol host or MCP face
remains demand-driven and unbuilt.
## See also
- [C16](c16-engine-project-split.md) — the game-engine analogy this extends to people
@@ -88,3 +90,5 @@ unbuilt.
- [C20](c20-strategy-harness.md), [C21](c21-world.md) — the node/harness/World tiers behind the 6a/6b split
- [C24](c24-blueprint-data.md) — topology-as-data, the strategy designer's artifact
- [C26](c26-input-binding.md) — a role's input contract carried in blueprint data
> History: [c25-role-model.history.md](c25-role-model.history.md)
@@ -9,3 +9,16 @@ the #310 `--tap` selector):** "…and the shared `bind_tap_plan`/`BoundTaps`
pair called by both declared-tap entry points, `run_signal_r`
(`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both
CLI verbs pass a record-all plan."
**Current-state "single CLI verb" clause (superseded by the #319 sugar
retirement, 2026-07-25):** "…both arms of the single CLI verb `aura run`,
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named`
selection data-reachable…"
**Current-state `RunOutcome` sentence (superseded 2026-07-27 by #311's
identity-keyed single-run trace directory):** "Since #309 both entry points
return that pair as a named `RunOutcome { report, skipped, trace_name }`, the
third field being the trace-store handle the run recorded under (`Some` under
exactly the condition that opens the write path, `None` otherwise) — the same
beside-the-report discipline, widened rather than re-cut: the record keeps its
shape and the shell renders the handle."
+36 -5
View File
@@ -42,7 +42,10 @@ input role, which `check_root_roles_bound` rejects ([C26](c26-input-binding.md))
observation is optional, a fed input is mandatory. A declared-but-unbound tap
compiles and runs, its producer evaluating and its output discarded (a no-out-edge
producer is a valid runnable sink — the Kahn sort emits it,
`check_ports_connected` gates only inputs).
`check_ports_connected` gates only inputs). The CLI surfaces this to the human
as a note (`aura: note: declared tap "…" unbound this run`) printed from the
unbound names the entry points return beside their report — never emitted by
the library itself (#297).
**Why.** Observability must be expressible in a hand-authored blueprint — the
measurement-shaped study computes in the graph and surfaces via taps, no throwaway
@@ -68,10 +71,20 @@ and the roster-enumerating refusal — plus a scalar-typed param schema; all cor
entries are param-less today, the seam ships in every entry's build signature),
and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
(`aura-runner::measure`) — both arms of the single CLI verb `aura run`, whose
repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
(`aura-runner::measure`) — both arms of the single CLI verb `aura exec` (#319),
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
data-reachable: no flag keeps the record-all default, any flag replaces the
plan entirely (unlisted taps stay unbound/inert). The boundary is thereby
plan entirely (unlisted taps stay unbound/inert). `BoundTaps` carries the
unbound tap names out (`skipped: Vec<String>`), riding beside the returned
report rather than inside it, so the CLI — not the library — prints the
unbound-tap note (#297). Since #309 both entry points return that pair as a
named `RunOutcome { report, skipped, trace_name }`, the third field being the
trace-store handle the run recorded under — since #311 the run's own identity,
`<render-name>-<id8>`, minted from the manifest the entry point now assembles
*before* the bind and reuses for the record (`Some` under exactly the
condition that opens the write path, `None` otherwise) — the same beside-the-report
discipline, widened rather than re-cut: the record keeps its shape and the
shell renders the handle. The boundary is thereby
fixed in place: *selecting* a subscription is run-mode authority, exercised
by the run-mode owner — on the one-shot path the CLI invocation itself, a
projection exercising this contract's authority, not a second home for
@@ -90,12 +103,30 @@ instant; `min`/`max` deliberately do not carry the extremum's timestamp (a
whole-window row privileges no interior instant) — ratified as-is, #335;
live closures run inline
(`aura-std::TapLive`). The sweep/reduce
path never calls `bind_tap`.
path never calls `bind_tap`. On the single-run path the wrap's own recording
sinks now use that same folded delivery as the declared-tap folds (#308,
`wrap_r`'s `fold_series`) — one summary row per series at finalize, the dense
record gated to closed rows plus the final row — so both entry points that
consume only aggregates, `run_signal_r` and `run_blueprint_member`, share one
delivery shape.
The chain-pruning benefit — a sweep paying zero for the study wires behind an
unbound tap — is **deferred to the future DCE cycle** ([C23](c23-graph-compilation.md));
the mechanism ships now, verified sound.
**#337 (2026-07-26 harvest) ships the positive discovery view**:
`Composite::declared_taps()` (`crates/aura-engine/src/blueprint.rs`) walks the
blueprint depth-first collecting `(tap name, source wire, column kind)`, bare
at every depth, bounds-total over an invalid wire; `graph introspect --taps
<FILE|ID>` renders one row per declared tap (a tap-less blueprint is a
stderr-noted listing, exit 0). This closes the **recovery-only** discovery
gap: before #337, the only way to learn a blueprint's declared tap names was
provoking `bind_tap_plan`'s `UnknownTap` refusal roster (#333) — a real name
surfaced only as the side effect of naming a wrong one first. The refusal
roster itself is unchanged and remains the **recovery** half (what to do once
a tap name is already wrong); `--taps` is the **discovery** half (learning the
right names up front).
## See also
- [C26](c26-input-binding.md) — the input-side twin (`input_roles`); `check_root_roles_bound`, the mandatory-input counterpart
- [C23](c23-graph-compilation.md) — compilation/lowering and the deferred DCE cycle the tap design anticipates
+25 -6
View File
@@ -167,12 +167,31 @@ demand-gated, no tracking issue): measurement runs as sweep-family citizens
(report unification, campaign engine generic-over-`M`), until a concrete
family/campaign demand exists.
**Deferred.** ~24 refusal sites inside `aura-runner`'s single-run verb paths still
terminate the process (`std::process::exit`; the #283 tap-plan refusals added four
— typed as `TapPlanError` before the exit, so the eventual conversion is a
mechanical rewrap); their conversion to `RunnerError` propagation is tracked as
**#297** (the campaign path already refuses via `MemberFault`, never a process
exit).
**#297 (closed, 2026-07-26).** `aura-runner` no longer terminates the host
process anywhere: the 14 recon-enumerated `std::process::exit` sites inside
the single-run verb paths (`member.rs` ×8, `measure.rs` ×5, `translate.rs`
×1 — the "~24" this paragraph used to cite predates the #319 dual-grammar
retirement and never updated in lockstep) are now `RunnerError { exit_code,
message }` constructors threaded through a fallible chain; the two entry
points, `run_signal_r` and `run_measurement`, return a fallible outcome
carrying the report beside the per-invocation values the shell renders
(`Result<RunOutcome<_>, RunnerError>` since #309 widened the original
`(_, Vec<String>)` pair), and the CLI's `exec_blueprint_leg`
remaps via one shared `exit_on_runner_error` helper (the `dispatch_reproduce`
pattern). The one hole in "a sweep worker never process-exits" — `cost_knob`,
reachable from `run_blueprint_member` — is closed the same cycle: the
campaign worker maps its fault into the cell's own `MemberFault` channel
(per-cell isolation, this contract's stated campaign guarantee unchanged),
while the campaign trace-persist path — the only other production caller —
reports it through its existing string-error channel into the campaign
summary's exit. Refusal prose is byte-identical everywhere;
only who prints it changed, plus — per the adjudicated C14 partition — the
exit class of refusals whose fault is in the content of what argv named.
What remains printing in the assembly crate is the deliberate note/info
residuum — the fold/trace notes and the traces-persisted line
(`runner.rs`) and the stale-dylib warning (`project.rs`) — benign
diagnostics that print and never exit; migrating them needs a note-channel
design of its own.
## See also
- [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve
@@ -60,6 +60,11 @@ content id and are blanked for the identity id, exactly as `Composite.doc`
([C24](c24-blueprint-data.md)), so node docs move no id. Documents have no
identity projection — their pair is: absent `description` is byte-identical
to the field-less form (existing content ids stable), present participates.
A single `aura exec` run's #311 trace-directory handle honours this same
treatment (2026-07-27 audit fix): its digest hashes the blueprint's identity
projection, not `manifest.topology_hash` (a content id), so a
description-only blueprint edit mints no fresh directory for a bit-identical
run (`aura-runner::run_identity_digest`, [C18](c18-registry.md)).
**Forbids.** The engine evaluating description text — the gate is
deterministic string shape, never content judgement (no freetext logic
@@ -0,0 +1,85 @@
# C30 — Stability discipline: artifacts are stable, code is movable
**Guarantee.** The project's compatibility promise attaches to recorded
data, never to Rust API. Before 1.0 the workspace's library surface
promises nothing between commits; every cargo consumer either moves with
the engine checkout (path-dep node crates, staleness refused at the load
handshake) or pins a rev deliberately (external embeddings). The one
handshake guarding the ABI seam compares build identity, never a
hand-maintained version.
1. **No API stability before 1.0.** Signatures, module homes, crate
rosters, and trait shapes move freely, commit over commit — no
deprecation cycles, no compat shims, no semver bumps. The workspace
version (0.1.0) is not a communication channel; nothing downstream
reads it. What a change may *not* silently break is behaviour pinned
by the artifact plane (point 4).
2. **Two consumer classes, two mechanisms.**
- A **native node crate** (scaffolded by `aura nodes new`) consumes
the engine as a cargo *path* dependency into the local engine
checkout — the scaffolder emits exactly this
(`crates/aura-cli/src/scaffold.rs`, template `CARGO_TOML`). Node
crate and engine move together: cargo rebuilds the crate when
aura-core changes, and a dylib from any other engine state is
refused at load (point 3), never trusted.
- An **external embedding** (a World program in its own repo)
consumes the workspace crates as a cargo git dependency pinned to a
rev. The pin names the engine; the embedding's own committed
`Cargo.lock` freezes the rest of the graph — dependencies the
engine itself takes as git-branch references (e.g. the data-server)
resolve at lock time, so rev + lockfile together are the
reproducible-build contract (fieldtest finding, 2026-07-26).
Updating either is a deliberate act that accepts whatever moved,
with the design ledger and commit bodies as the changelog.
3. **The load handshake refuses non-identical builds.** The cdylib
handshake already refuses a rustc mismatch (`RUSTC_VERSION`, emitted
by build.rs). The aura-core stamp must be equally honest: a
build-identity fingerprint derived mechanically from aura-core's
sources, so a node dylib loads only against the aura-core it was
built from — *source-level* identity, an accepted limit: the
consuming build's lockfile and feature selection stay outside the
stamp, so the seam refuses staleness of aura-core itself, never the
full link graph. A hand-maintained version that never moves disarms the
refusal (the pre-C30 state: `CORE_VERSION` frozen at 0.1.0 matched
every stale dylib); a hand-bumped one re-arms it only between bumps —
an implicit ABI promise this contract refuses to make. *(Re-armed by
#348: a source-derived fingerprint replaced the crate-version stamp.)*
4. **The artifact plane is the stable tier.** What was already law,
stated as the positive promise: registered artifacts are never
retroactively invalidated ([C29](c29-self-description.md)); document
format changes carry `format_version` with readers keeping old forms
readable ([C18](c18-registry.md)); deploy artifacts are frozen
([C13](c13-hot-reload-frozen-deploy.md), invariant 8). Compatibility
work happens here: a change that would orphan recorded artifacts is a
design decision for the ledger, never a refactor.
5. **The erosion guard.** No doc, README, or scaffold may describe any
Rust surface as "stable API" while this contract stands. Inviting a
consumer means naming the point-2 mechanism (path or pin), never
promising stillness. Amending this contract is the only way to
promise more.
**Rationale.** #296 recorded the tension: an invited external consumer
(#295 deliberately enlarges the public library surface) versus a working
discipline of behaviour-preserving reshaping ([C28](c28-stratification.md)
relocated whole rosters twice in one pass). The resolution promises where
the project can keep promises — the recorded-data plane, whose guarantees
already exist — and refuses to promise where it cannot. The empirical
ground at decision time: the one real downstream project is data-only (an
`Aura.toml` directory with no `Cargo.toml`, consuming the released binary
and documents), so the only cargo consumers are engine-scaffolded node
crates (path-dep by construction) and the engine's own fieldtest
fixtures. A stability promise to nobody would cost every cycle and inform
no one; hardening toward 1.0 stays available at any time, while stability
promised early is practically irrevocable.
## See also
- [C13](c13-hot-reload-frozen-deploy.md) — frozen deploy artifacts (the promise at the deploy edge)
- [C16](c16-engine-project-split.md) — engine/project split; reuse is cargo-native (invariant 9)
- [C18](c18-registry.md) — the run registry and `format_version` (the artifact plane's mechanics)
- [C28](c28-stratification.md) — the movable crate ladder this contract licenses
- [C29](c29-self-description.md) — registered artifacts never retroactively invalidated
- Decision record: #296 (options weighed, skeptic findings); re-arming the handshake: #348
+22 -20
View File
@@ -33,15 +33,15 @@ A strategy's primary, backtestable DAG output: one signed, bounded `f64 ∈ [-1,
### blueprint
**Avoid:** —
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while `run` uses it as-is.
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while a plain `exec` uses it as-is (#319).
### blueprint label
**Avoid:** —
A registry-level name pointing at a registered blueprint's content id (`graph register --name <label>`, #317) — a mutable, latest-wins pointer over the immutable content-addressed store, not a second identity: re-registering under an existing label *repoints* it (the earlier content id stays reachable by its own id, never invalidated). `graph introspect --registered` lists every label with its content-id prefix and root `doc` line — the discovery surface a `use (op)` reference by name resolves against.
A registry-level name pointing at a registered blueprint's content id (`graph register --name <label>`, #317) — a mutable, latest-wins pointer over the immutable content-addressed store, not a second identity: re-registering under an existing label *repoints* it (the earlier content id stays reachable by its own id, never invalidated). `graph introspect --registered` lists every label with its content-id prefix and root `doc` line — the discovery surface a `use (op)` reference by name resolves against. Orthogonal to the composite's own **render name** (the `name` field carried in the blueprint bytes, op-settable via `{"op":"name",...}`, #331; defaults to `"graph"` if omitted): the render name *prefixes* the run's trace directory (`traces/<name>-<id8>/`, #311 — the run's own identity digest completes it) and is a `use`-splice's default instance identifier, while the label is an explicit, separately-set store pointer.
### bootstrap
**Avoid:** —
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null, the `aura mc` R path, and since 0108 the `std::monte_carlo` stage's `stage bootstrap` annotation; context (construction vs annotation) disambiguates.
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null and, since 0108, the `std::monte_carlo` stage's `stage bootstrap` annotation (the campaign executor's one R-bootstrap path, #319); context (construction vs annotation) disambiguates.
### bot
**Avoid:** —
@@ -53,11 +53,11 @@ A downstream consumer node, never part of the strategy: the signal-quality side
### campaign document
**Avoid:** experiment doc, campaign file
The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored, validated, and executed headless (`aura campaign validate|introspect|register|show|run|runs``show` prints a registered document's canonical bytes back, #300), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura campaign run` (v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. `std::sweep`'s own selection group (`metric`+`select`) is optional, all-or-nothing, and permitted only as the pipeline's terminal stage when omitted (a selection-free sweep, #210).
The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored headless (`aura campaign validate|introspect|register|runs|show``show` prints a registered document's canonical bytes back, #300), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura exec` (#319; v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. `std::sweep`'s own selection group (`metric`+`select`) is optional, all-or-nothing, and permitted only as the pipeline's terminal stage when omitted (a selection-free sweep, #210).
### campaign run
**Avoid:** campaign execution record, realized campaign
One execution of a `campaign document` (`aura campaign run <file|content id>`; a file is register-then-run sugar — the content id is the canonical address). Realized once per (strategy, instrument, window) cell in doc order and recorded as a thin `CampaignRunRecord` line in the registry's `campaign_runs.jsonl` — linking the per-stage family ids, gate survivor ordinals, `stage bootstrap` annotations, and (campaign-scope, per strategy × window) `generalizations` entries (`generalization` with `worst_case`/`sign_agreement`/`per_instrument`, plus `winners` params and `missing` instruments on shortfall) over untouched family records, run-counted per campaign id. A cell whose gate leaves no survivors records its realized prefix and the run exits 0: a null result is a valid research result. Deterministic from doc + stores + data (C1): deflation and bootstrap nulls seed from the doc's `seed`. When the document requests `persist_taps`, the record's sparse `trace_name` (`"{campaign8}-{run}"`) points at the TraceStore family holding each nominee cell's persisted taps (0109/#201). NB the stdout emit wraps each record as `{"campaign_run": …}`; the stored `campaign_runs.jsonl` line is the bare record.
One execution of a `campaign document` (`aura exec <file|content id>`, #319; a file is register-then-run sugar — the content id is the canonical address). Realized once per (strategy, instrument, window) cell in doc order and recorded as a thin `CampaignRunRecord` line in the registry's `campaign_runs.jsonl` — linking the per-stage family ids, gate survivor ordinals, `stage bootstrap` annotations, and (campaign-scope, per strategy × window) `generalizations` entries (`generalization` with `worst_case`/`sign_agreement`/`per_instrument`, plus `winners` params and `missing` instruments on shortfall) over untouched family records, run-counted per campaign id. A cell whose gate leaves no survivors records its realized prefix and the run exits 0: a null result is a valid research result. Deterministic from doc + stores + data (C1): deflation and bootstrap nulls seed from the doc's `seed`. When the document requests `persist_taps`, the record's sparse `trace_name` (`"{campaign8}-{run}"`) points at the TraceStore family holding each nominee cell's persisted taps (0109/#201). NB the stdout emit wraps each record as `{"campaign_run": …}`; the stored `campaign_runs.jsonl` line is the bare record.
### cdylib
**Avoid:** —
@@ -89,7 +89,7 @@ A composable downstream **C9 graph of cost nodes**, in **R**, that **approximate
### cross-instrument generalization
**Avoid:** cross-symbol pooling, pooled generalization
The validation read that grades how consistently one *brought* candidate holds across a set of instruments, scored on its weakest one — the across-instrument axis of the anti-false-discovery discipline. Realised by `aura generalize` and, since 0108 (#200), by the `std::generalize` process stage at campaign scope (per (strategy, window) over the cells' nominees across instruments, recorded in the `campaign run`'s `generalizations`); an aggregator (a recomputable family score), never a selector that picks a winner.
The validation read that grades how consistently one *brought* candidate holds across a set of instruments, scored on its weakest one — the across-instrument axis of the anti-false-discovery discipline. Realised by the `std::generalize` process stage at campaign scope (since 0108, #200; per (strategy, window) over the cells' nominees across instruments, recorded in `CampaignRunRecord.generalizations`, executed via `aura exec`, #319) — the standalone `aura generalize` verb that used to persist its own per-instrument family is retired (#319: `FamilyKind::CrossInstrument` is now dead); an aggregator (a recomputable family score), never a selector that picks a winner.
### cycle
**Avoid:** —
@@ -203,7 +203,7 @@ The recorded chance that a deflated sweep winner is noise rather than edge — a
### plateau selection
**Avoid:** plateau-over-peak (as a noun)
A selection objective that argmaxes the neighbourhood-smoothed metric surface (mean or worst-case) rather than the bare in-sample peak, preferring a robust parameter plateau to a lucky spike. Opt-in via `--select plateau:mean|plateau:worst`; the default selection stays a bare argmax.
A selection objective that argmaxes the neighbourhood-smoothed metric surface (mean or worst-case) rather than the bare in-sample peak, preferring a robust parameter plateau to a lucky spike. Opt-in via a process document's `std::sweep`/`std::walk_forward` stage `"select": "plateau:mean"|"plateau:worst"` field; the default selection stays a bare argmax.
### playground
**Avoid:** —
@@ -245,13 +245,15 @@ A node that converts a finer stream to a coarser bar stream, emitting a complete
**Avoid:** risk section
One entry of a campaign document's structural risk axis (`risk`): a
serializable protective-stop regime (variants `vol{length,k}` — per-cycle —
and `vol_tf{period_minutes,length,k}` — per completed time bucket) the matrix
runs every cell under, so cells differ by execution discipline, never by
signal. Absent or empty = one implicit default regime; the regime's stop
defines the risk unit R — in `vol{length,k}` (stop = k·√EMA(Δ², length) over
m1 cycles) `length` only smooths the vol estimator while `k` scales the stop
distance, so the stop's timescale stays one cycle (`vol_tf` sets the stop's
timescale via `period_minutes`).
`vol_tf{period_minutes,length,k}` — per completed time bucket — and
`fixed{distance}` — a constant price-unit distance, binding the shipped
`FixedStop` composite) the matrix runs every cell under, so cells differ by
execution discipline, never by signal. Absent or empty = one implicit default
regime; the regime's stop defines the risk unit R — in `vol{length,k}` (stop =
k·√EMA(Δ², length) over m1 cycles) `length` only smooths the vol estimator
while `k` scales the stop distance, so the stop's timescale stays one cycle
(`vol_tf` sets the stop's timescale via `period_minutes`; `fixed` has no
timescale — the distance never adapts).
### run
**Avoid:** —
@@ -331,15 +333,15 @@ The harness's structural parameterization — which strategy, instrument(s), bro
### sweep
**Avoid:** param-sweep, parameter sweep
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`--list-axes`) is **required** — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (`--axis name=<one-value>`), there is no default. `aura sweep --axis` takes the `--list-axes`-printed, root-composite-wrapped name (e.g. `graph.fast.length`) — not the raw `param_space` name (`fast.length`) that `graph introspect --params` and a campaign document's axes use; the CLI strips exactly one leading wrapper segment (#210). The `aura sweep` CLI verb is now thin sugar over the `campaign document` path — its blueprint form (`<bp.json> --real`) translates to a generated, content-addressed campaign run through the one executor (#210); the built-in `--strategy` sweep surface was retired by #159 (its hard-wired harnesses removed) — sweep now runs from a blueprint + `--axis`, and a retired `--strategy` token falls to the generic usage error. A ganged pair contributes ONE axis.
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`graph introspect --params`) is **required** on a campaign document's `strategies[].axes` — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (one value in its `values` list), there is no default. One raw namespace, `<node>.<param>` (a splice path keeps its interior path), is the only axis name (#328): `graph introspect --params` and a campaign axis key are line-identical (open params bare, bound params with `default=`) — the older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`) is retired from the surface; naming it in a campaign document gets a did-you-mean toward the raw candidate, the one remaining intake seam (#319 retired the standalone `--axis`/`--list-axes` CLI flags along with the blueprint-form `aura sweep` verb, whose own `--strategy` built-in surface #159 had already retired). Realised as a campaign document's axes (#210), executed through the one `aura exec` executor (#319). A ganged pair contributes ONE axis.
### tap
**Avoid:** probe, monitor, scope (for the observation slot — "probe" is taken by the sweep-terminal `blueprint_axis_probe` sense; the #77 `Recorder``Probe` rename was retired, 2026-07-21)
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's via the record's `trace_name`, a `sweep`/`walkforward --trace` family's via the family handle the run prints (`aura chart <handle>`; its members are keyed `<cell>/<member>` in the chart) — or, equivalently, by the `--trace <NAME>` the user chose, when `NAME` uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing).
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's deterministic `trace_name` (`"{campaign8}-{run}"`, never a user-chosen name since #319) charts its whole family (`aura chart <handle>`; members keyed `<cell>/<member>` in the chart) — or, equivalently, a bare campaign id/name that uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing). A single run reports its own handle the same way, as `trace_name` on its stdout line (#309) — the run's render name plus its 8-hex **run identity** (`<render-name>-<id8>`, #311: a SHA-256 over the run's manifest with the two provenance keys removed — the aura binary's build sha and the project repo's HEAD/dirty marker — and `params`/`defaults` merged into one name-sorted sequence, since that partition records how a value was supplied, not what the run's effective parameterisation is — so two runs differing in any identity-bearing input land in two directories, and two runs with the same effective parameterisation, including one reached via a no-op `--override`, land in one), absent when the run recorded nothing. NB a `family id` is not a handle: it names one cell/stage record within a run, and cutting it down does not yield the run's handle.
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura run` constructs a recorder at each and persists the series through the trace store; a sweep leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura exec` constructs a recorder at each and persists the series through the trace store; a campaign member run leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura run` (`aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura run` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is emitted at finalize and stamped with the instant of the last contributing (warm) value — `first` alone pins the first contributing instant, and `min`/`max` deliberately do not carry the extremum's instant (ratified #335).
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura exec` (#319; `aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura exec` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is emitted at finalize and stamped with the instant of the last contributing (warm) value — `first` alone pins the first contributing instant, and `min`/`max` deliberately do not carry the extremum's instant (ratified #335).
### topology hash
**Avoid:** —
@@ -347,7 +349,7 @@ The `content id` of a run's signal blueprint in its run-record role: stamped int
### use (op)
**Avoid:** —
The construction op that splices a **registered** blueprint into the building graph as a nested `composite` under a chosen instance name (`{"op":"use","ref":{"content_id"|"name":…},"name":…,"bind":{…}}`, #317): the reference — a content id, a unique content-id prefix, or a `blueprint label` — resolves CLI-side, before the engine ever sees it; the fetched composite is C29-gated, spliced, and its resolution echoed on stderr (`aura: note:`, the existing benign marker). The emitted blueprint carries the splice inline as an ordinary nested composite — no reference or registry dependency survives into the artifact. An instance's open input roles become its `<instance>.<role>` in-ports, its output boundary fields its `<instance>.<field>` out-fields — the existing nested-composite param-prefix discipline extends unchanged (`graph.<instance>.<node>.<param>` on `sweep --list-axes`). Pairs with the `input` op: a pattern meant to be `use`d declares its formal parameters as open `input` roles, left unbound at `finish()` (#317's open-pattern amendment) — only running it standalone requires them bound.
The construction op that splices a **registered** blueprint into the building graph as a nested `composite` under a chosen instance name (`{"op":"use","ref":{"content_id"|"name":…},"name":…,"bind":{…}}`, #317): the reference — a content id, a unique content-id prefix, or a `blueprint label` — resolves CLI-side, before the engine ever sees it; the fetched composite is C29-gated, spliced, and its resolution echoed on stderr (`aura: note:`, the existing benign marker). The emitted blueprint carries the splice inline as an ordinary nested composite — no reference or registry dependency survives into the artifact. An instance's open input roles become its `<instance>.<role>` in-ports, its output boundary fields its `<instance>.<field>` out-fields — the existing nested-composite param-prefix discipline extends unchanged (`<instance>.<node>.<param>`, the raw axis form since #328). Pairs with the `input` op: a pattern meant to be `use`d declares its formal parameters as open `input` roles, left unbound at `finish()` (#317's open-pattern amendment) — only running it standalone requires them bound.
### veto
**Avoid:** risk-manager
@@ -355,7 +357,7 @@ The **optional** documented pre-trade-gate seam in the execution chain (`stop-ru
### walk-forward
**Avoid:** —
An orchestration axis: rolling in-sample optimize + out-of-sample test across moving windows, stitched into one out-of-sample verdict plus parameter stability. As with `sweep`, every open knob named by `--list-axes` is **required** on a loaded blueprint — a subset is refused with the missing knob named; pin one with a single-value axis, there is no default. The `aura walkforward <blueprint.json> --real <sym> --axis <name>=<csv> …` CLI verb is thin sugar over the `campaign document` path — translated to a generated campaign (`std::sweep → std::walk_forward`) run through the one executor (#210; blueprint-generic over arbitrary blueprints and axes since #220).
An orchestration axis: rolling in-sample optimize + out-of-sample test across moving windows, stitched into one out-of-sample verdict plus parameter stability. As with `sweep`, every open knob a campaign document's axes name is **required** — a subset is refused with the missing knob named; pin one with a single-value axis, there is no default. Realised as a campaign document's `std::sweep → std::walk_forward` process pipeline (#210; blueprint-generic over arbitrary blueprints and axes since #220), executed through the one `aura exec` executor (#319) — the standalone `aura walkforward` CLI verb is retired.
### World
**Avoid:** —
+14 -4
View File
@@ -91,6 +91,15 @@ deploy, spanning both halves.
> nodes new` scaffolder emits this line; a hand-rolled node crate adds it
> itself.
> **Compatibility across the seam
> ([C30](design/contracts/c30-stability-discipline.md)).** The node crate
> consumes `aura-core` as a *path* dependency into the engine checkout —
> the pair moves together, and cargo rebuilds the crate when the engine
> changes. No Rust API stability is promised before 1.0: an external
> embedding in its own repo instead pins the engine as a git dependency at
> a rev, and the load handshake refuses a dylib built against a different
> aura-core.
## Where reusable nodes live (three tiers)
Everything that plugs into the engine is fractally a `Node`. Reuse is plain
@@ -144,9 +153,10 @@ command sequences.
`ThirdCandleLong` node's `schema` + `eval` in it, against `aura-core`.
3. **Backtest:** an op-script wiring `ger40_lab_nodes::ThirdCandleLong` (§1,
`docs/authoring-guide.md`) is built into `blueprints/third-candle-long.json`
(`aura graph build`); `aura run blueprints/third-candle-long.json --real
GER40 --from 1704067200000 --to 1735689600000` (the window bounds are Unix
milliseconds — here 2024) → the strategy produces a broker-independent, unsized
(`aura graph build`); `aura exec blueprints/third-candle-long.json` (#319)
smoke-runs it over the synthetic stream — a real-data window over `GER40`
between two Unix-millisecond bounds is a one-cell campaign document (step
4) → the strategy produces a broker-independent, unsized
**bias stream** (one signed, bounded `f64 ∈ [-1,+1]` per cycle — sign = direction,
magnitude = optional conviction). A downstream **risk-based executor** (stop-rule →
position-management, in **R**, the protective stop defining 1R) turns the bias into
@@ -165,7 +175,7 @@ command sequences.
Monte-Carlo over seeds, or a structural matrix like "these 10 strategies ×
these 3 instruments × {fixed-stop, vol-stop} risk-executors" — instruments
× windows × strategy × param axes × process, headless-authorable and
registered under `blueprints/`. `aura campaign run <content-id>`
registered under `blueprints/`. `aura exec <content-id>` (#319)
bootstraps the matrix, fans the disjoint sims over all cores (C1), and
writes the comparable runs to `runs/`.
5. **Compose:** "combine it with `momentum-filter` as a weighted sum" → Claude
@@ -0,0 +1,86 @@
### exec (declared taps recorded by default)
{"manifest":{"commit":"7c27c070f466ee2bd92299a538590fb7667ce279","params":[],"defaults":[["fast.length",{"I64":2}],["slow.length",{"I64":4}],["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"70dbe25349fb19ca776b8652899f561fa3cac6b02d207fba004020f41aca4e2f","project":{"commit":"046448a03eba9c441dfe62aec7d7e5751e989d41-dirty"}},"metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"sqn_normalized":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}},"trace_name":"ft308-crossover-fb9f8933"}
### exec --tap spread=record --tap fast_ma=mean
{"manifest":{"commit":"7c27c070f466ee2bd92299a538590fb7667ce279","params":[],"defaults":[["fast.length",{"I64":2}],["slow.length",{"I64":4}],["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"70dbe25349fb19ca776b8652899f561fa3cac6b02d207fba004020f41aca4e2f","project":{"commit":"046448a03eba9c441dfe62aec7d7e5751e989d41-dirty"}},"metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"sqn_normalized":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}},"trace_name":"ft308-crossover-fb9f8933"}
### exec --tap equity=record (a campaign-only persist_tap name)
aura: the tap plan names 'equity', but the blueprint declares no such tap — declared taps: spread, fast_ma
EXIT=2
### exec --tap spread=bogusfold
aura: unknown fold 'bogusfold' — available: count, first, last, max, mean, min, record, sum
EXIT=2
### chart <handle> -> bytes of HTML
72246
### chart <render-name> (no handle suffix)
aura: no recorded trace is named `ft308-crossover`
trace handles beginning with `ft308-crossover`:
ft308-crossover-fb9f8933
pass one of these handles; a single run prints its own as `trace_name` on stdout, a campaign run as `campaign_run.trace_name`, one per run
EXIT=1
### chart <handle> --tap nosuch
aura: run has no tap named 'nosuch' (available: spread, fast_ma)
EXIT=1
### the recorded bias series behind the reported bias_sign_flips
{"manifest":{"commit":"7c27c070f466ee2bd92299a538590fb7667ce279","params":[],"defaults":[["fast.length",{"I64":2}],["slow.length",{"I64":4}],["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"28de04458f5c8d8e17cb0cbb18fb92acaae03edcdea11b8022912771f05fb59f","project":{"commit":"046448a03eba9c441dfe62aec7d7e5751e989d41-dirty"}},"metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"sqn_normalized":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}},"trace_name":"ft308-crosscheck-5ab4a35b"}
{"tap":"bias_out","kinds":["F64"],"ts":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18],"columns":[[0.0026000000000001577,0.0036000000000000476,0.0045999999999999375,0.0035999999999996035,-0.00039999999999995595,-0.0038999999999997925,-0.0049000000000001265,-0.004249999999999865,-0.0020999999999999908,0.001000000000000334,0.003349999999999742,0.0042999999999997485,0.0041999999999999815,0.003350000000000186,0.001700000000000479]]}
### pips scale with exposure, R does not
bias.scale=0.5 total_pips=0.34185000000002036 max_drawdown=0.11139999999998655 flips=2 expectancy_r=1.2710005136982836
bias.scale=0.25 total_pips=0.6837000000000407 max_drawdown=0.2227999999999731 flips=2 expectancy_r=1.2710005136982836
bias.scale=0.1 total_pips=1.709250000000102 max_drawdown=0.5569999999999329 flips=2 expectancy_r=1.2710005136982836
### measure ic <handle> (the second consumer of the printed handle)
{"run":"ft308-crosscheck-5ab4a35b","signal_tap":"bias_out","price_tap":"bias_out","horizon":1,"permutations":1000,"seed":0,"n_pairs":14,"information_coefficient":-0.0846365699719229,"overfit_probability":0.6403596403596403}
### measure ic <render-name> (compare with chart's answer above)
aura: reading run 'ft308-crosscheck' traces failed: no recorded run 'ft308-crosscheck' under runs/traces
EXIT=1
### campaign validate
campaign document valid (intrinsic): 1 strategy(ies), 3 axes (1 points), 1 instrument(s), 1 window(s), 1 regime(s) (default) — 1 cell(s)
campaign document valid (referential): all references resolve, axes are in the param space
campaign document valid (executable): pipeline shape and static guards pass
### exec <campaign> — GER40, September 2024 M1
aura: campaign run 0 recorded: 1 cells{"family_id":"b847b626-0-GER40-w0-r0-s0-0","report":{"manifest":{"commit":"7c27c070f466ee2bd92299a538590fb7667ce279","params":[["fast.length",{"I64":2}],["slow.length",{"I64":4}],["bias.scale",{"F64":0.5}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[],"window":[1725148800000000000,1727740799999000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"GER40","topology_hash":"28de04458f5c8d8e17cb0cbb18fb92acaae03edcdea11b8022912771f05fb59f","project":{"commit":"046448a03eba9c441dfe62aec7d7e5751e989d41-dirty"}},"metrics":{"total_pips":79.35000000003492,"max_drawdown":862.4750000000331,"bias_sign_flips":7253,"r":{"expectancy_r":0.004223442353595694,"n_trades":6604,"win_rate":0.3625075711689885,"avg_win_r":1.1572796179231308,"avg_loss_r":-0.6514574327802453,"profit_factor":1.0101696380483514,"max_r_drawdown":115.32309384711957,"n_open_at_end":1,"sqn":0.2550819145543203,"sqn_normalized":0.0313889028363593,"net_expectancy_r":0.004223442353595694,"conviction_terciles_r":[0.015934011871841916,0.0029310851320770884,-0.006190038693224412]}}}}
aura: traces persisted: b847b626-0 (1 tap(s) x 1 cell(s))
wall=0.06 s maxrss=26712 KB
### exec --trace (retired by #319)
error: unexpected argument '--trace' found
tip: to pass '--trace' as a value, use '-- --trace'
Usage: aura exec <TARGET>
For more information, try '--help'.
EXIT=2
### following the tip clap offers: exec -- --trace
aura: '--trace' is neither a readable .json file nor a 64-hex content id
EXIT=1
### a multi-column strategy on the synthetic stream — the refusal's own remedy
aura: strategy "hl_channel" consumes columns beyond close (high, low) — synthetic data generates a close series only; run with --real <SYMBOL>
EXIT=2
### following THAT remedy: exec --real GER40
error: unexpected argument '--real' found
tip: a similar argument exists: '--release'
Usage: aura exec --release <TARGET>
For more information, try '--help'.
EXIT=2
### is --real or --trace anywhere in the help tree?
(scan complete — no output above means neither flag exists)
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Fieldtest #308 (single-run folded delivery) — reproduce the whole session.
#
# usage: ft308_0_run_all.sh <aura-binary> <scratch-dir>
#
# Scaffolds a throwaway project in <scratch-dir>, builds the two op-scripts,
# runs the single-run leg, the chart/measure handle consumers, the real-data
# campaign leg, and the retired-flag probe. Every command is one a downstream
# consumer could type; nothing here reads the engine's source.
set -eu
A="$(realpath "$1")"; SCRATCH="$2"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EX="$HERE/../../crates/aura-cli/examples"
rm -rf "$SCRATCH"; mkdir -p "$SCRATCH"; cd "$SCRATCH"
"$A" new lab >/dev/null; cd lab
say() { printf '\n### %s\n' "$*"; }
# --- 1. the tapped crossover: record, handle, chart -------------------------
"$A" graph build < "$HERE/ft308_1_tapped_crossover.ops.json" > blueprints/crossover.json
say "exec (declared taps recorded by default)"
"$A" exec blueprints/crossover.json
say "exec --tap spread=record --tap fast_ma=mean"
"$A" exec blueprints/crossover.json --tap spread=record --tap fast_ma=mean
say "exec --tap equity=record (a campaign-only persist_tap name)"
"$A" exec blueprints/crossover.json --tap equity=record || echo "EXIT=$?"
say "exec --tap spread=bogusfold"
"$A" exec blueprints/crossover.json --tap spread=bogusfold || echo "EXIT=$?"
H=$("$A" exec blueprints/crossover.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["trace_name"])')
say "chart <handle> -> bytes of HTML"
"$A" chart "$H" | wc -c
say "chart <render-name> (no handle suffix)"
"$A" chart "${H%-*}" >/dev/null || echo "EXIT=$?"
say "chart <handle> --tap nosuch"
"$A" chart "$H" --tap nosuch >/dev/null || echo "EXIT=$?"
# --- 2. cross-checking the reported summary numbers -------------------------
"$A" graph build < "$HERE/ft308_2_bias_crosscheck.ops.json" > blueprints/crosscheck.json
say "the recorded bias series behind the reported bias_sign_flips"
"$A" exec blueprints/crosscheck.json --tap bias_out=record
cat runs/traces/*crosscheck*/bias_out.json; echo
say "pips scale with exposure, R does not"
for s in 0.5 0.25 0.1; do
printf 'bias.scale=%s ' "$s"
"$A" exec blueprints/crosscheck.json --override "bias.scale=$s" \
| python3 -c 'import sys,json;d=json.load(sys.stdin)["metrics"];print("total_pips=%r max_drawdown=%r flips=%r expectancy_r=%r"%(d["total_pips"],d["max_drawdown"],d["bias_sign_flips"],d["r"]["expectancy_r"]))'
done
say "measure ic <handle> (the second consumer of the printed handle)"
CH=$("$A" exec blueprints/crosscheck.json --tap bias_out=record | python3 -c 'import sys,json;print(json.load(sys.stdin)["trace_name"])')
"$A" measure ic "$CH" --signal bias_out --price bias_out || echo "EXIT=$?"
say "measure ic <render-name> (compare with chart's answer above)"
"$A" measure ic "${CH%-*}" --signal bias_out --price bias_out || echo "EXIT=$?"
# --- 3. the only long run a consumer can reach: a one-cell campaign ---------
BPID=$("$A" graph register blueprints/crosscheck.json | sed -E 's/registered blueprint ([0-9a-f]+).*/\1/')
cp "$HERE/ft308_3_process_sweep_only.json" .
PID=$("$A" process register ft308_3_process_sweep_only.json | sed -E 's/registered process ([0-9a-f]+).*/\1/')
sed -e "s/PROCESS_ID/$PID/" -e "s/28de04458f5c8d8e17cb0cbb18fb92acaae03edcdea11b8022912771f05fb59f/$BPID/" \
"$HERE/ft308_4_campaign_ger40_month.json" > campaign.json
say "campaign validate"
"$A" campaign validate campaign.json
say "exec <campaign> — GER40, September 2024 M1"
/usr/bin/time -f 'wall=%e s maxrss=%M KB' "$A" exec campaign.json | head -1
# --- 4. the retired flags ---------------------------------------------------
bash "$HERE/ft308_5_retired_flag_probe.sh" "$A" blueprints/crossover.json "$EX/r_channel.json"
@@ -0,0 +1,16 @@
[
{"op": "name", "name": "ft308-crossover"},
{"op": "doc", "text": "fast/slow SMA spread latched into a bias, with the raw spread tapped"},
{"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": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "tap", "from": "sub.value", "as": "spread"},
{"op": "tap", "from": "fast.value", "as": "fast_ma"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,15 @@
[
{"op": "name", "name": "ft308-crosscheck"},
{"op": "doc", "text": "SMA crossover whose emitted bias is tapped so the reported sign-flip count can be checked"},
{"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": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "tap", "from": "bias.bias", "as": "bias_out"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,9 @@
{
"format_version": 1,
"kind": "process",
"name": "ft308-sweep-only",
"description": "Selection-free single-stage sweep: the family itself is the result.",
"pipeline": [
{ "block": "std::sweep" }
]
}
@@ -0,0 +1,22 @@
{
"format_version": 1,
"kind": "campaign",
"name": "ft308-ger40-september-2024",
"seed": 0,
"data": {
"instruments": ["GER40"],
"windows": [ { "from_ms": 1725148800000, "to_ms": 1727740799999 } ]
},
"strategies": [
{
"ref": { "content_id": "28de04458f5c8d8e17cb0cbb18fb92acaae03edcdea11b8022912771f05fb59f" },
"axes": {
"fast.length": { "kind": "I64", "values": [2] },
"slow.length": { "kind": "I64", "values": [4] },
"bias.scale": { "kind": "F64", "values": [0.5] }
}
}
],
"process": { "ref": { "content_id": "PROCESS_ID" } },
"presentation": { "persist_taps": ["equity"], "emit": ["family_table"] }
}
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Fieldtest #308, axis 3 — the retired flags.
#
# A consumer who read pre-#319 documentation still reaches for `--trace` and
# `--real`. This probe records what the shipped binary answers, and what
# happens when the consumer follows that answer.
#
# usage: ft308_5_retired_flag_probe.sh <aura-binary> <blueprint.json> <r_channel.json>
set -u
A="$1"; BP="$2"; CHANNEL="$3"
say() { printf '\n### %s\n' "$*"; }
say "exec --trace (retired by #319)"
"$A" exec "$BP" --trace; echo "EXIT=$?"
say "following the tip clap offers: exec -- --trace"
"$A" exec -- --trace; echo "EXIT=$?"
say "a multi-column strategy on the synthetic stream — the refusal's own remedy"
"$A" exec "$CHANNEL"; echo "EXIT=$?"
say "following THAT remedy: exec --real GER40"
"$A" exec "$CHANNEL" --real GER40; echo "EXIT=$?"
say "is --real or --trace anywhere in the help tree?"
for c in "" exec graph runs reproduce chart measure data campaign process; do
# shellcheck disable=SC2086
"$A" $c --help 2>&1 | grep -E -- '--real|--trace' && echo " ^ found under: aura $c"
done
echo "(scan complete — no output above means neither flag exists)"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Fieldtest #308, axis 1 — does the shipped record change across the cycle?
#
# Ten single-run invocations whose stdout is compared byte for byte between a
# binary built from the pre-cycle commit and one built from the cycle's head.
# Run it once per binary, from inside a scratch project holding
# blueprints/ft308_crossover.json (built from ft308_1_tapped_crossover.ops.json):
#
# ft308_6_battery.sh <aura-binary> <output-file>
# diff <(sed -E 's/"commit":"[0-9a-f]{40}(-dirty)?"/"commit":"SHA"/g' before.txt) \
# <(sed -E 's/"commit":"[0-9a-f]{40}(-dirty)?"/"commit":"SHA"/g' after.txt)
#
# The manifest commit sha is the one field that must move across a rebuild;
# everything else is the cycle's "every reported number is unchanged" claim.
A="$1"; OUT="$2"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BP="$HERE/../../crates/aura-cli/examples"
: > "$OUT"
run() {
echo "### $*" >> "$OUT"
"$A" "$@" >> "$OUT" 2>&1
echo "EXIT=$?" >> "$OUT"
}
run exec "$BP/r_sma.json"
run exec "$BP/r_breakout.json"
run exec "$BP/r_channel.json"
run exec "$BP/r_meanrev.json"
run exec "$BP/r_sma.json" --override fast.length=3
run exec "$BP/r_sma.json" --override fast.length=3 --override slow.length=9
run exec blueprints/ft308_crossover.json
run exec blueprints/ft308_crossover.json --tap spread=record --tap fast_ma=mean
run exec blueprints/ft308_crossover.json --tap spread=mean
run exec blueprints/ft308_crossover.json --override bias.scale=0.25 --tap spread=record
@@ -0,0 +1,47 @@
# Fieldtest #308 — before/after evidence (2add97e vs 7c27c07)
Two release binaries built from the two commits, two freshly scaffolded
projects, the same ten invocations (ft308_6_battery.sh). Compared after
blanking the manifest commit sha — the only field that legitimately
moves across a rebuild.
## single-run leg — ten invocations, every reported number
```
$ diff before.norm after.norm
IDENTICAL after blanking commit shas
```
Covered: r_sma / r_breakout / r_channel / r_meanrev as shipped, two
--override forms, the tapped crossover plain and with three tap plans.
The trace handles (trace_name) match too: ft308-crossover-fb9f8933 and
ft308-crossover-5d723d93 on both sides.
## single-run leg — cost (r_sma.json, 18 synthetic cycles, 3 reps each)
```
before rep1 wall=0.00 s maxrss=8164 KB
before rep2 wall=0.00 s maxrss=8376 KB
before rep3 wall=0.00 s maxrss=8300 KB
after rep1 wall=0.00 s maxrss=8312 KB
after rep2 wall=0.00 s maxrss=8180 KB
after rep3 wall=0.00 s maxrss=8184 KB
```
## campaign leg — GER40 September 2024 M1, one cell, 6604 trades
```
before rep1 wall=0.06 s maxrss=26940 KB
before rep2 wall=0.06 s maxrss=26988 KB
after rep1 wall=0.05 s maxrss=27452 KB
after rep2 wall=0.06 s maxrss=26988 KB
```
## artifacts
```
$ cmp before/.../equity.json after/.../equity.json # 930 KB, ~30k rows
equity.json BYTE-IDENTICAL
$ diff <campaign stdout before> <campaign stdout after> # commit sha blanked
CAMPAIGN RECORD IDENTICAL
```
@@ -0,0 +1,204 @@
# cycle-309 (trace handle) — field-test transcript
Source-blind run against the public interface only: the built binary's help
and refusals, `docs/authoring-guide.md`, `docs/glossary.md`, `docs/design/`,
and `git log`. Nothing under `crates/` was opened.
Binary: `target/debug/aura`, built from this working tree with `cargo build`
(HEAD `9f87e5a`, manifests stamp `9f87e5a…-dirty` — the untracked corpus).
Replay: `./c309_0_run_all.sh` (rebuilds the lab from scratch; every step's
stdout/stderr lands beside it as `c309_*.out` / `c309_*.err`).
Project: `c309lab/`, scaffolded by `aura new`. Its `runs/` is git-ignored by
the scaffold's own `.gitignore`, so the ~15 MB of recorded traces stay out of
the commit; the authored fixtures (op-scripts, process + campaign documents)
and the captured outputs are the corpus.
---
## Axis 1 — the echo, on both legs
### Strategy leg — `c309_1_spread_strategy.ops.json`
An SMA-crossover bias with two declared taps (`spread` on `sub.value`,
`signal` on `bias.bias`) and a `name` op (`c309_spread`).
```
$ aura graph introspect --taps ../c309_1_spread_strategy.bp.json
spread sub.value F64
signal bias.bias F64
$ aura exec ../c309_1_spread_strategy.bp.json
{"manifest":{…},"metrics":{…},"trace_name":"c309_spread"} # exit 0, stderr empty
$ find runs/traces/c309_spread
runs/traces/c309_spread/{index.json,spread.json,signal.json}
```
The handle is the last key of the same stdout object as the report; nothing
is printed on stderr, and the report keys are unchanged.
### Measurement leg — `c309_3_measurement.ops.json`
No `expose`, no bias output: a `Scale(1.0)` price passthrough tapped as
`price` and the fastslow spread tapped as `signal`.
```
$ aura exec ../c309_3_measurement.bp.json
{"manifest":{…,"broker":"measurement",…},"taps":["price","signal"],"trace_name":"c309_measure"}
```
Same field, same position. (First attempt failed at build time on a port
name I guessed — `aura: op 4 (feed): node "px" has no input port "input"`;
`graph introspect --node Scale` names it `signal`. Clean diagnostic, one
retry.)
## Axis 2 — the chain, without knowing the name beforehand
```
$ H=$(jq -r '.trace_name' < ../c309_1_run.out) # -> c309_spread
$ aura chart "$H" # exit 0, HTML on stdout
series: c309_spread, spread, signal
$ aura chart "$H" --tap spread # exit 0, series: c309_spread, spread
$ M=$(jq -r '.trace_name' < ../c309_3_run.out) # -> c309_measure
$ aura measure ic "$M" --signal signal --price price
{"run":"c309_measure","signal_tap":"signal","price_tap":"price","horizon":1,
"permutations":1000,"seed":0,"n_pairs":13,"information_coefficient":0.148…,
"overfit_probability":0.300…}
```
Both destinations accept the printed value verbatim. No `ls runs/traces/`
anywhere in the loop.
Fold-only variant (`--tap spread=mean`) still reports `trace_name`; the
one-row trace charts, and the previous run's leftover `signal.json` in the
same directory is *not* resurrected — the rerun rewrites `index.json`
(`"taps":["spread"]`) and the chart shows `spread` alone.
Unnamed blueprint (`name` op stripped): handle `graph`, `aura chart graph`
exits 0.
## Axis 3 — the two refusal arms
### Arm A — not a family id
```
$ aura chart no_such_run # exit 1
aura: no recorded run or family 'no_such_run' under runs/traces (a single run
prints its handle as `trace_name` on stdout; a campaign run prints it as
`campaign_run.trace_name`, one per run — check that value for a typo. A trace
is produced by `aura exec --tap <NODE.FIELD>=<FOLD>` on a blueprint or a
campaign's `presentation.persist_taps` section, not by naming a handle here)
```
Claim-by-claim:
| claim | verdict |
|---|---|
| a single run prints `trace_name` on stdout | TRUE (both legs, above) |
| a campaign run prints `campaign_run.trace_name`, one per run | TRUE (two runs → `c364e8c3-0`, `c364e8c3-1`) |
| a campaign's `presentation.persist_taps` produces a trace | TRUE |
| `aura exec --tap <NODE.FIELD>=<FOLD>` produces a trace | **FALSE** (see below) |
```
$ aura exec ../c309_1_spread_strategy.bp.json --tap sub.value=record # exit 2
aura: the tap plan names 'sub.value', but the blueprint declares no such tap — declared taps: spread, signal
$ aura exec ../c309_1_spread_strategy.bp.json --tap spread=mean # exit 0
```
The flag takes the declared tap NAME, as `exec --help` says
(`--tap <TAP=FOLD>`); and it is not needed at all — the two runs at the top
of this transcript recorded their traces with no flag.
### Arm B — a family id out of the families listing
```
$ aura runs families
{"family_id":"c364e8c3-0-GER40-w0-r0-s0-0","kind":"Sweep","members":4}
$ aura chart c364e8c3-0-GER40-w0-r0-s0-0 # exit 1
aura: no recorded run or family 'c364e8c3-0-GER40-w0-r0-s0-0' under runs/traces — that
looks like a family id, not a trace handle. Recorded trace handles for campaign
'c364e8c3': c364e8c3-0.
aura: Did you mean: aura chart c364e8c3-0
$ aura chart c364e8c3-0 # exit 0
series: c364e8c3-0, 88a221aa-GER40-w0/fast.length_2__slow.length_8__… (4 members)
```
After a second run of the same campaign, the same arm lists both handles and
suggests nothing:
```
$ aura chart c364e8c3-0-GER40-w0-r0-s0-1 # exit 1
… Recorded trace handles for campaign 'c364e8c3': c364e8c3-0, c364e8c3-1.
```
That second case is the trap the arm exists for: the family id's *trailing*
segment is the run (`-s0-1` → run 1), so the naive cut to the leading pair
gives `c364e8c3-0` — a real, chartable handle holding a **different run's**
data. The arm refuses instead of guessing.
Zero-handle sub-case (a campaign run with `persist_taps: []`, and a
campaign head never recorded at all) — identical prose for both:
```
$ aura chart 94355db6-0-GER40-w0-r0-s0-0 # this campaign recorded nothing
$ aura chart deadbeef-0-GER40-w0-r0-s0-0 # no such campaign at all
aura: no recorded run or family '…' under runs/traces — that looks like a family id,
not a trace handle. The handle is the `trace_name` the campaign run printed; this id's
second segment counts strategies while the handle's counts runs, so cutting the id
down is not a way to derive it.
```
Following that advice: the run in question printed
`campaign_run` **without** a `trace_name` key (`has("trace_name") == false`).
The pointer is true but empty — the arm knows the campaign recorded nothing
and does not say so.
## Axis 4 — the tap-free case
```
$ aura exec blueprints/signal.json # the scaffold's own signal
{"manifest":{…},"metrics":{…}} # exit 0
$ jq 'has("trace_name")' < … false
```
No key, no note, no stderr. Same for the campaign leg with
`persist_taps: []` (`campaign_run` has no `trace_name` key) and for the
stored `campaign_runs.jsonl` record. Absent-when-nothing is consistent
across all three surfaces.
```
$ aura exec blueprints/signal.json --tap spread=record # exit 2
aura: the tap plan names 'spread', but the blueprint declares no such tap — declared taps:
$ aura graph introspect --taps blueprints/signal.json # exit 0
aura: note: blueprints/signal.json declares no taps
```
The refusal's roster runs dry mid-sentence where the introspect verb says it
plainly.
## Beyond the arms — `measure ic` on a handle it will not take
```
$ aura measure ic c364e8c3-0 --signal equity --price r_equity # exit 1
aura: reading run 'c364e8c3-0' traces failed: no recorded run 'c364e8c3-0' under runs/traces
```
`runs/traces/c364e8c3-0/` exists — `aura chart c364e8c3-0` renders it, and
the member path underneath is accepted:
```
$ aura measure ic 'c364e8c3-0/88a221aa-GER40-w0/fast.length_2__slow.length_8__stop_length_3__stop_k_2' \
--signal equity --price r_equity # exit 0
{"run":"c364e8c3-0/88a221aa-…","n_pairs":24455,"information_coefficient":0.00796…,…}
```
And `measure ic`'s own not-found refusal carries no route back to where a
handle comes from, unlike `chart`'s:
```
$ aura measure ic no_such_run --signal signal --price price # exit 1
aura: reading run 'no_such_run' traces failed: no recorded run 'no_such_run' under runs/traces
```
(Its wrong-tap refusal, by contrast, is exemplary:
`aura: run 'c309_measure' has no tap 'close' (taps: ["price", "signal"])`.)
@@ -0,0 +1,8 @@
count — number of warm rows (any kind; i64 row); one row at the last warm ts
first — first warm value, at its own timestamp (any kind; kind-preserving row)
last — last warm value, at its own timestamp (any kind; kind-preserving row)
max — maximum of the series (f64 taps; f64 row); one row at the last warm ts
mean — arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts
min — minimum of the series (f64 taps; f64 row); one row at the last warm ts
record — persist the full series, lossless, at constant memory (any kind)
sum — sum of the series (f64 taps; f64 row); one row at the last warm ts
@@ -0,0 +1 @@
created project "c309lab" (data-only; attach native nodes later with `aura nodes new`)
@@ -0,0 +1,39 @@
[c309_0_vocabulary] exit=0 -- aura graph introspect --vocabulary
[c309_0_folds] exit=0 -- aura graph introspect --folds
[c309_6_data_ger40] exit=0 -- aura data info GER40
[c309_4_tapfree] exit=0 -- aura exec blueprints/signal.json
[c309_12_tapfree_flag] exit=2 -- aura exec blueprints/signal.json --tap spread=record
[c309_12_tapfree_introspect] exit=0 -- aura graph introspect --taps blueprints/signal.json
[c309_1_taps] exit=0 -- aura graph introspect --taps ../c309_1_spread_strategy.bp.json
[c309_1_run] exit=0 -- aura exec ../c309_1_spread_strategy.bp.json
[chain] single-run handle from stdout: c309_spread
[c309_10b_chart_taponly] exit=0 -- aura chart c309_spread --tap spread
[c309_3_run] exit=0 -- aura exec ../c309_3_measurement.bp.json
[chain] measurement-leg handle from stdout: c309_measure
[c309_3_ic] exit=0 -- aura measure ic c309_measure --signal signal --price price
[c309_5c_tap_name] exit=0 -- aura exec ../c309_1_spread_strategy.bp.json --tap spread=mean
[c309_11_run] exit=0 -- aura exec ../c309_11_unnamed.bp.json
[c309_11_chart] exit=0 -- aura chart graph
[c309_5a_notfound] exit=1 -- aura chart no_such_run
[c309_5b_tap_nodefield] exit=2 -- aura exec ../c309_1_spread_strategy.bp.json --tap sub.value=record
[c309_10a_chart_tapname] exit=1 -- aura chart spread
[c309_6_process_register] exit=0 -- aura process register ../c309_6_process_sweep.json
[c309_6_strategy_register] exit=0 -- aura graph register ../c309_1_spread_strategy.bp.json
[c309_6_campaign_validate] exit=0 -- aura campaign validate ../c309_6_campaign.json
[c309_6_campaign_run] exit=0 -- aura exec ../c309_6_campaign.json
[c309_7_families] exit=0 -- aura runs families
[chain] family id from the listing: c364e8c3-0-GER40-w0-r0-s0-0
[c309_7_chart_familyid] exit=1 -- aura chart c364e8c3-0-GER40-w0-r0-s0-0
[c309_7_chart_suggested] exit=0 -- aura chart c364e8c3-0
[c309_8_campaign_run1] exit=0 -- aura exec ../c309_6_campaign.json
[c309_8_families] exit=0 -- aura runs families
[c309_8_chart_two_handles] exit=1 -- aura chart c364e8c3-0-GER40-w0-r0-s0-1
[c309_9_run] exit=0 -- aura exec ../c309_9_campaign_notaps.json
[c309_9_stored_record] exit=0 -- aura campaign runs 94355db6a5063d6765e04e9cd8ac9d3dc268c915962b6900ef0c9f1d3d420fd4
[c309_9_chart_notaps] exit=1 -- aura chart 94355db6-0-GER40-w0-r0-s0-0
[c309_9_chart_unknown] exit=1 -- aura chart deadbeef-0-GER40-w0-r0-s0-0
[c309_10c_measure_notfound] exit=1 -- aura measure ic no_such_run --signal signal --price price
[c309_13a_measure_badtap] exit=1 -- aura measure ic c309_measure --signal signal --price close
[c309_13b_measure_campaign] exit=1 -- aura measure ic c364e8c3-0 --signal equity --price r_equity
[c309_13c_measure_member] exit=0 -- aura measure ic c364e8c3-0/88a221aa-GER40-w0/fast.length_2__slow.length_8__stop_length_3__stop_k_2 --signal equity --price r_equity
done — outputs in /home/brummel/dev/aura/.claude/worktrees/issue-309-trace-handle/fieldtests/cycle-309-trace-handle
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Replay of the cycle-309 (trace handle) field test.
#
# Drives the `aura` binary as a downstream consumer: scaffolds a project,
# authors op-scripts, runs them, chains the printed handle into `chart` and
# `measure ic`, and provokes both arms of the chart not-found refusal.
#
# cd fieldtests/cycle-309-trace-handle && ./c309_0_run_all.sh
#
# Every command's stdout/stderr lands beside this script as c309_*.out /
# c309_*.err. Nothing here asserts; the transcript records what happened.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
AURA="$ROOT/target/debug/aura"
[ -x "$AURA" ] || { echo "build first: (cd $ROOT && cargo build)" >&2; exit 1; }
cd "$HERE"
rm -rf c309lab
"$AURA" new c309lab > c309_0_new.out 2>&1
cd c309lab
run() { # run <slug> <args...>
local slug="$1"; shift
"$AURA" "$@" > "../${slug}.out" 2> "../${slug}.err"
echo "[$slug] exit=$? -- aura $*"
}
# --- discovery -------------------------------------------------------------
run c309_0_vocabulary graph introspect --vocabulary
run c309_0_folds graph introspect --folds
run c309_6_data_ger40 data info GER40
# --- axis 4: the tap-free single run --------------------------------------
run c309_4_tapfree exec blueprints/signal.json
run c309_12_tapfree_flag exec blueprints/signal.json --tap spread=record
run c309_12_tapfree_introspect graph introspect --taps blueprints/signal.json
# --- axis 1a: the echo on the strategy leg --------------------------------
"$AURA" graph build < ../c309_1_spread_strategy.ops.json \
> ../c309_1_spread_strategy.bp.json 2> ../c309_1_build.err
run c309_1_taps graph introspect --taps ../c309_1_spread_strategy.bp.json
run c309_1_run exec ../c309_1_spread_strategy.bp.json
# --- axis 2a: chain the printed handle into chart -------------------------
H=$(jq -r '.trace_name' < ../c309_1_run.out)
echo "[chain] single-run handle from stdout: $H"
"$AURA" chart "$H" > /tmp/c309_chart.html 2> ../c309_2_chart.err
echo "[c309_2_chart] exit=$? series: $(grep -o 'name":"[^"]*"' /tmp/c309_chart.html | sort -u | tr '\n' ' ')" \
> ../c309_2_chart.summary.txt
run c309_10b_chart_taponly chart "$H" --tap spread
# --- axis 1b/2b: the measurement leg and `measure ic` ---------------------
"$AURA" graph build < ../c309_3_measurement.ops.json \
> ../c309_3_measurement.bp.json 2> ../c309_3_build.err
run c309_3_run exec ../c309_3_measurement.bp.json
M=$(jq -r '.trace_name' < ../c309_3_run.out)
echo "[chain] measurement-leg handle from stdout: $M"
run c309_3_ic measure ic "$M" --signal signal --price price
# --- the fold-only variant (a handle without a full series) ---------------
run c309_5c_tap_name exec ../c309_1_spread_strategy.bp.json --tap spread=mean
"$AURA" chart "$H" > /tmp/c309_chart2.html 2> ../c309_5d_chart_after_fold.err
echo "[c309_5d] exit=$? series: $(grep -o 'name":"[^"]*"' /tmp/c309_chart2.html | sort -u | tr '\n' ' ')" \
> ../c309_5d_chart_after_fold.summary.txt
# --- the default (unnamed) blueprint --------------------------------------
jq 'map(select(.op != "name"))' ../c309_1_spread_strategy.ops.json > ../c309_11_unnamed.ops.json
"$AURA" graph build < ../c309_11_unnamed.ops.json > ../c309_11_unnamed.bp.json 2>&1
run c309_11_run exec ../c309_11_unnamed.bp.json
run c309_11_chart chart "$(jq -r '.trace_name' < ../c309_11_run.out)"
# --- axis 3a: the not-found arm, and its advice taken literally -----------
run c309_5a_notfound chart no_such_run
run c309_5b_tap_nodefield exec ../c309_1_spread_strategy.bp.json --tap sub.value=record
run c309_10a_chart_tapname chart spread
# --- axis 3b: the family-id arm -------------------------------------------
run c309_6_process_register process register ../c309_6_process_sweep.json
run c309_6_strategy_register graph register ../c309_1_spread_strategy.bp.json
run c309_6_campaign_validate campaign validate ../c309_6_campaign.json
run c309_6_campaign_run exec ../c309_6_campaign.json
run c309_7_families runs families
FID=$(jq -r '.family_id' < ../c309_7_families.out | head -1)
echo "[chain] family id from the listing: $FID"
run c309_7_chart_familyid chart "$FID"
run c309_7_chart_suggested chart "$(tail -1 ../c309_6_campaign_run.out | jq -r '.campaign_run.trace_name')"
run c309_8_campaign_run1 exec ../c309_6_campaign.json # second run: two handles
run c309_8_families runs families
run c309_8_chart_two_handles chart "$(jq -r '.family_id' < ../c309_8_families.out | tail -1)"
jq '.name = "c309-spread-ger40-sweep-notaps" | .presentation.persist_taps = []' \
../c309_6_campaign.json > ../c309_9_campaign_notaps.json
run c309_9_run exec ../c309_9_campaign_notaps.json
CID=$(tail -1 ../c309_9_run.out | jq -r '.campaign_run.campaign')
run c309_9_stored_record campaign runs "$CID"
run c309_9_chart_notaps chart "${CID:0:8}-0-GER40-w0-r0-s0-0"
run c309_9_chart_unknown chart deadbeef-0-GER40-w0-r0-s0-0
# --- measure ic refusals and the campaign handle --------------------------
run c309_10c_measure_notfound measure ic no_such_run --signal signal --price price
run c309_13a_measure_badtap measure ic "$M" --signal signal --price close
CH=$(tail -1 ../c309_6_campaign_run.out | jq -r '.campaign_run.trace_name')
run c309_13b_measure_campaign measure ic "$CH" --signal equity --price r_equity
MEMBER=$(cd runs/traces/"$CH" && find . -mindepth 2 -maxdepth 2 -type d | head -1 | sed 's|^\./||')
run c309_13c_measure_member measure ic "$CH/$MEMBER" --signal equity --price r_equity
# --- trim: chart stdout is a self-contained HTML page (~70 KB each); keep a
# --- one-line series summary in the corpus instead of the regenerable blob.
cd "$HERE"
for f in c309_*.out; do
head -c 15 "$f" | grep -q '<!doctype html>' || continue
{ echo "# $(basename "$f" .out): HTML chart page, $(wc -c < "$f") bytes"
echo "# series: $(grep -o 'name":"[^"]*"' "$f" | sed 's/name":"//;s/"//' | sort -u | tr '\n' ' ')"
} > "${f%.out}.summary.txt"
: > "$f"
done
echo "done — outputs in $HERE"
@@ -0,0 +1,36 @@
Abs absolute value of the input series
Add elementwise sum of two input series
And logical AND of two boolean series
Bias clamps the strategy signal into the bias contract [-1,+1]
CarryCost cost-model node: cost accrued per held cycle (param carry_per_cycle)
Const constant-valued stream from a single param
ConstantCost cost-model node: fixed cost per trade (param cost_per_trade), charged at close
CostSum sums cost-model contributions into one cost-in-R stream
CumSum running cumulative sum of the input series
Delay emits the input from `lag` cycles ago — the explicit delay register (C9)
Div elementwise quotient of two input series
EMA exponential moving average over the input series
EqConst true where the input equals a constant param
FixedStop derives the protective stop that defines the risk unit R
Gt true where the first input exceeds the second
Latch level-sensitive set/reset register holding 1.0 when latched, 0.0 when reset
LinComb linear combination of its inputs with constant weights
LongOnly when enabled, clamps negative exposure to flat (long-only); otherwise passes exposure through
Max elementwise maximum of two input series
Min elementwise minimum of two input series
Mul elementwise product of two input series
PositionManagement turns bias plus protective stop into a managed position in R
Resample emits a completed bar once per resample boundary, never mid-bar
RollingMax rolling maximum over a fixed lookback window
RollingMin rolling minimum over a fixed lookback window
Scale input multiplied by a constant factor param
Select chooses between two inputs by a boolean selector
Session bars elapsed since the session open, from the configured open time and timezone
SessionFrankfurt bars elapsed since the Frankfurt session open (09:00 Europe/Berlin)
Sign sign of the input: -1, 0 or +1
Sizer size = risk_budget / stop_distance at the deploy edge; bias only gates firing, research runs flat-1R
SMA simple moving average over the input series
Sqrt square root of the input series
Sub elementwise difference of two input series
VolSlippageCost cost-model node: slippage proportional to volatility (slip_vol_mult × volatility input), charged at close
When emits the input only on cycles where the condition input is true
@@ -0,0 +1 @@
aura: no recorded run or family 'spread' under runs/traces (a single run prints its handle as `trace_name` on stdout; a campaign run prints it as `campaign_run.trace_name`, one per run — check that value for a typo. A trace is produced by `aura exec --tap <NODE.FIELD>=<FOLD>` on a blueprint or a campaign's `presentation.persist_taps` section, not by naming a handle here)

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