Compare commits

81 Commits

Author SHA1 Message Date
claude 4301d87e75 fieldtests: keep the proof for the current tree, drop the 51 spent corpora
A field test and a fixture are orthogonal artifacts. A fixture **pins** behaviour
for the future: something executes it, so a change that breaks it turns a test
red. A field test **proves** behaviour at one moment: a downstream consumer
reached the shipped surface from outside, and the green cycle records that. The
proof is complete when the cycle lands.

Everything in this corpus was the second kind. Nothing ever re-ran a driver,
re-read a transcript or re-validated a document here — so every file was
stale-by-design: it went out of date the moment the surface moved, and, having
no executor, it said nothing when it did.

Silent staleness is not the whole cost. What is written down is on the agenda of
anything that reads the tree. `--real`, retired two cycles ago, still occurred
130 times, and more than half of those sat in this corpus — not framed as
retired, but shown as working command lines in recorded output. #331 spent
~300k tokens making a surface class-complete that the next cycle deleted; the
agents found that surface where it was written down. 892 of the repository's
1236 tracked files were field-test evidence, so every tree-wide search paid a
threefold surcharge to reach the 344 live ones.

Removed: 51 directories, 883 files, from cycle-0006 (2026-06-04) through
milestone-safe-to-embed. Kept: `cycle-308-folded-delivery`, whose proof is the
one that still describes this tree.

Nothing is lost. `git log --grep='^fieldtest'` is the index — each subject
carries its own finding counts, which is where this repository's field-test
regression analysis came from, without opening a single corpus file — and
`git show <sha>:<path>` returns any of it verbatim. Deleting from the working
tree separates the two kinds of access that had to be separated: a human reaches
into history deliberately, an agent never does.

Going forward the corpus wants rotation depth 1: a cycle's field test clears the
previous one as it files its own, so the directory always holds exactly the proof
for the tree as it stands. That rule is not yet written into the field-test skill
— which still prescribes committing the fixtures — so it is owed there before the
next cycle refills the directory.

Suite 1652 passed / 0 failed (exit 0); clippy exit 0. No code reads this corpus:
the five prose citations were removed in the preceding commit.
2026-07-28 00:44:43 +02:00
claude a3cd6a5b68 docs, comments: nothing points into the fieldtest corpus
A field test is an empirical proof that the implementation was reproducible
from outside at one moment in time. Once that proof is recorded, the corpus is
spent — so nothing durable may cite it: a pointer into it either rots when the
corpus is cleared, or keeps a dead surface on the agenda of every agent that
greps the tree.

Five sites cited it. Each is replaced by the description it was standing in
for, never by a second pointer:

- `aura-cli/src/diag.rs` named a captured `.err` file for the pluralization
  bug. The sentence beside it already carried the finding; only the path went.
- `aura-engine/src/construction.rs` opened the acyclicity test with "Mirror
  <corpus path>", then described the two-cycle topology in full immediately
  below. The description was already adequate; the pointer was redundant.
- `c24-blueprint-data.md` cited a corpus blueprint as the worked example for
  the serialized value forms. It now cites `crates/aura-cli/examples/r_sma.json`
  — a shipped example carrying both an `{"I64":…}` and an `{"F64":…}` bind,
  which is what the passage is about.
- `authoring-guide.md` claimed its inline blueprint was byte-identical to a
  corpus file. The guide shows the document in full; the equality claim served
  nothing and was dropped for the property that matters — every command in the
  guide runs against exactly that document.
- `c24-blueprint-data.history.md` carried a corpus path inside an archived
  milestone note. The historical claim (a green end-to-end field test proved
  the story) is untouched; only the pointer is gone.

`.gitignore`'s `/fieldtests/*/target/` stays: it points at nothing, it keeps
4.2 GB of build artifacts out of the repository.

Suite 1652 passed / 0 failed (exit 0), including
`guide_worked_example_matches_the_scaffold_template`, the one test that reads a
file changed here.
2026-07-28 00:31:45 +02:00
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
claude eb2b0a132c docs(guide, glossary): pin each ArgKind's lexical canonical form
Fieldtest spec-gap ratified: Count is a plain positive decimal with no
sign and no leading zeros ('03' refuses) — and the per-kind asymmetry
against TimeOfDay's required zero-pad is deliberate, pinned with its
rationale: canonical is each domain's conventional notation,
fixed-width for wall-clock times, minimal for numbers.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

closes #330
2026-07-24 16:02:45 +02:00
648 changed files with 15165 additions and 34396 deletions
Generated
+3 -2
View File
@@ -160,6 +160,7 @@ dependencies = [
"aura-core", "aura-core",
"aura-engine", "aura-engine",
"aura-ingest", "aura-ingest",
"aura-market",
"aura-measurement", "aura-measurement",
"aura-registry", "aura-registry",
"aura-research", "aura-research",
@@ -167,6 +168,7 @@ dependencies = [
"aura-std", "aura-std",
"aura-strategy", "aura-strategy",
"aura-vocabulary", "aura-vocabulary",
"chrono-tz",
"clap", "clap",
"data-server", "data-server",
"libc", "libc",
@@ -192,6 +194,7 @@ dependencies = [
name = "aura-core" name = "aura-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono-tz",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -307,8 +310,6 @@ dependencies = [
"aura-backtest", "aura-backtest",
"aura-core", "aura-core",
"aura-strategy", "aura-strategy",
"chrono",
"chrono-tz",
] ]
[[package]] [[package]]
+34 -32
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 - **Blueprint** — a serialized signal graph as data: a param-generic
`price → bias` node graph. A blueprint file (`blueprint.json`) is the unit a `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*; - **Open vs. closed** — a blueprint with unbound (free) numeric knobs is *open*;
one with every knob bound is *closed*. Different verbs want different states one with every knob bound is *closed*. A plain `exec` wants closed; naming a
(see each verb below). knob as a campaign axis (or an `exec --override`) is what reopens it.
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid, - **Family** — the set of runs a campaign document's process pipeline produces
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted over a blueprint (a sweep grid, a Monte-Carlo seed set, a walk-forward window
in a content-addressed store and can be listed, ranked, and reproduced. sequence). Families are persisted in a content-addressed store and can be
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`), 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 bound with a comma-separated value list. A **gang** fuses several sibling
knobs into one axis (one value drives all members). 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**. `aura exec <target>` (#319) is the one executor verb: `<target>` is either a
Most drive a family of runs over it; `graph` renders its structure so a mis-wire loaded **blueprint** file (a single synthetic run) or a **campaign document**
is visible before a run. `walkforward`, `mc`, and `generalize` share (a `.json` file or its registered 64-hex content id — instruments × windows ×
`sweep`'s generic grammar — the blueprint positional plus `--real` and a strategy × param axes × process, see `docs/authoring-guide.md` §3). `graph`
repeatable `--axis <name>=<csv>` (`generalize` takes `--real <SYM1,SYM2,…>`, at renders a blueprint's structure so a mis-wire is visible before a run.
least two instruments, with a single value per axis) — see `--help`.
| Command | Purpose | | 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 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 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 sweep <bp.json> --axis <name>=<csv> [--axis …]` | Run a **grid family** over the named axes and persist it. | | `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 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 runs families` | List every persisted family (id, kind, member count). | | `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 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). | | `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 **Important contract — every open knob is mandatory on a campaign axis.** A
`walkforward`, the knobs enumerated by `--list-axes` are *all* required: you must campaign document's `strategies[].axes` must name every open knob
supply an `--axis` for each open knob. There is no default value — pin a knob you `graph introspect --params` lists: 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 don't want to vary with a single-value axis (`{"kind":"I64","values":[4]}`).
clean error naming the missing knob, not a silent default. 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`), Use `aura exec --help` for the exact flag grammar, `docs/authoring-guide.md`
naming (`--name`), and selection (`--select`) flags. for the campaign document shape (data window, naming, presentation/emit).
## Authoring & introspecting topology ## Authoring & introspecting topology
@@ -111,17 +110,20 @@ Each element is one op, tagged by `"op"`. Node params are bound with the **typed
``` ```
Piping this document into `aura graph build` emits a `blueprint.json` you can 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 ```sh
aura graph build < crossover.ops.json > crossover.bp.json 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`, The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior `gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
wire; `doc` declares the composite's one-line meaning, required at register — interior wire; `doc` declares the composite's one-line meaning, required at
C29; see the authoring guide). See register — C29; `use` splices a registered blueprint in as a nested composite,
by content id or label; `add` additionally takes an `args` object for
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
non-scalar construction consumed before `bind`; see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script `aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics. grammar in the design ledger for the full semantics.
@@ -1,16 +1,16 @@
{ {
"surface": "cli_fixed_cost", "surface": "cli_fixed_cost",
"metrics": { "metrics": {
"help_ms": 1.5046899999999999, "help_ms": 1.487171,
"run_ms": 3.63305 "run_ms": 3.376192
}, },
"fingerprint": "run_line_fnv=6bb0d796f760d140", "fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
"reps": 3, "reps": 3,
"host": { "host": {
"hostname": "Raki", "hostname": "Raki",
"nproc": 24 "nproc": 24
}, },
"profile": "release", "profile": "release",
"commit": "9c7f60b", "commit": "10570b7",
"date": "2026-07-23" "date": "2026-07-25"
} }
+7 -20
View File
@@ -95,31 +95,18 @@ fn blueprint_stems(dir: &Path) -> Vec<String> {
.unwrap_or_default() .unwrap_or_default()
} }
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the /// Seed one blueprint into the scratch store via `graph register` (#319: the
/// E2E `seed_blueprint` pattern) and return its content id. /// surviving registration surface) and return its content id.
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> { fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
let before = blueprint_stems(dir); let before = blueprint_stems(dir);
let (out, code) = run_in( let (out, code) = run_in(bin, dir, &["graph", "register", file, "--name", name])?;
bin,
dir,
&[
"sweep",
file,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
name,
],
)?;
if code != Some(0) { if code != Some(0) {
return Err(format!("seed sweep failed ({code:?}): {out}")); return Err(format!("seed register failed ({code:?}): {out}"));
} }
blueprint_stems(dir) blueprint_stems(dir)
.into_iter() .into_iter()
.find(|s| !before.contains(s)) .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> { 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> { fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?; 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) { if timed.exit != Some(0) {
return Err(format!( return Err(format!(
"campaign run exited {:?}\nstdout: {}\nstderr: {}", "aura exec exited {:?}\nstdout: {}\nstderr: {}",
timed.exit, timed.stdout, timed.stderr 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 //! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
//! computed) and a minimal data-only project run whose fingerprint is the //! computed) and a minimal data-only project exec whose fingerprint is the
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per //! FNV-1a hash of the exec's single stdout JSON line. Fresh scratch project per
//! repetition, so store counters start identical. //! repetition, so store counters start identical.
use super::{median, RepOutcome}; use super::{median, RepOutcome};
@@ -11,7 +11,7 @@ use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
use std::time::Instant; 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 /// 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 /// (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 /// 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 run_walls = Vec::new();
let mut first_line = None; let mut first_line = None;
for _ in 0..RUN_SAMPLES { 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) { 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); run_walls.push(timed.wall_s * 1000.0);
if first_line.is_none() { if first_line.is_none() {
@@ -70,7 +70,7 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
.stdout .stdout
.lines() .lines()
.next() .next()
.ok_or("aura run must print its JSON record line")? .ok_or("aura exec must print its JSON record line")?
.to_string(), .to_string(),
); );
} }
+2 -2
View File
@@ -105,7 +105,7 @@ mod tests {
/// `run_reps` composed with the real campaign sweep surface — a fresh /// `run_reps` composed with the real campaign sweep surface — a fresh
/// scratch project, a freshly seeded blueprint, and a genuine /// 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 /// in-process shortcut). Protects that two independently built-and-run
/// scratch campaigns agree on the winner-ordinal fingerprint extracted /// scratch campaigns agree on the winner-ordinal fingerprint extracted
/// from the child's stdout — C1's determinism carried through the /// 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 /// `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 --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 /// Protects that two independent scratch runs agree on the run-line FNV
/// fingerprint extracted from the child's stdout, and that the metrics /// fingerprint extracted from the child's stdout, and that the metrics
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs. /// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
+9
View File
@@ -65,6 +65,15 @@ zip = "2"
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module), # (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below. # so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
aura-composites = { path = "../aura-composites" } aura-composites = { path = "../aura-composites" }
# aura-market: the shell's own use is test-only (#271's identity-bridge test,
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
# `Session::configured` as the Rust-authored twin of the args op-script path)
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
aura-market = { path = "../aura-market" }
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
# `Session::configured` — test-only, pinned to the same version every other
# crate's chrono-tz entry uses.
chrono-tz = { version = "0.10", default-features = false }
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures # aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
# (mod ic_tests in main.rs) — a test-only edge, not a production one. # (mod ic_tests in main.rs) — a test-only edge, not a production one.
aura-analysis = { path = "../aura-analysis" } aura-analysis = { path = "../aura-analysis" }
+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, //! a realized run-set (#198). The execution *semantics* (preflight, cell loop,
//! stage sequencing, selection, registry writes) live in `aura-campaign`; the //! stage sequencing, selection, registry writes) live in `aura-campaign`; the
//! [`MemberRunner`](aura_campaign::MemberRunner) implementation over the shipped loaded-blueprint machinery //! [`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_registry::CampaignRunRecord;
use aura_research::{ use aura_research::{
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign, 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; use aura_runner::axes::is_content_id;
@@ -108,27 +109,17 @@ struct CampaignRunLine<'a> {
campaign_run: &'a CampaignRunRecord, campaign_run: &'a CampaignRunRecord,
} }
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated /// `aura exec <target>` (campaign legs): resolve, gate, execute, emit. Every `Err`
/// 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`
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok` /// 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 /// 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( pub(crate) fn run_campaign(
target: &str, target: &str,
env: &Env, env: &Env,
parallel_instruments: NonZeroUsize, parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<usize, String> { ) -> Result<usize, String> {
// Project gate FIRST: nothing (not even the file-sugar registration) // Project gate FIRST: nothing (not even the file-sugar registration)
// touches a store outside a project. // 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 /// 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 one campaign executor path from a resolved content id onward: fetch
/// the stored canonical bytes by id (so file addressing and id addressing /// the stored canonical bytes by id (so file addressing and id addressing
/// produce the same realization by construction) and re-run the intrinsic /// produce the same realization by construction) and re-run the intrinsic
/// tier on them, execute, and emit per `presentation`. Shared by /// tier on them, execute, and emit. Called by `run_campaign`.
/// `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).
pub(crate) fn run_campaign_by_id( pub(crate) fn run_campaign_by_id(
campaign_id: &str, campaign_id: &str,
env: &Env, env: &Env,
presentation: RunPresentation,
parallel_instruments: NonZeroUsize, parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<usize, String> { ) -> Result<usize, String> {
let run = run_campaign_returning(campaign_id, env, parallel_instruments)?; let run = run_campaign_returning(campaign_id, env, parallel_instruments, overrides)?;
present_campaign(run, presentation, env) present_campaign(run, env)
} }
/// The one campaign executor path from a resolved content id up to (not /// 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 /// outcome bundled with the context the presenter needs. Shared by
/// `run_campaign_by_id` (which then presents) and a forthcoming dissolved-verb /// `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, /// 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( pub(crate) fn run_campaign_returning(
campaign_id: &str, campaign_id: &str,
env: &Env, env: &Env,
parallel_instruments: NonZeroUsize, parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<CampaignRun, String> { ) -> Result<CampaignRun, String> {
let registry = env.registry(); let registry = env.registry();
let campaign_text = registry let campaign_text = registry
.get_campaign(campaign_id) .get_campaign(campaign_id)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?; .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))?; .map_err(|e| doc_error_prose("stored campaign document", &e))?;
let faults = validate_campaign(&campaign); let faults = validate_campaign(&campaign);
if !faults.is_empty() { 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). // Referential gate: zero faults or refuse (the campaign-validate seam).
let resolve = |t: &str| env.resolve(t); let resolve = |t: &str| env.resolve(t);
let ref_faults = registry let ref_faults = registry
@@ -295,15 +314,11 @@ pub(crate) fn run_campaign_returning(
Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() }) Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() })
} }
/// Emit an executed campaign's results per `presentation`: the zero-survivor /// Emit an executed campaign's results: the zero-survivor stderr notes, the
/// stderr notes, the emit-gated family/selection lines, the always-on record /// emit-gated family/selection lines, the always-on record line, then trace
/// line (Full only), then trace persistence. Split out of `run_campaign_by_id` /// persistence. Split out of `run_campaign_by_id` so a forthcoming caller
/// so the dissolved-verb sugar can consume the outcome without this stdout tail. /// could consume the outcome without this stdout tail.
fn present_campaign( fn present_campaign(run: CampaignRun, env: &Env) -> Result<usize, String> {
run: CampaignRun,
presentation: RunPresentation,
env: &Env,
) -> Result<usize, String> {
let CampaignRun { outcome, campaign, strategies, server } = run; let CampaignRun { outcome, campaign, strategies, server } = run;
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid // 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_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report"); let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
for cell_out in &outcome.cells { 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 { if emit_family {
for fam in &cell_out.families { for fam in &cell_out.families {
for report in &fam.reports { for report in &fam.reports {
@@ -381,23 +422,21 @@ fn present_campaign(
} }
} }
} }
if presentation == RunPresentation::Full { println!(
println!( "{}",
"{}", serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record }) .expect("campaign run record serializes")
.expect("campaign run record serializes") );
); eprintln!(
eprintln!( "aura: campaign run {} recorded: {} cells{}",
"aura: campaign run {} recorded: {} cells{}", outcome.record.run,
outcome.record.run, outcome.record.cells.len(),
outcome.record.cells.len(), if failed == 0 {
if failed == 0 { String::new()
String::new() } else {
} else { format!(", {failed} failed ({})", fail_labels.join(", "))
format!(", {failed} failed ({})", fail_labels.join(", ")) }
} );
);
}
// Trace persistence runs LAST, after the always-on record line (0109): // Trace persistence runs LAST, after the always-on record line (0109):
// stdout stays data-pure (the record line is the wire contract) and the // stdout stays data-pure (the record line is the wire contract) and the
@@ -417,6 +456,13 @@ fn present_campaign(
Ok(failed) 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 /// #272: the fault-kind label the failed-cell summary line names — the same
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl` /// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug /// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
+70 -11
View File
@@ -19,11 +19,9 @@ macro_rules! warning {
pub(crate) use {note, warning}; pub(crate) use {note, warning};
/// The zero-trade note's message text (#313), pluralization-aware — the /// The zero-trade note's message text (#313), pluralization-aware — a field
/// milestone fieldtest tripped over "all 1 walk-forward windows" /// test caught the ungrammatical "all 1 walk-forward windows" on a run whose
/// (captured: `fieldtests/milestone-stderr-honesty/captured/ /// window count was one. Pure over the count so the wording is unit-pinned.
/// example2_singular.err`). Pure over the count so the wording is
/// unit-pinned.
fn zero_trade_note_text(n_windows: usize) -> String { fn zero_trade_note_text(n_windows: usize) -> String {
if n_windows == 1 { if n_windows == 1 {
"the single walk-forward window recorded zero trades".to_string() "the single walk-forward window recorded zero trades".to_string()
@@ -32,11 +30,11 @@ fn zero_trade_note_text(n_windows: usize) -> String {
} }
} }
/// The #313 zero-trade note, shared by both walk-forward paths (the /// The #313 zero-trade note (#319 Task 5: migrated onto the campaign
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in /// walk-forward leg in `campaign_run.rs`, the surviving executor path) so
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one /// the wording AND the condition live in exactly one place. Takes the
/// place. Takes the per-window trade counts; a no-op unless there is at /// per-window trade counts; a no-op unless there is at least one window and
/// least one window and every one of them traded zero times. /// every one of them traded zero times.
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) { pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
let n_windows = window_trades.len(); let n_windows = window_trades.len();
if n_windows > 0 && window_trades.all(|n| n == 0) { if n_windows > 0 && window_trades.all(|n| n == 0) {
@@ -44,9 +42,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)] #[cfg(test)]
mod tests { mod tests {
use super::zero_trade_note_text; use super::{zero_trade_cell_note_text, zero_trade_note_text};
#[test] #[test]
/// #278 fieldtest friction: the single-window form must read as /// #278 fieldtest friction: the single-window form must read as
@@ -62,4 +105,20 @@ mod tests {
"all 3 walk-forward windows recorded zero trades" "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"
);
}
} }
File diff suppressed because it is too large Load Diff
+1186 -2052
View File
File diff suppressed because it is too large Load Diff
+45 -30
View File
@@ -65,7 +65,7 @@ impl DocIntrospectCmd {
/// Parse `--parallel-instruments` in domain terms: clap's built-in /// Parse `--parallel-instruments` in domain terms: clap's built-in
/// `NonZeroUsize` parser would leak the Rust type name ("number would be /// `NonZeroUsize` parser would leak the Rust type name ("number would be
/// zero for non-zero type") at exactly the moment a user needs guidance. /// 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(|| { 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 \ "must be a whole number of at least 1 — it bounds how many distinct \
instruments are resident in parallel" 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") format!("data.windows[{index}]: from_ms must be earlier than to_ms")
} }
DocFault::BadRegime { index } => { 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 } => { DocFault::BadCost { index } => {
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0") format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
@@ -294,7 +294,7 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
/// caller's existing not-found prose applies unchanged. `Err`: two or more /// caller's existing not-found prose applies unchanged. `Err`: two or more
/// candidates share the prefix — refused by name, listing every candidate, /// candidates share the prefix — refused by name, listing every candidate,
/// rather than guessed. /// rather than guessed.
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> { pub(crate) fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect(); let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
match matches.as_slice() { match matches.as_slice() {
[] => Ok(None), [] => Ok(None),
@@ -411,19 +411,6 @@ pub enum CampaignSub {
Introspect(DocIntrospectCmd), Introspect(DocIntrospectCmd),
/// Register a valid campaign document into the store under the runs root. /// Register a valid campaign document into the store under the runs root.
Register { file: PathBuf }, 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 /// List stored campaign realizations, or dump one campaign's records
/// (the bare store lines, not the run-emit wrapper). /// (the bare store lines, not the run-emit wrapper).
Runs { campaign: Option<String>, run: Option<usize> }, Runs { campaign: Option<String>, run: Option<usize> },
@@ -462,8 +449,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
RefFault::StrategyUnloadable { id, error } => { RefFault::StrategyUnloadable { id, error } => {
format!("strategy {id} cannot be loaded: {error}") format!("strategy {id} cannot be loaded: {error}")
} }
RefFault::AxisNotInParamSpace { strategy, axis } => { RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
format!("strategy {strategy}: axis \"{axis}\" is not in the param space") 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 } => { RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind") 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(()) 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) { 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 { let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env), CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => { CampaignSub::Introspect(i) => {
@@ -557,7 +539,6 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
CampaignSub::Register { file } => register_campaign(file, env), CampaignSub::Register { file } => register_campaign(file, env),
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env), CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
CampaignSub::Show { id } => show_campaign(id, env), CampaignSub::Show { id } => show_campaign(id, env),
CampaignSub::Run { .. } => unreachable!("handled above"),
}; };
if let Err(m) = result { if let Err(m) = result {
eprintln!("aura: {m}"); eprintln!("aura: {m}");
@@ -761,6 +742,7 @@ mod tests {
ref_fault_prose(&RefFault::AxisNotInParamSpace { ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(), strategy: "9f3a".into(),
axis: "nope".into(), axis: "nope".into(),
raw_candidate: None,
}), }),
ref_fault_prose(&RefFault::AxisKindMismatch { ref_fault_prose(&RefFault::AxisKindMismatch {
strategy: "9f3a".into(), 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] #[test]
/// #194 (the prefix trap): a doc ref that carries the display `content:` /// #194 (the prefix trap): a doc ref that carries the display `content:`
/// prefix (a copy-paste from register/introspect output) is bare-only in /// 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 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. 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 - Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream
params bound; bound values are defaults — any `--axis` may override them — the starter is closed; all params bound; bound values are defaults —
(#246)) `--override NODE.PARAM=VALUE` may override one for this run (#246))
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8` - Campaign: `aura exec <campaign.json>` executes a registered campaign
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes) 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, - Native nodes: when the project needs its first project-specific node,
`aura nodes new <name>` scaffolds a node crate beside this project and `aura nodes new <name>` scaffolds a node crate beside this project and
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`). 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 unit R, and quality metrics are R-based. Entry signals become held state
via the signal-side latch/edge-pulse idiom (see via the signal-side latch/edge-pulse idiom (see
`aura graph introspect --vocabulary`). `aura graph introspect --vocabulary`).
- Data plane: the research verbs are sugar over registered process/campaign - Data plane: process/campaign documents are authored directly with
documents — author them directly with `aura process` / `aura campaign`, `aura process` / `aura campaign`, growing one from a bare `{}` via
growing one from a bare `{}` via `aura campaign introspect --unwired`. `aura campaign introspect --unwired`.
"#; "#;
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__` /// 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"); let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("demo-lab")); assert!(claude.contains("demo-lab"));
// The CLAUDE.md sweep quickstart targets the closed starter itself // The CLAUDE.md quickstart targets the closed starter itself through
// with the rendered blueprint-name axis prefix (#246: a bound param // the surviving surface (#319): exec for both document classes, the
// is a default an axis overrides). // #246 override residue, and raw-namespace axis discovery (#328).
assert!(claude.contains("blueprints/signal.json")); 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 /// #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. //! handled cleanly, never a panic.
use std::io::Read; use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio}; 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 /// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`). /// crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura"); const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// A fresh, unique working directory so the spawned `aura sweep` writes its fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`). let text = format!(
fn temp_cwd(name: &str) -> std::path::PathBuf { "{}{}",
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}")); String::from_utf8_lossy(&out.stdout),
let _ = std::fs::remove_dir_all(&dir); String::from_utf8_lossy(&out.stderr)
std::fs::create_dir_all(&dir).expect("create temp cwd"); );
dir (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 /// Property: a family-emitting command whose stdout reader closes early exits on
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken /// 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 /// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
/// lines via `println!`; when the reader closes stdout after the first line, the /// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at /// surviving surface for what a bare `aura sweep` demo grid used to stream);
/// startup, so that write returns EPIPE and `println!` panics rather than the /// when the reader closes stdout after the first line, the next write hits a
/// process terminating quietly — exactly the behaviour a CLI must not have. This /// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
/// is the contract: any aura subcommand can be piped into `| head` / `| less` / /// 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. /// 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] #[test]
fn family_emitting_command_survives_early_reader_close() { 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) let mut child = Command::new(BIN)
.arg("sweep") .args(["exec", "broken-pipe.campaign.json"])
.current_dir(&cwd) .current_dir(&dir)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.expect("spawn aura sweep"); .expect("spawn aura exec");
// Read just enough to guarantee the writer is mid-stream, then drop the read // 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!` // 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() { if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr); let _ = err.read_to_string(&mut stderr);
} }
let status = child.wait().expect("reap aura sweep"); let status = child.wait().expect("reap aura exec");
let _ = std::fs::remove_dir_all(&cwd);
// The defect: stderr carries the broken-pipe panic. // The defect: stderr carries the broken-pipe panic.
assert!( 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 //! 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 //! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and —
//! and drive the `tests/fixtures/demo-project` fixture through the real //! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`,
//! `aura` binary (#223). //! `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 //! **Per-test project directories, no shared store.** [`fresh_project`] mints
//! a unique tempdir per test, wired to the shared, once-built fixture crate //! 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 //! no two tests ever race on the same `runs/` store — libtest's default
//! thread parallelism applies without a serializing lock (#250). //! thread parallelism applies without a serializing lock (#250).
//! //!
//! Each of the three test binaries compiles this file as its own `mod //! Each consuming test binary compiles this file as its own `mod common`, so
//! common`, so an item unused by one binary trips that binary's `-D //! an item unused by one binary trips that binary's `-D warnings` `dead_code`
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below //! lint; see the `#[allow(dead_code)]` markers below rather than deleting a
//! rather than deleting a helper just because one binary doesn't need it. //! helper just because one binary doesn't need it.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -182,7 +184,7 @@ impl Drop for ScratchGuard {
/// the two no-window `generalize` E2E tests get a real, tiny, hostless /// the two no-window `generalize` E2E tests get a real, tiny, hostless
/// archive instead of streaming the 6.6 GB host archive (#250). /// archive instead of streaming the 6.6 GB host archive (#250).
#[allow(dead_code)] #[allow(dead_code)]
mod synthetic_data { pub mod synthetic_data {
use std::io::Write; use std::io::Write;
use std::path::Path; use std::path::Path;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+109 -32
View File
@@ -1,7 +1,7 @@
//! End-to-end pins for the self-description surfaces (#315, #323): the help //! End-to-end pins for the self-description surfaces (#315, #323, #319): the
//! opens with the two-layer concept, the sugar verbs name the document shape //! help opens with the two-layer concept, `exec` names both document classes
//! they desugar to, every introspection roster carries per-entry meanings, //! it executes, every introspection roster carries per-entry meanings, and
//! and `graph build --help` carries the op-list reference. Driven over the //! `graph build --help` carries the op-list reference. Driven over the
//! built binary — the zero-setup surface a reader without repo access gets. //! built binary — the zero-setup surface a reader without repo access gets.
use std::process::Command; 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() { fn top_level_help_opens_with_the_two_layer_concept() {
let (out, err, ok) = run(&["--help"]); let (out, err, ok) = run(&["--help"]);
assert!(ok, "aura --help failed: {err}"); 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("directly authorable"), "names the document data plane: {out}");
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}"); assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}"); assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
@@ -34,30 +37,22 @@ fn top_level_help_opens_with_the_two_layer_concept() {
assert!(out.contains("aura chart"), "names the trace consumers: {out}"); assert!(out.contains("aura chart"), "names the trace consumers: {out}");
} }
/// #315: each document-bridged sugar verb's long help names the process /// #319: `exec --help` names both document classes it accepts — the sugar
/// shape it desugars to and points at the document layer. `run` (not /// verbs' retirement leaves `exec` as the one executor over a campaign
/// document-bridged) points at the canonical document-first form instead. /// document (file or content id) or a signal blueprint (single run).
#[test] #[test]
fn sugar_verbs_name_their_document_shape() { fn exec_help_names_both_document_classes() {
for (verb, needle) in [ let (out, err, ok) = run(&["exec", "--help"]);
("sweep", "std::sweep"), assert!(ok, "aura exec --help failed: {err}");
("walkforward", "std::walk_forward"), assert!(out.contains("campaign"), "names the campaign document class: {out}");
("mc", "std::monte_carlo"), assert!(out.contains("blueprint"), "names the blueprint document class: {out}");
("generalize", "std::generalize"), assert!(out.contains("single run"), "names the blueprint leg's single-run shape: {out}");
] {
let (out, err, ok) = run(&[verb, "--help"]);
assert!(ok, "aura {verb} --help failed: {err}");
assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}");
assert!(out.contains(needle), "{verb} --help names {needle}: {out}");
assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}");
}
let (out, err, ok) = run(&["run", "--help"]);
assert!(ok, "aura run --help failed: {err}");
assert!(out.contains("document-first"), "run --help names the canonical form: {out}");
} }
/// #323: `graph build --help` carries the op-list reference — the op kinds /// #323: `graph build --help` carries the op-list reference — the op kinds
/// and fields are learnable from the binary, not only from serde refusals. /// and fields are learnable from the binary, not only from serde refusals.
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
/// joins the roster (ten -> eleven).
#[test] #[test]
fn graph_build_help_carries_the_op_reference() { fn graph_build_help_carries_the_op_reference() {
let (out, err, ok) = run(&["graph", "build", "--help"]); let (out, err, ok) = run(&["graph", "build", "--help"]);
@@ -72,12 +67,84 @@ fn graph_build_help_carries_the_op_reference() {
r#"{"op":"tap""#, r#"{"op":"tap""#,
r#"{"op":"gang""#, r#"{"op":"gang""#,
r#"{"op":"doc""#, r#"{"op":"doc""#,
r#"{"op":"use""#,
r#"{"op":"name""#,
] { ] {
assert!(out.contains(op), "op reference carries {op}: {out}"); assert!(out.contains(op), "op reference carries {op}: {out}");
} }
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}"); assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
} }
/// #317: `graph introspect --registered` lists every registry label — row
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
/// as prose, not a blank/garbled listing.
#[test]
fn graph_introspect_registered_lists_labels() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
// Empty store: prose, not a blank listing, exit 0.
let out_empty = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out_empty.status.success(), "empty store still exits 0");
assert_eq!(
String::from_utf8_lossy(&out_empty.stdout),
"no labels registered\n",
"the empty-store form reads as prose"
);
// Register + label a described open pattern, then list it.
let pattern = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let bp_path = dir.join("pattern.ops.json");
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
let built = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
child.wait_with_output()
})
.expect("build the pattern");
assert!(built.status.success(), "the pattern builds");
let envelope = dir.join("pattern.bp.json");
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
let reg = Command::new(BIN)
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
.current_dir(&dir)
.output()
.expect("spawn aura register");
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(&reg.stderr));
let out = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out.status.success(), "listing succeeds");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
let fields: Vec<&str> = row.split_whitespace().collect();
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
}
/// #315: the node vocabulary listing carries each type's one-line meaning — /// #315: the node vocabulary listing carries each type's one-line meaning —
/// no more bare names that teach nothing. /// no more bare names that teach nothing.
#[test] #[test]
@@ -103,18 +170,28 @@ fn graph_introspect_node_head_line_carries_the_meaning() {
assert!(head.contains("moving average"), "head line carries the meaning: {out}"); assert!(head.contains("moving average"), "head line carries the meaning: {out}");
} }
/// #315: the fold vocabulary is introspectable from the binary — name, bind /// #332: `--folds` renders the fold-REGISTRY roster (the same roster
/// rule, output kind, and meaning per fold (it lived only in source comments). /// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
/// `FoldKind` table — lowercase, parseable labels, including the `record`
/// entry that has no `FoldKind` counterpart. Previously this surface printed
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
/// text's own discovery surface described input `--tap` refused.
#[test] #[test]
fn graph_introspect_folds_lists_the_fold_vocabulary() { fn graph_introspect_folds_lists_the_fold_registry_roster() {
let (out, err, ok) = run(&["graph", "introspect", "--folds"]); let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
assert!(ok, "folds listing failed: {err}"); assert!(ok, "folds listing failed: {err}");
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect(); let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 7, "one line per fold kind: {out}"); assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
let mean = lines.iter().find(|l| l.starts_with("Mean")).expect("Mean row"); assert!(
assert!(mean.contains("f64") && mean.contains("mean"), "Mean row rules + meaning: {mean}"); !out.split_whitespace().any(|w| w == "Mean"),
let count = lines.iter().find(|l| l.starts_with("Count")).expect("Count row"); "no capitalized FoldKind id leaks through: {out}"
assert!(count.contains("i64") && count.contains("any"), "Count row rules: {count}"); );
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
assert!(record.contains("series"), "record row meaning: {record}");
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
} }
/// #315: the metric roster carries each metric's one-line meaning beside its /// #315: the metric roster carries each metric's one-line meaning beside its
+73 -12
View File
@@ -31,15 +31,28 @@ fn two_tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize measurement blueprint") 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"); let bp = cwd.join("measurement.json");
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint"); std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
let out = Command::new(BIN) let out = Command::new(BIN)
.args(["run", bp.to_str().unwrap()]) .args(["exec", bp.to_str().unwrap()])
.current_dir(cwd) .current_dir(cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr)); 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 { 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] #[test]
fn measure_ic_emits_a_well_formed_report() { fn measure_ic_emits_a_well_formed_report() {
let cwd = temp_cwd("wellformed"); let cwd = temp_cwd("wellformed");
run_measurement(&cwd); let handle = run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &[]); let out = measure_ic(&cwd, &handle, &[]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); 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"); 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["signal_tap"], "signal");
assert_eq!(r["price_tap"], "price"); assert_eq!(r["price_tap"], "price");
assert_eq!(r["horizon"], 1); assert_eq!(r["horizon"], 1);
@@ -71,9 +90,9 @@ fn measure_ic_emits_a_well_formed_report() {
#[test] #[test]
fn measure_ic_is_deterministic() { fn measure_ic_is_deterministic() {
let cwd = temp_cwd("determinism"); let cwd = temp_cwd("determinism");
run_measurement(&cwd); let handle = run_measurement(&cwd);
let a = measure_ic(&cwd, "sma_signal", &["--seed", "9"]); let a = measure_ic(&cwd, &handle, &["--seed", "9"]);
let b = measure_ic(&cwd, "sma_signal", &["--seed", "9"]); let b = measure_ic(&cwd, &handle, &["--seed", "9"]);
assert!(a.status.success() && b.status.success()); assert!(a.status.success() && b.status.success());
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report"); assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
} }
@@ -97,8 +116,8 @@ fn measure_ic_unknown_run_errors() {
#[test] #[test]
fn measure_ic_oversized_horizon_degenerates_through_the_cli() { fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
let cwd = temp_cwd("oversizedhorizon"); let cwd = temp_cwd("oversizedhorizon");
run_measurement(&cwd); let handle = run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &["--horizon", "100000000"]); let out = measure_ic(&cwd, &handle, &["--horizon", "100000000"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); 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"); let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
assert_eq!(r["horizon"], 100_000_000); assert_eq!(r["horizon"], 100_000_000);
@@ -110,9 +129,9 @@ fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
#[test] #[test]
fn measure_ic_missing_tap_errors() { fn measure_ic_missing_tap_errors() {
let cwd = temp_cwd("missingtap"); let cwd = temp_cwd("missingtap");
run_measurement(&cwd); let handle = run_measurement(&cwd);
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn"); .expect("spawn");
@@ -123,3 +142,45 @@ fn measure_ic_missing_tap_errors() {
String::from_utf8_lossy(&out.stderr) 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 //! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture
//! project once (cargo, same toolchain, path-dep on this workspace's //! project once (cargo, same toolchain, path-dep on this workspace's
//! aura-core), then drives the aura binary from inside the fixture dir. //! aura-core), then drives the aura binary from inside the fixture dir.
//! `aura run` persists nothing without --trace, so the tracked fixture stays //! `aura exec` persists only the taps a blueprint declares (C27) and the
//! clean (target/, Cargo.lock, runs/ are fixture-gitignored). //! fixture blueprints declare none, so the tracked fixture stays clean
//! (target/, Cargo.lock, runs/ are fixture-gitignored).
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
@@ -38,13 +39,13 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
#[test] #[test]
fn project_run_resolves_demo_node_and_is_bit_identical() { fn project_run_resolves_demo_node_and_is_bit_identical() {
let dir = built_project(); let dir = built_project();
let a = aura(&["run", "demo_signal.json"], dir); let a = aura(&["exec", "demo_signal.json"], dir);
assert!( assert!(
a.status.success(), a.status.success(),
"run failed: {}", "run failed: {}",
String::from_utf8_lossy(&a.stderr) 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!(b.status.success());
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)"); 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] #[test]
fn project_run_manifest_carries_provenance() { fn project_run_manifest_carries_provenance() {
let dir = built_project(); let dir = built_project();
let out = aura(&["run", "demo_signal.json"], dir); let out = aura(&["exec", "demo_signal.json"], dir);
assert!(out.status.success()); assert!(out.status.success());
let v: serde_json::Value = let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("run report is JSON"); 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. // cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
let cwd = Path::new(env!("CARGO_MANIFEST_DIR")); let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
let bp = fixture_dir().join("demo_signal.json"); 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()); assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr); let err = String::from_utf8_lossy(&out.stderr);
assert!( 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 /// `<project-root>/runs/`, and no stray `runs/` must appear under the
/// subdirectory. This is the property that makes `[paths]` project-relative /// subdirectory. This is the property that makes `[paths]` project-relative
/// rather than shell-relative (C17). /// 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] #[test]
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() { fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
let dir = built_project(); let (dir, _fixture) = common::fresh_project_with_data();
let sub = dir.join("src"); let sub = dir.join("sub");
std::fs::create_dir_all(&sub).expect("create invocation subdirectory");
let runs_dir = dir.join("runs"); let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok(); 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")) let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args([ .args(["exec", "../anchor.campaign.json"])
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"proj-anchor",
])
.current_dir(&sub) .current_dir(&sub)
.output() .output()
.expect("spawn aura sweep"); .expect("spawn aura exec");
assert!( assert!(
out.status.success(), out.status.success(),
"sweep failed: {}", "exec failed: {}",
String::from_utf8_lossy(&out.stderr) String::from_utf8_lossy(&out.stderr)
); );
assert!( assert!(
@@ -162,7 +223,7 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
"badcharter fixture build failed:\n{}", "badcharter fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr) String::from_utf8_lossy(&build.stderr)
); );
let run = aura(&["run", "x.json"], &dir); let run = aura(&["exec", "x.json"], &dir);
assert_eq!( assert_eq!(
run.status.code(), run.status.code(),
Some(1), Some(1),
@@ -191,7 +252,7 @@ fn undescribed_vocabulary_entry_refuses_end_to_end() {
"undescribed fixture build failed:\n{}", "undescribed fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr) String::from_utf8_lossy(&build.stderr)
); );
let run = aura(&["run", "x.json"], &dir); let run = aura(&["exec", "x.json"], &dir);
assert_eq!( assert_eq!(
run.status.code(), run.status.code(),
Some(1), Some(1),
@@ -229,7 +290,7 @@ fn restated_name_vocabulary_entry_refuses_end_to_end() {
"restated fixture build failed:\n{}", "restated fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr) String::from_utf8_lossy(&build.stderr)
); );
let run = aura(&["run", "x.json"], &dir); let run = aura(&["exec", "x.json"], &dir);
assert_eq!( assert_eq!(
run.status.code(), run.status.code(),
Some(1), Some(1),
@@ -287,7 +348,7 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
set_mtime(&dylib, DYLIB_MTIME); set_mtime(&dylib, DYLIB_MTIME);
set_mtime(&src, SOURCE_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 // Restore sane mtimes before asserting, so a failed assertion does not
// leave the shared fixture wedged "stale" (or with a future mtime that // 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() { fn data_only_project_runs_and_stamps_commit_only_provenance() {
let dir = dataonly_dir(); let dir = dataonly_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura")) let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "signal.json"]) .args(["exec", "signal.json"])
.current_dir(&dir) .current_dir(&dir)
.output() .output()
.expect("spawn aura run"); .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() { fn multi_crate_pointer_refuses_end_to_end() {
let dir = multicrate_dir(); let dir = multicrate_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura")) let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "x.json"]) .args(["exec", "x.json"])
.current_dir(&dir) .current_dir(&dir)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
@@ -427,7 +488,7 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
String::from_utf8_lossy(&build.stderr) String::from_utf8_lossy(&build.stderr)
); );
let out = Command::new(env!("CARGO_BIN_EXE_aura")) let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "pointer_signal.json"]) .args(["exec", "pointer_signal.json"])
.current_dir(&dir) .current_dir(&dir)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
@@ -455,7 +516,7 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
let bp = tmp.join("bp.json"); 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(); 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")) 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) .current_dir(&tmp)
.output() .output()
.expect("spawn aura"); .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", "[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
) )
.unwrap(); .unwrap();
let out = aura(&["run", "x.json"], &tmp); let out = aura(&["exec", "x.json"], &tmp);
assert_eq!(out.status.code(), Some(1), "runtime refusal"); assert_eq!(out.status.code(), Some(1), "runtime refusal");
let err = String::from_utf8_lossy(&out.stderr); let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cargo build"), "build hint present: {err}"); 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 //! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction //! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the
//! — plus the refusal contract. Scaffolded projects live in per-test temp //! refusal contract. Scaffolded projects live in per-test temp dirs.
//! dirs.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
mod common;
fn aura(args: &[&str], cwd: &Path) -> Output { fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura")) Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args) .args(args)
@@ -61,17 +62,17 @@ fn init_and_commit(dir: &Path) {
/// ///
/// Documented deviation from the task text: the plan's wording asks this /// 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/`". /// headline to also assert that the run "writes a manifest under `x/runs/`".
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the /// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r`
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the /// (main.rs): a single-blueprint `aura exec` never touches the trace store —
/// trace store — it only prints the `RunReport` to stdout; only /// it only prints the `RunReport` to stdout; only exec's campaign leg
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241 /// persists (`env.trace_store()`), a pre-#241 split this task's file list
/// split this task's file list does not touch (giving `run` a store write /// does not touch (giving the blueprint leg a store write would widen scope
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` /// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test
/// and this test file). There is thus no on-disk manifest for THIS verb to /// file). There is thus no on-disk manifest for THIS leg to assert against;
/// assert against; the assertion below proves that absence directly instead /// the assertion below proves that absence directly instead of only
/// of only asserting it in prose, and the persisted-store property the plan /// asserting it in prose, and the persisted-store property the plan wanted
/// wanted is covered by `data_only_project_sweeps_without_any_build` below, /// is covered by `data_only_project_execs_a_campaign_without_any_build`
/// which does assert `proj.join("runs").exists()`. /// below, which does assert `proj.join("runs").exists()`.
#[test] #[test]
fn new_scaffolds_and_runs_the_data_only_loop() { fn new_scaffolds_and_runs_the_data_only_loop() {
let base = tmp("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("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"); 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!( assert!(
a.status.success(), a.status.success(),
"run failed: {}", "exec failed: {}",
String::from_utf8_lossy(&a.stderr) 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)"); 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 v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
let p = &v["manifest"]["project"]; 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!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
assert!( assert!(
!proj.join("runs").exists(), !proj.join("runs").exists(),
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \ "a plain single-blueprint `aura exec` must not persist a trace store (only exec's \
the task text's \"writes a manifest under x/runs/\" does not hold for this verb" 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); let _ = std::fs::remove_dir_all(&base);
} }
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's /// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// gate) with zero build step — using the scaffolded (closed) starter /// `research_docs.rs`'s constant of the same name.
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises. 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 /// #246: the closed starter IS the campaign axis target — a bound param is a
/// an axis overrides. /// default an axis overrides.
#[test] #[test]
fn data_only_project_sweeps_without_any_build() { fn data_only_project_execs_a_campaign_without_any_build() {
let base = tmp("sweep"); let base = tmp("sweep");
let new = aura(&["new", "scratch"], &base); let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr)); assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch"); let proj = base.join("scratch");
let out = aura( seed_synthetic_archive(&proj);
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
&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!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(proj.join("runs").exists(), "store must land inside the project"); assert!(proj.join("runs").exists(), "store must land inside the project");
let _ = std::fs::remove_dir_all(&base); 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 /// bound `fast.length` param — the persisted family has exactly one member
/// per axis value, not a single collapsed member. A silent regression where /// per axis value, not a single collapsed member. A silent regression where
/// the bound-param override is dropped (member always built from the /// the bound-param override is dropped (member always built from the
/// blueprint's own default) would still exit 0 and still create a `runs` /// 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 /// store, so `data_only_project_execs_a_campaign_without_any_build` above
/// it; only the member count can. /// cannot catch it; only the member count can.
#[test] #[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 base = tmp("sweep-members");
let new = aura(&["new", "scratch"], &base); let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr)); assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch"); let proj = base.join("scratch");
let out = aura( seed_synthetic_archive(&proj);
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
&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)); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let fams = aura(&["runs", "families"], &proj); let fams = aura(&["runs", "families"], &proj);
assert!(fams.status.success(), "runs families exit: {:?}", fams.status); 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 // ...so the first quickstart run stamps a commit into the manifest, not
// an empty provenance block. // an empty provenance block.
let run = aura(&["run", "blueprints/signal.json"], &proj); let run = aura(&["exec", "blueprints/signal.json"], &proj);
assert!( assert!(
run.status.success(), run.status.success(),
"run failed: {}", "exec failed: {}",
String::from_utf8_lossy(&run.stderr) String::from_utf8_lossy(&run.stderr)
); );
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON"); 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 /// 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, /// discoverable as an axis, execs over real data (a campaign — #319: the
/// and that family reproduces bit-identically — the defining research loop of a /// surviving surface for what `aura sweep --axis` used to do inline) into a
/// genuine external project ("sweep my OWN node's knob"), proven end to end. /// 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] #[test]
fn project_node_open_param_sweeps_and_reproduces() { fn project_node_open_param_sweeps_and_reproduces() {
let (dir, _g) = fresh_project(); let (dir, _g) = fresh_project();
// (a) `--list-axes` discovers the PROJECT node's own open knob, followed by // (a) `graph introspect --params` discovers the PROJECT node's own open
// the fixture's three BOUND params as `default=`-lines (#246: every bound // knob, followed by the fixture's three BOUND params as `default=`-lines
// param is an equally re-openable `--axis`, listed regardless of how many // (#246: every bound param is an equally re-openable axis, listed
// knobs happen to be open alongside it). NOT gated: enumerating axes needs // regardless of how many knobs happen to be open alongside it). NOT
// no data, so the discoverability half runs on every host. The wrapped // gated: enumerating axes needs no data, so the discoverability half runs
// name is `<blueprint>.<node>.<param>`. // on every host. The name is RAW `<node>.<param>` (#328: the blueprint
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]); // name stays out of axis paths).
let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]);
assert!( assert!(
axes.status.success(), axes.status.success(),
"--list-axes stderr: {}", "graph introspect --params stderr: {}",
String::from_utf8_lossy(&axes.stderr) String::from_utf8_lossy(&axes.stderr)
); );
assert_eq!( assert_eq!(
String::from_utf8_lossy(&axes.stdout), String::from_utf8_lossy(&axes.stdout),
"scaled_signal.gain.factor:F64\n\ "gain.factor:F64\n\
scaled_signal.fast.length:I64 default=2\n\ fast.length:I64 default=2\n\
scaled_signal.slow.length:I64 default=4\n\ slow.length:I64 default=4\n\
scaled_signal.bias.scale:F64 default=0.5\n", bias.scale:F64 default=0.5\n",
"the project node's own OPEN param is the one open axis, alongside the \ "the project node's own OPEN param is the one open axis, alongside the \
fixture's bound defaults; stderr: {}", fixture's bound defaults; stderr: {}",
String::from_utf8_lossy(&axes.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() { if !local_data_present() {
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
return; return;
} }
// (b) sweep the project node's knob over real GER40 data -> two members. // (b) exec a campaign over the project node's knob, real GER40 data ->
let sweep = aura_in( // two members. Register the blueprint straight (not a sweep side-effect)
dir, // and a minimal sweep-only process, then reference both by content id.
&[ let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]);
"sweep", assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
"blueprints/scaled_open.json", let reg_out = String::from_utf8_lossy(&reg.stdout).into_owned();
"--real", let bp_id = reg_out
"GER40", .lines()
"--from", .find(|l| l.starts_with("registered blueprint "))
GER40_FROM_MS, .expect("register line")
"--to", .trim_start_matches("registered blueprint ")
GER40_TO_MS, .split(' ')
"--axis", .next()
"scaled_signal.gain.factor=0.5,1.0", .expect("blueprint id")
"--name", .to_string();
"knob",
], 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!( assert!(
sweep.status.success(), exec.status.success(),
"sweep stderr: {}", "exec stderr: {}",
String::from_utf8_lossy(&sweep.stderr) String::from_utf8_lossy(&exec.stderr)
); );
let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned(); let stdout = String::from_utf8_lossy(&exec.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().collect(); 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}"); assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
// Each member's manifest carries the swept PROJECT-node param binding under // Each member's manifest carries the swept PROJECT-node param binding
// its wrapped name — the load-bearing proof that it is MY node's knob that // under its RAW name (this cycle's tidy fix, #328 batch 2: the
// varied across the family, not some incidental std axis. // 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]) { 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"); let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
assert_eq!( assert_eq!(
@@ -227,7 +265,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
.expect("manifest.params is an array"); .expect("manifest.params is an array");
let bound = params let bound = params
.iter() .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()); .and_then(|p| p[1]["F64"].as_f64());
assert_eq!( assert_eq!(
bound, 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"); 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!( let out = format!(
"{}{}", "{}{}",
String::from_utf8_lossy(&run.stdout), String::from_utf8_lossy(&run.stdout),
+139 -134
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 // Seed one real, content-addressed blueprint into the project's own
// store via a real sweep over its bound params, which the axes reopen // store via `aura graph register` (#319 — the retired `sweep`
// as overridable defaults (#246) (mirrors `project_load.rs`'s anchor // side-effect seeding this used to ride).
// test). let bp_id = seed_blueprint(&dir, "campaign-ref-seed");
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();
// Register a valid AND executable process into the same project store: // Register a valid AND executable process into the same project store:
// this test's OK campaign must pass all three validate tiers, and // 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:") .trim_start_matches("content:")
.to_string(); .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 // `validate_campaign_refs` loads the stored blueprint bare, unlike the
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the // sweep's `wrap_r`-wrapped axis probe, so it never carries a wrap prefix
// "sma_signal." prefix `aura sweep --axis` binds against. // to begin with.
let campaign = format!( let campaign = format!(
r#"{{ r#"{{
"format_version": 1, "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 // Seed one real, content-addressed blueprint (mirrors the content_id
// sibling test above). // sibling test above; #319 — the retired `sweep` side-effect seeding).
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let bp_id = seed_blueprint(&dir, "campaign-identity-seed");
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();
let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json")); let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json"));
// The identity id of the SAME stored bytes — `--content-id FILE // 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"]); let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}"); assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( 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}" "stdout/stderr: {out}"
); );
assert!(!out.contains("BadRegime"), "Debug leak: {out}"); assert!(!out.contains("BadRegime"), "Debug leak: {out}");
@@ -996,7 +948,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
write_doc(&dir, "draft.campaign.json", draft); write_doc(&dir, "draft.campaign.json", draft);
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]); let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
assert_eq!(code, Some(0)); assert_eq!(code, Some(0));
assert!(out.contains("open slot: process.ref (required, content id of a process document)")); assert!(out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"));
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)")); assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
} }
@@ -1022,7 +974,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
"strategy `ref: null` placeholder not enumerated: {out}" "strategy `ref: null` placeholder not enumerated: {out}"
); );
assert!( assert!(
out.contains("open slot: process.ref (required, content id of a process document)"), out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"),
"process `ref: {{}}` placeholder not enumerated: {out}" "process `ref: {{}}` placeholder not enumerated: {out}"
); );
} }
@@ -1240,7 +1192,7 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
assert_eq!(code, Some(1), "stdout/stderr: {out}"); assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}"); assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
assert!( 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}" "stdout/stderr: {out}"
); );
assert!( 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 /// Seed one blueprint into the built demo project's store via `aura graph
/// #246) into the built demo project's store via a real sweep and return /// register` and return its content id (the referential test's recipe).
/// 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 { fn seed_blueprint(dir: &Path, name: &str) -> String {
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 (out, code) = run_code_in( let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
dir, assert_eq!(code, Some(0), "seed register failed: {out}");
&[ out.lines()
"sweep", .find(|l| l.starts_with("registered blueprint "))
&closed_bp, .expect("register line")
"--axis", .trim_start_matches("registered blueprint ")
"sma_signal.fast.length=2,4", .split(' ')
"--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")
.next() .next()
.expect("one stored blueprint") .expect("id")
.expect("dir entry") .trim_start_matches("content:")
.path() .to_string()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned()
} }
/// Register `doc` as a process document in the project store; returns its id. /// 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() { fn campaign_run_outside_project_refuses() {
let dir = temp_cwd("campaign-run-outside-project"); let dir = temp_cwd("campaign-run-outside-project");
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}"); assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
assert!( assert!(
@@ -1507,7 +1450,7 @@ fn campaign_run_outside_project_refuses() {
#[test] #[test]
fn campaign_run_bogus_target_refuses() { fn campaign_run_bogus_target_refuses() {
let (dir, _fixture) = fresh_project(); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( assert!(
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"), 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() { fn campaign_run_unknown_id_refuses() {
let (dir, _fixture) = fresh_project(); let (dir, _fixture) = fresh_project();
let id = "0".repeat(64); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( assert!(
out.contains(&format!("no campaign {id} in the project store")), 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 bp_id = seed_blueprint(&dir, "campaign-run-mcwf-seed");
let proc_id = register_process_doc(&dir, "mcwf.process.json", MC_BEFORE_WF_PROCESS_DOC); 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), "", "")); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}"); assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
assert!( assert!(
@@ -1688,7 +1631,7 @@ fn campaign_run_refuses_a_non_terminal_selection_free_sweep() {
let proc_id = let proc_id =
register_process_doc(&dir, "selfree.process.json", SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC); 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), "", "")); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( assert!(
out.contains( out.contains(
@@ -1802,7 +1745,7 @@ fn campaign_run_refuses_a_grid_stage_not_immediately_before_walk_forward() {
"gridgate.campaign.json", "gridgate.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""), &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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}"); assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
assert!( 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}"); assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = 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"] { for k in ["1", "2"] {
let (out, code) = run_code_in( let (out, code) = run_code_in(
&dir, &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}"); assert_eq!(code, Some(0), "K={k}: campaign run failed: {out}");
let line = 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( let (out, code) = run_code_in(
&dir, &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}"); assert_eq!(code, Some(3), "one contained fault, one success: {out}");
let line = 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); write_doc(&dir, "gateempty.campaign.json", &doc);
let (out, code) = 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_eq!(code, Some(0), "a zero-survivor cell is a valid result, not a fault: {out}");
assert!( assert!(
out.contains("aura: note: cell "), 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 bp_id = seed_blueprint(&dir, "campaign-run-gen1-seed");
let proc_id = register_process_doc(&dir, "gen1.process.json", GENERALIZE_PROCESS_DOC); 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), "", "")); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( assert!(
out.contains("std::generalize needs at least 2"), 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); 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}"); assert_eq!(code, Some(1), "stdout/stderr: {out}");
// #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable // #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable
// R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is // 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 bp_id = seed_blueprint(&dir, "campaign-run-wfmc-seed");
let proc_id = register_process_doc(&dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC); 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), "", "")); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( assert!(
out.contains("std::walk_forward cannot follow std::monte_carlo"), 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 bp_id = seed_blueprint(&dir, "campaign-run-zeromc-seed");
let proc_id = register_process_doc(&dir, "zeromc.process.json", ZERO_RESAMPLES_MC_PROCESS_DOC); 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), "", "")); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( assert!(
out.contains("process stage 1: monte_carlo resamples must be > 0"), 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", "badtap.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""), &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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}"); assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
assert!( assert!(
@@ -2477,7 +2420,7 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() {
"taps.campaign.json", "taps.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""), &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_eq!(code, Some(3), "stdout/stderr: {out}");
assert!( assert!(
!out.contains("unknown tap"), !out.contains("unknown tap"),
@@ -2547,7 +2490,7 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
"\"family_table\", \"selection_report\"", "\"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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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 // 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. // line is read from each run's own stdout, so no isolation is needed.
let pooled = |doc: &str| -> serde_json::Value { 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}"); assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out let line = out
.lines() .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); write_doc(&dir, "net_ps.campaign.json", &with_cost);
let per_survivor = |doc: &str| -> Vec<(u64, serde_json::Value)> { 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}"); assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out let line = out
.lines() .lines()
@@ -2863,7 +2806,7 @@ fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() {
(1709251200000, 1717199999999), (1709251200000, 1717199999999),
); );
write_doc(&dir, "percost.campaign.json", &doc); 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}"); assert_eq!(code, Some(0), "stdout/stderr: {out}");
// Member emit lines carry report.manifest; group the stamped resolved // 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"); assert_ne!(with_risk, base, "replacen must actually match the seed field");
write_doc(&dir, "voltf.campaign.json", &with_risk); 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}"); assert_eq!(code, Some(0), "stdout/stderr: {out}");
let record_line = 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, "close.campaign.json", &base);
write_doc(&dir, "open.campaign.json", &rebound); 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) if code_close == Some(3)
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument")) && (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; return;
} }
assert_eq!(code_close, Some(0), "stdout/stderr: {out_close}"); 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}"); assert_eq!(code_open, Some(0), "stdout/stderr: {out_open}");
let first_member = |out: &str| -> serde_json::Value { 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"); assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "regime.campaign.json", &with_regime); 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) if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument")) && (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): /// 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 /// 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 /// 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"); assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "tworegimes.campaign.json", &with_regimes); 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) if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument")) && (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"); assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "regime-persist.campaign.json", &with_regime); 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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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 /// trace-persist re-run must not false-fail the C1 drift alarm, exercised
/// through the REAL production path — `campaign run` -> `run_campaign_returning` /// through the REAL production path — `campaign run` -> `run_campaign_returning`
/// -> `CliMemberRunner` (net-mode member run) -> `persist_campaign_traces` /// -> `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 /// 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 /// `main.rs`'s unit tests. Mirrors the sibling non-default-regime e2e above
/// (same #219-class divergence risk, cost edition): a costed campaign /// (same #219-class divergence risk, cost edition): a costed campaign
/// document, `persist_taps` non-empty so the re-run actually executes, must /// 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"); assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "cost-persist.campaign.json", &with_cost); 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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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"); assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "net-persist.campaign.json", &with_cost); 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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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 /// its `constant` sibling
/// (`campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm`): /// (`campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm`):
/// `campaign run` -> `CliMemberRunner` (net-mode) -> `persist_campaign_traces` /// `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 /// 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 /// 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 /// 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"); assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "volcost-persist.campaign.json", &with_cost); 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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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"); assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "tworegimes-persist.campaign.json", &with_regimes); 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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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 // (a) the cost-less baseline: net == gross, and net_r_equity skips with
// the remedy notice while equity still persists. // 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) if code_gross == Some(3)
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument")) && (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 // (b) the costed run: gross identical member-for-member, net strictly
// below gross, no skip notice, net_r_equity persisted on disk. // 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_eq!(code_net, Some(0), "costed run: {out_net}");
assert!( assert!(
!out_net.contains("skipped"), !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 /// charge or `vol_slippage`'s volatility-proxy-scaled charge) — nets a real
/// campaign member through the SAME production path as its `constant`/ /// campaign member through the SAME production path as its `constant`/
/// `vol_slippage` siblings (`campaign run` -> `CliMemberRunner` (net-mode) -> /// `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 /// 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 /// 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 /// `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"); assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "carrycost-persist.campaign.json", &with_cost); 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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) if code == Some(3)
@@ -4034,7 +4039,7 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
"mc.campaign.json", "mc.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1725148800000, 1727740799999), "", ""), &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. // Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3) 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); 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), "", "")); 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}"); assert_eq!(file_code, Some(3), "stdout/stderr: {file_out}");
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]); 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") .expect("id")
.trim_start_matches("content:") .trim_start_matches("content:")
.to_string(); .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}"); 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"); 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:") .trim_start_matches("content:")
.to_string(); .to_string();
let (bare_out, _) = run_code_in(&dir, &["campaign", "run", &id]); let (bare_out, _) = run_code_in(&dir, &["exec", &id]);
let (pfx_out, _) = run_code_in(&dir, &["campaign", "run", &format!("content:{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"); let pfx_line = pfx_out.lines().find(|l| l.starts_with("aura: ")).expect("prefixed refusal line");
assert!( 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); 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), "", "")); 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_eq!(code, Some(3), "stdout/stderr: {out}");
assert!( assert!(
out.contains("no recorded geometry") || out.contains("no data for instrument"), 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 bad_path = write_doc(&dir, "bad.campaign.json", &bad);
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone()), ScratchPath::File(bad_path)]); 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_eq!(code, Some(1), "stdout/stderr: {out}");
assert!( assert!(
out.contains("aura: campaign document invalid:"), 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); 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}"); assert_eq!(code, Some(3), "stdout/stderr: {out}");
let record_line = 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. /// clap refuses it as a usage error (exit 2) before any target resolution.
#[test] #[test]
fn campaign_run_rejects_a_zero_parallel_instruments_bound() { 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}"); assert_eq!(code, Some(2), "clap usage error expected, got: {out}");
// Domain prose, not clap's raw NonZeroUsize wording ("number would be // Domain prose, not clap's raw NonZeroUsize wording ("number would be
// zero for non-zero type") — the fieldtest friction finding. // 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 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 /// 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 /// 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. /// 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"); std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
@@ -49,7 +62,8 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
assert_eq!(report["manifest"]["broker"], "measurement"); assert_eq!(report["manifest"]["broker"], "measurement");
// The tapped series is persisted through the trace store. // 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) 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())); .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"); 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"); let bp_path = cwd.join("measurement.json");
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint"); std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); 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")) let trace_text = std::fs::read_to_string(
.expect("read fold trace"); 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 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"); let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row"); 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; let expected = (1.0097_f64 + 1.0092) / 2.0;
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}"); 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 //! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the //! cannot run — a clean `aura:`-prefixed message on stderr and exit 2, the
//! established binding-refusal register — never a process panic. Both cases //! argv-named blueprint-content class (C14) — never a process panic. Both
//! are user mistakes on the hand-authored single-run path, so each deserves a //! cases are user mistakes on the hand-authored single-run path, so each
//! diagnosis, not a stack trace. //! deserves a diagnosis, not a stack trace.
//! //!
//! Two independent unrunnable shapes, each pinned as its own minimal, //! Two independent unrunnable shapes, each pinned as its own minimal,
//! self-contained blueprint (inlined here — no shared fixture file): //! 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"); std::fs::write(&bp_path, bp_json).expect("write blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
@@ -51,16 +51,18 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
!stderr.to_lowercase().contains("panic"), !stderr.to_lowercase().contains("panic"),
"{what}: expected a clean refusal, got a PANIC:\n{stderr}" "{what}: expected a clean refusal, got a PANIC:\n{stderr}"
); );
// The clean-refusal register: `aura: <msg>` on stderr, exit 1 — exactly // The clean-refusal register: `aura: <msg>` on stderr — the prefix every
// what the binding refusals in `run_signal_r` already emit. // refusal class (C14) shares regardless of exit code.
assert!( assert!(
stderr.contains("aura:"), stderr.contains("aura:"),
"{what}: refusal must carry the `aura:` prefix:\n{stderr}" "{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!( assert_eq!(
out.status.code(), out.status.code(),
Some(1), Some(2),
"{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}" "{what}: a refusal exits 2 (blueprint-content fault, not 101 panic, not 1 env/data fault):\n{stderr}"
); );
} }
+404 -34
View File
@@ -41,6 +41,20 @@ fn tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize tapped blueprint") 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 /// 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 /// built-in synthetic stream, no `--real`), a declared tap is bound and its
/// per-cycle series is persisted through the trace store — the persisted /// 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"); std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); 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| { 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()) 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 cwd = temp_cwd("tap-free-writes-nothing");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(BIN) let out = Command::new(BIN)
.args(["run", &bp]) .args(["exec", &bp])
.current_dir(&cwd) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .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"); std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound"); 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); let stderr = String::from_utf8_lossy(&out.stderr);
assert!( assert!(
stderr.contains("dup") && stderr.contains("more than once"), 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"); std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(!out.status.success(), "an unwritable store must refuse, not succeed"); 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); let stderr = String::from_utf8_lossy(&out.stderr);
assert!( assert!(
stderr.contains("writing tap traces failed"), 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 /// but the record consumer's deferred open in `initialize` fails; the run
/// COMPLETES, the failure surfaces terminally through the register, and no /// COMPLETES, the failure surfaces terminally through the register, and no
/// `index.json` is written (the store's treat-as-absent crash shape). /// `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)] #[cfg(unix)]
#[test] #[test]
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() { 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 cwd = temp_cwd("run-dir-read-only");
let bp_path = cwd.join("tapped_r_sma.json"); let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint"); 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)) std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
.expect("make run dir read-only"); .expect("make run dir read-only");
let out = Command::new(BIN) let out = exec();
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
// restore permissions FIRST so the next run's temp_cwd cleanup works. // restore permissions FIRST so the next run's temp_cwd cleanup works.
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
.expect("restore run dir permissions"); .expect("restore run dir permissions");
assert!(!out.status.success(), "a failed writer must surface, not pass silently"); 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); let stderr = String::from_utf8_lossy(&out.stderr);
assert!( assert!(
stderr.contains("writing tap traces failed"), stderr.contains("writing tap traces failed"),
"the tap-trace register must fire terminally: {stderr}" "the tap-trace register must fire terminally: {stderr}"
); );
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape"); 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` /// #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"); std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); 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")) let run_dir = cwd.join("runs/traces").join(trace_handle(&out.stdout));
.expect("read index.json"); 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"); let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
assert_eq!(index["taps"], serde_json::json!(["fast_tap"])); 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")) let trace_text =
.expect("read fold trace"); 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"); let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
assert_eq!(trace["tap"], "fast_tap"); assert_eq!(trace["tap"], "fast_tap");
let rows = trace["columns"][0].as_array().expect("columns[0] array"); 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()) std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
.expect("write two-tap blueprint"); .expect("write two-tap blueprint");
} }
let run = |cwd: &std::path::Path, extra: &[&str]| { let run = |cwd: &std::path::Path, extra: &[&str]| -> std::path::PathBuf {
let mut args = vec!["run", "two_tap.json"]; let mut args = vec!["exec", "two_tap.json"];
args.extend_from_slice(extra); args.extend_from_slice(extra);
let out = Command::new(BIN) let out = Command::new(BIN)
.args(&args) .args(&args)
@@ -284,21 +329,28 @@ fn run_explicit_record_plan_matches_the_record_all_default() {
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
cwd.join("runs/traces").join(trace_handle(&out.stdout))
}; };
run(&cwd_a, &[]); let dir_a = run(&cwd_a, &[]);
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]); 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"] { 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 a = std::fs::read(dir_a.join(file)).expect("default trace");
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit 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"); 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 taps_of = |dir: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json")) let text = std::fs::read_to_string(dir.join("index.json")).expect("read index.json");
.expect("read index.json");
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone() 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 /// #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"); let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint"); std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(!out.status.success(), "an unknown fold label must refuse"); 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); let stderr = String::from_utf8_lossy(&out.stderr);
assert!( assert!(
stderr.contains("medain") && stderr.contains("mean"), stderr.contains("medain") && stderr.contains("mean"),
@@ -322,24 +376,82 @@ fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
assert!(!cwd.join("runs").exists(), "a refused run must not write the store"); assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
} }
/// #310: a selection naming a tap the blueprint does not declare is /// #310/#333: a selection naming a tap the blueprint does not declare is
/// refused typed (UndeclaredTap), before any store write. /// refused typed (UndeclaredTap), before any store write, and the refusal
/// enumerates the blueprint's declared taps (the same way the unknown-fold
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
/// both `fast_tap` and `slow_tap` must be named so the author does not have
/// to reopen the blueprint JSON.
#[test] #[test]
fn run_tap_selector_refuses_an_undeclared_tap() { fn run_tap_selector_refuses_an_undeclared_tap() {
let cwd = temp_cwd("tap-selector-undeclared"); let cwd = temp_cwd("tap-selector-undeclared");
let bp_path = cwd.join("tapped_r_sma.json"); let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint"); std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN) 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) .current_dir(&cwd)
.output() .output()
.expect("spawn aura run"); .expect("spawn aura run");
assert!(!out.status.success(), "an undeclared tap must refuse"); 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); let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}"); assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
assert!(
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
"refusal must enumerate the blueprint's declared taps: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store"); assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
} }
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
/// gets no such note; `slow_tap` (left unlisted) does.
#[test]
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
let cwd = temp_cwd("tap-selector-unbound-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
"stderr must note the unbound declared tap: {stderr}"
);
assert!(
!stderr.contains("\"fast_tap\" unbound"),
"the subscribed tap must not be noted as unbound: {stderr}"
);
}
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
/// unbound — nothing is skipped, so the note must never appear.
#[test]
fn run_with_no_tap_flag_emits_no_unbound_note() {
let cwd = temp_cwd("tap-selector-default-no-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("unbound this run"),
"the record-all default must never note a skipped tap: {stderr}"
);
}
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2) /// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
/// before anything runs. /// before anything runs.
#[test] #[test]
@@ -351,7 +463,7 @@ fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
vec!["--tap", "fast_tapmean"], vec!["--tap", "fast_tapmean"],
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"], 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); full.extend(args);
let out = Command::new(BIN) let out = Command::new(BIN)
.args(&full) .args(&full)
@@ -364,3 +476,261 @@ fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
assert!(!cwd.join("runs").exists()); 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"
);
}
+3 -3
View File
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
let sqrt = g.add(Sqrt::builder()); // → σ (price units) let sqrt = g.add(Sqrt::builder()); // → σ (price units)
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None). // k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
let scale = g.add(match knobs { let scale = g.add(match knobs {
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)), Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::builder(1).named("stop_k"), None => LinComb::configured(1).named("stop_k"),
}); });
g.feed(price, [delay.input("series"), sub.input("lhs")]); g.feed(price, [delay.input("series"), sub.input("lhs")]);
g.connect(delay.output("value"), sub.input("rhs")); g.connect(delay.output("value"), sub.input("rhs"));
@@ -174,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
let entry = g.input_role("entry_price"); let entry = g.input_role("entry_price");
let stop = g.input_role("stop_price"); let stop = g.input_role("stop_price");
let agg = g.add(CostSum::builder(n)); let agg = g.add(CostSum::configured(n));
// Per-role geometry fan targets, collected across all nodes, fed once per role. // Per-role geometry fan targets, collected across all nodes, fed once per role.
let mut closed_t = Vec::with_capacity(n); let mut closed_t = Vec::with_capacity(n);
+4
View File
@@ -7,6 +7,10 @@ publish.workspace = true
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
# already a workspace dependency of four other crates (see docs/specs/
# construction-args.md § Dependency note).
chrono-tz = { version = "0.10", default-features = false }
[dev-dependencies] [dev-dependencies]
serde_json = { workspace = true } serde_json = { workspace = true }
+64 -4
View File
@@ -1,7 +1,37 @@
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the //! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION`, and a
//! project descriptor (src/project.rs) can stamp it. Runs per consuming build: //! deterministic fingerprint of aura-core's own sources into
//! the host binary and a project cdylib each recompile aura-core under their //! `AURA_CORE_FINGERPRINT`, so the project descriptor (src/project.rs) can
//! own toolchain, which is exactly what makes the two stamps comparable. //! 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() { fn main() {
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
@@ -13,4 +43,34 @@ fn main() {
"cargo:rustc-env=AURA_RUSTC_VERSION={}", "cargo:rustc-env=AURA_RUSTC_VERSION={}",
String::from_utf8_lossy(&out.stdout).trim() 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");
} }
+3 -2
View File
@@ -44,8 +44,9 @@ pub use column::{Column, Window};
pub use ctx::Ctx; pub use ctx::Ctx;
pub use error::KindMismatch; pub use error::KindMismatch;
pub use node::{ pub use node::{
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node, doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder,
}; };
pub use scalar::{Scalar, ScalarKind, Timestamp}; pub use scalar::{Scalar, ScalarKind, Timestamp};
pub use series_fold::SeriesFold; pub use series_fold::SeriesFold;
+368 -2
View File
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
instance_name: Option<String>, instance_name: Option<String>,
schema: NodeSchema, schema: NodeSchema,
bound: Vec<BoundParam>, bound: Vec<BoundParam>,
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
// The build closure's type is exactly the recipe contract (a param slice in, a // The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it. // boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
schema: NodeSchema, schema: NodeSchema,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static, build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> Self { ) -> Self {
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) } Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
}
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
/// not yet constructible. `schema` carries only the doc line (empty
/// inputs/output/params — the real signature forms inside `make`, once
/// `try_args` has parsed values to hand it); `build` on a pending builder
/// panics (the `bind` panic-contract twin) — the data path always goes
/// through `try_args` first, so no pending builder ever reaches `build`.
pub fn pending(
name: &'static str,
doc: &'static str,
specs: &'static [ArgSpec],
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
) -> Self {
Self {
name,
instance_name: None,
schema: NodeSchema { doc, ..Default::default() },
bound: Vec::new(),
args: ArgsState::Pending { specs, make },
build: Box::new(move |_| {
panic!(
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
call try_args first"
)
}),
}
}
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
/// configured builder (`args: Configured{..}`) with this builder's
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
/// (uniform op application, #157's try_bind precedent); `Plain` +
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
/// first unknown arg name, then a duplicate, then the first spec-order
/// missing arg, then per-kind parse failures (`BadValue`).
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
let (specs, make) = match &self.args {
ArgsState::Plain | ArgsState::Configured { .. } => {
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
}
ArgsState::Pending { specs, make } => (*specs, *make),
};
for (name, _) in raw {
if !specs.iter().any(|s| s.name == name.as_str()) {
return Err(ArgOpError::UnknownArg(name.clone()));
}
}
for i in 0..raw.len() {
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
}
}
for spec in specs {
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
return Err(ArgOpError::MissingArg(spec.name.to_string()));
}
}
let mut values = Vec::with_capacity(specs.len());
let mut accepted = Vec::with_capacity(specs.len());
for spec in specs {
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
arg: spec.name.to_string(),
kind: spec.kind,
got: raw_val.clone(),
})?;
values.push((spec.name.to_string(), value));
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
}
let mut built = make(&values);
built.instance_name = self.instance_name;
built.args = ArgsState::Configured { values: accepted };
Ok(built)
}
/// The declared construction-arg specs (a view into the pending recipe);
/// empty unless this builder `is_pending()`.
pub fn arg_specs(&self) -> &[ArgSpec] {
match &self.args {
ArgsState::Pending { specs, .. } => specs,
_ => &[],
}
}
/// The accepted, verbatim construction-arg pairs (the serialize surface);
/// empty unless this builder was configured via `try_args`.
pub fn construction_args(&self) -> &[ConstructionArg] {
match &self.args {
ArgsState::Configured { values } => values,
_ => &[],
}
}
/// True for an arg-bearing recipe not yet configured via `try_args`.
pub fn is_pending(&self) -> bool {
matches!(self.args, ArgsState::Pending { .. })
} }
/// Set this node instance's explicit name. Must be non-empty: the name forms /// Set this node instance's explicit name. Must be non-empty: the name forms
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in /// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the /// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its /// data-level construction surface (`GraphSession`, #157). `bind` keeps its
/// panic contract (and its pinned messages); `try_bind` reports the same three /// panic contract (and its pinned messages); `try_bind` reports the same three
/// conditions as values. /// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
/// follow-up) does, refusing a ganged member's raw path at the use seam
/// before it silently de-fuses the gang.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum BindOpError { pub enum BindOpError {
/// No still-open param has this name. /// No still-open param has this name.
@@ -333,6 +438,141 @@ pub enum BindOpError {
AmbiguousParam(String), AmbiguousParam(String),
/// The value's kind does not equal the param's declared kind. /// The value's kind does not equal the param's declared kind.
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind }, KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
/// `param` (the full qualified path) is a member of the gang named
/// `gang` — binding it directly would freeze that one member while the
/// gang's public knob keeps driving its siblings. Bind the gang's own
/// public knob instead (accepted residue: unbindable at the use seam,
/// #317).
AlreadyGanged { param: String, gang: String },
}
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
/// are bootstrap metadata, never streamed, so the two closedness axes stay
/// separate types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArgKind {
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
Tz,
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
TimeOfDay,
/// A plain positive decimal count (>= 1, no leading zeros).
Count,
}
impl ArgKind {
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
/// the canonical form (spec §Closedness) — there is no normalization
/// layer, so a near-miss string refuses rather than being silently
/// rewritten (content ids stay input-variance-free by refusal).
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
// no information of its own — `try_args` is the only caller and always maps
// it to `ArgOpError::BadValue`, which is where the real detail lives.
#[allow(clippy::result_unit_err)]
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
match self {
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
ArgKind::TimeOfDay => {
let bytes = raw.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return Err(());
}
let (h, m) = (&raw[0..2], &raw[3..5]);
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
let hour: u32 = h.parse().map_err(|_| ())?;
let minute: u32 = m.parse().map_err(|_| ())?;
if hour > 23 || minute > 59 {
return Err(());
}
Ok(ArgValue::TimeOfDay { hour, minute })
}
ArgKind::Count => {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
if raw.len() > 1 && raw.starts_with('0') {
return Err(()); // leading zero: not the canonical form
}
let n: usize = raw.parse().map_err(|_| ())?;
if n == 0 {
return Err(()); // Count is >= 1
}
Ok(ArgValue::Count(n))
}
}
}
/// The static, per-kind hint line (introspection surface, C29) — a closed
/// table keyed only by `ArgKind`, never per-entry freetext.
pub fn hint(&self) -> &'static str {
match self {
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
ArgKind::TimeOfDay => "local wall-clock HH:MM",
ArgKind::Count => "positive integer count",
}
}
}
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ArgSpec {
pub name: &'static str,
pub kind: ArgKind,
}
/// A parsed, validated construction-arg value (in-memory only; documents carry
/// the canonical string form in [`ConstructionArg`]).
#[derive(Clone, Debug, PartialEq)]
pub enum ArgValue {
Tz(chrono_tz::Tz),
TimeOfDay { hour: u32, minute: u32 },
Count(usize),
}
/// A consumed construction arg — the id-bearing, serialized twin of
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
/// (never re-normalized — see `ArgKind::parse`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConstructionArg {
pub name: String,
pub value: String,
}
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
/// for the data-level construction surface.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArgOpError {
/// Args were given, but this type declares none (a `Plain` builder).
NotArgBearing,
/// A given arg name is not among the declared `ArgSpec`s.
UnknownArg(String),
/// The same arg name was given more than once.
DuplicateArg(String),
/// A declared arg has no value in the given set.
MissingArg(String),
/// A given value failed its declared kind's strict-form parse.
BadValue { arg: String, kind: ArgKind, got: String },
}
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
enum ArgsState {
/// No construction args declared (every existing node, unaffected).
Plain,
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
/// parsed value set into the real, configured builder.
Pending {
specs: &'static [ArgSpec],
#[allow(clippy::type_complexity)]
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
},
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
/// pairs — the render/serialize surface (`construction_args`).
Configured { values: Vec<ConstructionArg> },
} }
/// A node's declared interface: its inputs (in order) and its output record — an /// A node's declared interface: its inputs (in order) and its output record — an
@@ -835,6 +1075,132 @@ mod tests {
fn doc_gate_accepts_a_meaning_line() { fn doc_gate_accepts_a_meaning_line() {
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(())); assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
} }
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
const DEMO_ARG_SPECS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// A minimal `make`: the fixture does not need `values` to shape a real
/// schema (that's `Session::make`'s job, Task 2) — it only proves
/// `try_args` reaches `make` with parsed values and carries the result
/// forward as `Configured`.
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let _ = values;
PrimitiveBuilder::new(
"Demo",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|_| Box::new(Bare),
)
}
fn demo_pending() -> PrimitiveBuilder {
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
}
#[test]
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
assert_eq!(
plain.try_args(&[("x".into(), "1".into())]).err(),
Some(ArgOpError::NotArgBearing),
);
}
#[test]
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
let plain = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|_| Box::new(Bare),
);
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
assert_eq!(after.schema().doc, "plain doc");
assert!(after.construction_args().is_empty());
}
#[test]
fn try_args_unknown_arg_names_the_first_unknown() {
assert_eq!(
demo_pending()
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
.err(),
Some(ArgOpError::UnknownArg("nope".into())),
);
}
#[test]
fn try_args_duplicate_arg_names_the_repeated_name() {
assert_eq!(
demo_pending()
.try_args(&[
("tz".into(), "Europe/Berlin".into()),
("tz".into(), "Europe/Berlin".into()),
("open".into(), "09:30".into()),
])
.err(),
Some(ArgOpError::DuplicateArg("tz".into())),
);
}
#[test]
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
assert_eq!(
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
Some(ArgOpError::MissingArg("open".into())),
);
}
#[test]
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
assert_eq!(
demo_pending()
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
.err(),
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
);
}
/// Strict form IS the canonical form (spec §Closedness): no normalization
/// layer, so a near-miss string refuses rather than being rewritten.
#[test]
fn arg_kind_parse_strict_form_refusals_and_accepts() {
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
assert!(ArgKind::Count.parse("2").is_ok());
}
#[test]
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
let configured = demo_pending()
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
.expect("a full valid set configures");
assert!(!configured.is_pending());
assert_eq!(
configured.construction_args(),
&[
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
ConstructionArg { name: "open".into(), value: "09:30".into() },
],
);
}
#[test]
fn pending_arg_specs_are_visible_before_configuration() {
let pending = demo_pending();
assert!(pending.is_pending());
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
assert!(pending.construction_args().is_empty());
}
#[test]
#[should_panic(expected = "unconfigured")]
fn pending_build_panics() {
let _ = demo_pending().build(&[]);
}
} }
#[cfg(test)] #[cfg(test)]
+21 -8
View File
@@ -2,7 +2,7 @@
//! //!
//! A research project compiles to a cdylib exporting ONE symbol, //! A research project compiles to a cdylib exporting ONE symbol,
//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers: //! `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 //! namespace — all C-compatible field types) the host validates BEFORE
//! trusting anything, and a **Rust tier** (the vocabulary resolver and the //! trusting anything, and a **Rust tier** (the vocabulary resolver and the
//! enumerable type-id list) the host touches only after both stamps match. //! 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). /// The compiling rustc's `--version` line (via build.rs).
pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION"); pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION");
/// This aura-core's crate version. /// Build-identity fingerprint of aura-core's own sources (via build.rs),
pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION"); /// 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. /// C-ABI string slice: pointer + length over `'static` UTF-8 bytes.
/// No NUL convention needed; readable without trusting the Rust ABI. /// No NUL convention needed; readable without trusting the Rust ABI.
@@ -63,7 +66,7 @@ pub struct ProjectDescriptor {
pub magic: u64, pub magic: u64,
pub descriptor_version: u32, pub descriptor_version: u32,
pub rustc_version: StrSlice, pub rustc_version: StrSlice,
pub aura_core_version: StrSlice, pub aura_core_fingerprint: StrSlice,
pub namespace: StrSlice, pub namespace: StrSlice,
// ---- Rust tier: only after both stamps match ---- // ---- Rust tier: only after both stamps match ----
pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>, pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>,
@@ -86,8 +89,8 @@ macro_rules! aura_project {
rustc_version: $crate::project::StrSlice::new( rustc_version: $crate::project::StrSlice::new(
$crate::project::RUSTC_VERSION, $crate::project::RUSTC_VERSION,
), ),
aura_core_version: $crate::project::StrSlice::new( aura_core_fingerprint: $crate::project::StrSlice::new(
$crate::project::CORE_VERSION, $crate::project::CORE_FINGERPRINT,
), ),
namespace: $crate::project::StrSlice::new($ns), namespace: $crate::project::StrSlice::new($ns),
vocabulary: $vocab, vocabulary: $vocab,
@@ -117,7 +120,17 @@ mod tests {
#[test] #[test]
fn stamps_are_baked_and_nonempty() { fn stamps_are_baked_and_nonempty() {
assert!(RUSTC_VERSION.starts_with("rustc ")); 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] #[test]
@@ -152,7 +165,7 @@ mod tests {
assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION); assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION);
assert_eq!(unsafe { d.namespace.as_str() }, Some("t")); assert_eq!(unsafe { d.namespace.as_str() }, Some("t"));
assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION)); 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!((d.vocabulary)("nope").is_none());
assert_eq!((d.type_ids)(), &["t::A"]); assert_eq!((d.type_ids)(), &["t::A"]);
} }
+274 -8
View File
@@ -15,7 +15,8 @@
//! C23) and no external dependency (C16). //! C23) and no external dependency (C16).
use aura_core::{ use aura_core::{
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind, BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
}; };
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target}; use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
@@ -194,6 +195,40 @@ pub struct GangMember {
pub name: String, 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 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), /// 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 /// interior edges (local indices), input roles (role `r` fans into the interior
@@ -240,6 +275,37 @@ impl Composite {
pub fn doc(&self) -> Option<&str> { pub fn doc(&self) -> Option<&str> {
self.doc.as_deref() self.doc.as_deref()
} }
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
/// interior — nodes, edges, roles, output — is untouched; only `name()`
/// changes, so a fetched, registered subgraph renders (and path-prefixes
/// its `param_space()`, via `collect_params`'s composite arm) under the
/// caller-chosen instance identifier instead of its own authored name.
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
self.name = name.into();
self
}
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
/// same underlying mechanic every bind goes through
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
/// nested composite frames by node/composite-name prefix — the segmentation
/// `reopen_in` (#246) also walks, but binding an open param instead of
/// unbinding a bound one. A path matching no node at any level is
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
/// param lives there); a path that reaches a primitive but whose leaf
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
/// own fault to carry the full qualified path in place of the bare leaf
/// name, so every fault out of this walk names the same thing: the path
/// the caller wrote. A path landing on a GANGED member's own param is
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
/// it directly would silently de-fuse the gang (the member frozen while
/// the gang's public knob keeps driving its siblings); the gang's own
/// public knob stays unbindable at the use seam (accepted residue). On
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
/// posture `reopen` uses.
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
Ok(self)
}
/// Install declared measurement taps (the output-side twin of `input_roles`). /// Install declared measurement taps (the output-side twin of `input_roles`).
/// Fluent, mirroring `with_doc`. Empty by default. /// Fluent, mirroring `with_doc`. Empty by default.
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self { pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
@@ -297,6 +363,24 @@ impl Composite {
out 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 /// 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 /// param returns to the open surface at the slot `collect_params` order
/// dictates; its bound value is forgotten by this value (the authored /// dictates; its bound value is forgotten by this value (the authored
@@ -510,7 +594,8 @@ pub enum ReopenError {
/// One entry of the aggregated BOUND param surface (#246): the path-qualified /// One entry of the aggregated BOUND param surface (#246): the path-qualified
/// twin of [`ParamSpec`] carrying the bound value — the default a sweep axis /// 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)] #[derive(Debug, Clone, PartialEq)]
pub struct BoundSpec { pub struct BoundSpec {
pub name: String, pub name: String,
@@ -1066,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 /// Recursive mutable walk for `Composite::reopen` (#246): mirrors
/// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns /// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns
/// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns /// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns
@@ -1097,6 +1207,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
hits hits
} }
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
/// `full_path` is the whole original path (named in every fault this
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
/// its (node, original-pos) is checked against every gang member; a hit
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
/// the member out from under its gang's public knob (review finding,
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
/// `BlueprintNode`s by value rather than mutating through a reference.
fn bind_path_in(
items: &mut Vec<BlueprintNode>,
gangs: &[Gang],
full_path: &str,
rest: &str,
value: Scalar,
) -> Result<(), BindOpError> {
// The prefix this frame has already consumed off `full_path` (composite
// names and up) — used to qualify a gang's public name the same way
// `collect_params` does, so the refusal below names the SAME address
// `param_space()` would show for that gang.
let prefix = &full_path[..full_path.len() - rest.len()];
for i in 0..items.len() {
let child_rest = match &items[i] {
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
};
let Some(child_rest) = child_rest else { continue };
let child_rest = child_rest.to_string();
// Gang guard (#317 follow-up review finding): a raw path landing on a
// GANGED member's own param must not silently freeze it while the
// gang's public knob keeps driving its siblings — refuse by
// identifier before ever reaching `try_bind`. The gang's own public
// knob staying unbindable at the use seam is accepted residue.
if let BlueprintNode::Primitive(b) = &items[i]
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
{
return Err(BindOpError::AlreadyGanged {
param: full_path.to_string(),
gang: format!("{prefix}{}", g.name),
});
}
let item = items.remove(i);
return match item {
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
Ok(b2) => {
items.insert(i, BlueprintNode::Primitive(b2));
Ok(())
}
Err(e) => Err(qualify_bind_error(e, full_path)),
},
BlueprintNode::Composite(mut c) => {
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
items.insert(i, BlueprintNode::Composite(c));
r
}
};
}
// no node at any level matched `rest`'s leading segment: no open param
// lives at this path.
Err(BindOpError::UnknownParam(full_path.to_string()))
}
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
/// visibility past the one primitive it was called on).
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
match e {
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
BindOpError::KindMismatch { expected, got, .. } => {
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
}
// `try_bind` never raises this one (it has no gang awareness) — the
// gang guard above constructs it directly, already fully qualified.
// Kept here only so this match stays total over `BindOpError`.
BindOpError::AlreadyGanged { gang, .. } => {
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
}
}
}
/// Read-only twin of `collect_params` over the BOUND surface (#246): same /// Read-only twin of `collect_params` over the BOUND surface (#246): same
/// recursion shape and prefix rules, but enumerating `bound_params()` (with /// recursion shape and prefix rules, but enumerating `bound_params()` (with
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang /// values) instead of the open `params()`. Gangs are irrelevant here — a gang
@@ -1401,6 +1597,19 @@ mod tests {
use aura_strategy::Bias; use aura_strategy::Bias;
use std::sync::mpsc; 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 /// One knob fanning into two sibling open params passes the gate; the
/// value carries the gang table. /// value carries the gang table.
#[test] #[test]
@@ -1703,7 +1912,7 @@ mod tests {
Composite::new( Composite::new(
"sig3", "sig3",
vec![ vec![
LinComb::builder(2).into(), LinComb::configured(2).into(),
Bias::builder().into(), Bias::builder().into(),
Sma::builder().named("c").into(), Sma::builder().named("c").into(),
], ],
@@ -1742,7 +1951,7 @@ mod tests {
Composite::new( Composite::new(
"sig3", "sig3",
vec![ vec![
LinComb::builder(2).into(), LinComb::configured(2).into(),
Bias::builder().into(), Bias::builder().into(),
Sma::builder().named("c").into(), Sma::builder().named("c").into(),
], ],
@@ -3115,7 +3324,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1]) // outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new( let strategy = Composite::new(
"strategy", "strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()], vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
vec![], vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }], vec![OutField { node: 0, field: 0, name: "out".into() }],
@@ -3213,7 +3422,8 @@ mod tests {
} }
/// #246: bound_param_space mirrors param_space's path qualification and /// #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] #[test]
fn bound_param_space_is_path_qualified_with_values() { fn bound_param_space_is_path_qualified_with_values() {
use aura_strategy::Bias; use aura_strategy::Bias;
@@ -3323,7 +3533,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1]) // outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new( let strategy = Composite::new(
"strategy", "strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()], vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
// fan fast_slow's output into both LinComb terms so every interior slot // fan fast_slow's output into both LinComb terms so every interior slot
// is wired (the totality check, cycle 0040); param order is unaffected. // is wired (the totality check, cycle 0040); param order is unaffected.
vec![ vec![
@@ -3383,7 +3593,7 @@ mod tests {
use aura_std::{LinComb, Sma}; use aura_std::{LinComb, Sma};
let bp = Composite::new( let bp = Composite::new(
"root", "root",
vec![Sma::builder().into(), LinComb::builder(2).into()], vec![Sma::builder().into(), LinComb::configured(2).into()],
vec![], vec![],
vec![], vec![],
vec![], // output vec![], // output
@@ -3789,4 +3999,60 @@ mod tests {
// the inner Sub lowered to flat node 0; the hoisted tap points at it // 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 }]); 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");
}
} }
+307 -26
View File
@@ -7,18 +7,29 @@
//! closure and the declared schema are re-derived on load from the injected //! closure and the declared schema are re-derived on load from the injected
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it //! resolver. This is the inverse of the lossy render half (`model_to_json`); it
//! carries no node logic (C17) and references a closed vocabulary (C24). //! carries no node logic (C17) and references a closed vocabulary (C24).
//!
//! Construction args (#271) are structural, id-bearing data — like `bound`
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
//! `PrimitiveData.args` untouched.
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role}; use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge; use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder}; use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
/// The format version the loader understands. Bumped only by a load-bearing /// The CEILING format version this build's loader understands (and the
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship /// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
/// dormancy (#61, 2026-07-10): while the project is unshipped every document /// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump /// 2026-07-10): while the project is unshipped every document lives in-repo
/// discipline activates at the first external ship — which consciously /// and reader/writer change atomically, so the Tier-2 bump discipline
/// freezes v1 (gangs included). /// activates at the first external ship.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1; ///
/// #271 (data-driven version): the writer no longer emits a single fixed
/// version. A document emits `1` when args-free (byte-identical to every
/// pre-#271 document — content ids stable, C18) and `2` the moment any
/// primitive, at any nesting depth, carries construction `args` — the
/// must-understand signal a pre-#271 loader needs. The loader accepts the
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
/// Top-level envelope: the version is read before the payload is interpreted. /// Top-level envelope: the version is read before the payload is interpreted.
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
@@ -61,24 +72,64 @@ pub enum NodeData {
} }
/// A primitive node as data: its compiled-in type identity, optional instance /// A primitive node as data: its compiled-in type identity, optional instance
/// name, and bound params. The schema + build closure are re-derived on load. /// name, its accepted construction args (#271), and bound params. The schema
/// + build closure are re-derived on load.
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct PrimitiveData { pub struct PrimitiveData {
#[serde(rename = "type")] #[serde(rename = "type")]
pub type_id: String, pub type_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
/// The consumed `(name, value)` construction-arg pairs (#271) — the
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
/// (empty) for every args-free primitive, so a document with no args
/// anywhere stays byte-identical to its pre-#271 form.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound: Vec<BoundParam>, pub bound: Vec<BoundParam>,
} }
/// One serialized construction-arg pair (#271) — the wire twin of
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
/// string, stored verbatim (never re-normalized).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ArgData {
pub name: String,
pub value: String,
}
/// Serializer failure (typed, named). /// Serializer failure (typed, named).
#[derive(Debug)] #[derive(Debug)]
pub enum SerializeError { pub enum SerializeError {
Json(serde_json::Error), 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 // Canonical gang order (bind-order-independent, mirroring `project_node`'s
// bound-param canonicalization below): each gang's members ascending // bound-param canonicalization below): each gang's members ascending
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted) // `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
@@ -89,21 +140,30 @@ fn project(c: &Composite) -> CompositeData {
} }
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos)); gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
CompositeData { Ok(CompositeData {
name: c.name().to_string(), name: c.name().to_string(),
doc: c.doc().map(str::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(), edges: c.edges().to_vec(),
input_roles: c.input_roles().to_vec(), input_roles: c.input_roles().to_vec(),
output: c.output().to_vec(), output: c.output().to_vec(),
taps: c.taps().to_vec(), taps: c.taps().to_vec(),
gangs, gangs,
} })
} }
fn project_node(n: &BlueprintNode) -> NodeData { fn project_node(n: &BlueprintNode) -> Result<NodeData, SerializeError> {
match n { match n {
BlueprintNode::Primitive(b) => { 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 // Canonical: bound params in ascending original-slot order, mirroring
// the loader's re-bind canonicalization, so serialization is // the loader's re-bind canonicalization, so serialization is
// bind-order-independent (a precondition for content-addressing, #158). // bind-order-independent (a precondition for content-addressing, #158).
@@ -111,18 +171,52 @@ fn project_node(n: &BlueprintNode) -> NodeData {
// construction once a multi-param node enters the vocabulary. // construction once a multi-param node enters the vocabulary.
let mut bound = b.bound_params().to_vec(); let mut bound = b.bound_params().to_vec();
bound.sort_by_key(|bp| bp.pos); bound.sort_by_key(|bp| bp.pos);
NodeData::Primitive(PrimitiveData { // Construction args (#271) are declared in a fixed `ArgSpec` order
// (not player-chosen like binds), so no re-canonicalization is
// needed — `construction_args()` already returns them in that
// deterministic order.
let args: Vec<ArgData> = b
.construction_args()
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
Ok(NodeData::Primitive(PrimitiveData {
type_id: b.label(), type_id: b.label(),
name: b.instance_name().map(str::to_string), name: b.instance_name().map(str::to_string),
args,
bound, bound,
}) }))
} }
BlueprintNode::Composite(c) => NodeData::Composite(project(c)), BlueprintNode::Composite(c) => Ok(NodeData::Composite(project(c)?)),
} }
} }
fn build_doc(c: &Composite) -> BlueprintDoc { /// Any primitive, at any nesting depth, carries construction args (#271) —
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) } /// the must-understand signal for the data-driven version (spec §Data-driven
/// format version).
fn has_args(b: &CompositeData) -> bool {
b.nodes.iter().any(|n| match n {
NodeData::Primitive(p) => !p.args.is_empty(),
NodeData::Composite(c) => has_args(c),
})
}
/// The version THIS document must declare (#271): `1` for an args-free
/// document (byte-identical to every pre-#271 document, C18) or `2` the
/// moment any primitive anywhere carries args — never the fixed
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
fn document_version(b: &CompositeData) -> u32 {
if has_args(b) {
2
} else {
1
}
}
fn build_doc(c: &Composite) -> Result<BlueprintDoc, SerializeError> {
let blueprint = project(c)?;
let format_version = document_version(&blueprint);
Ok(BlueprintDoc { format_version, blueprint })
} }
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> { fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
@@ -133,7 +227,7 @@ fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is /// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form. /// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> { 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 /// The identity-canonical form (#171): the canonical document with every
@@ -146,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 /// 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). /// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> { 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); strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc) serialize_doc(&doc)
} }
@@ -200,11 +294,16 @@ pub enum LoadError {
/// addition needs finer granularity than a version bump. /// addition needs finer granularity than a version bump.
UnsupportedVersion { found: u32, supported: u32 }, UnsupportedVersion { found: u32, supported: u32 },
/// `resolve` returned `None` for a serialized `type_id` (outside the injected /// `resolve` returned `None` for a serialized `type_id` (outside the injected
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set). /// vocabulary — unknown node, or a sink node not in #155's set).
UnknownNodeType(String), UnknownNodeType(String),
/// The document's gangs section fails structural validation against its /// The document's gangs section fails structural validation against its
/// own nodes (a hand-edited or corrupted document). /// own nodes (a hand-edited or corrupted document).
Gang(crate::blueprint::CompileError), Gang(crate::blueprint::CompileError),
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
/// an arg-bearing type serialized/hand-written with an empty `args` —
/// a document naming an arg-bearing type without args must not load.
BadArg(aura_core::ArgOpError),
} }
fn reconstruct( fn reconstruct(
@@ -220,6 +319,15 @@ fn reconstruct(
if let Some(n) = &p.name { if let Some(n) = &p.name {
b = b.named(n); b = b.named(n);
} }
// Apply construction args (#271) BEFORE bound params — the
// `try_args` seam. An arg-bearing type with no `args` in the
// document refuses HERE as `MissingArg` (no pending builder
// ever reaches `bind`/enters the built graph); a `Plain` type
// has empty `p.args`, so this is `Ok(self)` unchanged for
// every pre-#271 document.
let args: Vec<(String, String)> =
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
b = b.try_args(&args).map_err(LoadError::BadArg)?;
// Re-apply bound params BY NAME. The effective param vector is // Re-apply bound params BY NAME. The effective param vector is
// order-independent (each `bind` computes its slot against the // order-independent (each `bind` computes its slot against the
// shrunk schema); ascending original `pos` is the canonical order. // shrunk schema); ascending original `pos` is the canonical order.
@@ -251,7 +359,9 @@ pub fn blueprint_from_json(
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>, resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> { ) -> Result<Composite, LoadError> {
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?; let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
if doc.format_version != BLUEPRINT_FORMAT_VERSION { // #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
return Err(LoadError::UnsupportedVersion { return Err(LoadError::UnsupportedVersion {
found: doc.format_version, found: doc.format_version,
supported: BLUEPRINT_FORMAT_VERSION, supported: BLUEPRINT_FORMAT_VERSION,
@@ -266,7 +376,8 @@ mod tests {
use crate::blueprint::{Composite, GangMember, OutField, Role}; use crate::blueprint::{Composite, GangMember, OutField, Role};
use crate::harness::{Edge, Target}; use crate::harness::{Edge, Target};
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923) use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
use aura_core::{Scalar, ScalarKind, Timestamp}; use aura_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
use aura_market::Session;
use aura_std::{Recorder, Sma, Sub}; use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias; use aura_strategy::Bias;
use std::sync::mpsc; use std::sync::mpsc;
@@ -436,11 +547,14 @@ mod tests {
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode")); assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
} }
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
/// used to be refused (v1 was the only understood version); now that v2 loads
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
#[test] #[test]
fn unsupported_version_fails_named() { fn unsupported_version_fails_named() {
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#; let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap(); let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 })); assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
} }
#[test] #[test]
@@ -813,4 +927,171 @@ mod tests {
"the gang itself is identity-bearing" "the gang itself is identity-bearing"
); );
} }
// ---- construction args (#271) --------------------------------------
// A single-node composite around an arg-configured `Session` (arity-free —
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
Composite::new(
"sess",
vec![Session::configured(9, 30, tz, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
)
}
/// #271 acceptance (engine layer): an args-bearing composite serializes its
/// construction-arg pairs, declares `format_version: 2` (the data-driven
/// must-understand tier, spec §Data-driven format version), and round-trips
/// byte-stably through load -> re-serialize.
#[test]
fn args_round_trip_preserves_pairs_and_version_2() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
let json = blueprint_to_json(&c).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
assert!(
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
"carries the verbatim accepted arg pairs: {json}"
);
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
json,
"args round-trip byte-stably"
);
}
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
/// here under the #271-specific test name the plan names.
#[test]
fn args_free_document_serializes_byte_identical_and_version_1() {
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&signal).expect("serializes");
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
}
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
/// outer composite still trips the outer document's version to 2 — the
/// version walk recurses through `NodeData::Composite`, not just the
/// top-level node list.
#[test]
fn nested_spliced_args_composite_is_version_2() {
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
let outer = Composite::new(
"outer",
vec![crate::blueprint::BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
}
/// #271 (identity is arg-bearing): construction args are structural,
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
/// two builds differing ONLY in one accepted arg value never share an
/// identity JSON, while renaming the composite (a pure debug symbol)
/// still does not affect it.
#[test]
fn identity_json_retains_args() {
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
assert_ne!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&paris).expect("identity-serializes"),
"a differing construction-arg value survives the identity projection"
);
// renaming the composite (a debug symbol) does not affect identity.
let renamed = Composite::new(
"sess_renamed",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
assert_eq!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&renamed).expect("identity-serializes"),
"the composite's own debug name does not affect identity"
);
}
/// #271 (refuse, don't guess): a hand-written document naming an
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
/// must never silently enter the built graph.
#[test]
fn loading_arg_bearing_type_without_args_refuses() {
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
assert!(
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
/// #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");
}
} }
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -52,12 +52,13 @@ mod sweep;
mod walkforward; mod walkforward;
pub use blueprint::{ pub use blueprint::{
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault, name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire, GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
Tap, TapWire,
}; };
pub use blueprint_serde::{ pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData, blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION, CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
}; };
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle}; pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
pub use construction::{replay, GraphSession, Op, OpError}; pub use construction::{replay, GraphSession, Op, OpError};
@@ -88,7 +89,8 @@ pub use walkforward::{
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar / // (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
// Firing / Timestamp) so a graph builder has one import surface, not two. // Firing / Timestamp) so a graph builder has one import surface, not two.
pub use aura_core::{ pub use aura_core::{
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp, ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
Timestamp,
}; };
#[cfg(test)] #[cfg(test)]
+14 -14
View File
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
fn signal_ops() -> Vec<Op> { fn signal_ops() -> Vec<Op> {
vec![ vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }, Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
#[test] #[test]
fn replayed_construction_compiles_identically_to_graphbuilder() { fn replayed_construction_compiles_identically_to_graphbuilder() {
// (a) op-script replay through the public construction surface. // (a) op-script replay through the public construction surface.
let replay_flat = replay("root", signal_ops(), &std_vocabulary) let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
.expect("op-script resolves") .expect("op-script resolves")
.compile_with_params(&params()) .compile_with_params(&params())
.expect("replay compiles"); .expect("replay compiles");
@@ -96,14 +96,14 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
#[test] #[test]
fn replay_attributes_eager_fault_to_its_op_index() { fn replay_attributes_eager_fault_to_its_op_index() {
let ops = vec![ let ops = vec![
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 0 ok
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 1 ok Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 1 ok
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // 2 fails Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, // 3 never runs
]; ];
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build // `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
// closures and is not `Debug`, which `unwrap_err`'s bound would require. // closures and is not `Debug`, which `unwrap_err`'s bound would require.
let err = replay("g", ops, &std_vocabulary).err().unwrap(); let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into()))); assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
} }
@@ -120,15 +120,15 @@ fn replay_attributes_eager_fault_to_its_op_index() {
fn replay_attributes_holistic_fault_to_finalize_step() { fn replay_attributes_holistic_fault_to_finalize_step() {
let ops = vec![ let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0 Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1 Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 1
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2 Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, // 2
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 3 Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 3
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4 Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5 Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
// sub.rhs deliberately left unwired -> only finalize can decide totality. // sub.rhs deliberately left unwired -> only finalize can decide totality.
]; ];
let finalize_index = ops.len(); // 6 — the implicit finalize step let finalize_index = ops.len(); // 6 — the implicit finalize step
let err = replay("g", ops, &std_vocabulary).err().unwrap(); let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index), assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
"the holistic fault is still attributed to the finalize step, got {err:?}"); "the holistic fault is still attributed to the finalize step, got {err:?}");
} }
+1 -1
View File
@@ -110,7 +110,7 @@ fn build_harness(
Resample::builder().schema().clone(), // 0 Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1 Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2 Gt::builder().schema().clone(), // 2
Session::builder(9, 0, Berlin, 15).schema().clone(), // 3 Session::configured(9, 0, Berlin, 15).schema().clone(), // 3
EqConst::builder().schema().clone(), // 4 EqConst::builder().schema().clone(), // 4
EqConst::builder().schema().clone(), // 5 EqConst::builder().schema().clone(), // 5
And::builder().schema().clone(), // 6 And::builder().schema().clone(), // 6
+1 -1
View File
@@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec<f64> {
let sq = g.add(Mul::builder()); // dev * dev let sq = g.add(Mul::builder()); // dev * dev
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
let sigma = g.add(Sqrt::builder()); let sigma = g.add(Sqrt::builder());
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma let band = g.add(LinComb::configured(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
let upper = g.add(Add::builder()); // mean + k*sigma let upper = g.add(Add::builder()); // mean + k*sigma
let lower = g.add(Sub::builder()); // mean - k*sigma let lower = g.add(Sub::builder()); // mean - k*sigma
let gt_hi = g.add(Gt::builder()); // price > upper let gt_hi = g.add(Gt::builder()); // price > upper
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
Resample::builder().schema().clone(), // 0 Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1 Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2 Gt::builder().schema().clone(), // 2
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES) Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
.schema() .schema()
.clone(), // 3 .clone(), // 3
EqConst::builder().schema().clone(), // 4 EqConst::builder().schema().clone(), // 4
@@ -248,7 +248,7 @@ pub fn ger40_breakout_blueprint(
); );
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let gt = g.add(Gt::builder()); let gt = g.add(Gt::builder());
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes)); let session = g.add(Session::configured(open_hour, open_minute, tz, bar_period_minutes));
// The ONLY two params: the EqConst targets, named so the space reads // The ONLY two params: the EqConst targets, named so the space reads
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND. // `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
let entry_bar = g.add(EqConst::builder().named("entry_bar")); let entry_bar = g.add(EqConst::builder().named("entry_bar"));
+82 -9
View File
@@ -33,10 +33,17 @@
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`. //! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
use aura_core::{ use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder, Scalar, ScalarKind,
}; };
use chrono::{TimeZone, Timelike}; use chrono::{TimeZone, Timelike};
/// The declared construction args of a `Session` recipe (spec §Concrete code
/// shapes): the timezone and local open time — structural, non-scalar
/// configuration, never a swept param.
const SESSION_ARGS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local /// Frankfurt-session bar indexer. Holds the open offset (minutes past local
/// midnight), the IANA timezone, and the bar period — baked at construction, /// midnight), the IANA timezone, and the bar period — baked at construction,
/// never streamed and never a swept param (a timezone is not a scalar). /// never streamed and never a swept param (a timezone is not a scalar).
@@ -60,23 +67,57 @@ impl Session {
} }
} }
/// The param-generic recipe for a blueprint primitive. The open time, /// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
/// timezone, and period are **structural** config baked into the closure /// timezone and open time come through the `args` channel (`try_args`),
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the /// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
/// declared param list is empty and the `build_fn` ignores its slice (like /// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
/// `Gt` / `Latch`). pub fn builder() -> PrimitiveBuilder {
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder { PrimitiveBuilder::pending(
"Session",
"bars elapsed since the session open, from the configured open time and timezone",
SESSION_ARGS,
Self::make,
)
}
/// Turn a validated `(tz, open)` value pair into the real signature: one
/// `trigger` input, one `bars_since_open` output, and `period_minutes` as
/// the sole (now scalar) `ParamSpec` — read from the param slice at build.
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let tz = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("tz", ArgValue::Tz(tz)) => Some(*tz),
_ => None,
})
.expect("try_args validated `tz` as ArgKind::Tz before calling make");
let (open_hour, open_minute) = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("open", ArgValue::TimeOfDay { hour, minute }) => Some((*hour, *minute)),
_ => None,
})
.expect("try_args validated `open` as ArgKind::TimeOfDay before calling make");
PrimitiveBuilder::new( PrimitiveBuilder::new(
"Session", "Session",
NodeSchema { NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }], inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }], output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![], params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
doc: "bars elapsed since the session open, from the configured open time and timezone", doc: "bars elapsed since the session open, from the configured open time and timezone",
}, },
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)), move |p| Box::new(Session::new(open_hour, open_minute, tz, p[0].i64())),
) )
} }
/// Rust-path convenience — the same recipe as the data path (twin
/// identity): `builder().try_args([tz, open]) + .bind("period_minutes", …)`.
pub fn configured(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
Self::builder()
.try_args(&[("tz".to_string(), tz.name().to_string()), ("open".to_string(), format!("{open_hour:02}:{open_minute:02}"))])
.expect("configured: a valid chrono_tz::Tz and in-range hour/minute always parse")
.bind("period_minutes", Scalar::i64(period_minutes))
}
} }
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open /// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
@@ -244,4 +285,36 @@ mod tests {
let inputs = trigger_input(); let inputs = trigger_input();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
} }
/// Twin identity (spec §aura-market): `configured` is exactly
/// `builder().try_args([tz, open]).bind("period_minutes", …)` — same
/// accepted args, same declared signature, whichever path authored it.
#[test]
fn session_configured_twin_equals_builder_try_args() {
let via_configured = Session::configured(9, 30, Berlin, 15);
let via_try_args = Session::builder()
.try_args(&[("tz".into(), "Europe/Berlin".into()), ("open".into(), "09:30".into())])
.expect("valid tz/open configure")
.bind("period_minutes", Scalar::i64(15));
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
assert_eq!(via_configured.schema(), via_try_args.schema());
}
/// Behaviour re-anchor (#271 Task 2): the DST-aware headline property
/// (local 09:45 reads bar 3 in both CEST summer and CET winter) holds
/// identically when the node is built through `configured` instead of
/// `Session::new` directly.
#[test]
fn session_configured_behaviour_unchanged() {
let mut node = Session::configured(9, 0, Berlin, 15).build(&[]);
let mut inputs = trigger_input();
let summer = Berlin.with_ymd_and_hms(2024, 7, 1, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(summer))).map(|r| r[0].i64()), Some(3));
let winter = Berlin.with_ymd_and_hms(2024, 1, 2, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(winter))).map(|r| r[0].i64()), Some(3));
}
} }
+390 -14
View File
@@ -173,6 +173,95 @@ impl Registry {
} }
} }
/// The label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`,
/// the same "fixed-name sidecar, per-directory isolation" discipline (#191).
fn blueprint_names_path(&self) -> PathBuf {
self.path.with_file_name("blueprint_names.jsonl")
}
/// Raw, ungated append: one label line, unconditionally, under the
/// registry's write mutex (#276). No shape or content-id check —
/// [`Registry::put_blueprint_label`] is the gated public entry; this
/// exists so tests can hand-append a stale line, mirroring
/// `append_identity_index`.
fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let path = self.blueprint_names_path();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&BlueprintNameLine {
name: name.to_string(),
content_id: content_id.to_string(),
})
.expect("two plain strings serialize");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}`
/// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated
/// on a deterministic label shape (nonempty after trim, no whitespace, no
/// control characters — `RegistryError::BadLabel`, C29 discipline: shape
/// only, never content judgement) and on `content_id` resolving via
/// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint`
/// otherwise) — both checked BEFORE the write lock is taken. Re-labelling
/// an existing name appends a new line (a repoint); resolution is
/// latest-wins (see [`Registry::resolve_blueprint_label`]).
pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
gate_label_shape(name)?;
if self.get_blueprint(content_id)?.is_none() {
return Err(RegistryError::UnknownBlueprint(content_id.to_string()));
}
self.append_blueprint_label_line(name, content_id)
}
/// Resolve a label to the content id of the LATEST line naming it, skipping
/// any line whose content id no longer resolves via `get_blueprint`
/// (self-repair spirit, #191) — so "latest" always means the latest VALID
/// entry, even when a later append points at a since-vanished id. Missing
/// sidecar file -> `None` (treat-as-empty, like `load_identity_index`).
pub fn resolve_blueprint_label(&self, name: &str) -> Option<String> {
self.latest_blueprint_labels().remove(name)
}
/// Every registered label, latest-wins-deduped and name-sorted — the
/// discovery surface's data source (`graph introspect --registered`).
/// Dangling entries (content id no longer resolves) are skipped.
pub fn blueprint_labels(&self) -> Vec<(String, String)> {
self.latest_blueprint_labels().into_iter().collect()
}
/// Every content id currently stored in the blueprint store — the
/// candidate list a `use` ref's content-id-PREFIX resolution (#302
/// semantics, #317) filters against, mirroring `list_process_ids`/
/// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")`
/// name the same directory, so `list_doc_ids`'s filename-stem walk
/// applies unchanged.
pub fn list_blueprint_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("blueprints")
}
/// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl`
/// into a name -> content_id map (`BTreeMap` gives the name-sorted order
/// `blueprint_labels` promises for free). A garbage line (torn write,
/// manual edit) is skipped, like every other JSONL sidecar read in this
/// module.
fn latest_blueprint_labels(&self) -> std::collections::BTreeMap<String, String> {
let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else {
return std::collections::BTreeMap::new();
};
let mut map = std::collections::BTreeMap::new();
for raw in text.lines() {
let Ok(line) = serde_json::from_str::<BlueprintNameLine>(raw) else { continue };
if self.get_blueprint(&line.content_id).ok().flatten().is_some() {
map.insert(line.name, line.content_id);
}
}
map
}
/// The content-addressed document-store dir for one document kind — /// The content-addressed document-store dir for one document kind —
/// a sibling of the runs store, like `blueprints/`. /// a sibling of the runs store, like `blueprints/`.
fn doc_dir(&self, kind: &str) -> PathBuf { fn doc_dir(&self, kind: &str) -> PathBuf {
@@ -273,10 +362,42 @@ impl Registry {
} }
} }
/// One label->content mapping, one JSON object per line in
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
/// shape, mirrored for the label store.
#[derive(serde::Serialize, serde::Deserialize)]
struct BlueprintNameLine {
name: String,
content_id: String,
}
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
/// after trim, no whitespace, no control characters. String shape only —
/// never content judgement (the C29 discipline `doc_gate` also follows).
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
if name.trim().is_empty() {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
}
if name.chars().any(char::is_whitespace) {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
}
if name.chars().any(|c| c.is_control()) {
return Err(RegistryError::BadLabel {
name: name.to_string(),
rule: "must not contain control characters",
});
}
Ok(())
}
/// C29 walk (#316): the root and every named nested composite must carry a /// C29 walk (#316): the root and every named nested composite must carry a
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are /// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
/// the Empty fault ("carries no doc"). /// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> { /// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
/// before it enters a new composition (fail fast on a doc-less fetched
/// entry) — the one walk, two call sites (register's write-path gate, use's
/// read-path gate), never a second implementation.
pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
let fault = match c.doc.as_deref() { let fault = match c.doc.as_deref() {
None => Some(aura_core::DocGateFault::Empty), None => Some(aura_core::DocGateFault::Empty),
Some(d) => aura_core::doc_gate(&c.name, d).err(), Some(d) => aura_core::doc_gate(&c.name, d).err(),
@@ -300,7 +421,18 @@ pub enum RefFault {
StrategyNotFound(String), StrategyNotFound(String),
IdentityUnmatched(String), IdentityUnmatched(String),
StrategyUnloadable { id: String, error: 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 }, AxisKindMismatch { strategy: String, axis: String },
/// An open param of the resolved strategy is bound by no campaign axis — /// An open param of the resolved strategy is bound by no campaign axis —
/// the executor's every-open-knob-required rule, mirrored at validate /// the executor's every-open-knob-required rule, mirrored at validate
@@ -402,10 +534,22 @@ impl Registry {
}); });
} }
} }
None => faults.push(RefFault::AxisNotInParamSpace { None => {
strategy: label.clone(), // #328 did-you-mean: strip one leading segment off the
axis: axis.clone(), // 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 // The reverse direction: every open param must be bound by some
@@ -938,6 +1082,14 @@ pub enum RegistryError {
/// the store without a gate-passing doc. Registered artifacts are never /// the store without a gate-passing doc. Registered artifacts are never
/// retroactively invalidated — the gate guards the write path only. /// retroactively invalidated — the gate guards the write path only.
UndescribedComposite { name: String, fault: aura_core::DocGateFault }, UndescribedComposite { name: String, fault: aura_core::DocGateFault },
/// Label sidecar (#317): a `put_blueprint_label` name failing the
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
/// control characters). By-identifier, naming the violated rule.
BadLabel { name: String, rule: &'static str },
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
/// labels a blueprint the store does not have.
UnknownBlueprint(String),
} }
impl fmt::Display for RegistryError { impl fmt::Display for RegistryError {
@@ -978,6 +1130,12 @@ impl fmt::Display for RegistryError {
its name a registered composite describes itself (C29)" its name a registered composite describes itself (C29)"
), ),
}, },
RegistryError::BadLabel { name, rule } => {
write!(f, "blueprint label {name:?}: {rule}")
}
RegistryError::UnknownBlueprint(id) => {
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
}
} }
} }
} }
@@ -1172,6 +1330,134 @@ mod tests {
); );
} }
/// A described fixture blueprint, ready for `put_blueprint`'s C29 gate —
/// `put_blueprint_label`'s content-id verification routes through
/// `get_blueprint`, so its target must first be a gate-passing entry.
fn described_fixture_blueprint(name: &str) -> String {
format!(
r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"#
)
}
/// Property (#317): a label round-trips to the blueprint it names, and
/// re-labelling the SAME name repoints it — resolution and the discovery
/// listing both answer with the latest target, never the first.
#[test]
fn blueprint_label_round_trips_latest_wins() {
let reg = Registry::open(temp_family_dir("label_round_trip"));
let a = described_fixture_blueprint("a");
let b = described_fixture_blueprint("b");
reg.put_blueprint("ha", &a).expect("put a");
reg.put_blueprint("hb", &b).expect("put b");
reg.put_blueprint_label("x", "ha").expect("label x -> a");
assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string()));
reg.put_blueprint_label("x", "hb").expect("re-label x -> b");
assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins");
assert_eq!(
reg.blueprint_labels(),
vec![("x".to_string(), "hb".to_string())],
"the discovery listing dedups to the latest target",
);
}
/// Property (#317): the label-shape gate is deterministic string shape
/// only (nonempty after trim, no whitespace, no control characters) —
/// each bad shape refuses with `BadLabel` naming a (non-empty) rule.
#[test]
fn blueprint_label_refuses_bad_shapes() {
let reg = Registry::open(temp_family_dir("label_bad_shapes"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
for bad in ["", " ", "a b", "a\tb"] {
match reg.put_blueprint_label(bad, "h1") {
Err(RegistryError::BadLabel { name, rule }) => {
assert_eq!(name, bad);
assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}");
}
other => panic!("expected BadLabel for {bad:?}, got {other:?}"),
}
}
}
/// Property (#317): a label may only ever point at a blueprint the store
/// actually has — labelling an unregistered content id refuses
/// by-identifier, never silently writing a dangling line.
#[test]
fn blueprint_label_refuses_unknown_content_id() {
let reg = Registry::open(temp_family_dir("label_unknown_id"));
match reg.put_blueprint_label("x", "never-written") {
Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"),
other => panic!("expected UnknownBlueprint, got {other:?}"),
}
assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written");
}
/// Property (#317, #191 spirit): a dangling LATEST line (content id no
/// longer resolves) is skipped by resolution, which falls back to the
/// earlier still-valid line for the same name — latest-wins means
/// latest-VALID-wins.
#[test]
fn blueprint_label_resolution_skips_a_dangling_entry() {
let reg = Registry::open(temp_family_dir("label_dangling"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
reg.put_blueprint_label("x", "h1").expect("label x -> h1");
// hand-append a dangling line after the valid one, mirroring
// `append_identity_index`'s stale-line test shape: the content id it
// names was never registered.
reg.append_blueprint_label_line("x", "never-written").expect("append stale line");
assert_eq!(
reg.resolve_blueprint_label("x"),
Some("h1".to_string()),
"the dangling latest line is skipped; the earlier valid line for \"x\" wins",
);
assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]);
}
/// Property (#317, #276 discipline): N threads appending labels through
/// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed —
/// every successful append surviving as its own intact, resolvable line,
/// none torn or merged (mirrors `concurrent_appends_keep_the_family_
/// store_well_formed`).
#[test]
fn concurrent_label_appends_keep_the_sidecar_well_formed() {
let reg = Registry::open(temp_family_dir("concurrent_label"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|tid| {
let reg = &reg;
scope.spawn(move || {
let mut ok = 0usize;
for iter in 0..iters {
let name = format!("t{tid}-l{iter}");
if reg.put_blueprint_label(&name, "h1").is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
assert_eq!(
reg.blueprint_labels().len(),
total_ok,
"every successful label append must survive as its own intact, resolvable entry",
);
}
#[test] #[test]
fn document_stores_round_trip_by_content_id() { fn document_stores_round_trip_by_content_id() {
let reg = Registry::open(temp_family_dir("document_store_round_trip")); let reg = Registry::open(temp_family_dir("document_store_round_trip"));
@@ -1891,14 +2177,12 @@ mod tests {
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t); let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
// A minimal fixture composite with one OPEN param, built from a // A minimal fixture composite with one OPEN param, built from a
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually // plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load. `aura-composites`' shipped composites (vol_stop, // resolve it on load. `aura-composites`' shipped composites (vol_stop,
// risk_executor*) all route through `LinComb`, whose builder needs a // risk_executor*) route through `LinComb`, an arg-bearing type (#271,
// structural arity argument and is therefore deliberately absent from // `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs) // independent of that construction channel, so it stands in as the
// — they cannot round-trip through `blueprint_from_json` with this // "real, open-param, vocabulary-loadable composite" the test needs.
// resolver, so this fixture stands in as the "real, open-param,
// vocabulary-loadable composite" the test needs.
let composite = Composite::new( let composite = Composite::new(
"fixture", "fixture",
vec![aura_strategy::Bias::builder().named("b").into()], vec![aura_strategy::Bias::builder().named("b").into()],
@@ -1990,6 +2274,98 @@ mod tests {
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new()); 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 /// Property (#191): the identity-index sidecar is a last-line-wins cache
/// over identity id → content id — a missing file reads as empty (no /// 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 /// 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, Sweep,
MonteCarlo, MonteCarlo,
WalkForward, 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, CrossInstrument,
} }
+81 -1
View File
@@ -158,6 +158,25 @@ impl TraceStore {
NameKind::NotFound 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 /// Read every member of a family. Two on-disk shapes are resolved: the
/// depth-1 campaign layout (`<name>/<key>/index.json`, `key` = the immediate /// depth-1 campaign layout (`<name>/<key>/index.json`, `key` = the immediate
/// subdir name) and the depth-2 #224 sweep/walk-forward fan-out /// 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::Family => "family",
NameKind::NotFound => "name", 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); 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] #[test]
fn name_kind_classifies_run_family_and_absent() { fn name_kind_classifies_run_family_and_absent() {
let root = temp_traces_root("namekind"); let root = temp_traces_root("namekind");
@@ -656,6 +715,27 @@ mod tests {
let _ = fs::remove_dir_all(&root); 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 /// The load-bearing compatibility pin: for the same rows, the streamed
/// writer's file bytes equal `TraceStore::write`'s — the legacy path's /// writer's file bytes equal `TraceStore::write`'s — the legacy path's
/// bytes, whatever they are, define the shape. Covers all four /// bytes, whatever they are, define the shape. Covers all four
+111 -13
View File
@@ -513,16 +513,17 @@ pub struct Axis {
} }
/// A protective-stop regime: a serializable, content-addressable mirror of the /// A protective-stop regime: a serializable, content-addressable mirror of the
/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is /// runtime `StopRule` structural axis (C10/C20). `Fixed{distance}` (#338) makes
/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive /// the shipped `FixedStop` composite campaign-reachable, beside the vol-stop
/// variant (it runs as a composite today but is not yet campaign-reachable). /// family — `distance` is the same price-unit knob `FixedStop`'s own `distance`
/// Externally tagged so adding `Fixed` is additive — no content-id churn on /// param carries. Externally tagged so each variant was additive on arrival —
/// stored `Vol` regimes. /// no content-id churn on stored `Vol`/`VolTf` regimes.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")] #[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime { pub enum RiskRegime {
Vol { length: i64, k: f64 }, Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, 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 /// 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 }); 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() { for (i, c) in doc.cost.iter().enumerate() {
@@ -1102,7 +1111,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
SlotKind::Strings => "list of strings", SlotKind::Strings => "list of strings",
SlotKind::Windows => "list of { from_ms, to_ms }", SlotKind::Windows => "list of { from_ms, to_ms }",
SlotKind::Ref => "one of { content_id } | { identity_id }", SlotKind::Ref => "one of { content_id } | { identity_id }",
SlotKind::ContentRef => "content id of a process document", SlotKind::ContentRef => "{ content_id }: content id of a process document",
SlotKind::Axes => "map: param name -> { kind, values }", SlotKind::Axes => "map: param name -> { kind, values }",
SlotKind::EmitKinds => "list of: family_table | selection_report", SlotKind::EmitKinds => "list of: family_table | selection_report",
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity", SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
@@ -1301,20 +1310,39 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
} }
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) { if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
// deliberately narrower than the strategy-ref hint: validate_campaign // deliberately narrower than the strategy-ref hint: validate_campaign
// refuses an identity_id process ref, so the guide must not offer it // refuses an identity_id process ref, so the guide must not offer it.
slots.push(open("process.ref", "required, content id of a process document")); // The tagged { content_id } shape is stated explicitly (#329) so the
// hint cannot be misread as accepting a bare id string.
slots.push(open("process.ref", "required, { content_id }: content id of a process document"));
} }
if v.get("seed").and_then(|s| s.as_u64()).is_none() { if v.get("seed").and_then(|s| s.as_u64()).is_none() {
slots.push(open("seed", "required, non-negative integer")); slots.push(open("seed", "required, non-negative integer"));
} }
if v.get("presentation").is_none() { match v.get("presentation") {
slots.push(open( None => slots.push(open(
"presentation", "presentation",
format!( format!(
"required section: persist_taps ({}) + emit", "required section: persist_taps ({}) + emit",
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ") 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) Ok(slots)
} }
@@ -1647,6 +1675,18 @@ mod tests {
assert_eq!(back, r); 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] #[test]
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() { fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
@@ -1705,6 +1745,26 @@ mod tests {
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}"); 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] #[test]
fn validate_campaign_accepts_a_valid_risk_section() { fn validate_campaign_accepts_a_valid_risk_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
@@ -2292,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 /// #256 fork B: the fieldless enumerate-only stage round-trips through
/// the schema-strict parser, and any slot on it is refused (its slot /// 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). /// list is empty, so the generic unknown-slot check covers every key).
@@ -2374,6 +2464,14 @@ mod tests {
label.contains("content"), label.contains("content"),
"process_ref describe must still name the content-id form: {label}" "process_ref describe must still name the content-id form: {label}"
); );
// #329: the label must state the TAGGED shape ({ content_id: ... }) the
// parser actually requires, not read like a bare id string — mirroring
// the `{ content_id } | { identity_id }` bracket notation std::strategy
// already uses, narrowed to the one key process.ref accepts.
assert!(
label.contains("{ content_id }"),
"process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {label}"
);
let strategy = describe_block("std::strategy").expect("strategy describable"); let strategy = describe_block("std::strategy").expect("strategy describable");
let strat_ref = let strat_ref =
@@ -2430,7 +2528,7 @@ mod tests {
"presentation": { "persist_taps": [], "emit": [] } }"#; "presentation": { "persist_taps": [], "emit": [] } }"#;
let slots = open_slots_campaign(draft).unwrap(); let slots = open_slots_campaign(draft).unwrap();
assert!(slots.iter().any(|s| s.path == "process.ref" assert!(slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document")); && s.hint == "required, { content_id }: content id of a process document"));
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow" assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set")); && s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
@@ -2466,7 +2564,7 @@ mod tests {
// ...and the `{}` process ref reports the narrower content-id-only hint. // ...and the `{}` process ref reports the narrower content-id-only hint.
assert!( assert!(
slots.iter().any(|s| s.path == "process.ref" slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"), && s.hint == "required, { content_id }: content id of a process document"),
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}" "process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
); );
} }
+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 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 /// Suffix-join each raw campaign axis onto exactly one wrapped param
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one /// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
/// node segment yields raw), then require every wrapped slot to be covered. /// 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 } 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] #[test]
/// The only shape treated as a direct store address is a bare 64-char /// The only shape treated as a direct store address is a bare 64-char
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex) /// 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 //! The interior-gap-month walk behind a campaign cell's coverage annotation
//! campaign cell's coverage annotation //! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s //! data coverage`'s archive-wide report (#295 dedup: two independent walk
//! archive-wide report — two independent walk implementations before this //! implementations before this module existed) before that verb retired in
//! module existed (#295 dedup). //! favor of per-cell fault isolation (#272, #273).
/// `"YYYY-MM"` rendering of a `(year, month)` pair. /// `"YYYY-MM"` rendering of a `(year, month)` pair.
pub fn fmt_year_month((y, m): (u16, u8)) -> String { 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 /// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
/// month, never a collapsed range: the registry's `CellCoverage::gap_months` /// month, never a collapsed range: the registry's `CellCoverage::gap_months`
/// is a flat list an aggregate counts directly. `months` is assumed sorted /// is a flat list an aggregate counts directly. `months` is assumed sorted
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk /// ([`aura_ingest::list_m1_months`]'s own contract). `DefaultMemberRunner::
/// implementation (#295): both `DefaultMemberRunner::window_coverage` /// window_coverage` (a swept cell's own window) is the sole caller since
/// (a swept cell's own window) and [`data_coverage_report`] (the full /// `aura data coverage`'s archive-wide report retired (#273); the walk stays
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead /// its own function because it was single-sourced with that verb before then
/// of maintaining independent copies. /// (#295).
pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> { 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 from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1); let 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 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -125,37 +63,4 @@ mod tests {
"both interior gap months must be named, in order" "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). //! Family assembly and orchestration (#295).
//! //!
//! The blueprint sweep / walk-forward / Monte-Carlo family builders — pure //! The blueprint sweep / walk-forward / Monte-Carlo family builders that used
//! member-run recipes driven off a [`DataSource`] (the shared synthetic/real //! to live here are retired (#319 — the research-verb quintet's own
//! data provider every family builder threads, plus the `--select` objective //! machinery; the campaign document path, `aura-campaign::exec`, is the
//! [`Selection`] walk-forward resolves its winner under) — together with the //! surviving family-execution surface, with its own independent axis/window/
//! winner-selection (`select_winner`) and axis-grid validation //! winner-selection machinery). What remains is the shared [`DataSource`]
//! (`validate_axis_grid`) machinery they share. No `aura` binary is needed to //! data provider — the synthetic/real price-source abstraction
//! drive a family: the shell (`aura-cli`) wraps these builders with //! `aura_runner::reproduce` drives its own bit-identical re-derivation
//! persistence + stdout rendering (`run_blueprint_sweep`, //! over — and its synthetic stream primitives (`showcase_prices`,
//! `run_blueprint_walkforward`, `run_blueprint_mc`), which stay in the shell, //! `walkforward_prices`, `walkforward_window_source`, `synthetic_walk_sources`).
//! along with the `--select`/`--real` argv grammar (`parse_select`,
//! `select_rule_of`) and the `topology_hash`/`content_id` naming primitive
//! (inlined here instead, mirroring `member::run_signal_r`'s own copy — the
//! CLI shell still needs its own for call sites outside any family builder).
use std::collections::BTreeMap; use std::sync::Arc;
use std::sync::{Arc, Mutex};
use aura_campaign::{catch_member_panic, member_fault_prose, MemberFault}; use aura_core::{Scalar, Timestamp};
use aura_composites::StopRule; use aura_engine::{window_of, SyntheticSpec, VecSource};
use aura_core::{Cell, ParamSpec, Scalar, Timestamp}; use aura_backtest::{WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS};
use aura_engine::{
blueprint_from_json, walk_forward, window_of, BindError, Composite, FamilySelection,
RollMode, RunManifest, SyntheticSpec, VecSource, WindowBounds, WindowRoller,
};
use aura_registry::{
optimize_deflated, optimize_plateau, PlateauMode, DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
};
use aura_backtest::{
monte_carlo, McFamily, RunMetrics, RunReport, SweepFamily, SweepPoint, WalkForwardResult,
WindowRun, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS,
};
use crate::binding::ResolvedBinding; use crate::member::{no_real_data, pip_or_refuse, probe_window, SYNTHETIC_PIP_SIZE};
use crate::member::{
blueprint_axis_probe, blueprint_axis_probe_reopened, no_real_data, override_paths,
pip_or_refuse, probe_window, reopen_all, run_blueprint_member, wrapped_bound_overrides_of,
SYNTHETIC_PIP_SIZE,
};
use crate::project::Env; use crate::project::Env;
use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH}; use crate::RunnerError;
/// The demo default stop regime, shared by every family builder in this module.
const DEFAULT_STOP: StopRule = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
/// The in-sample winner-selection objective for walk-forward's per-window IS
/// refit (cycle 0077). `Argmax` is the bare-best pick deflated for trials
/// (#144, the default); `Plateau` argmaxes the neighbourhood-smoothed surface
/// instead (opt-in via `--select`). The CLI shell's `--select` grammar
/// (`parse_select`) and campaign-rule mapping (`select_rule_of`) build this
/// value; they stay in the shell since only this type crosses the boundary.
#[derive(Clone, Copy)]
pub enum Selection {
Argmax,
Plateau(PlateauMode),
}
/// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising /// 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 /// 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> }, Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
} }
/// The source provider threaded into the family builders: synthetic built-in /// The source provider `aura_runner::reproduce` threads through its
/// streams, or real M1 close bars from the data-server archive. Replaces the /// re-derivation: synthetic built-in streams, or real M1 close bars from the
/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come /// data-server archive. Replaces the hardcoded `VecSource` so a member's
/// from one place (Fork B/D/F). /// 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 /// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed
/// series: the full-window consumers (`full_window` / `run_sources`, used by /// series: the full-window consumers (`full_window` / `run_sources`) draw the
/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers /// 18-bar `showcase_prices()`, while the windowed consumers
/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar /// (`windowed_sources` / `wf_window_sizes`) draw the 60-bar
/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two /// `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 /// faces never reach one consumer (full-window vs. windowed reproduction), so
/// the split is invisible per call site but real across the type — read both /// the split is invisible per call site but real across the type.
/// family builders to see it whole.
pub enum DataSource { pub enum DataSource {
Synthetic, Synthetic,
Real { Real {
@@ -128,19 +91,20 @@ pub enum DataSource {
} }
impl DataSource { impl DataSource {
/// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol /// Build a provider from a parsed choice, or refuse (#297: returned, not
/// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G), /// printed) on a symbol with no recorded geometry / absent data — both
/// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses. /// BEFORE any member runs (Fork C/G), via the same `pip_or_refuse` /
pub fn from_choice(choice: DataChoice, env: &Env) -> DataSource { /// `no_real_data` helpers `open_real_source` uses.
pub fn from_choice(choice: DataChoice, env: &Env) -> Result<DataSource, RunnerError> {
match choice { match choice {
DataChoice::Synthetic => DataSource::Synthetic, DataChoice::Synthetic => Ok(DataSource::Synthetic),
DataChoice::Real { symbol, from_ms, to_ms } => { DataChoice::Real { symbol, from_ms, to_ms } => {
let server = Arc::new(data_server::DataServer::new(env.data_path())); 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) { 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: /// The full run window, probed once. Synthetic: the showcase span. Real:
/// `probe_window` drains a separate single-pass probe source for first/last ts /// `probe_window` drains a separate single-pass probe source for first/last ts
/// (the same helper `open_real_source` uses for its manifest window). /// (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 { match self {
DataSource::Synthetic => { DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))]; 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, .. } => { DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms, env) 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` /// longer built-in stream (byte-unchanged from the retired pre-`DataSource`
/// `walkforward_family`, which derived its span the same way). Real: the same /// `walkforward_family`, which derived its span the same way). Real: the same
/// probed `--from..--to` window as `full_window`. /// 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 { match self {
DataSource::Synthetic => { DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(walkforward_prices()))]; 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, .. } => { DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms, env) 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 /// A fresh full-window source set per member (single-pass): the synthetic
/// showcase close stream, or one real source per resolved binding column /// showcase close stream, or one real source per resolved binding column
/// in canonical order (callers guard the synthetic arm to `{close}`). /// 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 { 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, .. } => { DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields) 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. /// binding column over the ns-native window.
pub fn windowed_sources( pub fn windowed_sources(
&self, from: Timestamp, to: Timestamp, env: &Env, fields: &[aura_ingest::M1Field], &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 { 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, .. } => { DataSource::Real { server, symbol, .. } => {
aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields) 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 /// A fresh seeded synthetic price walk for one Monte-Carlo draw (a distinct
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params` /// realization per seed). A FIXED `SyntheticSpec` shared by the reproduce
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never /// MonteCarlo branch (`aura_runner::reproduce`, its only remaining
/// originate on these paths and the instrument context is genuinely inert. /// production caller — #319 retired the family builder that used to mint
/// Named once so every such call site states its intent by reference instead /// such a family), so the seed->walk reconstruction is bit-exact (C1).
/// of repeating the justifying comment. /// Length 60 comfortably warms the loaded r-sma graph (SMA slow=4 + the
const NO_INSTRUMENT_CONTEXT: &str = ""; /// len-3 vol stop) so draws produce differing trades.
/// The winner-selection objective for walk-forward's per-window IS refit — the
/// deflation-aware SQN variant (#144 default). Named once so `select_winner`
/// and `blueprint_walkforward_family` (the only two family-builder-side call
/// sites) cannot drift apart on the token; the CLI shell's campaign-sugar
/// bridge keeps its own copy of this token (main.rs `WINNER_SELECTION_METRIC`)
/// since it is not itself a family builder.
const WINNER_SELECTION_METRIC: &str = "sqn_normalized";
/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward
/// winner selection. Recorded on each winner's manifest (so `overfit_probability`
/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The
/// resample count and block length are the shared `aura_registry::DEFLATION_*`.
const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
/// Renders a [`BindError`] as one-line prose in `member::override_paths`' sibling
/// register above (#247) — never the raw Rust `Debug` struct name
/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep
/// terminal actually raises past `override_paths`' own pre-flight (which
/// already rejects an unresolvable axis name as prose before either call
/// site below ever reaches the terminal). `UnknownKnob` already carries a
/// fully-prosed message string (wrapped from `override_paths`' own
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
/// the walkforward path's rejection reaches stderr as bare prose too.
pub fn render_bind_error(e: &BindError) -> String {
match e {
BindError::MissingKnob(name) => format!(
"axis {name}: an open param with no axis and no bound default — \
bind it with `--axis {name}=<value>` see `aura sweep <bp> --list-axes`"
),
BindError::KindMismatch { knob, expected, got } => format!(
"axis {knob}: expected {expected:?}, supplied {got:?} — \
see `aura sweep <bp> --list-axes`"
),
BindError::UnknownKnob(msg) => msg.clone(),
BindError::DuplicateBinding(name) => format!(
"axis {name}: bound twice — each param takes exactly one axis — \
see `aura sweep <bp> --list-axes`"
),
BindError::EmptyAxis(name) => format!(
"axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`"
),
BindError::EmptyRange(name) => format!(
"axis {name}: the named range is empty — give it at least one value"
),
// A blueprint defect, not an axis usage error: the point passed name
// resolution but its bootstrap failed. Prose frame with the compile
// detail explicitly labelled as internal — per-variant prose for
// CompileError belongs to the graph-build surface, not this boundary.
BindError::Compile(e) => format!(
"the resolved axis point failed to bootstrap — the blueprint is \
defective at this point, re-validate it with `aura graph build` \
(internal detail: {e:?})"
),
}
}
/// Resolve the in-sample winner under the chosen selection objective. `Argmax`
/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid
/// surface — it needs the grid lattice, so a sweep with no lattice (a future random
/// walk-forward producer) is refused rather than silently argmaxed. The metric is
/// always known at the call sites, so a metric error is unreachable (`expect`); the
/// only fallible outcome is the plateau-without-lattice refusal, returned as
/// `Err(message)` for the caller to print and exit 2.
pub fn select_winner(
family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>,
) -> Result<(SweepPoint, FamilySelection), String> {
match select {
Selection::Argmax => Ok(optimize_deflated(
family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED,
).expect("walk-forward metrics are known")),
Selection::Plateau(mode) => match lattice {
Some(lens) => Ok(optimize_plateau(family, lens, metric, mode)
.expect("walk-forward metrics are known")),
None => Err(
"--select plateau requires a grid sweep; a random sweep has no parameter lattice"
.to_string(),
),
},
}
}
/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal
/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all
/// run BEFORE this closure is invoked per grid point, so its body never influences the
/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to
/// satisfy the terminal, at O(1) cost per point instead of a full member run. Reused
/// (#278) as the discarded slot value for a member run [`catch_member_panic`]
/// contains: the caller always resolves the captured fault and exits before this
/// value is ever rendered or persisted, so its content is equally irrelevant there.
fn axis_grid_probe_report() -> RunReport {
RunReport {
manifest: RunManifest {
commit: String::new(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "wf-axis-preflight-placeholder".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
}
}
/// The LOWEST enumeration index's captured member-panic message, found by
/// walking `family.points` — guaranteed by `assemble_sweep`'s own
/// `collect()` to be in grid-enumeration order regardless of which worker
/// finished first (C1) — for the first point whose params match a captured
/// fault. Thread-order-independent by construction (mirrors the lowest-
/// index-fault convention `aura-campaign::exec::run_members` establishes for
/// the identical #272 seam), without needing a separate numeric-index
/// side-channel: the returned family already carries the points in order.
fn lowest_point_fault(family: &SweepFamily, faults: Mutex<Vec<(Vec<Cell>, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.points
.iter()
.find_map(|pt| captured.iter().find(|(p, _)| *p == pt.params).map(|(_, m)| m.clone()))
}
/// The LOWEST seed's captured member-panic message (#278), found by walking
/// `family.draws` — `monte_carlo`'s own contract guarantees seed-**input**
/// order regardless of thread completion (C1) — for the first draw whose
/// seed matches a captured fault. Thread-order-independent by construction,
/// mirroring [`lowest_point_fault`]'s and `lowest_window_fault`'s identical
/// convention one level over (per-point sweep / per-window walk-forward).
fn lowest_seed_fault(family: &McFamily, faults: Mutex<Vec<(u64, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.draws
.iter()
.find_map(|d| captured.iter().find(|(s, _)| *s == d.seed).map(|(_, m)| m.clone()))
}
/// Render + exit(3) on a contained member panic (#278: the deliberate
/// failed-cells exit, C14, never the raw 101 an uncontained panic would
/// yield) — reuses `aura_campaign::member_fault_prose`'s established
/// "a member panicked: …" wording rather than re-wording it, and the
/// `aura: warning: ` class marker (#278 decision) the CLI's own `diag`
/// macro would emit, hand-written here since that macro is presentation-
/// crate-private (#295 boundary) and this fires from `aura-runner`.
fn exit_on_member_panic(msg: &str) -> ! {
eprintln!("aura: warning: {}", member_fault_prose(&MemberFault::Panic(msg.to_string())));
std::process::exit(3);
}
/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any
/// member (#253). Reuses the SAME strict, erroring axis-name check
/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two
/// paths cannot drift to differently-worded rejections) to derive the #246 override
/// set, then drives the sweep terminal's own `resolve_axes`/arity/kind checks
/// (`SweepBinder::sweep_with_lattice`) with [`axis_grid_probe_report`] standing in for
/// the run closure — no data access, no sim engine tick. Axis resolution is
/// window-agnostic, so the caller derives or passes no window at all.
fn validate_axis_grid(
doc: &str, axes: &[(String, Vec<Scalar>)], raw_space: &[ParamSpec], probe_signal: &Composite,
env: &Env,
) -> Result<(), BindError> {
let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ())
}
/// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it
/// keeps the shape of the retired `r_sma_sweep_family` demo builder (#159), with three
/// deviations. (1) The signal source is
/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is
/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3)
/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs):
/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a
/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message
/// string — a named error, never a panic. An axis naming a bound param re-opens it
/// (#246: bound value = default); an axis matching neither space is refused by
/// `override_paths` before any run. Every member manifest carries the shared
/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired
/// mirror's default (no-trace) arm.
pub fn blueprint_sweep_family(
doc: &str,
axes: &[(String, Vec<Scalar>)],
data: &DataSource,
env: &Env,
) -> Result<SweepFamily, String> {
// Identity + binding read the AUTHORED doc, raw (no override re-open):
// topology and the resolved role plan are properties of the document, not
// of any one sweep's axis choices.
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at
// `aura_research`.
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// Strict binding resolution (name defaults — the verb path carries no
// campaign overrides): the family's open plan and wrap plan in one value.
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
}
let pip = data.pip_size();
let window = data.full_window(env);
// The un-reopened wrapped OPEN space (#246): derives the override set (which
// named axes re-open a bound param) before the real, reopened probe is built —
// probe and per-member reloads must re-open identically so points resolve
// against one space. A name matching neither space is the error here.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
// The doc is parse-validated at the dispatch boundary (with file-path context),
// so every reload here is infallible: the builder has a single error contract —
// the `BindError` returned by the sweep terminal — and no hidden process exit.
// Member reloads re-open the SAME override set derived above, so every member
// resolves its axes against the identical (reopened) param space the probe used.
let reload = |d: &str| {
reopen_all(
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
&overrides,
)
};
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
// sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked.
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
// otherwise unwind straight through this sweep to an uncaught exit 101 —
// contained the same way the real/campaign path contains it, via
// `catch_member_panic` + a per-point capture, resolved to the lowest
// enumeration index's message after the sweep joins (thread-order-
// independent, C1).
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let family = binder
.sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs.
match catch_member_panic(|| {
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})
// render the sweep terminal's BindError to prose (#247), the fn's String error
// contract — never the raw Debug struct.
.map_err(|e| render_bind_error(&e))?;
if let Some(msg) = lowest_point_fault(&family, faults) {
exit_on_member_panic(&msg);
}
Ok(family)
}
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
/// `[from,to]` — the windowed, lattice-carrying twin of
/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select
/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the
/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis
/// naming a bound param re-opens it (#246: bound value = default, same
/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is
/// its walk-forward in-sample twin); an axis matching neither space is refused
/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired
/// "fully bound; nothing to sweep" refusal) before any member runs.
///
/// The returned `Option<String>` (#278) is this fn's OWN resolved member-panic
/// capture: this fn's nested sweep runs inside `blueprint_walkforward_family`'s
/// per-window parallel closure, so a contained member panic here must NOT
/// `eprintln!`+`exit` on the spot (that would race across windows, the exact
/// `#177` duplicated-rejection class the dispatch-boundary axis pre-flight
/// above already avoids) — captured internally, resolved to the lowest-index
/// message via [`lowest_point_fault`] before returning, for the caller to push
/// onto its own per-window fault list only after every window has joined.
pub fn blueprint_sweep_over(
doc: &str, axes: &[(String, Vec<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
env: &Env, binding: &ResolvedBinding,
) -> Result<(SweepFamily, Vec<usize>, Option<String>), BindError> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let pip = data.pip_size();
let probe_signal = reload(doc);
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set (which named axes re-open a bound param) before
// the reopened probe is built — probe and per-member reloads must re-open
// identically so points resolve against one space, exactly like
// `blueprint_sweep_family`.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let (family, lattice) = binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty in-sample window");
match catch_member_panic(|| {
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})?;
let fault = lowest_point_fault(&family, faults);
Ok((family, lattice, fault))
}
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
/// blueprint. The reduce-mode member
/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching
/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides`
/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and
/// resolved `space`/`params` against — the OOS reload must re-open it too, or a
/// bound-param axis's winner point (kind-checked against the REOPENED space) fails
/// `bootstrap_with_cells`'s arity check against this still-closed reload.
#[allow(clippy::too_many_arguments)]
pub fn run_oos_blueprint(
doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp,
topo: &str, data: &DataSource, env: &Env, binding: &ResolvedBinding,
overrides: &[String],
) -> (Vec<(Timestamp, f64)>, RunReport) {
let reload = reopen_all(
blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
overrides,
);
let pip = data.pip_size();
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty out-of-sample window");
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT);
(Vec::new(), report)
}
/// A discarded placeholder [`WindowRun`] for a window whose IS refit or OOS
/// run was contained after a member panic (#278): `chosen_params` matches
/// `space`'s arity with inert zero cells (the `assemble_walk_forward` arity
/// invariant), `oos_equity` is empty (an empty segment leaves the stitched
/// curve unbroken, same convention as a genuinely equity-less window), and
/// the report reuses [`axis_grid_probe_report`]'s zero-cost placeholder.
/// Never rendered: the caller always resolves the captured window fault and
/// exits before touching a faulted window's run.
fn placeholder_window_run(space: &[ParamSpec]) -> WindowRun {
WindowRun {
chosen_params: space.iter().map(|_| Cell::from_i64(0)).collect(),
oos_equity: Vec::new(),
oos_report: axis_grid_probe_report(),
}
}
/// The LOWEST roll-order window's captured member-panic message (#278),
/// found by walking `result.windows` — guaranteed by `assemble_walk_forward`
/// to be in roll order regardless of thread completion (C1) — for the first
/// window whose bounds match a captured fault. Thread-order-independent by
/// construction, mirroring [`lowest_point_fault`]'s identical convention one
/// level down (per-point within one window's IS sweep) and
/// `aura-campaign::exec`'s own lowest-index walk-forward fault attribution.
fn lowest_window_fault(result: &WalkForwardResult, faults: Mutex<Vec<(WindowBounds, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
result
.windows
.iter()
.find_map(|w| captured.iter().find(|(b, _)| *b == w.bounds).map(|(_, m)| m.clone()))
}
/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the
/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the
/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`;
/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors
/// (a bad `--axis`) `exit(2)` with the sweep terminal's message.
pub fn blueprint_walkforward_family(
doc: &str, axes: &[(String, Vec<Scalar>)], data: &DataSource, select: Selection,
env: &Env,
) -> WalkForwardResult {
let span = data.wf_full_span(env);
let (is_len, oos_len, step) = data.wf_window_sizes();
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
Ok(r) => r,
Err(e) => {
eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}");
std::process::exit(2);
}
};
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set ONCE for the whole family — every per-window
// sweep AND the OOS reload re-open the SAME set, mirroring
// `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant
// (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis
// matching neither space is simply not an override here — the strict check + its
// established error message stay single-sourced in `blueprint_sweep_over`'s own
// pre-flight call below, so this derivation cannot double-validate with a
// differently-worded rejection.
let axis_names: Vec<String> = axes.iter().map(|(n, _)| n.clone()).collect();
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal);
let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space();
// Strict binding resolution, once per family; refusal is the established
// `aura: ` + exit-1 register (the roller's usage refusals stay exit 2).
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(probe_signal.name(), &binding));
std::process::exit(1);
}
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
// (which resolves its axes a single time before any member runs). `walk_forward` fans
// the per-window closure out across the windows in parallel, so a `BindError` raised
// *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one
// exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without
// running a single member — no IS window (or a second roller) is needed here at all.
if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) {
eprintln!("aura: {}", render_bind_error(&e));
std::process::exit(2);
}
// #278: `blueprint_sweep_over`'s inner sweep and `run_oos_blueprint`'s single
// member run both drive `run_blueprint_member` bare, so either could
// otherwise unwind an uncaught member-compile panic straight through this
// window closure. Both are contained (`blueprint_sweep_over` resolves its
// own capture and returns it; `run_oos_blueprint` via `catch_member_panic`
// directly) and captured PER WINDOW here rather than printed on the spot —
// this closure runs across windows in parallel (`walk_forward`), so an
// inline `eprintln!`+`exit` would race the same way the axis pre-flight
// above was written to avoid (#177). The lowest roll-order window's fault
// is resolved once, after every window has joined.
let window_faults: Mutex<Vec<(WindowBounds, String)>> = Mutex::new(Vec::new());
let result = walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice, is_fault) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
.expect("axes validated in the dispatch-boundary pre-flight");
if let Some(msg) = is_fault {
window_faults.lock().expect("fault capture lock").push((w, msg));
return placeholder_window_run(&space);
}
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
match catch_member_panic(|| {
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides)
}) {
Ok((oos_equity, mut oos_report)) => {
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
}
Err(msg) => {
window_faults.lock().expect("fault capture lock").push((w, msg));
placeholder_window_run(&space)
}
}
});
if let Some(msg) = lowest_window_fault(&result, window_faults) {
exit_on_member_panic(&msg);
}
result
}
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s
/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the
/// `aura mc <blueprint.json>` persist path AND the reproduce MonteCarlo branch, so the
/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded
/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades.
pub fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> { pub fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> {
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 }; let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
vec![Box::new(spec.source(seed))] 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; 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 /// A refusal a library function reports instead of exiting the process
/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place /// itself. The shell (`aura-cli`'s `exit_on_runner_error`, shared by the
/// that maps it back to the identical stderr bytes + exit code, so the /// dispatch arms) maps it back to the stderr bytes + exit code; the class
/// binary's observable behaviour stays byte-unchanged (#295, spec §Error /// follows the C14 partition — argv-named content 2, environment/data/IO 1
/// handling). /// (#295/#297).
#[derive(Debug)] #[derive(Debug)]
pub struct RunnerError { pub struct RunnerError {
pub exit_code: i32, pub exit_code: i32,
pub message: String, 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 std::collections::BTreeMap;
use aura_core::{Scalar, Timestamp}; 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::project::Env;
use crate::tap_plan::{bind_tap_plan, TapPlan}; use crate::tap_plan::{bind_tap_plan, TapPlan};
use crate::RunnerError;
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`, /// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.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 /// 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). /// 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 /// No broker, no risk executor, no equity/exposure/r sinks at all — the R leg is
/// is where the measured O(cycles) retention is removed. The tap machinery 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 /// the shared `bind_tap_plan`/`BoundTaps` pair — one wiring for both entry
/// points, so they cannot drift (#283). /// points, so they cannot drift (#283).
pub fn run_measurement( pub fn run_measurement(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env, signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan, plan: TapPlan,
) -> MeasurementReport { ) -> Result<crate::RunOutcome<MeasurementReport>, RunnerError> {
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r): // 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 // `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at // `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( let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"), &aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed ); // 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()) let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| { .map_err(|m| RunnerError { exit_code: 2, message: m })?;
eprintln!("aura: {m}"); // C14 class 2: fault in argv-named content (#297)
std::process::exit(1);
});
if matches!(data, RunData::Synthetic) && !binding.close_only() { if matches!(data, RunData::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding)); return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) });
std::process::exit(1);
} }
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect(); let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
let defaults = wrapped_bound_defaults(&signal); // #339 item 3: `signal`'s own root-role names, captured before
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding); // `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. // 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| { // C14 class 2: fault in argv-named content (#297)
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}"); let mut flat = signal.compile_with_params(params)
std::process::exit(1); .map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &role_names) })?;
});
// 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");
// #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)> = let named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect(); names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = measurement_manifest(named_params, window, seed); let mut manifest = measurement_manifest(named_params, window, seed);
manifest.defaults = defaults; manifest.defaults = defaults;
manifest.topology_hash = Some(topo); manifest.topology_hash = Some(topo);
manifest.project = env.provenance(); 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). // Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
let tap_names: Vec<String> = bound.declared_names().to_vec(); let tap_names: Vec<String> = bound.declared_names().to_vec();
bound.finish(&manifest).unwrap_or_else(|e| { let skipped = bound.skipped().to_vec();
eprintln!("aura: {e}"); bound.finish(&manifest)
std::process::exit(1); .map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
}); Ok(crate::RunOutcome {
MeasurementReport { manifest, taps: tap_names } report: MeasurementReport { manifest, taps: tap_names },
skipped,
trace_name,
})
} }
#[cfg(test)] #[cfg(test)]
@@ -165,11 +214,15 @@ mod tests {
let (env, root) = temp_project_env("fold"); let (env, root) = temp_project_env("fold");
let mut plan = TapPlan::empty(); let mut plan = TapPlan::empty();
plan.subscribe("fast_tap", TapSubscription::named("mean")); 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()]); assert_eq!(report.taps, vec!["fast_tap".to_string()]);
let text = std::fs::read_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"); .expect("one-row fold trace persisted");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse"); 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 doc = include_str!("../../aura-cli/examples/r_sma.json");
let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads"); let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads");
let report = 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!(report.taps.is_empty(), "no declared taps");
assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry"); 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::{DocGateFault, PrimitiveBuilder, doc_gate};
use aura_core::project::{ use aura_core::project::{
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL, 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_engine::ProjectProvenance;
use aura_registry::{Registry, TraceStore}; use aura_registry::{Registry, TraceStore};
@@ -378,7 +378,7 @@ fn validate_c_tier(
magic: u64, magic: u64,
descriptor_version: u32, descriptor_version: u32,
rustc_version: StrSlice, rustc_version: StrSlice,
aura_core_version: StrSlice, aura_core_fingerprint: StrSlice,
namespace: StrSlice, namespace: StrSlice,
dylib_path: &Path, dylib_path: &Path,
) -> Result<String, ProjectError> { ) -> Result<String, ProjectError> {
@@ -401,13 +401,13 @@ fn validate_c_tier(
host: RUSTC_VERSION.to_string(), 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()))?; .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_core != CORE_VERSION { if dylib_core != CORE_FINGERPRINT {
return Err(ProjectError::Incompatible { return Err(ProjectError::Incompatible {
what: "aura-core version", what: "aura-core build",
dylib: dylib_core.to_string(), dylib: dylib_core.to_string(),
host: CORE_VERSION.to_string(), host: CORE_FINGERPRINT.to_string(),
}); });
} }
unsafe { namespace.as_str() } 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 magic = unsafe { (*desc_ptr).magic };
let descriptor_version = unsafe { (*desc_ptr).descriptor_version }; let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
let rustc_version = unsafe { (*desc_ptr).rustc_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_stamp = unsafe { (*desc_ptr).namespace };
let namespace = validate_c_tier( let namespace = validate_c_tier(
magic, magic,
descriptor_version, descriptor_version,
rustc_version, rustc_version,
aura_core_version, aura_core_fingerprint,
namespace_stamp, namespace_stamp,
&dylib_path, &dylib_path,
)?; )?;
@@ -806,7 +806,7 @@ mod tests {
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION, AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION), StrSlice::new(RUSTC_VERSION),
StrSlice::new(CORE_VERSION), StrSlice::new(CORE_FINGERPRINT),
) )
} }
@@ -857,7 +857,7 @@ mod tests {
} }
#[test] #[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 (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so"); let path = PathBuf::from("/tmp/x.so");
let bad_core = StrSlice::new("9.9.9-fake"); let bad_core = StrSlice::new("9.9.9-fake");
@@ -865,10 +865,56 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
err, 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 /// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
/// than dereferencing it, same as the standalone `StrSlice::as_str` test /// than dereferencing it, same as the standalone `StrSlice::as_str` test
/// in aura-core — here exercised through the loader's own refusal path. /// 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. //! Bit-identical reproduction (`aura reproduce`) — #295.
//! //!
//! A library function here reports a refusal as a returned //! Both outcomes here are values, not process control flow: success returns a
//! [`crate::RunnerError`] rather than ending the process. The shell dispatch //! [`ReproduceReport`] the caller inspects (`ReproduceReport::all_identical`);
//! arm (`aura-cli`'s `dispatch_reproduce`) is the single place that prints //! a refusal returns a [`crate::RunnerError`] — neither ends the process. The
//! the error's message to stderr and calls `std::process::exit` on its code, //! shell dispatch arm (`aura-cli`'s `dispatch_reproduce`) is the single place
//! keeping the observable stderr/exit bytes unchanged (C18). //! 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::collections::BTreeMap;
use std::sync::mpsc; use std::sync::mpsc;
use aura_core::Scalar;
use aura_engine::{blueprint_from_json, window_of}; use aura_engine::{blueprint_from_json, window_of};
use aura_registry::{group_families, Family, FamilyKind, Registry}; use aura_registry::{group_families, Family, FamilyKind, Registry};
use aura_backtest::point_from_params; 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 /// The outcome of reproducing one persisted family: per member, whether its re-run
/// metrics are bit-identical to the stored metrics (C1). /// metrics are bit-identical to the stored metrics (C1).
#[derive(Debug)]
pub struct ReproduceReport { pub struct ReproduceReport {
pub outcomes: Vec<(String, bool)>, 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 /// 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` /// 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 /// 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 /// 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). /// 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( pub fn reproduce_family_in(
reg: &Registry, reg: &Registry,
id: &str, id: &str,
data: &DataSource, data: &DataSource,
env: &Env, 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> { ) -> Result<ReproduceReport, RunnerError> {
let family = load_family(reg, id)?; let family = load_family(reg, id)?;
let pip = data.pip_size(); let pip = data.pip_size();
@@ -82,6 +118,52 @@ pub fn reproduce_family_in(
exit_code: 1, exit_code: 1,
message: "family member has no topology_hash; not a generated run".to_string(), 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 let doc = reg
.get_blueprint(&hash) .get_blueprint(&hash)
.map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })? .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 })?; .map_err(|m| RunnerError { exit_code: 1, message: m })?;
// The member's binding, re-derived from the stored blueprint's own // The member's binding, re-derived from the stored blueprint's own
// input roles (name defaults — family manifests carry no overrides). // 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()) let binding = crate::binding::resolve_binding(&hash, reload()?.input_roles(), &BTreeMap::new())
.map_err(|m| RunnerError { exit_code: 1, message: m })?; .map_err(|m| RunnerError { exit_code: 1, message: m })?;
if matches!(data, DataSource::Synthetic) && !binding.close_only() { 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 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 })?; .map_err(|m| RunnerError { exit_code: 1, message: m })?;
// A MonteCarlo member carries no tuning params (the params-join is empty), so its // 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 // 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 // stored window bounds; the winner params come from the shared
// manifest->cells recovery below (as Sweep members do). // manifest->cells recovery below (as Sweep members do).
let (from, to) = stored.manifest.window; 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"); let w = window_of(&s).expect("non-empty OOS window");
(s, w) (s, w)
} }
@@ -187,9 +296,11 @@ pub fn reproduce_family_in(
_ => match data { _ => match data {
DataSource::Real { .. } => { DataSource::Real { .. } => {
let (from, to) = stored.manifest.window; 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( let rerun = run_blueprint_member(
@@ -209,7 +320,8 @@ pub fn reproduce_family_in(
// construction (`cost_specs_from_params`), so the instrument is // construction (`cost_specs_from_params`), so the instrument is
// inert; the fallback is never resolved against a map. // inert; the fallback is never resolved against a map.
stored.manifest.instrument.as_deref().unwrap_or(""), 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)); outcomes.push((label, rerun.metrics == stored.metrics));
} }
Ok(ReproduceReport { outcomes }) 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 /// 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. /// per-member window loader for how the reconstructed source is actually used.
/// ///
/// Prints its own per-member + summary lines to stdout (unchanged from the /// Returns the report as a value (#299): divergence is data the caller inspects
/// pre-#295 shell function — this is the report's normal output, not an error /// (`ReproduceReport::all_identical`), not a process exit — this library never
/// path); only the final "not every member reproduced" outcome is a refusal, /// prints and never exits. The CLI's `dispatch_reproduce` renders the report and
/// returned with an EMPTY message (there is no accompanying stderr line today, /// owns the exit; a library embedder (a World program) reads `report.outcomes`
/// so the shell dispatch arm must not synthesize one either — see /// directly.
/// `dispatch_reproduce`). ///
pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> { /// 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 reg = env.registry();
let family = load_family(&reg, id)?; let family = load_family(&reg, id)?;
// Reconstruct the DataSource the family was minted over: `None` instrument // 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()) { let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) {
None => DataSource::Synthetic, None => DataSource::Synthetic,
Some(symbol) => { 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)?; reproduce_family_with_class(&reg, id, &data, env, 1)
let total = rep.outcomes.len();
let ok = rep.outcomes.iter().filter(|(_, b)| *b).count();
for (label, identical) in &rep.outcomes {
let verdict = if *identical { "bit-identical" } else { "DIVERGED" };
println!("{id} member {label} reproduced: {verdict}");
}
println!("reproduced {ok}/{total} members bit-identically");
if ok != total {
return Err(RunnerError { exit_code: 1, message: String::new() });
}
Ok(())
} }
#[cfg(test)] #[cfg(test)]
@@ -262,7 +367,9 @@ mod tests {
use aura_engine::RunManifest; use aura_engine::RunManifest;
use aura_registry::{FamilyKind, Registry}; 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: /// A registry over a fresh per-test directory (the #258 tag-keyed pattern:
/// fixed name under the build-tree tmp anchor, pre-create wipe). /// 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 /// One id vocabulary across enumeration and reproduction (C18, #298): the
/// family-identity string a consumer lifts off the registry's own member /// family-identity string a consumer lifts off the registry's own member
/// enumeration (`FamilyRunRecord.family`) resolves through the reproduce /// 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 /// 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 /// via `run_blueprint_member`) over windowed real M1 close bars
/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this /// (`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 /// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the
@@ -116,6 +116,21 @@ impl MemberRunner for DefaultMemberRunner<'_> {
.param_space(); .param_space();
let point = bind_axes(&space, &cell.strategy_id, params)?; let point = bind_axes(&space, &cell.strategy_id, params)?;
let signal = reopen_all(signal, &overrides); 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 // The member's resolved input binding (campaign data.bindings
// overrides win over name defaults). A refusal is a member fault, // 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( let mut report = run_blueprint_member(
signal, signal,
&point, &point,
&space, &manifest_space,
sources, sources,
(from, to), (from, to),
0, 0,
@@ -184,7 +199,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
&binding, &binding,
&self.cost, &self.cost,
&cell.instrument, &cell.instrument,
); )?;
report.manifest.instrument = Some(cell.instrument.clone()); report.manifest.instrument = Some(cell.instrument.clone());
Ok(report) 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 /// that family under its own `<cell_key>/<member_key>/` subdirectory — never
/// narrowing to one nominated member, which would silently drop the others /// narrowing to one nominated member, which would silently drop the others
/// the sweep actually produced. Each written member is independently re-run /// 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 /// asserting the re-run METRICS equal the recorded member metrics (the C1
/// drift alarm — manifest fields are fresh-context and not compared), and /// drift alarm — manifest fields are fresh-context and not compared), and
/// writes the requested-AND-producible taps through the sweep verbs' /// 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() { for (member_subdir, member_report) in members.iter().cloned() {
// Re-run the member, non-reduce: the SAME member the executor ran // Re-run the member under retained delivery: the SAME member the
// (same wrapped space, same params, same window, seed-free real // executor ran (same wrapped space, same params, same window,
// data), mirroring `DefaultMemberRunner::run_member` with the // seed-free real data), mirroring `DefaultMemberRunner::run_member`
// reduce fold off so the per-cycle tap streams exist. The window // with the series fold off so the per-cycle tap streams exist. The window
// is the member report's own `manifest.window` — already // is the member report's own `manifest.window` — already
// epoch-ns (`run_blueprint_member` stamped the post-seam // epoch-ns (`run_blueprint_member` stamped the post-seam
// bounds), so no second ms->ns crossing here. // 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 (from, to) = member_report.manifest.window;
let no_data = || { let no_data = || {
format!( format!(
@@ -543,11 +579,13 @@ pub fn persist_campaign_traces(
// not just on a real divergence. // not just on a real divergence.
let (tx_cost, rx_cost) = mpsc::channel(); let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, rx_net) = mpsc::channel(); let (tx_net, rx_net) = mpsc::channel();
let cost_leg = (!campaign.cost.is_empty()).then(|| CostLeg { let cost_leg = if campaign.cost.is_empty() {
nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument), None
tx_cost, } else {
tx_net, 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 = let mut h =
wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg) wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg)
.bootstrap_with_cells(&point) .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(); let net_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_net.try_iter().collect();
// The C1 drift alarm: metrics equality against the recorded // The C1 drift alarm: metrics equality against the recorded
// member. The reduce-mode fold shares its arithmetic with this // member. The folded delivery shares its arithmetic with this
// non-reduce reduction (SeriesFold via `summarize`; GatedRecorder // retained-side reduction (SeriesFold via `summarize`; GatedRecorder
// emits exactly the rows `summarize_r`'s ledger reads), so equality // emits exactly the rows `summarize_r`'s ledger reads), so equality
// is bit-exact. // is bit-exact.
let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
+136 -13
View File
@@ -161,17 +161,21 @@ impl FoldRegistry {
}), }),
}); });
for (label, doc, fold) in [ for (label, doc, fold) in [
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count), ("count", "number of warm rows (any kind; i64 row); one row at the last warm ts", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum), ("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean), (
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min), "mean",
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max), "arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
FoldKind::Mean,
),
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
( (
"first", "first",
"first warm value, at its own timestamp (any kind; kind-preserving row)", "first warm value, at its own timestamp (any kind; kind-preserving row)",
FoldKind::First, FoldKind::First,
), ),
("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last), ("last", "last warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::Last),
] { ] {
r.register(FoldEntry { r.register(FoldEntry {
label, label,
@@ -213,11 +217,12 @@ impl FoldRegistry {
} }
/// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal /// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal
/// store fault. Entry points map every variant to the established /// store fault. Entry points wrap every variant into a returned
/// `aura: ` + exit-1 refusal register via `Display`. /// `RunnerError` (#297) — prose via `Display`, class via `exit_class`
/// (content faults 2, store I/O 1, the C14 partition).
pub enum TapPlanError { pub enum TapPlanError {
/// The plan names a tap the blueprint does not declare. /// The plan names a tap the blueprint does not declare.
UnknownTap { name: String }, UnknownTap { name: String, declared: Vec<String> },
/// The plan names a label the registry does not carry. /// The plan names a label the registry does not carry.
UnknownLabel { label: String, roster: Vec<&'static str> }, UnknownLabel { label: String, roster: Vec<&'static str> },
/// The entry's bind rule rejects the tap's column kind. /// The entry's bind rule rejects the tap's column kind.
@@ -237,8 +242,12 @@ pub enum TapPlanError {
impl fmt::Display for TapPlanError { impl fmt::Display for TapPlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
TapPlanError::UnknownTap { name } => { TapPlanError::UnknownTap { name, declared } => {
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap") write!(
f,
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
declared.join(", ")
)
} }
TapPlanError::UnknownLabel { label, roster } => { TapPlanError::UnknownLabel { label, roster } => {
write!(f, "unknown fold '{label}' — available: {}", roster.join(", ")) write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
@@ -277,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 /// Validate one `Named` subscription's param bindings against the entry's
/// schema: every binding names a schema param of the right kind; every /// schema: every binding names a schema param of the right kind; every
/// schema param is bound. /// schema param is bound.
@@ -340,6 +368,15 @@ pub struct BoundTaps {
persisted: Vec<String>, persisted: Vec<String>,
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>, rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>, 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 { impl BoundTaps {
@@ -348,6 +385,17 @@ impl BoundTaps {
&self.declared &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 /// Drain the ≤1-message-per-tap channels and close the run: record
/// outcomes first (declared order), then fold rows written through the /// outcomes first (declared order), then fold rows written through the
/// same streamer, then `index.json` last. Any fault returns before the /// same streamer, then `index.json` last. Any fault returns before the
@@ -402,7 +450,10 @@ pub fn bind_tap_plan(
// Unknown-tap guard: every plan name must be declared. // Unknown-tap guard: every plan name must be declared.
for name in plan.by_name.keys() { for name in plan.by_name.keys() {
if !declared_taps.iter().any(|t| &t.name == name) { if !declared_taps.iter().any(|t| &t.name == name) {
return Err(TapPlanError::UnknownTap { name: name.clone() }); return Err(TapPlanError::UnknownTap {
name: name.clone(),
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
});
} }
} }
@@ -413,6 +464,7 @@ pub fn bind_tap_plan(
Live(Box<dyn FnMut(Timestamp, Cell) + Send>), Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
} }
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new(); let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for tap in &declared_taps { for tap in &declared_taps {
let kind = flat.signatures[tap.node].output[tap.field].kind; let kind = flat.signatures[tap.node].output[tap.field].kind;
let sub = match plan.by_name.remove(&tap.name) { let sub = match plan.by_name.remove(&tap.name) {
@@ -422,7 +474,18 @@ pub fn bind_tap_plan(
Some((label, params)) => { Some((label, params)) => {
Resolved::Named { label: label.clone(), params: params.clone() } Resolved::Named { label: label.clone(), params: params.clone() }
} }
None => continue, // unbound, inert (C27) // Unbound, inert (C27) — but only reachable when `plan` carries
// no default (an EXPLICIT plan, e.g. from `--tap`): record-all
// (`default_named` = `Some(("record", …))`) always resolves the
// Some arm above, so this arm never fires under record-all and
// the note is exactly the C14 benign skipped-tap class (#334).
// 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 => {
skipped.push(tap.name.clone());
continue;
}
}, },
}; };
if let Resolved::Named { label, params } = &sub { if let Resolved::Named { label, params } = &sub {
@@ -454,6 +517,8 @@ pub fn bind_tap_plan(
persisted: Vec::new(), persisted: Vec::new(),
rows: Vec::new(), rows: Vec::new(),
outcomes: Vec::new(), outcomes: Vec::new(),
skipped,
trace_name: if persists { Some(run_name.to_string()) } else { None },
}; };
for (name, kind, sub) in resolved { for (name, kind, sub) in resolved {
let node: Box<dyn Node> = match sub { let node: Box<dyn Node> = match sub {
@@ -504,6 +569,64 @@ mod tests {
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself"); assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
} }
/// C29 entry seam for the registry roster: every entry ships a gate-clean
/// one-line meaning, and the bind/output prose inside it matches the
/// entry's executable rules — the drift-pin the retired aura-std
/// `fold_vocabulary` table carried, moved here with the surface (#332).
#[test]
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
let r = FoldRegistry::core();
for entry in r.entries.values() {
aura_core::doc_gate(entry.label, entry.doc)
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
if entry.doc.contains("f64 taps") {
assert!(
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
"{} claims f64-only but binds wider",
entry.label
);
} else {
assert!(
entry.doc.contains("any kind"),
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
entry.label,
entry.doc
);
assert!(
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
"{} claims any-kind but refuses a kind",
entry.label
);
}
if entry.doc.contains("i64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
"{} claims an i64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("f64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
"{} claims an f64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("kind-preserving row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
"{} claims kind-preserving but outputs otherwise",
entry.label
);
} else {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
"{}: doc names no row kind, so it must be the series entry",
entry.label
);
}
}
}
#[test] #[test]
fn unknown_label_refusal_enumerates_the_roster() { fn unknown_label_refusal_enumerates_the_roster() {
let r = FoldRegistry::core(); let r = FoldRegistry::core();
+112 -24
View File
@@ -4,10 +4,11 @@
//! the cost model) must still round-trip through a manifest, so a stored //! the cost model) must still round-trip through a manifest, so a stored
//! member (family, reproduce) re-derives bit-identically. These translators //! member (family, reproduce) re-derives bit-identically. These translators
//! are the single source for each such write<->read pair — the manifest //! are the single source for each such write<->read pair — the manifest
//! `stop_length`/`stop_k`/`stop_period_minutes` <-> [`StopRule`] binding, and //! `stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance` <->
//! the `cost[k].<knob>` <-> [`aura_research::CostSpec`] binding — so the two //! [`StopRule`] binding, and the `cost[k].<knob>` <->
//! halves of each pair cannot drift out of sync between the run/campaign //! [`aura_research::CostSpec`] binding — so the two halves of each pair
//! paths and reproduce/persist. //! cannot drift out of sync between the run/campaign paths and
//! reproduce/persist.
use aura_composites::StopRule; use aura_composites::StopRule;
use aura_core::{PrimitiveBuilder, Scalar}; 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; pub const R_SMA_STOP_K: f64 = 2.0;
/// Re-derive the `StopRule` a member was minted under from its manifest params /// 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`): /// (`stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance`, stamped by
/// if `stop_period_minutes` is present alongside `stop_length`/`stop_k`, this /// `run_blueprint_member`): if `stop_period_minutes` is present alongside
/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default /// `stop_length`/`stop_k`, this re-derives `VolTf`; `stop_length`/`stop_k`
/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233 /// alone re-derives `Vol`; `stop_distance` alone (#338) re-derives `Fixed` —
/// members), mirroring [`stop_rule_for_regime`]'s `None` arm for the same /// 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 /// one-directional widening `point_from_params` already applies to missing
/// manifest params. /// manifest params.
pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule { 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()); 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 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()); let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64());
match (period_minutes, length, k) { let distance = params.iter().find(|(n, _)| n == "stop_distance").map(|(_, s)| s.as_f64());
(Some(period_minutes), Some(length), Some(k)) => { match (period_minutes, length, k, distance) {
(Some(period_minutes), Some(length), Some(k), _) => {
StopRule::VolTf { period_minutes, length, 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 }, _ => 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 /// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own
/// params. Single-sourced so the persist-side re-run structurally cannot /// params. Single-sourced so the persist-side re-run structurally cannot
/// diverge from the run-side binding again (the #219 divergence class). /// 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 { pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_composites::StopRule {
match regime { match regime {
None => aura_composites::StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, 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 }) => { Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => {
aura_composites::StopRule::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, /// cannot diverge from the run-side cost model (the #219 divergence class,
/// cost edition). The bound knob names are the builders' own `ParamSpec` names /// cost edition). The bound knob names are the builders' own `ParamSpec` names
/// — the `CostSpec` serde vocabulary conforms to them. /// — 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 specs
.iter() .iter()
.map(|s| { .map(|s| {
let (knob, v) = cost_knob(s, instrument); let (knob, v) = cost_knob(s, instrument)?;
match s { Ok(match s {
aura_research::CostSpec::Constant { .. } => { aura_research::CostSpec::Constant { .. } => {
ConstantCost::builder().bind(knob, Scalar::f64(v)) 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 { .. } => { aura_research::CostSpec::Carry { .. } => {
CarryCost::builder().bind(knob, Scalar::f64(v)) CarryCost::builder().bind(knob, Scalar::f64(v))
} }
} })
}) })
.collect() .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 /// The one CostSpec-variant -> (knob name, value) mapping, shared by
/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp: /// `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 /// 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 /// `CostSpec` from a stored manifest, so both sites read the name off this
/// single function rather than each carrying its own literal. /// 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 { let (knob, value) = match spec {
aura_research::CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade), 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::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult),
aura_research::CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle), 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); // Unreachable after intrinsic validation (map keys ≡ instruments);
// refuse loudly rather than charging 0 silently if it ever surfaces. // refuse loudly rather than charging 0 silently if it ever surfaces.
eprintln!("aura: cost {knob}: no entry for instrument {instrument}"); None => Err(CostKnobFault { knob, instrument: instrument.to_string() }),
std::process::exit(1); }
});
(knob, v)
} }
#[cfg(test)] #[cfg(test)]
@@ -178,6 +207,15 @@ mod tests {
assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 }); 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] #[test]
/// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to /// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to
/// `StopRule::VolTf` field-for-field — the resolve-side half of the /// `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] #[test]
/// #234: the one CostSpec -> builder binding maps each component to its /// #234: the one CostSpec -> builder binding maps each component to its
/// shipped cost node with the knob BOUND — a bound component adds no open /// shipped cost node with the knob BOUND — a bound component adds no open
@@ -212,14 +261,53 @@ mod tests {
}, },
], ],
"GER40", "GER40",
); )
.expect("every knob resolves against the GER40-keyed fixture");
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect(); let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]); assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
assert!( assert!(
nodes.iter().all(|n| n.params().is_empty()), nodes.iter().all(|n| n.params().is_empty()),
"every component must be fully bound (no open param leaks into param_space)" "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] #[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");
}
-7
View File
@@ -7,13 +7,6 @@ publish.workspace = true
[dependencies] [dependencies]
aura-core = { path = "../aura-core" } aura-core = { path = "../aura-core" }
# DST-correct wall-clock math for the `Session` node. A vetted
# standard crate for timezone/DST — never hand-rolled (C16 per-case policy).
# `chrono` is already a transitive workspace dep (0.4 via aura-ingest's
# data-server); aligned to that major. `chrono-tz` brings the IANA `Europe/Berlin`
# zone + DST transitions.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
chrono-tz = { version = "0.10", default-features = false }
[dev-dependencies] [dev-dependencies]
aura-strategy = { path = "../aura-strategy" } aura-strategy = { path = "../aura-strategy" }
+1 -1
View File
@@ -74,6 +74,6 @@ pub use sma::Sma;
pub use sqrt::Sqrt; pub use sqrt::Sqrt;
pub use sub::Sub; pub use sub::Sub;
pub use tap_cell::newest_cell; pub use tap_cell::newest_cell;
pub use tap_fold::{fold_binds_at, fold_output_kind, fold_vocabulary, FoldKind, FoldSchema, TapFold}; pub use tap_fold::{fold_binds_at, fold_output_kind, FoldKind, TapFold};
pub use tap_live::TapLive; pub use tap_live::TapLive;
pub use when::When; pub use when::When;
+50 -9
View File
@@ -7,10 +7,15 @@
//! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19). //! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19).
use aura_core::{ use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
ScalarKind, PrimitiveBuilder, ScalarKind,
}; };
/// The declared construction args of a `LinComb` recipe: `arity` fixes the
/// node's input/param count (topology, C19), taken through the `args`
/// channel instead of a Rust-side builder parameter.
const LINCOMB_ARGS: &[ArgSpec] = &[ArgSpec { name: "arity", kind: ArgKind::Count }];
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are /// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are
/// construction parameters that configure the node and fix its arity /// construction parameters that configure the node and fix its arity
/// (`weights.len()` inputs, in slot order). Emits `None` until *all* inputs /// (`weights.len()` inputs, in slot order). Emits `None` until *all* inputs
@@ -40,10 +45,29 @@ impl LinComb {
Self { weights, out: [Cell::from_f64(0.0)] } Self { weights, out: [Cell::from_f64(0.0)] }
} }
/// The param-generic recipe for a blueprint primitive. The `arity` is topology /// Roster factory: zero-arg, arg-bearing. `arity` is topology (fixed per
/// (fixed per blueprint, C19), taken as a builder arg; only the weight *values* /// blueprint, C19), now taken through the `args` channel instead of a
/// are injected, slot by slot, through `LinComb::new` (the single sizing gate). /// Rust-side parameter.
pub fn builder(arity: usize) -> PrimitiveBuilder { pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::pending(
"LinComb",
"linear combination of its inputs with constant weights",
LINCOMB_ARGS,
Self::make,
)
}
/// Turn a validated `arity` into the real, arity-sized signature; only
/// the weight *values* are injected, slot by slot, through `LinComb::new`
/// (the single sizing gate) once params are bound/built.
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let arity = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("arity", ArgValue::Count(n)) => Some(*n),
_ => None,
})
.expect("try_args validated `arity` as ArgKind::Count before calling make");
let inputs = (0..arity) let inputs = (0..arity)
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") }) .map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
.collect(); .collect();
@@ -63,6 +87,13 @@ impl LinComb {
)), )),
) )
} }
/// Rust-path convenience — same recipe as the data path (twin identity).
pub fn configured(arity: usize) -> PrimitiveBuilder {
Self::builder()
.try_args(&[("arity".to_string(), arity.to_string())])
.expect("configured: a positive arity is always the canonical strict-form Count string")
}
} }
impl Node for LinComb { impl Node for LinComb {
@@ -149,7 +180,7 @@ mod tests {
#[test] #[test]
fn input_slots_are_named_term_index() { fn input_slots_are_named_term_index() {
let lc = LinComb::builder(3); let lc = LinComb::configured(3);
let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect(); let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]); assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
} }
@@ -157,7 +188,7 @@ mod tests {
#[test] #[test]
fn chained_bind_reconstructs_positional_vector() { fn chained_bind_reconstructs_positional_vector() {
// bind BOTH weights, in reverse slot order, to DISTINCT values; build empty. // bind BOTH weights, in reverse slot order, to DISTINCT values; build empty.
let builder = LinComb::builder(2) let builder = LinComb::configured(2)
.bind("weights[1]", Scalar::f64(2.0)) .bind("weights[1]", Scalar::f64(2.0))
.bind("weights[0]", Scalar::f64(0.5)); .bind("weights[0]", Scalar::f64(0.5));
assert!(builder.params().is_empty()); assert!(builder.params().is_empty());
@@ -173,7 +204,7 @@ mod tests {
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice())); assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
// partial: bind weights[0], leave weights[1] open → inject it at build // partial: bind weights[0], leave weights[1] open → inject it at build
let partial = LinComb::builder(2).bind("weights[0]", Scalar::f64(0.5)); let partial = LinComb::configured(2).bind("weights[0]", Scalar::f64(0.5));
assert_eq!( assert_eq!(
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["weights[1]"], ["weights[1]"],
@@ -187,4 +218,14 @@ mod tests {
inputs2[1].push(Scalar::f64(3.0)).unwrap(); inputs2[1].push(Scalar::f64(3.0)).unwrap();
assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice())); assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
} }
/// Twin identity (spec §aura-std): `configured(arity)` is exactly
/// `builder().try_args([("arity", arity)])`.
#[test]
fn lincomb_configured_twin_equals_builder_try_args() {
let via_configured = LinComb::configured(3);
let via_try_args = LinComb::builder().try_args(&[("arity".into(), "3".into())]).expect("valid arity configures");
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
assert_eq!(via_configured.schema(), via_try_args.schema());
}
} }
+1 -1
View File
@@ -238,7 +238,7 @@ mod tests {
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }], vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
); );
// vector knob expands flat to N indexed F64 entries // vector knob expands flat to N indexed F64 entries
let lc = LinComb::builder(2).schema().params.clone(); let lc = LinComb::configured(2).schema().params.clone();
assert_eq!(lc.len(), 2); assert_eq!(lc.len(), 2);
assert_eq!(lc[0].name, "weights[0]"); assert_eq!(lc[0].name, "weights[0]");
assert_eq!(lc[1].name, "weights[1]"); assert_eq!(lc[1].name, "weights[1]");
-67
View File
@@ -45,32 +45,6 @@ pub fn fold_output_kind(fold: FoldKind, kind: ScalarKind) -> ScalarKind {
} }
} }
/// One fold entry's introspection row (C29): id, bind/output rules in prose,
/// one-line meaning. `fold_vocabulary` is the binary's discovery surface for
/// [`FoldKind`] (`aura graph introspect --folds`, #315); the unit tests pin
/// each row's rule prose against [`fold_binds_at`] / [`fold_output_kind`] so
/// the table cannot drift from the executable rules.
pub struct FoldSchema {
pub id: &'static str,
pub kind: FoldKind,
pub binds: &'static str,
pub out: &'static str,
pub doc: &'static str,
}
/// The closed fold vocabulary as introspection rows, in [`FoldKind`] order.
pub fn fold_vocabulary() -> &'static [FoldSchema] {
&[
FoldSchema { id: "Count", kind: FoldKind::Count, binds: "any tap", out: "i64", doc: "number of rows the tap recorded" },
FoldSchema { id: "Sum", kind: FoldKind::Sum, binds: "f64 taps", out: "f64", doc: "sum of the tap's recorded values" },
FoldSchema { id: "Mean", kind: FoldKind::Mean, binds: "f64 taps", out: "f64", doc: "arithmetic mean of the tap's recorded values" },
FoldSchema { id: "Min", kind: FoldKind::Min, binds: "f64 taps", out: "f64", doc: "smallest recorded value" },
FoldSchema { id: "Max", kind: FoldKind::Max, binds: "f64 taps", out: "f64", doc: "largest recorded value" },
FoldSchema { id: "First", kind: FoldKind::First, binds: "any tap", out: "tap kind", doc: "earliest recorded value, kind-preserving" },
FoldSchema { id: "Last", kind: FoldKind::Last, binds: "any tap", out: "tap kind", doc: "latest recorded value, kind-preserving" },
]
}
/// Owned accumulator for every fold kind at once (a handful of words; keeping /// Owned accumulator for every fold kind at once (a handful of words; keeping
/// it total avoids a per-kind state enum). Sequential `sum += v` matches a /// it total avoids a per-kind state enum). Sequential `sum += v` matches a
/// slice-driven mean's operation order, so streamed and slice `Mean` agree /// slice-driven mean's operation order, so streamed and slice `Mean` agree
@@ -190,47 +164,6 @@ mod tests {
use aura_core::AnyColumn; use aura_core::AnyColumn;
use std::sync::mpsc; use std::sync::mpsc;
/// The vocabulary rows are prose renderings of the executable rules —
/// pin them against `fold_binds_at` / `fold_output_kind` so the
/// introspection surface cannot drift from what the engine enforces.
#[test]
fn fold_vocabulary_rows_match_the_executable_rules() {
let rows = fold_vocabulary();
assert_eq!(rows.len(), 7, "one row per FoldKind");
for row in rows {
let f64_only = fold_binds_at(row.kind, ScalarKind::F64)
&& !fold_binds_at(row.kind, ScalarKind::I64);
match row.binds {
"f64 taps" => assert!(f64_only, "{} claims f64-only but binds wider", row.id),
"any tap" => assert!(
fold_binds_at(row.kind, ScalarKind::I64)
&& fold_binds_at(row.kind, ScalarKind::Bool),
"{} claims any-tap but refuses a kind",
row.id
),
other => panic!("unknown binds prose {other:?} on {}", row.id),
}
match row.out {
"i64" => assert_eq!(fold_output_kind(row.kind, ScalarKind::F64), ScalarKind::I64),
"f64" => assert_eq!(fold_output_kind(row.kind, ScalarKind::F64), ScalarKind::F64),
"tap kind" => {
assert_eq!(fold_output_kind(row.kind, ScalarKind::F64), ScalarKind::F64);
assert_eq!(fold_output_kind(row.kind, ScalarKind::I64), ScalarKind::I64);
}
other => panic!("unknown out prose {other:?} on {}", row.id),
}
}
}
/// C29 entry seam: every fold row ships a gate-clean one-line meaning.
#[test]
fn fold_vocabulary_docs_pass_the_doc_gate() {
for row in fold_vocabulary() {
aura_core::doc_gate(row.id, row.doc)
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", row.id));
}
}
/// Drive a fold over an f64 series and return the emitted rows. /// Drive a fold over an f64 series and return the emitted rows.
fn run_f64_fold(fold: FoldKind, values: &[f64]) -> Vec<(Timestamp, Vec<Scalar>)> { fn run_f64_fold(fold: FoldKind, values: &[f64]) -> Vec<(Timestamp, Vec<Scalar>)> {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
+1
View File
@@ -29,6 +29,7 @@ impl CarryCost {
"CarryCost", "CarryCost",
Vec::new(), Vec::new(),
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }], vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
"cost-model node: cost accrued per held cycle (param carry_per_cycle)",
|p| Box::new(CarryCost::new(p[0].f64())), |p| Box::new(CarryCost::new(p[0].f64())),
) )
} }
@@ -28,6 +28,7 @@ impl ConstantCost {
"ConstantCost", "ConstantCost",
Vec::new(), Vec::new(),
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }], vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
"cost-model node: fixed cost per trade (param cost_per_trade), charged at close",
|p| Box::new(ConstantCost::new(p[0].f64())), |p| Box::new(ConstantCost::new(p[0].f64())),
) )
} }
+24 -14
View File
@@ -214,26 +214,20 @@ impl<F: CostNode> Node for CostRunner<F> {
} }
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's /// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
/// extra inputs, the standard 3-field cost output, the given params, and a build /// extra inputs, the standard 3-field cost output, the given params, a
/// closure. The single home for the cost-node schema shape. /// factor-specific one-line `doc` (C29 — each cost node names its own charge
/// basis rather than sharing one generic sentence, #330), and a build closure.
/// The single home for the cost-node schema shape.
pub fn cost_node_builder( pub fn cost_node_builder(
name: &'static str, name: &'static str,
extra_inputs: Vec<PortSpec>, extra_inputs: Vec<PortSpec>,
params: Vec<ParamSpec>, params: Vec<ParamSpec>,
doc: &'static str,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static, build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> PrimitiveBuilder { ) -> PrimitiveBuilder {
let mut inputs = geometry_input_ports(); let mut inputs = geometry_input_ports();
inputs.extend(extra_inputs); inputs.extend(extra_inputs);
PrimitiveBuilder::new( PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params, doc }, build)
name,
NodeSchema {
inputs,
output: cost_output_fields(),
params,
doc: "cost-model node: charges its cost in R from position geometry, at close or accrued per held cycle",
},
build,
)
} }
#[cfg(test)] #[cfg(test)]
@@ -501,6 +495,7 @@ mod tests {
"Probe", "Probe",
extra.clone(), extra.clone(),
params.clone(), params.clone(),
"cost-model node: probe factor for the builder-assembly test",
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) }, |_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
); );
let schema = builder.schema(); let schema = builder.schema();
@@ -537,6 +532,21 @@ mod tests {
assert_eq!(a, "volatility"); assert_eq!(a, "volatility");
} }
#[test]
fn cost_node_descriptions_are_pairwise_distinct() {
// C29: `aura graph introspect --vocabulary` must be able to tell the
// three cost nodes apart by their charge basis (flat-per-trade vs
// per-held-cycle-accrual vs volatility-scaled), not read one
// identical generic sentence three times (#330).
use crate::{CarryCost, ConstantCost, VolSlippageCost};
let constant_doc = ConstantCost::builder().schema().doc;
let carry_doc = CarryCost::builder().schema().doc;
let vol_slippage_doc = VolSlippageCost::builder().schema().doc;
assert_ne!(constant_doc, carry_doc, "ConstantCost vs CarryCost doc must differ");
assert_ne!(constant_doc, vol_slippage_doc, "ConstantCost vs VolSlippageCost doc must differ");
assert_ne!(carry_doc, vol_slippage_doc, "CarryCost vs VolSlippageCost doc must differ");
}
#[test] #[test]
fn producer_and_aggregator_share_the_triple() { fn producer_and_aggregator_share_the_triple() {
// The structural lockstep: producer output and aggregator output/inputs all // The structural lockstep: producer output and aggregator output/inputs all
@@ -545,10 +555,10 @@ mod tests {
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect(); ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
assert_eq!(prod, COST_FIELD_NAMES.to_vec()); assert_eq!(prod, COST_FIELD_NAMES.to_vec());
let agg_out: Vec<String> = let agg_out: Vec<String> =
CostSum::builder(1).schema().output.iter().map(|f| f.name.clone()).collect(); CostSum::configured(1).schema().output.iter().map(|f| f.name.clone()).collect();
assert_eq!(agg_out, COST_FIELD_NAMES.to_vec()); assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
let agg_in: Vec<String> = let agg_in: Vec<String> =
CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect(); CostSum::configured(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
let expected: Vec<String> = let expected: Vec<String> =
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect(); COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
assert_eq!(agg_in, expected); assert_eq!(agg_in, expected);
+49 -6
View File
@@ -7,9 +7,15 @@
//! the cost path is uniform whether one or several cost nodes are wired. //! the cost path is uniform whether one or several cost nodes are wired.
use aura_core::{ use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind, ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec,
PrimitiveBuilder, ScalarKind,
}; };
/// The declared construction args of a `CostSum` recipe: `n_costs` fixes the
/// node's input count (topology, C19), taken through the `args` channel
/// instead of a Rust-side builder parameter.
const COST_SUM_ARGS: &[ArgSpec] = &[ArgSpec { name: "n_costs", kind: ArgKind::Count }];
/// The cost-record field triple and its width come from the shared cost contract /// The cost-record field triple and its width come from the shared cost contract
/// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by /// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by
/// both the producer side (`cost_node_builder`) and this aggregator. The input-name /// both the producer side (`cost_node_builder`) and this aggregator. The input-name
@@ -32,10 +38,29 @@ impl CostSum {
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] } Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
} }
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19), /// Roster factory: zero-arg, arg-bearing. `n_costs` is topology (fixed per
/// captured by the build closure (no per-build params). The input names are a /// blueprint, C19), now taken through the `args` channel instead of a
/// lockstep contract with the connect side (`cost[k].<field>`). /// Rust-side parameter.
pub fn builder(n_costs: usize) -> PrimitiveBuilder { pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::pending(
"CostSum",
"sums cost-model contributions into one cost-in-R stream",
COST_SUM_ARGS,
Self::make,
)
}
/// Turn a validated `n_costs` into the real, arity-sized signature. The
/// input names are a lockstep contract with the connect side
/// (`cost[k].<field>`).
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let n_costs = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("n_costs", ArgValue::Count(n)) => Some(*n),
_ => None,
})
.expect("try_args validated `n_costs` as ArgKind::Count before calling make");
let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH); let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH);
for k in 0..n_costs { for k in 0..n_costs {
for field in COST_FIELD_NAMES { for field in COST_FIELD_NAMES {
@@ -63,6 +88,13 @@ impl CostSum {
move |_| Box::new(CostSum::new(n_costs)), move |_| Box::new(CostSum::new(n_costs)),
) )
} }
/// Rust-path convenience — same recipe as the data path (twin identity).
pub fn configured(n_costs: usize) -> PrimitiveBuilder {
Self::builder()
.try_args(&[("n_costs".to_string(), n_costs.to_string())])
.expect("configured: a positive n_costs is always the canonical strict-form Count string")
}
} }
impl Node for CostSum { impl Node for CostSum {
@@ -140,7 +172,7 @@ mod tests {
#[test] #[test]
fn input_slots_are_named_cost_index_field() { fn input_slots_are_named_cost_index_field() {
let s = CostSum::builder(2); let s = CostSum::configured(2);
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect(); let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!( assert_eq!(
names, names,
@@ -161,4 +193,15 @@ mod tests {
fn new_panics_on_zero() { fn new_panics_on_zero() {
let _ = CostSum::new(0); let _ = CostSum::new(0);
} }
/// Twin identity (spec §aura-strategy): `configured(n_costs)` is exactly
/// `builder().try_args([("n_costs", n_costs)])`.
#[test]
fn cost_sum_configured_twin_equals_builder_try_args() {
let via_configured = CostSum::configured(2);
let via_try_args =
CostSum::builder().try_args(&[("n_costs".into(), "2".into())]).expect("valid n_costs configures");
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
assert_eq!(via_configured.schema(), via_try_args.schema());
}
} }
@@ -43,6 +43,7 @@ impl VolSlippageCost {
"VolSlippageCost", "VolSlippageCost",
volatility_input_ports(), volatility_input_ports(),
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }], vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
"cost-model node: slippage proportional to volatility (slip_vol_mult × volatility input), charged at close",
|p| Box::new(VolSlippageCost::new(p[0].f64())), |p| Box::new(VolSlippageCost::new(p[0].f64())),
) )
} }
+32 -19
View File
@@ -8,22 +8,27 @@
//! takes it as an injected resolver, and a project `cdylib` later supplies its //! takes it as an injected resolver, and a project `cdylib` later supplies its
//! own (C16) through the same seam. //! own (C16) through the same seam.
//! //!
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are //! Scope (#155, extended #271): the roster resolves any type whose factory is
//! resolvable. Builders that take structural construction arguments //! `Type::builder()` with zero RUST-side arguments — this now includes
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`) //! arg-bearing types (`Session`, `LinComb`, `CostSum`), whose `builder()` is
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which //! itself zero-arg and returns a *pending* `PrimitiveBuilder`, configured
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id` //! through the typed `args` channel (`try_args`) at the `add` op, before any
//! bind. `SimBroker(pip_size)` stays out of scope: it is a plain scalar Rust
//! parameter, not a structural construction arg — a different (unaddressed)
//! gap. The recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
//! capture an `mpsc::Sender`) stay deliberately absent — an absent `type_id`
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`) //! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
//! rather than guessing. Serialising structural-axis construction args is a //! rather than guessing.
//! later, additive extension (#156/C20).
use aura_backtest::PositionManagement; use aura_backtest::PositionManagement;
use aura_market::{Resample, SessionFrankfurt}; use aura_market::{Resample, Session, SessionFrankfurt};
use aura_std::{ use aura_std::{
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, Max, Min, Mul, RollingMax, Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, LinComb, Max, Min, Mul,
RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When, RollingMax, RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
};
use aura_strategy::{
Bias, CarryCost, ConstantCost, CostSum, FixedStop, LongOnly, Sizer, VolSlippageCost,
}; };
use aura_strategy::{Bias, CarryCost, ConstantCost, FixedStop, LongOnly, Sizer, VolSlippageCost};
use aura_core::PrimitiveBuilder; use aura_core::PrimitiveBuilder;
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg /// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
@@ -67,6 +72,7 @@ std_vocabulary_roster! {
"CarryCost" => CarryCost, "CarryCost" => CarryCost,
"Const" => Const, "Const" => Const,
"ConstantCost" => ConstantCost, "ConstantCost" => ConstantCost,
"CostSum" => CostSum,
"CumSum" => CumSum, "CumSum" => CumSum,
"Delay" => Delay, "Delay" => Delay,
"Div" => Div, "Div" => Div,
@@ -75,6 +81,7 @@ std_vocabulary_roster! {
"FixedStop" => FixedStop, "FixedStop" => FixedStop,
"Gt" => Gt, "Gt" => Gt,
"Latch" => Latch, "Latch" => Latch,
"LinComb" => LinComb,
"LongOnly" => LongOnly, "LongOnly" => LongOnly,
"Max" => Max, "Max" => Max,
"Min" => Min, "Min" => Min,
@@ -85,6 +92,7 @@ std_vocabulary_roster! {
"RollingMin" => RollingMin, "RollingMin" => RollingMin,
"Scale" => Scale, "Scale" => Scale,
"Select" => Select, "Select" => Select,
"Session" => Session,
"SessionFrankfurt" => SessionFrankfurt, "SessionFrankfurt" => SessionFrankfurt,
"Sign" => Sign, "Sign" => Sign,
"Sizer" => Sizer, "Sizer" => Sizer,
@@ -103,8 +111,11 @@ mod tests {
/// label back, and only the rostered keys do. The builder's own `label()` /// label back, and only the rostered keys do. The builder's own `label()`
/// is the independent oracle: a typo'd roster key fails the lookup-label /// is the independent oracle: a typo'd roster key fails the lookup-label
/// agreement even though the match arms and the list share the same /// agreement even though the match arms and the list share the same
/// (mistyped) literal; construction-arg nodes and sinks stay absent so the /// (mistyped) literal; an unresolvable id and a sink stay absent so the
/// loader fails cleanly rather than guessing. /// loader fails cleanly rather than guessing. `LinComb` — an arg-bearing
/// type, #271 — now resolves to a PENDING builder (its own `.label()`
/// still round-trips; it just is not yet constructible without
/// `try_args`).
#[test] #[test]
fn std_vocabulary_resolves_known_and_rejects_unknown() { fn std_vocabulary_resolves_known_and_rejects_unknown() {
for &type_id in std_vocabulary_types() { for &type_id in std_vocabulary_types() {
@@ -116,9 +127,9 @@ mod tests {
"resolver key must round-trip to the same type label" "resolver key must round-trip to the same type label"
); );
} }
// an unknown id, a construction-arg node, and a sink are all absent // an unknown id and a sink are absent; LinComb is now resolved (#271)
assert!(std_vocabulary("nope").is_none()); assert!(std_vocabulary("nope").is_none());
assert!(std_vocabulary("LinComb").is_none()); assert!(std_vocabulary("LinComb").is_some());
assert!(std_vocabulary("Recorder").is_none()); assert!(std_vocabulary("Recorder").is_none());
} }
@@ -129,14 +140,16 @@ mod tests {
/// act. The residual #160 drift — a new zero-arg node never rostered at /// act. The residual #160 drift — a new zero-arg node never rostered at
/// all — is not catchable here (no enumeration of zero-arg builders /// all — is not catchable here (no enumeration of zero-arg builders
/// exists); it fails safe (clean `UnknownNodeType` on load, merely absent /// exists); it fails safe (clean `UnknownNodeType` on load, merely absent
/// from `--vocabulary`). /// from `--vocabulary`). #271: `Session`/`LinComb`/`CostSum` moved IN
/// (arg-bearing types now have zero-arg `builder()` factories); the count
/// pin moves 33 -> 36.
#[test] #[test]
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() { fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
// known non-members stay out of the list // known non-members stay out of the list
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(std_vocabulary_types().contains(&"LinComb")); // now arg-bearing-reachable (#271)
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
assert!(!std_vocabulary_types().contains(&"nope")); assert!(!std_vocabulary_types().contains(&"nope"));
// count guard: pins the roster at exactly 33 entries // count guard: pins the roster at exactly 36 entries
assert_eq!(std_vocabulary_types().len(), 33); assert_eq!(std_vocabulary_types().len(), 36);
} }
} }
@@ -185,7 +185,6 @@ fn the_shell_defines_no_domain_modules_and_no_lib_target() {
"render.rs", "render.rs",
"research_docs.rs", "research_docs.rs",
"scaffold.rs", "scaffold.rs",
"verb_sugar.rs",
], ],
"C28 (#295): the shell holds argv/dispatch, argv->document translation, \ "C28 (#295): the shell holds argv/dispatch, argv->document translation, \
and presentation only a new module needs a library home" and presentation only a new module needs a library home"
+285 -57
View File
@@ -10,7 +10,7 @@ JSON artifact kinds you author headlessly along that arc:
through `aura process validate|introspect|register|show`; through `aura process validate|introspect|register|show`;
3. a **campaign document** (role 6b) — experiment intent (instruments × 3. a **campaign document** (role 6b) — experiment intent (instruments ×
windows × strategy × axes × process) through `aura campaign 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 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* 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 scaffolds a **data-only** project (`docs/project-layout.md`, "A project
repo (two tiers)") — a strategy over the std vocabulary is a repo (two tiers)") — a strategy over the std vocabulary is a
blueprint/campaign document, not Rust: the scaffold ships **one** closed blueprint/campaign document, not Rust: the scaffold ships **one** closed
`signal.json` starter that serves both verbs`aura run` uses its bound `signal.json` starter that serves both legs of `exec` (#319) — `aura exec
values as-is, `aura sweep --axis <bp>.fast.length=2,4,8` overrides them signal.json` uses its bound values as-is, `aura exec signal.json --override
(bound = default, not fixed); `--list-axes` lists the open knobs and the 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. bound defaults alike, so every override target is discoverable.
- **A project-specific *native* node type** — the moment §0's three-part - **A project-specific *native* node type** — the moment §0's three-part
pattern is actually needed — lives in an attached **node crate**: pattern is actually needed — lives in an attached **node crate**:
@@ -49,8 +51,9 @@ the same shape every node in `aura-std` already follows.
`src/lib.rs`, registered under the project's own `<namespace>::` prefix) `src/lib.rs`, registered under the project's own `<namespace>::` prefix)
and appends its path to the project's `Aura.toml [nodes]` section. and appends its path to the project's `Aura.toml [nodes]` section.
- **A block promoted to universal** — reused across projects and folded into - **A block promoted to universal** — reused across projects and folded into
the engine itself — lives in `crates/aura-std/src/`, unprefixed, and is the engine itself — lives in `crates/aura-std/` (or its sibling domain
rostered in `crates/aura-std/src/vocabulary.rs` instead of a node crate's crates: `aura-market`, `aura-strategy`), unprefixed, and is rostered in
`crates/aura-vocabulary/src/lib.rs` instead of a node crate's
`vocabulary()` function. The pattern below is identical either way; only `vocabulary()` function. The pattern below is identical either way; only
the rostering call site differs (see "Rostering the type" below). the rostering call site differs (see "Rostering the type" below).
@@ -89,11 +92,16 @@ Every node type — std or project-local — is three things:
`aura_core::aura_project!` macro wires up (every `aura new` scaffold `aura_core::aura_project!` macro wires up (every `aura new` scaffold
emits a starter pair — see the worked example below). Add one match arm emits a starter pair — see the worked example below). Add one match arm
to `vocabulary()` and one entry to `type_ids()`'s slice. to `vocabulary()` and one entry to `type_ids()`'s slice.
- **Std-side** (only when promoting a node into `aura-std` itself): one - **Std-side** (only when promoting a node into the shipped std
line in the `std_vocabulary_roster!` macro invocation in vocabulary): one line in the `std_vocabulary_roster!` macro invocation in
[`crates/aura-std/src/vocabulary.rs`](../crates/aura-std/src/vocabulary.rs) [`crates/aura-vocabulary/src/lib.rs`](../crates/aura-vocabulary/src/lib.rs)
`"TypeId" => Type,` — which expands into both the resolver `match` and `"TypeId" => Type,` — which expands into both the resolver `match` and
the enumerable type-id list, so the two surfaces cannot drift apart. the enumerable type-id list, so the two surfaces cannot drift apart. A
zero-arg `Type::builder()` rosters directly; an **arg-bearing** type
(structural, non-scalar construction — see "Session anchoring" below and
`docs/glossary.md`'s `construction arg` entry, #271) rosters the exact
same way — its `builder()` is itself zero-arg, returning a *pending*
recipe the `add` op's `args` configures before any `bind`.
An unrostered type fails safe either way: the loader refuses with a clean An unrostered type fails safe either way: the loader refuses with a clean
`LoadError::UnknownNodeType` naming the missing id, and the type is simply `LoadError::UnknownNodeType` naming the missing id, and the type is simply
absent from `aura graph introspect --vocabulary` — never a silent partial absent from `aura graph introspect --vocabulary` — never a silent partial
@@ -221,20 +229,54 @@ $ aura graph build < smacross.json > blueprint.json
Nodes are referenced by an **identifier** (given by `add`, see below); ports Nodes are referenced by an **identifier** (given by `add`, see below); ports
are dotted `<identifier>.<port>` on both sides of a wire. are dotted `<identifier>.<port>` on both sides of a wire.
### The nine ops ### The eleven ops
| op | JSON shape | does | | 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. | | `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"`). | | `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) — for a fragment meant to be wired by an *enclosing* graph. A standalone document built with `aura graph build` finalizes as a closed root, so an `input` role that is never bound refuses at the end: `finalize: root input role <name> is unbound`. | | `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). |
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. | | `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `args` (#271) configures an **arg-bearing** type's structural, non-scalar construction (`Session`'s timezone/open, `LinComb`/`CostSum`'s arity) — a closed table of string values, applied BEFORE `bind`; `aura graph introspect --node <T>` lists a type's declared `arg` rows. `bind` sets zero or more of the (now real) params. |
| `use` | `{"op":"use","ref":{"content_id":<id-or-prefix>}\|{"name":<label>},"name":<str>?,"bind":{<path>:<Scalar>}?}` | splice a **registered blueprint** into the graph as a nested composite under an instance identifier (`name`; default: the stored composite's own name). The ref resolves by content id (full or unique prefix) or by a register-time label; `graph build` echoes every resolution (`aura: note: use "…": … -> <id>`) so the exact id can be pinned. The instance's open input roles wire as `<instance>.<role>`, its outputs as `<instance>.<field>`; its open params surface path-qualified (`<instance>.<node>.<param>`), sweepable (ganging an instance's params is not yet supported — the `gang` op refuses a composite instance's member path). A doc-less stored source refuses at the op (C29). The emitted blueprint contains the subgraph *inline* — no reference survives into the artifact. |
| `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). | | `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). | | `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. | | `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). | | `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 Value forms are the typed-tag representations used everywhere in this
family of artifacts: family of artifacts:
@@ -261,15 +303,15 @@ family of artifacts:
``` ```
(`fast.length`, `slow.length`, and `bias.scale` are all left unbound here on (`fast.length`, `slow.length`, and `bias.scale` are all left unbound here on
purpose — three open campaign axes, see §3. This is the milestone fieldtest purpose — three open campaign axes, see §3. Every command shown below in this
corpus's own example, verified below; byte-identical to the on-disk guide is run against exactly this document.)
`fieldtests/milestone-research-artifacts/mra_1_strategy_smacross.json`.)
A `bind` in an `add` op pins that param to a value and removes it from the 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 **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 plain `exec` uses it as-is, while a campaign axis naming it re-opens it for
re-opens it for that family and binds it per cell. `--list-axes` lists it that family (bound per cell) and `exec`'s own `--override NODE.PARAM=VALUE`
after the open knobs as `<name>:<KIND> default=<value>`. `bind` is for a (#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 value the strategy carries by default; leave a param unbound, as all three
are here, to make binding it mandatory for every sweep. are here, to make binding it mandatory for every sweep.
@@ -277,7 +319,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 **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 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 — 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 ### Worked example: declaring a measurement tap
@@ -306,13 +348,101 @@ bias scaling) under the name `spread`:
The built blueprint carries a `taps` array naming the resolved wire The built blueprint carries a `taps` array naming the resolved wire
(`{"name":"spread","from":{"node":<sub's index>,"field":0}}` — the name is (`{"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 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 `ColumnarTrace` in the run's trace store. The run prints the handle it wrote
in a sweep (no per-cell recorder) — it is a single-run observation surface. 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 (`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.) 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 ### Session anchoring: the `SessionFrankfurt` preset
To anchor logic to the trading session, the closed vocabulary ships a To anchor logic to the trading session, the closed vocabulary ships a
@@ -337,10 +467,42 @@ 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 handled by `chrono-tz`, so the same local minute reads the same index in
summer (CEST) and winter (CET). 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 — Wire it like any node —
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`, `{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`. feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
**Any other timezone/open: `Session` + `args` (#271).** `SessionFrankfurt`
stays baked sugar for the one Frankfurt open; the canonical path for ANY IANA
timezone and local open time is the base `Session` type through the typed
construction-args channel — `args` configures it BEFORE `bind`:
```json
{"op":"add","type":"Session","name":"ny",
"args":{"tz":"America/New_York","open":"09:30"},
"bind":{"period_minutes":{"I64":15}}}
```
`tz` is `chrono_tz`'s own exact, case-sensitive IANA name (`"berlin"` refuses;
`"Europe/Berlin"` accepts); `open` is strict zero-padded local `HH:MM`
(`"9:30"` refuses; `"09:30"` accepts); a `Count` arg (`LinComb`/`CostSum`'s
arity) is a plain positive decimal — no sign, no leading zeros (`"03"`
refuses; `"3"` accepts). The accepted string IS the canonical form, no
normalization — and note the deliberate asymmetry: each kind's canonical
form is its domain's conventional notation, fixed-width for wall-clock
times, minimal for numbers. Giving `Session` no `args` at all refuses the `add`
op (`node <name> is missing required arg "tz"`) — an arg-bearing type never
silently enters the graph half-configured. `aura graph introspect --node
Session` lists both declared `arg` rows with their kind and hint text before
you write the JSON.
### Commands ### Commands
``` ```
@@ -363,6 +525,9 @@ SMA
param length:I64 (bind {"I64": <v>}) param length:I64 (bind {"I64": <v>})
$ aura graph introspect --unwired < partial.json # open slots of a partial op-script $ aura graph introspect --unwired < partial.json # open slots of a partial op-script
sub.rhs:F64 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 $ aura graph introspect --content-id smacross.json # SHA-256 of the canonical form
597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876dafba9a 597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876dafba9a
$ aura graph introspect --content-id smacross.json --identity-id # + debug-name-blind identity id, combinable $ aura graph introspect --content-id smacross.json --identity-id # + debug-name-blind identity id, combinable
@@ -374,23 +539,42 @@ slow.length:I64
bias.scale:F64 bias.scale:F64
``` ```
These printed names are the **raw param-space namespace**: op-script params These printed names are the **one** axis namespace (#328): op-script params
and a campaign document's `strategies[].axes` keys (§3) share this one raw and a campaign document's `strategies[].axes` keys (§3) speak the same raw
form. There is a third, *wrapped* surface: the dissolved `aura sweep `<node>.<param>` form as `graph introspect --params` prints (open params
<blueprint> --axis` CLI (glossary `sweep`) accepts only the names `aura bare, bound params trailing `default=<value>`) — every discovered name is
sweep <blueprint> --list-axes` prints — the raw name prefixed with the verbatim legal as a document axis key, bound params included (#246's
root-composite instance name, `graph.<param>` — never the raw form; the re-open contract), and as the token `exec --override NODE.PARAM=VALUE` (#319)
campaign document the sweep sugar generates still stores the raw form takes on the single-run leg:
(#210):
```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 $ aura exec smacross.json --override fast.length=5 # single run: raw works
wrapped (--list-axes, --axis): graph.fast.length ```
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 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. 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 `--content-id`, `--identity-id`, `--params`, and `graph register` all accept
**either** shape: the raw op-script array or an already-built `#155` **either** shape: the raw op-script array or an already-built `#155`
@@ -405,6 +589,13 @@ registered blueprint 597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876da
The printed content id is the address a campaign document's `strategies[].ref` The printed content id is the address a campaign document's `strategies[].ref`
points at (§3). points at (§3).
`graph register <file> --name <label>` additionally records a **blueprint
label** — a legible, re-pointable handle for the `use` op's `{"name":…}`
ref form (re-registering under the same label repoints it; the echo names
the previous target). `aura graph introspect --registered` lists the
labels with their id prefix and root doc line — the discovery surface for
what `use` can reference.
## 2. Process documents — a validation methodology (role 5) ## 2. Process documents — a validation methodology (role 5)
A process document is a named, versionable pipeline of **std stage blocks** A process document is a named, versionable pipeline of **std stage blocks**
@@ -633,9 +824,9 @@ campaign document valid (executable): pipeline shape and static guards pass
``` ```
$ aura campaign register mra_3_campaign_full_v2.json $ aura campaign register mra_3_campaign_full_v2.json
registered campaign 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd (…/runs/campaigns/42ed…json) registered campaign 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd (…/runs/campaigns/42ed…json)
$ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd $ aura exec 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd
{"family_id":"42edebd2-0-GER40-w0-s0-0","report":{...}} {"family_id":"42edebd2-0-GER40-w0-r0-s0-0","report":{...}}
{"family_id":"42edebd2-0-GER40-w0-s0-0-r1","report":{...}} {"family_id":"42edebd2-0-GER40-w0-r1-s0-0","report":{...}}
{"campaign_run":{"campaign":"42edebd2…","process":"cd9127…","run":0,"seed":42, {"campaign_run":{"campaign":"42edebd2…","process":"cd9127…","run":0,"seed":42,
"cells":[{"strategy":"597d719b…","instrument":"GER40","window_ms":[...], "cells":[{"strategy":"597d719b…","instrument":"GER40","window_ms":[...],
@@ -654,32 +845,69 @@ $ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b6
``` ```
With two stop regimes every (instrument, window) cell runs twice — the four 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 cells above are the "4 cell(s)" the validate summary counted. The regime
regime's family ids and trace dirs carry the `-r1` ordinal suffix (the ordinal is an INFIX segment, `-r{k}`, right after the window segment
default/first regime stays unsuffixed), each cell record names its regime, (`{id8}-{ordinal}-{instrument}-w{k}-r{k}-s{stage}-{run}`) — the
and generalization is keyed per regime — regimes are compared, never pooled. 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 canonical address is always the content id — running a bare file the first
time registers it implicitly. `aura campaign runs` lists stored time registers it implicitly. `aura campaign runs` lists stored
realizations; `aura campaign runs <id>` dumps the bare stored record(s) (not realizations; `aura campaign runs <id>` dumps the bare stored record(s) (not
the `{"campaign_run": …}` emit wrapper above). If `presentation.persist_taps` the `{"campaign_run": …}` emit wrapper above). If `presentation.persist_taps`
is non-empty, the run also persists the named taps under is non-empty, the run also persists every member's taps under
`runs/traces/<trace_name>/…`, chartable with `aura chart`. The `runs/traces/<trace_name>/…` — the deterministic `{campaign8}-{run}` handle
`sweep`/`walkforward --trace` analog persists every member's taps as a the `campaign_run` record's `trace_name` field prints, never a user-chosen
family charted the same way, by the handle the run prints (members keyed name — chartable with `aura chart <trace_name>` (members keyed
`<cell>/<member>`) — also by the `--trace <NAME>` you chose, when that name `<cell>/<member>`); `aura chart` also resolves a bare campaign id/name
uniquely names one recorded run (`aura chart <NAME>` resolves it against the against the stored campaign documents when no literal trace directory
stored campaign documents; a name reused across runs refuses rather than matches, refusing rather than guessing when the name is ambiguous across
guessing which one you mean). 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 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; 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 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 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 (#272). A directly-run blueprint (`aura exec <blueprint>.json`, no campaign)
member — is a *successful* cell, not a failed one: the gate legitimately has no cells at all: its own refusals — a binding mismatch, a synthetic-data
answered "no survivors", so the run still exits 0, the cell's realization is mismatch, a compile error, a tap-bind content fault (unknown fold/tap,
recorded as truncated at that stage, and an informational `aura:`-prefixed duplicate) — are the content of the argv-named file and exit 2 like a usage
note names the cell on stderr. Exit 3 is reserved for cells that could not be error; only its environment refusals (a tap-trace store write failure)
evaluated (faults), never for an empty-but-valid result. 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 optional gated document `description`); descriptions never influence
execution or identity ids, and registered artifacts are never execution or identity ids, and registered artifacts are never
retroactively invalidated. 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 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 the raw instrument string, so uniqueness is what keeps concurrent appends from
racing one name's run-index assignment. 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 → bit-identical run. Two backtests are fully disjoint and run concurrently
without locking. The bit-identity is *per run*: one backtest of given (inputs, 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* 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 recomputed for the same params by two *different command paths* (e.g. a
member's `sqn` vs the same cell re-run under `aura generalize`), which may differ 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 by floating-point reassociation (≤1 ULP) because the two paths accumulate the
same logical reduction in a different operation order (IEEE-754 non-associativity). 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 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 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 its name on the legacy live/`--trace`/test-tap surface, whose migration onto the
subscriber shape is tracked as #308. 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 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 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` `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 (`aura-std/src/recorder.rs`), which survives for the callers that read a
path. 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 `BlueprintNode::signature()` (`aura-engine/src/blueprint.rs`) answers uniformly for
both arms: a primitive returns its recipe's schema, a composite *derives* it via 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 `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 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 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 proxy in production, a gated cost recorder under folded delivery as the
input, the `LinComb(4)` `net_r_equity` curve in trace mode). Both re-run sides `summarize_r` join input, the `LinComb(4)` `net_r_equity` curve under
re-derive the model (`cost_specs_from_params`), so the C1 drift alarm compares like retained delivery). Both re-run sides re-derive the model
with like; costed families reproduce bit-identically (incl. `Carry`). (`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 `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 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. 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 **Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol, VolTf}`
realized at the campaign-document level as `CampaignDoc.risk: [RiskRegime]` axis is realized at the campaign-document level as `CampaignDoc.risk:
(`aura-research`, variants `Vol{length, k}` and `VolTf{period_minutes, length, k}` [RiskRegime]` (`aura-research`, variants `Vol{length, k}`,
(#262), the fixed-stop rule additive when needed) — a kept-separate matrix axis, `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, peer of instruments and windows. The executor keys the nominee map by `(strategy,
window, regime)`, so `generalize` aggregates *within* a regime, never across. window, regime)`, so `generalize` aggregates *within* a regime, never across.
Regimes are **compared** at presentation, never argmax-**selected** across (a 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 `Arc<DataServer>` (one `FileCache`), so a window is parsed once and every member's
`M1FieldSource` borrows the same cached `Arc<[M1Parsed]>` chunks `M1FieldSource` borrows the same cached `Arc<[M1Parsed]>` chunks
(`crates/aura-ingest/src/lib.rs:316`). The single-pass *parse* cost stays tracked as #95. (`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 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 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, 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 overridable by an axis*, so a campaign axis (param-sweep) may name a bound
family boundary re-opens it on the probe and on every member reload, and the axis param — the family boundary re-opens it on the probe and on every member
binds it per cell; `run`/`mc` still require every param resolved (a truly open reload, and the axis binds it per cell; `exec`'s blueprint leg still requires
param refuses). Identity is untouched: `content_id_of` every param resolved (a truly open param refuses), and its own `--override
(`crates/aura-research/src/lib.rs`) and `topology_hash` NODE.PARAM=VALUE` (#246 residue, #319) reopens exactly the named bound param
(`crates/aura-cli/src/main.rs`) read the authored document, never a re-opened for that one execution — the single-member analogue of a campaign axis. The
probe, and each member manifest records its per-cell bindings (#246, ratified 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 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 implementation consequence of `bind()` shrinking the param surface, not a recorded
decision). Raw data is shared read-only across sims via `Arc<[T]>` (the 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_PROJECT`, a two-tier `#[repr(C)]` `ProjectDescriptor`
(`aura-core::project`, `crates/aura-core/src/project.rs`, emitted by the (`aura-core::project`, `crates/aura-core/src/project.rs`, emitted by the
`aura_project!` macro). A **C tier**`magic` (`AURAPROJ`), `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 namespace, all C-compatible field types — is validated **before** any Rust-ABI
field is touched; a **Rust tier** — the vocabulary resolver field is touched; a **Rust tier** — the vocabulary resolver
`fn(&str) -> Option<PrimitiveBuilder>` and the enumerable type-id list — is read `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` **load-and-hold** — the `Library` is leaked, never unloaded, so its `'static`
strings and fn-pointers stay valid (`aura-runner::project::load`, strings and fn-pointers stay valid (`aura-runner::project::load`,
`crates/aura-runner/src/project.rs`). `validate_c_tier` checks the four stamps `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 both sides (`ProjectError::Incompatible`). The project vocabulary is
charter-checked at load (`check_charter`): non-empty namespace, every id charter-checked at load (`check_charter`): non-empty namespace, every id
`<ns>::`-prefixed, no duplicate, no collision against the std vocabulary, `<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 refusal diagnostics stay unprefixed (diagnostics are not usage lines). The
machine-first help surface (JSON/manifest help, stdin op-scripts) stays on 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). #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 — in `crates/aura-cli/src/main.rs`, threaded from the run registry —
[C18](c18-registry.md)). [C18](c18-registry.md)).
**Dual grammar.** The four dual-grammar subcommands (run/sweep/walkforward/mc) **`aura-runner`'s single-run refusals now honor this same partition (#297,
keep both grammars under one token via an optional `[blueprint]` positional plus 2026-07-26).** Before this cycle, every refusal inside `aura-runner`'s
a post-parse `is_file()` dispatch; the execution layer is unchanged (arg-plumbing single-run verb paths (`aura exec <blueprint.json>`, both the bias-output and
via thin `*_from` adapters). The machine-first help surface (JSON/manifest help, bare-measurement legs) exited 1 uniformly — a `std::process::exit(1)` baked
stdin op-scripts) is deferred to the #157 / [C21](c21-world.md) track, distinct into the library itself, bypassing the partition above rather than
from this human/GNU-convention compliance. 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). **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 The data layer the programmatic face emits is a public interface read raw (by
@@ -17,10 +17,14 @@ stream; reference data feeds source/session nodes from beside the hot path.
**Session context — `bars_since_open` is the shipped contract (#154).** The **Session context — `bars_since_open` is the shipped contract (#154).** The
session-context node `Session` (`crates/aura-market/src/session.rs`) emits session-context node `Session` (`crates/aura-market/src/session.rs`) emits
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct, over a **one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct), over a
baked Frankfurt open), and takes no scalar params; the zero-arg timezone/open time that is now data-authorable via the typed construction-args
`SessionFrankfurt` roster preset (#261) exposes it with a single channel (`Session` roster-reaches `std_vocabulary` as an arg-bearing type,
`period_minutes` knob. This is a deliberate narrowing pinned in the node's own #271: `args: {"tz": <IANA name>, "open": "HH:MM"}`, applied before its one
scalar param, `period_minutes`). The zero-arg `SessionFrankfurt` roster preset
(#261) stays as baked sugar over the 09:00 `Europe/Berlin` open — the
canonical path for any OTHER open/timezone is `Session` + `args`, not a new
preset per market. This is a deliberate narrowing pinned in the node's own
contract: `bars_since_open` alone is the gate — a downstream `EqConst(== N)` contract: `bars_since_open` alone is the gate — a downstream `EqConst(== N)`
subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which
never match), and nothing consumes a `session_open_ts: timestamp`. The two never match), and nothing consumes a `session_open_ts: timestamp`. The two
@@ -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 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 whose equivalence to the loaded-composite path no green test ratifies (decision
log: #191 comments). 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 - `runs.jsonl` — the append-only flat store, one `RunReport` per line
(`RunManifest` + `RunMetrics`), with a typed read-path (`load`) and best-first (`RunManifest` + `RunMetrics`), with a typed read-path (`load`) and best-first
ranking (`rank_by`/`optimize`). No live producer writes it today — sweep / ranking (`rank_by`/`optimize`). No live producer writes it today — the campaign
walk-forward / mc persist to the family store and `aura run` does not persist — executor (`exec`'s campaign leg, `aura-campaign`, #319) persists to the family
so the flat lib API is retained but selectively live: `rank_by` backs 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 runs family … rank`, `optimize` backs walk-forward's in-sample step
(`aura-campaign` and the registry's own selection helper both call it), while (`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 `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 / - `families.jsonl` — the family store. A sweep / Monte-Carlo / walk-forward /
cross-instrument run persists as a *set of related records*, each a cross-instrument run persists as a *set of related records*, each a
`FamilyRunRecord` (a `RunReport` stamped with `family` + `run` + `kind` + `FamilyRunRecord` (a `RunReport` stamped with `family` + `run` + `kind` +
`ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`). `ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`,
`group_families` re-derives a family from the stored links (re-listable / the last now dead — below). `group_families` re-derives a family from the
rankable as a unit — C21). The user-facing `family_id = "{family}-{run}"` handle stored links (re-listable / rankable as a unit — C21). The user-facing
is **derived** from the stored `family` name plus a per-name `run` index `family_id = "{family}-{run}"` handle is **derived** from the stored `family`
(numeric max+1 — not a content hash, so re-running the same family mints a fresh name plus a per-name `run` index (numeric max+1 — not a content hash, so
id). CLI: `aura runs families`, `aura runs family <id> [rank <metric>]`; re-running the same family mints a fresh id). CLI: `aura runs families`,
`aura sweep`/`walkforward`/`mc` persist via `append_family` with an optional `aura runs family <id> [rank <metric>]`; the campaign executor persists per-cell
`--name`. 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), - `campaign_runs.jsonl` — one thin `CampaignRunRecord` per campaign run (below),
over untouched family records. over untouched family records.
- `blueprints/<hash>.json`, `processes/`, `campaigns/` — the content-addressed - `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 the blueprint does not use leaves the id byte-stable. `--content-id` and
`--identity-id` are combinable. `--identity-id` are combinable.
**Cross-instrument generalization.** `FamilyKind::CrossInstrument`: `aura generalize` **Cross-instrument generalization (retired standalone family, #319).** The
runs one candidate across an instrument list and persists the M per-instrument runs `FamilyKind::CrossInstrument` variant is now dead: the standalone `aura
via `append_family`, each member self-identifying through `RunManifest.instrument`. generalize` verb that used to run one candidate across an instrument list and
The generalization score (worst-case R floor + sign-agreement + per-instrument persist the M per-instrument runs via `append_family` is retired. The
breakdown) is a **recomputable aggregate** over those members, not a persisted surviving cross-instrument grading is `std::generalize`'s campaign-scope
family-level record. 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 **Research-artifact document stores.** `processes/` and `campaigns/` hold two
document types (C25 roles 5 / 6b): the **process document** (a named 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 generate → retrieve → hand-extend → re-register loop needs no direct store
filesystem access. 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 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 zero-fault referential gate, then the process pipeline. The executable shape is
`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` `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 realization is a pure function of doc + stores + data (C1). Harness/data binding
stays consumer-side behind the one-method **`MemberRunner`** seam (the shipped stays consumer-side behind the one-method **`MemberRunner`** seam (the shipped
implementation is `aura_runner::DefaultMemberRunner`; the CLI binds the loaded- implementation is `aura_runner::DefaultMemberRunner`; the CLI binds the loaded-
blueprint reduce convention with a unique suffix-join of raw axis names onto the blueprint folded-delivery convention with a unique suffix-join of raw axis names
wrapped `param_space`). The `campaign_runs.jsonl` sibling records one thin onto the wrapped `param_space`). The `campaign_runs.jsonl` sibling records one thin
`CampaignRunRecord` per run — campaign/process ids, seed, and per-cell realized `CampaignRunRecord` per run — campaign/process ids, seed, and per-cell realized
stage prefixes linking family ids, gate survivor ordinals, and sweep selections — 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 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 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). 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 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 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 recorded nominee — the C1 drift alarm, a hard refusal on divergence (the reproduce
precedent, enforced). Traces land in the existing `TraceStore` as 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 of). Known debt: `aura chart` over the campaign family ROOT (cells spanning
instruments) is untested / semantically undefined — only per-cell read-back is pinned. 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` **Identity-ref resolution is index-first (#191).** `find_blueprint_by_identity`
consults the persistent `blueprint_identity_index.jsonl` sidecar (identity id → 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) 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 `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 archive columns by name (C26). `wrap_r`'s remaining R-scaffolding retirement
stays #159.] 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). document (C25/C18).
**Pre-C24 scaffolding retired.** The pre-C24 scaffolding — `HarnessKind` and the **Pre-C24 scaffolding retired.** The pre-C24 scaffolding — `HarnessKind` and the
per-strategy `*_sweep_family` functions — is **gone**. The family builders are now per-strategy `*_sweep_family` functions — is **gone**. The generic blueprint-driven
generic and blueprint-driven: `blueprint_sweep_family`, `blueprint_walkforward_family`, family builders that first replaced it (`blueprint_sweep_family`,
`blueprint_mc_family` (and `blueprint_sweep_over`) in `blueprint_walkforward_family`, `blueprint_mc_family`, `blueprint_sweep_over`) are,
`crates/aura-runner/src/family.rs`. The R-evaluator scaffold `wrap_r` in turn, retired by the #319 sugar retirement (2026-07-25): the **campaign
(defined in `crates/aura-runner/src/member.rs`; imported and called from executor** (`aura_campaign::exec::execute`) now builds every family, driven by
`runner.rs`) survives; its last hard data weld — the the process document's stage pipeline (`std::sweep`/`std::grid`,
hard-wired `price`←close role and the `M1Field::Close`-only open sites — is retired `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 (a strategy's input roles now bind archive columns by name, C26), and `wrap_r`'s
remaining R-scaffolding retirement is tracked at #159. 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` 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 budget flag and true intra-bucket min/max ordering for the non-default continuous
x-mode (#110). 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 **Encoding / storage / rendering split (C14).** A drained tap is encoded as a
columnar (SoA, C7) `ColumnarTrace` — struct→JSON only — in `aura-engine` columnar (SoA, C7) `ColumnarTrace` — struct→JSON only — in `aura-engine`
(`report.rs`); file I/O is `aura-registry::TraceStore`, persisting each run under (`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 `aura-cli` (`render_chart_html` + the vendored `chart-viewer.js`). The engine stays
headless. headless.
**Single run.** `aura run <blueprint.json>` persists every tap the blueprint **Single run.** `aura exec <blueprint.json>` (#319) persists every tap the
declares to the trace store under the run's own name, on both shapes: a blueprint declares to the trace store under the run's own identity-keyed
`bias`-output strategy (`aura-runner::member::run_signal_r`, R-wrapped) and a bare handle — `<render-name>-<id8>`, #311 — on both shapes: a `bias`-output strategy (`aura-runner::member::run_signal_r`,
measurement blueprint with ≥1 declared tap but no `bias` R-wrapped) and a bare measurement blueprint with ≥1 declared tap but no
(`aura-runner::measure::run_measurement`). A tap-free run writes nothing to `runs/` `bias` (`aura-runner::measure::run_measurement`). A tap-free run writes
and its stdout stays byte-identical. The CLI `--trace <NAME>` flag is **retired** on nothing to `runs/` and its stdout stays byte-identical. `exec` carries no
`run` (and on `mc`): it parses but is refused at dispatch (exit 2) — naming a trace free-standing `--trace <NAME>` flag — naming a trace is the family / campaign
is the family / campaign path's job, not a per-run flag. 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 A run that persists anything reports the handle it wrote under as
member* via the campaign path: the dissolved verb is translated into a `trace_name` on its stdout line (#309) — appended to the record's own bytes,
content-addressed process + campaign document whose `presentation.persist_taps` absent entirely when the run recorded nothing — so the handle is read, never
requests the tap vocabulary, run through the one campaign executor; guessed. The library hands it to an embedding caller as a value beside the
`aura-runner::runner::persist_campaign_traces` writes each member under a depth-2 report, like the unbound-tap names (C27/#297); it is not part of the stored
fan-out `runs/traces/<name>/<cell>/<member>/`. Every written member is record.
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 **Single-run trace identity (#311).** The handle is `<render-name>-<id8>`,
drift alarm** that refuses (exit 1) rather than persist a silently-wrong trace. The where `id8` is the 8-hex head of a SHA-256 over the run's own `RunManifest`
**synthetic** sweep / walk-forward path refuses `--trace` (exit 2); with two keys *removed*`commit` (the aura binary's build sha) and
**Monte-Carlo is excluded from trace persistence** (`mc --real` refuses `project.commit` (the project repo's HEAD plus a `-dirty` marker, re-derived
`--name`/`--trace`, exit 2 — the real-data R-bootstrap campaign itself runs, but on every invocation) — and `params`/`defaults` *merged* into one name-sorted
records no per-member family traces; the synthetic `--seeds` family's realization sequence. The two removed keys are provenance — who built or checked out the
argument, C12, does not carry over to one real series). code — not what the run was; including either would make the store an
`TraceStore::ensure_name_free` makes name resolution a total function, refusing unbounded directory generator. The merge is the same distinction applied to a
cross-kind reuse of one name by both a run and a family. 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 **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 (`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 The engine also ships example blueprints under
`crates/aura-cli/examples/r_{sma,breakout,channel,meanrev}.json`. Honest gap against `crates/aura-cli/examples/r_{sma,breakout,channel,meanrev}.json`. Honest gap against
the Guarantee's "populated trace immediately": these shipped blueprints are 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 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 from a blueprint that declares taps (single run) or from a campaign document
campaign. requesting `presentation.persist_taps`.
**Deferred.** Live sink streams *during* a run are not built — taps are **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 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 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 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 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 — any transform that leaves every observable sink trace bit-identical (C1 is the
correctness invariant) — on two levels: 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. - [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. - [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. - [C19](c19-bootstrap.md) — the flat-graph representation this optimises.
> History: [c23-graph-compilation.history.md](c23-graph-compilation.history.md)
@@ -57,7 +57,7 @@ so the Tier-2 bump discipline is dormant and structurally-semantic additions
(the `gangs` section) land as additive-optional fields of v1; the first ship (the `gangs` section) land as additive-optional fields of v1; the first ship
consciously freezes v1, gangs included, and activates the bump discipline. consciously freezes v1, gangs included, and activates the bump discipline.
**Milestone delivered — 2026-06-30, cycle 0090 — the serializable format + loader + construction service.** A green end-to-end milestone fieldtest (`fieldtests/milestone-topology-as-data/`) proves the full author → serialize → load → construct → introspect → reproduce story from the public surface alone, 0 behavioural bugs; the round-trippable format (#155), the construction service (#157), and the forward-compat two-tier discipline (#156) all shipped. Polish filed forward: op-script grammar docs + a stale example (#163), the canonical trailing-newline / `Composite` value ergonomics (#164), CLI discoverability of `build`/`introspect` (#159). **Milestone delivered — 2026-06-30, cycle 0090 — the serializable format + loader + construction service.** A green end-to-end milestone fieldtest proved the full author → serialize → load → construct → introspect → reproduce story from the public surface alone, 0 behavioural bugs; the round-trippable format (#155), the construction service (#157), and the forward-compat two-tier discipline (#156) all shipped. Polish filed forward: op-script grammar docs + a stale example (#163), the canonical trailing-newline / `Composite` value ergonomics (#164), CLI discoverability of `build`/`introspect` (#159).
**Realization (2026-06-30, cycle 0092 — runs are built FROM blueprint-data, #165).** **Realization (2026-06-30, cycle 0092 — runs are built FROM blueprint-data, #165).**
`aura run <blueprint.json>` now loads a serialized **signal** blueprint `aura run <blueprint.json>` now loads a serialized **signal** blueprint
@@ -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 — the same cross-purpose coupling that rules out regenerating the
fixture from the scaffolder. 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.) **[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.)
+171 -69
View File
@@ -70,37 +70,41 @@ loop for the round-trippable vocabulary.
**Out of the round-trippable set** (deliberate; fails clean as `UnknownNodeType`): **Out of the round-trippable set** (deliberate; fails clean as `UnknownNodeType`):
recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic
data, C19) and construction-arg builders (`LinComb` / `CostSum` / `SimBroker` / data, C19) and `SimBroker` (a scalar-typed baked value, a narrower gap than the args
`Session` — structural-axis args, a C20 concern), additively addable later (#156). channel). `LinComb` / `CostSum` / `Session` — formerly listed here — entered the
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 `aura exec <blueprint.json>` loads a serialized **signal** blueprint and runs it
`RunReport` **bit-identical** (C1) to its Rust-built twin; the run scaffolding (sinks once, emitting a `RunReport` **bit-identical** (C1) to its Rust-built twin (a
/ broker / data) is supplied **at run**, not serialized. Beyond a single run, the no-bias blueprint with ≥1 declared tap runs the measurement leg instead); the
World constructs and orchestrates **families** of harnesses from topology-data: 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 Every member manifest carries the **shared** `topology_hash` (one signal
needs an **open** blueprint (a fully-bound one has an empty `param_space`). topology, only params vary; `member_key` distinguishes members), and the
- `aura mc <blueprint.json> --seeds N``FamilyKind::MonteCarlo`, each seed a executor stores its blueprint(s) content-addressed so `aura reproduce`
distinct synthetic price walk drawn disjoint-parallel through the engine re-derives every member bit-identically (C18). The synthetic price walks
`monte_carlo` seam (invariant 1). MC binds no axis, so it needs a **closed** (`aura_runner::family::synthetic_walk_sources`) survive the #319 retirement
blueprint (the sweep's distinction inverted); an open one returns a named `Err`, only as `reproduce`'s re-derivation inputs for historically minted synthetic
rendered exit-2 at the builder boundary — no hidden exit in the pure builder. families — no family-minting path draws them anymore (the campaign executor
- `aura walkforward <blueprint.json> --axis <name>=<csv> [--select is the one family builder, C20) — and they are not trader-grade statistics; a
argmax|plateau:mean|plateau:worst]` → `FamilyKind::WalkForward`: re-optimizes the real-data block-bootstrap — and retiring the `len:60`↔warm-up coupling —
loaded blueprint's params over the `--axis` grid on each 24/12/12 IS window, rides #172.
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.
### Reproduction identity ### Reproduction identity
@@ -115,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`. The byte-exact content id keeps the store/reproduce roles; the
identity id is introspection-only until a dedup consumer exists. 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 ### Axis discovery
`aura sweep <blueprint.json> --list-axes` lists a loaded blueprint's open sweepable `aura graph introspect --params <blueprint.json|id>` (#328) is the one
knobs (one `<name>:<kind>` per line, `param_space()` order) and exits; the printed axis-discovery surface: it lists a loaded blueprint's open knobs (one
names are exactly what `--axis` binds. Every listed name is **mandatory** on `sweep` / `<name>:<kind>` per line, `param_space()` order) followed by its bound knobs
`walkforward` — the blueprint must be fully bound before it runs — so a subset grid is (`<name>:<kind> default=<value>`) — the campaign-axis namespace
refused with the missing knob named (`BindError::MissingKnob`, `aura-engine`) and `validate_campaign_refs` checks a campaign document's `strategies[].axes`
there is no default; pin a knob you do not want to vary with a single-value axis. A against. Every open name a campaign document's `axes` varies is **mandatory**:
single `blueprint_axis_probe` (`aura-runner`) single-sources the wrapped probe for the a subset is refused with the missing knob named (`BindError::MissingKnob`,
sweep terminal, the MC closed-check, and the listing, so **listed == swept by `aura-engine`) and there is no default; pin a knob you do not want to vary with
construction** (and stays so across the harness retirement — the listing tracks a single-value axis. One raw namespace, `<node>.<param>` (a splice path keeps
whatever the sweep actually resolves). The names are prefixed by the current wrapping its interior path, e.g. `anchor.sess.period_minutes`), is the only user-facing
(`sma_signal.fast.length`, the nested-composite prefix), which is why discovery lives axis name (#328) — the wrapped `<blueprint>.<node>.<param>` form is retired
on the sweep verb (it owns the wrapping), not `graph introspect`. `--trace` on from the surface, with a did-you-mean refusal naming the raw candidate at the
`sweep` / `walkforward` writes per-member traces on the real-data campaign path one remaining intake seam, `campaign validate` (#319 retired the standalone
(depth-2 fan-out, chartable by the printed family handle, #224); the synthetic path `--axis`/`--list-axes` CLI flags this used to also gate). Per-member traces
still refuses (`run` / `mc` refuse `--trace` outright). The live trace-writer is the are the campaign document's `presentation.persist_taps` (closed vocabulary,
campaign `presentation.persist_taps` (`persist_campaign_traces`, `aura-runner::runner`). 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) ### The construction service (op-script)
@@ -144,17 +168,45 @@ per-op checks — name resolution, edge kind-match (`edge_kind_check`), the doub
arm, param bind, and acyclicity (a `connect` that would close a cycle is rejected arm, param bind, and acyclicity (a `connect` that would close a cycle is rejected
eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic** eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic**
finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity
(`check_param_namespace_injective`), root-role boundness (`check_root_roles_bound`). (`check_param_namespace_injective`). Both cadences call the **same extracted
Both cadences call the **same extracted predicates** — no second validator. The predicates** — no second validator. The holistic faults read **by-identifier**
holistic faults read **by-identifier** (#162): `finish()` translates the (#162): `finish()` translates the index-carrying `CompileError`s (`UnconnectedPort` /
index-carrying `CompileError`s (`UnconnectedPort` / `RoleKindMismatch` / `RoleKindMismatch`) into by-identifier `OpError` variants while still *calling* the
`UnboundRootRole`) into by-identifier `OpError` variants while still *calling* the unchanged holistic gates. **Root-role boundness moved off this list (#317, open
unchanged holistic gates. Build-free introspection answers over the closed vocabulary: patterns):** `check_root_roles_bound` no longer runs in `finish()` — a `finish()`ed
`graph introspect --vocabulary | --node <T> | --unwired`. The engine `Op` stays op-script may still carry an open (unbound) root role, declaring a reusable
serde-free; the wire DTO (`OpDoc`) is CLI-side (`aura-cli::graph_construct`). pattern's formal parameter; only `compile`/bootstrap (the runnability gate, not a
Acceptance: a graph built purely through the ops compiles identical (C1) to its construction gate) still call it, unchanged. Build-free introspection answers over
Rust-built twin, an invalid op is rejected at the op naming the cause, introspection the closed vocabulary: `graph introspect --vocabulary | --node <T> | --unwired`. The
answers without a build. engine `Op` stays serde-free; the wire DTO (`OpDoc`) is CLI-side
(`aura-cli::graph_construct`). Acceptance: a graph built purely through the ops
compiles identical (C1) to its Rust-built twin, an invalid op is rejected at the op
naming the cause, introspection answers without a build.
**Registered-blueprint splice (#317).** The `use` op resolves a reference — a store
content id, a unique content-id prefix (#302 semantics), or a registry **name
label** (`graph register --name`, latest-wins, `aura-registry`'s
`blueprint_names.jsonl` sidecar) — to a previously registered blueprint and
splices it into the building graph as a nested `BlueprintNode::Composite` under an
instance identifier. The **engine never resolves**: `GraphSession`/`replay` take a
second injected closure, `subgraph: &dyn Fn(&str) -> Option<Composite>`, mirroring
`vocab`'s closed-lookup posture exactly — this is the same enforcement-shift
permission §"Enforcement shift" above already grants the node vocabulary, extended
to registered-blueprint references. All store I/O — label/prefix resolution, the
`get_blueprint` fetch, the C29 doc-gate re-walk over the fetched composite (before
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; 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.
**Invariant-5 lockstep.** The eager acyclicity gate is a *second* home of invariant 5 **Invariant-5 lockstep.** The eager acyclicity gate is a *second* home of invariant 5
alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two
@@ -166,16 +218,44 @@ rejected at construction.
The op-script is a JSON **array of ops**, each object internally tagged by `"op"`, 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 replayed in order; nodes are referenced **by identifier**, ports as dotted
`<identifier>.<port>`. The eight 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``{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
source role producing a base column of `kind`. source role producing a base column of `kind`.
- `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred - `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred
from the slots it feeds). from the slots it feeds) — a reusable **pattern**'s formal parameter, left open
- `add``{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` until an enclosing graph wires it (#317's open patterns): `finish()` accepts an
op-script that never binds it, only `compile`/bootstrap require it bound.
- `add``{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}`
add a node of compiled-in type identity `type`; **`name` is its identifier** add a node of compiled-in type identity `type`; **`name` is its identifier**
(mirrors the builder's `.named(...)`; defaults to the lowercased type label, so (mirrors the builder's `.named(...)`; defaults to the lowercased type label, so
two unnamed nodes of one type collide); `bind` sets params. two unnamed nodes of one type collide); `args` (#271) configures an **arg-bearing**
type's structural, non-scalar construction (`Session`'s IANA timezone/open,
`LinComb`/`CostSum`'s arity) through the closed, load-time-validated `ArgKind`
table (`Tz`/`TimeOfDay`/`Count`) — applied BEFORE `bind`, so a bound param
`args` unlocks (e.g. `LinComb`'s `weights[i]`) is only bindable once `arity` is
consumed; an arg-bearing type given no `args` refuses (`BadArg(MissingArg)`), a
`Plain` type given `args` it does not declare also refuses
(`BadArg(NotArgBearing)`); `bind` sets the (now real) params.
- `feed``{"op":"feed","role":<str>,"into":[<port>,…]}` — fan a root role into - `feed``{"op":"feed","role":<str>,"into":[<port>,…]}` — fan a root role into
interior input slots. interior input slots.
- `connect``{"op":"connect","from":<port>,"to":<port>}` — wire an interior output - `connect``{"op":"connect","from":<port>,"to":<port>}` — wire an interior output
@@ -189,19 +269,32 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
recorded observation point (a `Composite.taps` entry, C27), not a boundary output. 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 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 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 the built `Composite` (`.with_taps`); a single `aura exec` (#319) records
leaves them inert. each, a campaign member run leaves them inert.
- `gang``{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` - `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 — 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 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 value fans out to every member at bootstrap. Members must share one scalar
kind and stay open (un-bound). kind and stay open (un-bound).
- `doc``{"op":"doc","text":<str>}` — declare the composite's one-line meaning
(C29, #316) — the op-script twin of the builder's `.doc(...)`; at most one per
script, a second is a fault (a declarative script carries no last-wins
ambiguity).
- `use``{"op":"use","ref":{"content_id":<str>}|{"name":<str>},"name":<str>?,"bind":{<path>:<Scalar>}?}`
— splice a **registered** blueprint into the building graph as a nested
composite under an instance identifier (`name`, defaulting to the fetched
composite's own name); `bind` path-qualifies the spliced instance's params
(e.g. `"sma.length"`), applied after the splice (#317). An instance's open
input roles become its `<instance>.<role>` in-ports, its output boundary
aliases its `<instance>.<field>` out-fields — the same `Composite` arm every
other add/wiring path walks, not a new mechanism. See §"The construction
service" below for the resolution split.
Value forms are the serialized representations: a `Scalar` bind value is the typed tag Value forms are the serialized representations: a `Scalar` bind value is the typed tag
form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind` form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind`
is the capitalized `ScalarKind` (`"F64"`). Worked example: is the capitalized `ScalarKind` (`"F64"`). Worked example:
`fieldtests/cycle-0088-construction-op-script/c0088_1_sma_crossover.json` (an `crates/aura-cli/examples/r_sma.json` — an SMA-crossover bias carrying both an
SMA-crossover bias). `{"I64":…}` and an `{"F64":…}` bind value.
### Canonical form (#164) ### Canonical form (#164)
@@ -224,12 +317,21 @@ Tier-1 (additive-optional) is serde-default silent-ignore with **no** `format_ve
bump — a new optional field defaults to prior behaviour (C1). Tier-2 (must-understand: bump — a new optional field defaults to prior behaviour (C1). Tier-2 (must-understand:
a new node type, edge semantics, or structural-axis kind) **bumps** `format_version` so a new node type, edge semantics, or structural-axis kind) **bumps** `format_version` so
an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`). an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`).
**Pre-ship dormancy (#61):** until the first external ship there are no out-of-repo **Pre-ship dormancy (#61, ended by #271):** until the first external ship there were
readers — reader and writer change atomically in one commit — so the Tier-2 bump no out-of-repo readers — reader and writer changed atomically in one commit — so the
discipline is dormant and structurally-semantic additions (the `gangs` section) land as Tier-2 bump discipline lay dormant and structurally-semantic additions (the `gangs`
additive-optional fields of v1; the first ship consciously freezes v1, gangs included, section) landed as additive-optional fields of v1. #271's construction args are the
and activates the bump discipline. The per-section required-flag scheme is deferred (no first exercised Tier-2 bump (data-driven, next paragraph); v1 remains the version of
current Tier-2 section to validate it; recorded on #156). every args-free document, gangs included. The per-section required-flag scheme stays
deferred (no per-section case yet; recorded on #156).
**First data-driven bump (#271):** construction args are the first Tier-2 case whose
version is decided PER DOCUMENT, not a single global bump. The writer emits
`format_version: 1` for an args-free document (byte-identical to every pre-#271
document — content ids stable) and `2` the moment any primitive, at any composite
nesting depth, carries `args` — the must-understand signal an old (pre-#271) reader
needs, since it cannot construct a pending arg-bearing type at all. The loader accepts
the closed range `1..=2`.
### Enforcement shift — invariant 9 on the data plane ### Enforcement shift — invariant 9 on the data plane
@@ -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). layout and docs-by-role are open (#192).
**Document-first completion** is the resolved direction on the control-surface **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 amendment (owner-minuted 2026-07-21 on #295), delivered by #300. The executor
set is settled: `run`, the four thin per-verb generators, and the document verbs surface is settled again, one reduction further (#319, 2026-07-25): a single
validate/introspect/register/show/run/runs — `show` being #300's read-back `exec <target>` verb runs both document classes — a campaign (file or content
addition. The verbs' per-verb identity is re-ratified (#300 F8 — reduction, not id) and a signal blueprint (single run) — over the document verbs
grammar collapse). A typed-protocol host or MCP face remains demand-driven and validate/introspect/register/show/runs (`show` #300's read-back addition); the
unbuilt. 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 ## See also
- [C16](c16-engine-project-split.md) — the game-engine analogy this extends to people - [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 - [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 - [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 - [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` pair called by both declared-tap entry points, `run_signal_r`
(`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both (`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both
CLI verbs pass a record-all plan." 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."
+42 -6
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 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 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, 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 **Why.** Observability must be expressible in a hand-authored blueprint — the
measurement-shaped study computes in the graph and surfaces via taps, no throwaway 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), 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 and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
points, `run_signal_r` (`aura-runner::member`) and `run_measurement` points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
(`aura-runner::measure`) — both arms of the single CLI verb `aura run`, whose (`aura-runner::measure`) — both arms of the single CLI verb `aura exec` (#319),
repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
data-reachable: no flag keeps the record-all default, any flag replaces the 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 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 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 projection exercising this contract's authority, not a second home for
@@ -84,13 +97,36 @@ consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's
streaming writer in-graph — `initialize` opens (deferred acquisition), `eval` streaming writer in-graph — `initialize` opens (deferred acquisition), `eval`
appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports
exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one
summary row; live closures run inline (`aura-std::TapLive`). The sweep/reduce summary row, emitted at finalize and stamped with the instant of the last
path never calls `bind_tap`. contributing (warm) value — `first` alone pins the first contributing
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`. 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 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)); unbound tap — is **deferred to the future DCE cycle** ([C23](c23-graph-compilation.md));
the mechanism ships now, verified sound. 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 ## See also
- [C26](c26-input-binding.md) — the input-side twin (`input_roles`); `check_root_roles_bound`, the mandatory-input counterpart - [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 - [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 (report unification, campaign engine generic-over-`M`), until a concrete
family/campaign demand exists. family/campaign demand exists.
**Deferred.** ~24 refusal sites inside `aura-runner`'s single-run verb paths still **#297 (closed, 2026-07-26).** `aura-runner` no longer terminates the host
terminate the process (`std::process::exit`; the #283 tap-plan refusals added four process anywhere: the 14 recon-enumerated `std::process::exit` sites inside
— typed as `TapPlanError` before the exit, so the eventual conversion is a the single-run verb paths (`member.rs` ×8, `measure.rs` ×5, `translate.rs`
mechanical rewrap); their conversion to `RunnerError` propagation is tracked as ×1 — the "~24" this paragraph used to cite predates the #319 dual-grammar
**#297** (the campaign path already refuses via `MemberFault`, never a process retirement and never updated in lockstep) are now `RunnerError { exit_code,
exit). 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 ## See also
- [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve - [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve
+22 -5
View File
@@ -27,13 +27,23 @@ cannot pass its short-name alibi
restatement. restatement.
3. **Register seam (data plane).** The registry's public `put_blueprint` 3. **Register seam (data plane).** The registry's public `put_blueprint`
parses the canonical bytes and requires a gate-passing doc on the root parses the canonical bytes and requires a gate-passing doc on the root
composite and on every named nested composite the gate guards the composite and on **every** nested composite (the walk recurses
store boundary itself, so the register verb, every register-then-run unconditionally — it does not stop at an unnamed one) — the gate guards
the store boundary itself, so the register verb, every register-then-run
sugar path, and any future caller are gated by construction. The sugar path, and any future caller are gated by construction. The
op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s
twin ([C25](c25-role-model.md): a closed, typed metadata construct). twin ([C25](c25-role-model.md): a closed, typed metadata construct).
Process/campaign documents take an additive-optional top-level Process/campaign documents take an additive-optional top-level
`description`, gated only when present (`DocFault::BadDescription`). `description`, gated only when present (`DocFault::BadDescription`).
4. **Use seam (data plane, construction-time, #317).** The `use`
construction op re-runs the SAME `gate_composite_docs` walk over a
blueprint FETCHED from the store, before it ever splices into a new
composition — a build-time gate on already-registered content, not a
second write-path check. A doc-less entry (reachable only through the
raw in-crate write survivor class, since `put_blueprint`'s own gate keeps
the store clean going forward) cannot enter a NEW composition this way,
but stays readable and runnable on its own — no retroactive invalidation,
the same discipline the Forbids clause states for the register seam.
**Scope at this record's writing (#321).** The gate walks five **Scope at this record's writing (#321).** The gate walks five
vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and
@@ -50,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 ([C24](c24-blueprint-data.md)), so node docs move no id. Documents have no
identity projection — their pair is: absent `description` is byte-identical identity projection — their pair is: absent `description` is byte-identical
to the field-less form (existing content ids stable), present participates. 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 **Forbids.** The engine evaluating description text — the gate is
deterministic string shape, never content judgement (no freetext logic deterministic string shape, never content judgement (no freetext logic
@@ -67,6 +82,8 @@ for whom the binary is the only always-present teacher. Field evidence
execution semantics and document schemas by CAS forensics — the removed execution semantics and document schemas by CAS forensics — the removed
failure class is exactly that forensic recovery. failure class is exactly that forensic recovery.
Rendering of the carried texts on the help/introspection surfaces is #315; Rendering of the carried texts on the help/introspection surfaces shipped
the generated agent bootstrap card is #267; a fold introspection surface with #315; the agent bootstrap card was retired as superseded (#267 — the
is blocked on #310. C29 surfaces themselves carry it); the fold introspection surface shipped
with #310 and renders the fold-registry roster — labels exactly as the
`--tap` selector accepts them, doc lines from the registry entries (#332).

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