Compare commits

72 Commits

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

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

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

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

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

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

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

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

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

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

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

refs #267, refs #312, refs #319
2026-07-24 11:06:22 +02:00
359 changed files with 20764 additions and 10435 deletions
Generated
+3 -2
View File
@@ -160,6 +160,7 @@ dependencies = [
"aura-core",
"aura-engine",
"aura-ingest",
"aura-market",
"aura-measurement",
"aura-registry",
"aura-research",
@@ -167,6 +168,7 @@ dependencies = [
"aura-std",
"aura-strategy",
"aura-vocabulary",
"chrono-tz",
"clap",
"data-server",
"libc",
@@ -192,6 +194,7 @@ dependencies = [
name = "aura-core"
version = "0.1.0"
dependencies = [
"chrono-tz",
"serde",
"serde_json",
]
@@ -307,8 +310,6 @@ dependencies = [
"aura-backtest",
"aura-core",
"aura-strategy",
"chrono",
"chrono-tz",
]
[[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
`price → bias` node graph. A blueprint file (`blueprint.json`) is the unit a
downstream consumer loads and drives verbs over.
downstream consumer loads and executes or varies.
- **Open vs. closed** — a blueprint with unbound (free) numeric knobs is *open*;
one with every knob bound is *closed*. Different verbs want different states
(see each verb below).
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
in a content-addressed store and can be listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
one with every knob bound is *closed*. A plain `exec` wants closed; naming a
knob as a campaign axis (or an `exec --override`) is what reopens it.
- **Family** — the set of runs a campaign document's process pipeline produces
over a blueprint (a sweep grid, a Monte-Carlo seed set, a walk-forward window
sequence). Families are persisted in a content-addressed store and can be
listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
bound with a comma-separated value list. A **gang** fuses several sibling
knobs into one axis (one value drives all members).
## Running & orchestrating a loaded blueprint
## Executing a document
These verbs all take the **blueprint file as their first positional argument**.
Most drive a family of runs over it; `graph` renders its structure so a mis-wire
is visible before a run. `walkforward`, `mc`, and `generalize` share
`sweep`'s generic grammar — the blueprint positional plus `--real` and a
repeatable `--axis <name>=<csv>` (`generalize` takes `--real <SYM1,SYM2,…>`, at
least two instruments, with a single value per axis) — see `--help`.
`aura exec <target>` (#319) is the one executor verb: `<target>` is either a
loaded **blueprint** file (a single synthetic run) or a **campaign document**
(a `.json` file or its registered 64-hex content id — instruments × windows ×
strategy × param axes × process, see `docs/authoring-guide.md` §3). `graph`
renders a blueprint's structure so a mis-wire is visible before a run.
| Command | Purpose |
|---|---|
| `aura run <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or use `sweep`. |
| `aura exec <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or vary it as a campaign axis. |
| `aura exec <bp.json> --override <node.param>=<value>` | Reopen one **bound** param for this single execution only (the value is recorded raw in the manifest); repeatable. |
| `aura exec <bp.json> --tap <name>=<fold>` | Subscribe a declared tap to a fold (`record`/`count`/`sum`/`mean`/`min`/`max`/`first`/`last`) for this run; repeatable. |
| `aura graph <bp.json>` | **Render** the blueprint's structure as an interactive HTML DAG so a mis-wire is visible before a run. Omit the file to render the built-in sample; a named-but-unreadable file is a usage error. |
| `aura sweep <bp.json> --list-axes` | **Discover** the blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`. Run this first to learn the axis names. |
| `aura sweep <bp.json> --axis <name>=<csv> [--axis …]` | Run a **grid family** over the named axes and persist it. |
| `aura mc <bp.json> --seeds <n>` | Run a **synthetic Monte-Carlo family** of `n` seeded realizations. Wants a **closed** blueprint (the inverse of sweep). |
| `aura mc <bp.json> --real <sym> --axis <name>=<csv> [--axis …]` | Run a **Monte-Carlo R-bootstrap campaign** over recorded data — driven through the same generated-campaign pipeline as `sweep`; `--block-len`/`--resamples`/`--seed` tune the bootstrap. |
| `aura walkforward <bp.json> --axis <name>=<csv> [--axis …]` | Run an **in-sample-refit walk-forward family** — rolling optimize + out-of-sample test across windows, stitched into one verdict + parameter stability. `--select` chooses the per-window objective. |
| `aura graph introspect --params <bp.json\|id>` | **Discover** a blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`, bound params trailing `default=<value>`. Run this first to learn the axis names for a campaign document. |
| `aura exec <campaign.json\|id> [--parallel-instruments <n>]` | Execute a **campaign document** — a grid family, gates, walk-forward, Monte-Carlo, and cross-instrument generalization per its process document, one cell per (strategy, instrument, window). |
| `aura runs families` | List every persisted family (id, kind, member count). |
| `aura runs family <id> [rank <metric>]` | List one family's members, optionally ranked best-first by an R metric (e.g. `sqn_normalized`, `expectancy_r`). |
| `aura reproduce <family-id>` | Re-derive every member of a persisted family from the content-addressed store and check it is bit-identical (the C18/C1 determinism guarantee). |
**Important contract — every open knob is mandatory.** On `sweep` and
`walkforward`, the knobs enumerated by `--list-axes` are *all* required: you must
supply an `--axis` for each open knob. There is no default value — pin a knob you
don't want to vary with a single-value axis (`--axis name=4`). Omitting one is a
clean error naming the missing knob, not a silent default.
**Important contract — every open knob is mandatory on a campaign axis.** A
campaign document's `strategies[].axes` must name every open knob
`graph introspect --params` lists: there is no default value — pin a knob you
don't want to vary with a single-value axis (`{"kind":"I64","values":[4]}`).
Omitting one is a clean error naming the missing knob, not a silent default.
Use `aura <cmd> --help` for the full data-window (`--real`/`--from`/`--to`),
naming (`--name`), and selection (`--select`) flags.
Use `aura exec --help` for the exact flag grammar, `docs/authoring-guide.md`
for the campaign document shape (data window, naming, presentation/emit).
## Authoring & introspecting topology
@@ -111,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
then feed to `run` / `sweep` / `mc` / `walkforward` above:
then feed to `exec` above:
```sh
aura graph build < crossover.ops.json > crossover.bp.json
aura sweep crossover.bp.json --list-axes
aura graph introspect --params crossover.bp.json
```
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
wire; `doc` declares the composite's one-line meaning, required at register —
C29; see the authoring guide). See
`gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
interior wire; `doc` declares the composite's one-line meaning, required at
register — C29; `use` splices a registered blueprint in as a nested composite,
by content id or label; `add` additionally takes an `args` object for
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
non-scalar construction consumed before `bind`; see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics.
@@ -1,16 +1,16 @@
{
"surface": "cli_fixed_cost",
"metrics": {
"help_ms": 1.5046899999999999,
"run_ms": 3.63305
"help_ms": 1.487171,
"run_ms": 3.376192
},
"fingerprint": "run_line_fnv=6bb0d796f760d140",
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "9c7f60b",
"date": "2026-07-23"
"commit": "10570b7",
"date": "2026-07-25"
}
+7 -20
View File
@@ -95,31 +95,18 @@ fn blueprint_stems(dir: &Path) -> Vec<String> {
.unwrap_or_default()
}
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
/// E2E `seed_blueprint` pattern) and return its content id.
/// Seed one blueprint into the scratch store via `graph register` (#319: the
/// surviving registration surface) and return its content id.
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
let before = blueprint_stems(dir);
let (out, code) = run_in(
bin,
dir,
&[
"sweep",
file,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
name,
],
)?;
let (out, code) = run_in(bin, dir, &["graph", "register", file, "--name", name])?;
if code != Some(0) {
return Err(format!("seed sweep failed ({code:?}): {out}"));
return Err(format!("seed register failed ({code:?}): {out}"));
}
blueprint_stems(dir)
.into_iter()
.find(|s| !before.contains(s))
.ok_or_else(|| "seed sweep must store a new blueprint".to_string())
.ok_or_else(|| "seed register must store a new blueprint".to_string())
}
fn register_process(bin: &Path, dir: &Path, file: &str, doc: &str) -> Result<String, String> {
@@ -261,10 +248,10 @@ pub fn winner_fingerprint(stdout: &str) -> Result<String, String> {
fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
let timed: TimedChild = run_timed(bin, &["campaign", "run", &doc], &scratch.0)?;
let timed: TimedChild = run_timed(bin, &["exec", &doc], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!(
"campaign run exited {:?}\nstdout: {}\nstderr: {}",
"aura exec exited {:?}\nstdout: {}\nstderr: {}",
timed.exit, timed.stdout, timed.stderr
));
}
+6 -6
View File
@@ -1,6 +1,6 @@
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
//! computed) and a minimal data-only project run whose fingerprint is the
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per
//! computed) and a minimal data-only project exec whose fingerprint is the
//! FNV-1a hash of the exec's single stdout JSON line. Fresh scratch project per
//! repetition, so store counters start identical.
use super::{median, RepOutcome};
@@ -11,7 +11,7 @@ use std::collections::BTreeMap;
use std::path::Path;
use std::time::Instant;
/// Fingerprint of an `aura run` record line, invariant across rebuilds: the
/// Fingerprint of an `aura exec` record line, invariant across rebuilds: the
/// line's `manifest.commit` is the aura binary's compile-time build sha
/// (crates/aura-cli/build.rs — the invariant-8 audit trail), so hashing the
/// raw line would flip the fingerprint on EVERY new commit and report a
@@ -59,9 +59,9 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
let mut run_walls = Vec::new();
let mut first_line = None;
for _ in 0..RUN_SAMPLES {
let timed = run_timed(bin, &["run", "bench_sma_a.json"], &scratch.0)?;
let timed = run_timed(bin, &["exec", "bench_sma_a.json"], &scratch.0)?;
if timed.exit != Some(0) {
return Err(format!("aura run exited {:?}: {}", timed.exit, timed.stdout));
return Err(format!("aura exec exited {:?}: {}", timed.exit, timed.stdout));
}
run_walls.push(timed.wall_s * 1000.0);
if first_line.is_none() {
@@ -70,7 +70,7 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
.stdout
.lines()
.next()
.ok_or("aura run must print its JSON record line")?
.ok_or("aura exec must print its JSON record line")?
.to_string(),
);
}
+2 -2
View File
@@ -105,7 +105,7 @@ mod tests {
/// `run_reps` composed with the real campaign sweep surface — a fresh
/// scratch project, a freshly seeded blueprint, and a genuine
/// `aura campaign run` child process spawned per repetition (not an
/// `aura exec` child process spawned per repetition (not an
/// in-process shortcut). Protects that two independently built-and-run
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
/// from the child's stdout — C1's determinism carried through the
@@ -136,7 +136,7 @@ mod tests {
/// `run_reps` composed with the real CLI fixed-cost surface — a genuine
/// `aura --help` spawn-floor wall plus a fresh scratch project and a real
/// `aura run` child process per repetition (not an in-process shortcut).
/// `aura exec` child process per repetition (not an in-process shortcut).
/// Protects that two independent scratch runs agree on the run-line FNV
/// fingerprint extracted from the child's stdout, and that the metrics
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
+9
View File
@@ -65,6 +65,15 @@ zip = "2"
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
aura-composites = { path = "../aura-composites" }
# aura-market: the shell's own use is test-only (#271's identity-bridge test,
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
# `Session::configured` as the Rust-authored twin of the args op-script path)
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
aura-market = { path = "../aura-market" }
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
# `Session::configured` — test-only, pinned to the same version every other
# crate's chrono-tz entry uses.
chrono-tz = { version = "0.10", default-features = false }
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
aura-analysis = { path = "../aura-analysis" }
+101 -55
View File
@@ -1,4 +1,5 @@
//! `aura campaign run` — the driver that turns a stored campaign document into
//! `aura exec`'s campaign legs (#319; formerly `aura campaign run`) — the
//! driver that turns a stored campaign document into
//! a realized run-set (#198). The execution *semantics* (preflight, cell loop,
//! stage sequencing, selection, registry writes) live in `aura-campaign`; the
//! [`MemberRunner`](aura_campaign::MemberRunner) implementation over the shipped loaded-blueprint machinery
@@ -27,7 +28,7 @@ use aura_engine::FamilySelection;
use aura_registry::CampaignRunRecord;
use aura_research::{
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign,
validate_process, CampaignDoc, DocRef,
validate_process, Axis, CampaignDoc, DocRef,
};
use aura_runner::axes::is_content_id;
@@ -108,27 +109,17 @@ struct CampaignRunLine<'a> {
campaign_run: &'a CampaignRunRecord,
}
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
/// member/selection lines + the always-on final `campaign_run` record line).
/// `MemberLinesOnly` is dissolved-verb sugar: the generated document's
/// `emit: ["family_table"]` already limits emission to member lines; the mode
/// additionally suppresses the final record line so the verb's stdout
/// contract is reproduced byte-for-byte. The record append is identical in
/// both modes — presentation changes, the record does not.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum RunPresentation {
Full,
MemberLinesOnly,
}
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
/// `aura exec <target>` (campaign legs): resolve, gate, execute, emit. Every `Err`
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok`
/// carries the failed-cell count (#272) so the caller can exit 3 on a
/// completed-with-failures run via `exit_on_campaign_result`.
/// completed-with-failures run via `exit_on_campaign_result`. `overrides`
/// (#319 Task 4, the `exec --override` campaign leg) is threaded through
/// unchanged to `run_campaign_returning`, the one injection seam.
pub(crate) fn run_campaign(
target: &str,
env: &Env,
parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<usize, String> {
// Project gate FIRST: nothing (not even the file-sugar registration)
// touches a store outside a project.
@@ -162,7 +153,7 @@ pub(crate) fn run_campaign(
));
};
run_campaign_by_id(&campaign_id, env, RunPresentation::Full, parallel_instruments)
run_campaign_by_id(&campaign_id, env, parallel_instruments, overrides)
}
/// An executed campaign plus the context its presenter and the dissolved-verb
@@ -181,20 +172,15 @@ pub(crate) struct CampaignRun {
/// The one campaign executor path from a resolved content id onward: fetch
/// the stored canonical bytes by id (so file addressing and id addressing
/// produce the same realization by construction) and re-run the intrinsic
/// tier on them, execute, and emit per `presentation`. Shared by
/// `run_campaign` (`RunPresentation::Full`) and the dissolved-verb sugar path
/// (`verb_sugar::run_sweep_sugar`, `RunPresentation::MemberLinesOnly`) — no
/// project gate here: the sugar path must work project-less exactly as the
/// inline verb it replaces did (store mechanics run against `env.registry()`
/// in both cases).
/// tier on them, execute, and emit. Called by `run_campaign`.
pub(crate) fn run_campaign_by_id(
campaign_id: &str,
env: &Env,
presentation: RunPresentation,
parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<usize, String> {
let run = run_campaign_returning(campaign_id, env, parallel_instruments)?;
present_campaign(run, presentation, env)
let run = run_campaign_returning(campaign_id, env, parallel_instruments, overrides)?;
present_campaign(run, env)
}
/// The one campaign executor path from a resolved content id up to (not
@@ -204,18 +190,32 @@ pub(crate) fn run_campaign_by_id(
/// outcome bundled with the context the presenter needs. Shared by
/// `run_campaign_by_id` (which then presents) and a forthcoming dissolved-verb
/// sugar path that reads the outcome and self-prints (lands in a later task,
/// alongside the generalize translator's own runner).
/// alongside the generalize translator's own runner). `overrides` (#319
/// Task 4) is injected into the parsed document AFTER intrinsic validation
/// and BEFORE the referential gate — the stored bytes and the recorded
/// `campaign_id` stay those of the ORIGINAL document; only the executed
/// run-set changes, its audit record being the raw member manifests.
///
/// One deliberate exception to "every refusal is an `Err`" (review Minor-4):
/// the injection loop's axis-collision check exits directly with code 2
/// instead of returning `Err`. That collision is a USAGE-class fault (C14) —
/// `--override` naming a path the document already declares as an axis is a
/// malformed invocation, exactly like `--override`'s own malformed-token
/// refusal (`parse_override_tokens`, exit 2) — not a RUNTIME-class refusal
/// (missing environment/recorded state, exit 1), which is what this fn's
/// `Result<_, String>` return otherwise carries end to end.
pub(crate) fn run_campaign_returning(
campaign_id: &str,
env: &Env,
parallel_instruments: NonZeroUsize,
overrides: &[(String, Scalar)],
) -> Result<CampaignRun, String> {
let registry = env.registry();
let campaign_text = registry
.get_campaign(campaign_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?;
let campaign = parse_campaign(&campaign_text)
let mut campaign = parse_campaign(&campaign_text)
.map_err(|e| doc_error_prose("stored campaign document", &e))?;
let faults = validate_campaign(&campaign);
if !faults.is_empty() {
@@ -225,6 +225,25 @@ pub(crate) fn run_campaign_returning(
));
}
// --override NODE.PARAM=VALUE (#246 residue, #319 Task 4): inject as a
// single-value axis over the named bound param into EVERY strategy
// entry. Collision with a document-declared axis refuses (an override
// overrides a bound value, never an axis); an unknown path then falls
// out of `validate_campaign_refs` below with its existing did-you-mean
// prose — no `RefFault` change, no registry change.
for (path, val) in overrides {
for entry in &mut campaign.strategies {
if entry.axes.contains_key(path) {
eprintln!(
"aura: exec: --override `{path}` collides with a declared axis \
of the campaign; an override overrides a bound value, never an axis"
);
std::process::exit(2);
}
entry.axes.insert(path.clone(), single_value_axis(*val));
}
}
// Referential gate: zero faults or refuse (the campaign-validate seam).
let resolve = |t: &str| env.resolve(t);
let ref_faults = registry
@@ -295,15 +314,11 @@ pub(crate) fn run_campaign_returning(
Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() })
}
/// Emit an executed campaign's results per `presentation`: the zero-survivor
/// stderr notes, the emit-gated family/selection lines, the always-on record
/// line (Full only), then trace persistence. Split out of `run_campaign_by_id`
/// so the dissolved-verb sugar can consume the outcome without this stdout tail.
fn present_campaign(
run: CampaignRun,
presentation: RunPresentation,
env: &Env,
) -> Result<usize, String> {
/// Emit an executed campaign's results: the zero-survivor stderr notes, the
/// emit-gated family/selection lines, the always-on record line, then trace
/// persistence. Split out of `run_campaign_by_id` so a forthcoming caller
/// could consume the outcome without this stdout tail.
fn present_campaign(run: CampaignRun, env: &Env) -> Result<usize, String> {
let CampaignRun { outcome, campaign, strategies, server } = run;
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
@@ -355,6 +370,32 @@ fn present_campaign(
let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
for cell_out in &outcome.cells {
// #313 zero-trade note (#319 Task 5, migrated from the retiring
// `dispatch_walkforward`/`run_walkforward_sugar` paths): every campaign
// cell's `std::walk_forward` family, if any, gets the same shared
// `note_zero_trade_windows` producer over its OOS members' trade
// counts — independent of `emit` (a stderr diagnostic, not gated
// stdout presentation).
if let Some(wf) = cell_out.families.iter().find(|f| f.block == "std::walk_forward") {
crate::diag::note_zero_trade_windows(
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
);
} else if !cell_out.families.is_empty() {
// #324 (re-scoped): the non-walk-forward sibling — a cell whose
// families are ALL non-`std::walk_forward` (grid sweep, MC
// bootstrap, …) has no OOS-window vocabulary, so without a note
// here a cell that never traded is indistinguishable from a
// break-even one. Over every report across every family this
// cell ran (a cell with zero families, e.g. gate-truncated,
// emits nothing new here).
let trades: Vec<u64> = cell_out
.families
.iter()
.flat_map(|f| f.reports.iter())
.map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades))
.collect();
crate::diag::note_zero_trade_cell(trades.into_iter());
}
if emit_family {
for fam in &cell_out.families {
for report in &fam.reports {
@@ -381,23 +422,21 @@ fn present_campaign(
}
}
}
if presentation == RunPresentation::Full {
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
eprintln!(
"aura: campaign run {} recorded: {} cells{}",
outcome.record.run,
outcome.record.cells.len(),
if failed == 0 {
String::new()
} else {
format!(", {failed} failed ({})", fail_labels.join(", "))
}
);
}
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
eprintln!(
"aura: campaign run {} recorded: {} cells{}",
outcome.record.run,
outcome.record.cells.len(),
if failed == 0 {
String::new()
} else {
format!(", {failed} failed ({})", fail_labels.join(", "))
}
);
// Trace persistence runs LAST, after the always-on record line (0109):
// stdout stays data-pure (the record line is the wire contract) and the
@@ -417,6 +456,13 @@ fn present_campaign(
Ok(failed)
}
/// Build the single-value `Axis` an `exec --override` injects (#319 Task 4):
/// exactly the shape the document form `{"kind": "I64", "values": [v]}`
/// deserializes to — `Scalar::kind` names the tag, one value in the list.
fn single_value_axis(v: Scalar) -> Axis {
Axis { kind: v.kind(), values: vec![v] }
}
/// #272: the fault-kind label the failed-cell summary line names — the same
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
+67 -6
View File
@@ -32,11 +32,11 @@ fn zero_trade_note_text(n_windows: usize) -> String {
}
}
/// The #313 zero-trade note, shared by both walk-forward paths (the
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
/// place. Takes the per-window trade counts; a no-op unless there is at
/// least one window and every one of them traded zero times.
/// The #313 zero-trade note (#319 Task 5: migrated onto the campaign
/// walk-forward leg in `campaign_run.rs`, the surviving executor path) so
/// the wording AND the condition live in exactly one place. Takes the
/// per-window trade counts; a no-op unless there is at least one window and
/// every one of them traded zero times.
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
let n_windows = window_trades.len();
if n_windows > 0 && window_trades.all(|n| n == 0) {
@@ -44,9 +44,54 @@ pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<
}
}
/// #324 (re-scoped): the non-walk-forward sibling of `note_zero_trade_windows`
/// — a campaign cell whose families are ALL non-walk-forward (grid sweep, MC
/// bootstrap, …) has no per-window vocabulary to note through, yet a
/// consumer still cannot tell "0.0 = break-even" from "0.0 = never traded"
/// without this. Pure over the count so the wording is unit-pinned (mirrors
/// `zero_trade_note_text`'s singular/plural split, #313).
fn zero_trade_cell_note_text(n_reports: usize) -> String {
if n_reports == 1 {
"the cell's one report recorded zero trades; its metrics are vacuous, not a break-even \
result"
.to_string()
} else {
format!(
"the cell's {n_reports} reports all recorded zero trades; its metrics are vacuous, \
not a break-even result"
)
}
}
/// See `zero_trade_cell_note_text`. Takes the per-report trade counts across
/// every family the cell ran; a no-op unless there is at least one report
/// and every one of them traded zero times. Called from the campaign cell
/// loop's non-walk-forward branch (`campaign_run.rs`), independent of `emit`.
pub(crate) fn note_zero_trade_cell(mut report_trades: impl ExactSizeIterator<Item = u64>) {
let n_reports = report_trades.len();
if n_reports > 0 && report_trades.all(|n| n == 0) {
note!("{}", zero_trade_cell_note_text(n_reports));
}
}
/// #324 (comment 4501): `exec <blueprint.json>`'s single-run signal/bias leg
/// always drives the built-in synthetic stream (`RunData::Synthetic`, no
/// direct-blueprint exec ever binds real data — only a campaign document's
/// `data` section does) — a tiny `n_cycles`-bar smoke fixture, not market
/// data. Zero trades on it is expected, not a broken strategy; a caller that
/// validates against it needs telling so. Unconditional — the caller gates
/// on the zero-trade condition before calling.
pub(crate) fn note_synthetic_smoke_zero_trades(n_cycles: usize) {
note!(
"the built-in synthetic stream ({n_cycles} cycles) is a small smoke fixture; zero \
trades is expected here — bind real data via a campaign document's `data` section \
(`aura exec <campaign.json>`) for a meaningful run"
);
}
#[cfg(test)]
mod tests {
use super::zero_trade_note_text;
use super::{zero_trade_cell_note_text, zero_trade_note_text};
#[test]
/// #278 fieldtest friction: the single-window form must read as
@@ -62,4 +107,20 @@ mod tests {
"all 3 walk-forward windows recorded zero trades"
);
}
#[test]
/// #324: the cell-level sibling pluralizes the same way and names the
/// vacuous-metrics property, not just the raw zero count.
fn zero_trade_cell_note_pluralizes() {
assert_eq!(
zero_trade_cell_note_text(1),
"the cell's one report recorded zero trades; its metrics are vacuous, not a \
break-even result"
);
assert_eq!(
zero_trade_cell_note_text(4),
"the cell's 4 reports all recorded zero trades; its metrics are vacuous, not a \
break-even result"
);
}
}
File diff suppressed because it is too large Load Diff
+1067 -2013
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
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
/// zero for non-zero type") at exactly the moment a user needs guidance.
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
pub(crate) fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
"must be a whole number of at least 1 — it bounds how many distinct \
instruments are resident in parallel"
@@ -161,7 +161,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
}
DocFault::BadRegime { index } => {
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
format!("risk[{index}]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0")
}
DocFault::BadCost { index } => {
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
@@ -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
/// candidates share the prefix — refused by name, listing every candidate,
/// rather than guessed.
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
pub(crate) fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
match matches.as_slice() {
[] => Ok(None),
@@ -411,19 +411,6 @@ pub enum CampaignSub {
Introspect(DocIntrospectCmd),
/// Register a valid campaign document into the store under the runs root.
Register { file: PathBuf },
/// Execute a stored campaign into a realized run-set (a .json file is
/// register-then-run sugar; the canonical address is the content id).
Run {
target: String,
/// Bound on distinct instruments resident in parallel (the RAM
/// lever; 1 reproduces the sequential loop's footprint).
#[arg(
long,
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
value_parser = parse_parallel_instruments,
)]
parallel_instruments: std::num::NonZeroUsize,
},
/// List stored campaign realizations, or dump one campaign's records
/// (the bare store lines, not the run-emit wrapper).
Runs { campaign: Option<String>, run: Option<usize> },
@@ -462,8 +449,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
RefFault::StrategyUnloadable { id, error } => {
format!("strategy {id} cannot be loaded: {error}")
}
RefFault::AxisNotInParamSpace { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
if let Some(candidate) = raw_candidate {
msg.push_str(&format!(
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
));
}
msg
}
RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
@@ -536,18 +529,7 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
Ok(())
}
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
/// via `exit_on_campaign_result`), so it is pulled out of the unified
/// `Result<(), String>` match the other four subcommands still share.
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
target,
env,
*parallel_instruments,
));
return;
}
let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => {
@@ -557,7 +539,6 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
CampaignSub::Register { file } => register_campaign(file, env),
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
CampaignSub::Show { id } => show_campaign(id, env),
CampaignSub::Run { .. } => unreachable!("handled above"),
};
if let Err(m) = result {
eprintln!("aura: {m}");
@@ -761,6 +742,7 @@ mod tests {
ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
}),
ref_fault_prose(&RefFault::AxisKindMismatch {
strategy: "9f3a".into(),
@@ -781,6 +763,39 @@ mod tests {
}
}
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
/// and verbatim, exactly when `raw_candidate` is present — the document-
/// side refusal seam's mirror of the sweep `--axis` intake translation
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
/// `axis names are raw node.param paths` clause pinned there.
#[test]
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "graph.fast.length".into(),
raw_candidate: Some("fast.length".into()),
});
assert_eq!(
msg,
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
axis names are raw node.param paths — did you mean \"fast.length\"?"
);
}
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
/// leading segment yields no param-space hit, or the axis has no leading
/// segment to strip at all) renders today's prose unchanged — no
/// speculative suggestion.
#[test]
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
});
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
}
#[test]
/// #194 (the prefix trap): a doc ref that carries the display `content:`
/// prefix (a copy-paste from register/introspect output) is bare-only in
+15 -12
View File
@@ -231,11 +231,13 @@ const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data
This directory is an aura project: blueprints + research documents over the
std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
- Run: `aura run blueprints/signal.json` (the starter is closed — all
params bound; bound values are defaults — any `--axis` may override them
(#246))
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
- Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream
— the starter is closed; all params bound; bound values are defaults —
`--override NODE.PARAM=VALUE` may override one for this run (#246))
- Campaign: `aura exec <campaign.json>` executes a registered campaign
document (file or content id) — the multi-cell/axis surface
- Axes: `aura graph introspect --params blueprints/signal.json` lists the
open + bound-overridable axes, RAW `<node>.<param>` names (#328)
- Native nodes: when the project needs its first project-specific node,
`aura nodes new <name>` scaffolds a node crate beside this project and
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
@@ -245,9 +247,9 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
unit R, and quality metrics are R-based. Entry signals become held state
via the signal-side latch/edge-pulse idiom (see
`aura graph introspect --vocabulary`).
- Data plane: the research verbs are sugar over registered process/campaign
documents — author them directly with `aura process` / `aura campaign`,
growing one from a bare `{}` via `aura campaign introspect --unwired`.
- Data plane: process/campaign documents are authored directly with
`aura process` / `aura campaign`, growing one from a bare `{}` via
`aura campaign introspect --unwired`.
"#;
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
@@ -458,11 +460,12 @@ mod tests {
}
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("demo-lab"));
// The CLAUDE.md sweep quickstart targets the closed starter itself
// with the rendered blueprint-name axis prefix (#246: a bound param
// is a default an axis overrides).
// The CLAUDE.md quickstart targets the closed starter itself through
// the surviving surface (#319): exec for both document classes, the
// #246 override residue, and raw-namespace axis discovery (#328).
assert!(claude.contains("blueprints/signal.json"));
assert!(claude.contains("demo_lab_signal.fast.length"));
assert!(claude.contains("--override NODE.PARAM=VALUE"));
assert!(claude.contains("graph introspect --params"));
}
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
File diff suppressed because it is too large Load Diff
+113 -26
View File
@@ -5,47 +5,136 @@
//! handled cleanly, never a panic.
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
mod common;
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// A fresh, unique working directory so the spawned `aura sweep` writes its
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, out.status.code())
}
fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(&p, text).expect("write doc");
p
}
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Seed one blueprint into the built demo project's store via `aura graph
/// register` and return its content id — copied verbatim from
/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep`
/// side-effect seeding this used to ride played no role in the returned
/// content id, a pure function of the loaded blueprint's canonical bytes;
/// `name` labels the registered id, mirroring the old per-call sweep family
/// name).
fn seed_blueprint(dir: &Path, name: &str) -> String {
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
assert_eq!(code, Some(0), "seed register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Register `doc` as a process document in the project store; returns its
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
write_doc(dir, file, doc);
let (out, code) = run_code_in(dir, &["process", "register", file]);
assert_eq!(code, Some(0), "process register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` —
/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same
/// shape, which yields exactly four family members (the four-line stream
/// this test's property needs).
fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "broken-pipe",
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = window.0,
to = window.1,
)
}
/// Property: a family-emitting command whose stdout reader closes early exits on
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken
/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON
/// lines via `println!`; when the reader closes stdout after the first line, the
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at
/// startup, so that write returns EPIPE and `println!` panics rather than the
/// process terminating quietly — exactly the behaviour a CLI must not have. This
/// is the contract: any aura subcommand can be piped into `| head` / `| less` /
/// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
/// when the reader closes stdout after the first line, the next write hits a
/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
/// write returns EPIPE and `println!` panics rather than the process
/// terminating quietly — exactly the behaviour a CLI must not have. This is
/// the contract: any aura subcommand can be piped into `| head` / `| less` /
/// a UI pane that goes away, and it must finish quietly, not panic.
///
/// Autonomous: constructs its own early-closing reader inline (no `head`, no
/// shell, no fixture files) by spawning the binary with a piped stdout and
/// dropping the read handle after a single short read, which closes the pipe's
/// read end and makes the writer's next write hit EPIPE.
#[test]
fn family_emitting_command_survives_early_reader_close() {
let cwd = temp_cwd("broken-pipe-sweep");
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir),
ScratchPath::File(dir.join("broken-pipe.process.json")),
ScratchPath::File(dir.join("broken-pipe.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "broken-pipe-seed");
let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC);
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
write_doc(&dir, "broken-pipe.campaign.json", &doc);
let mut child = Command::new(BIN)
.arg("sweep")
.current_dir(&cwd)
.args(["exec", "broken-pipe.campaign.json"])
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura sweep");
.expect("spawn aura exec");
// Read just enough to guarantee the writer is mid-stream, then drop the read
// handle. Dropping it closes the read end of the pipe; the next `println!`
@@ -63,9 +152,7 @@ fn family_emitting_command_survives_early_reader_close() {
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
let status = child.wait().expect("reap aura sweep");
let _ = std::fs::remove_dir_all(&cwd);
let status = child.wait().expect("reap aura exec");
// The defect: stderr carries the broken-pipe panic.
assert!(
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -1,7 +1,9 @@
//! Shared fixture helpers for the `aura-cli` end-to-end test binaries
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`), all of which build
//! and drive the `tests/fixtures/demo-project` fixture through the real
//! `aura` binary (#223).
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and —
//! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`,
//! `graph_construct.rs`), all of which build and drive the
//! `tests/fixtures/demo-project` fixture through the real `aura` binary
//! (#223).
//!
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
//! a unique tempdir per test, wired to the shared, once-built fixture crate
@@ -9,10 +11,10 @@
//! no two tests ever race on the same `runs/` store — libtest's default
//! thread parallelism applies without a serializing lock (#250).
//!
//! Each of the three test binaries compiles this file as its own `mod
//! common`, so an item unused by one binary trips that binary's `-D
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below
//! rather than deleting a helper just because one binary doesn't need it.
//! Each consuming test binary compiles this file as its own `mod common`, so
//! an item unused by one binary trips that binary's `-D warnings` `dead_code`
//! lint; see the `#[allow(dead_code)]` markers below rather than deleting a
//! helper just because one binary doesn't need it.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
@@ -182,7 +184,7 @@ impl Drop for ScratchGuard {
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
/// archive instead of streaming the 6.6 GB host archive (#250).
#[allow(dead_code)]
mod synthetic_data {
pub mod synthetic_data {
use std::io::Write;
use std::path::Path;
File diff suppressed because it is too large Load Diff
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
//! opens with the two-layer concept, the sugar verbs name the document shape
//! they desugar to, every introspection roster carries per-entry meanings,
//! and `graph build --help` carries the op-list reference. Driven over the
//! End-to-end pins for the self-description surfaces (#315, #323, #319): the
//! help opens with the two-layer concept, `exec` names both document classes
//! it executes, every introspection roster carries per-entry meanings, and
//! `graph build --help` carries the op-list reference. Driven over the
//! built binary — the zero-setup surface a reader without repo access gets.
use std::process::Command;
@@ -26,7 +26,10 @@ fn run(args: &[&str]) -> (String, String, bool) {
fn top_level_help_opens_with_the_two_layer_concept() {
let (out, err, ok) = run(&["--help"]);
assert!(ok, "aura --help failed: {err}");
assert!(out.contains("research verbs"), "names the sugar layer: {out}");
assert!(
out.contains("the one executor over both document classes"),
"names exec + the document classes: {out}"
);
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
@@ -34,30 +37,22 @@ fn top_level_help_opens_with_the_two_layer_concept() {
assert!(out.contains("aura chart"), "names the trace consumers: {out}");
}
/// #315: each document-bridged sugar verb's long help names the process
/// shape it desugars to and points at the document layer. `run` (not
/// document-bridged) points at the canonical document-first form instead.
/// #319: `exec --help` names both document classes it accepts — the sugar
/// verbs' retirement leaves `exec` as the one executor over a campaign
/// document (file or content id) or a signal blueprint (single run).
#[test]
fn sugar_verbs_name_their_document_shape() {
for (verb, needle) in [
("sweep", "std::sweep"),
("walkforward", "std::walk_forward"),
("mc", "std::monte_carlo"),
("generalize", "std::generalize"),
] {
let (out, err, ok) = run(&[verb, "--help"]);
assert!(ok, "aura {verb} --help failed: {err}");
assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}");
assert!(out.contains(needle), "{verb} --help names {needle}: {out}");
assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}");
}
let (out, err, ok) = run(&["run", "--help"]);
assert!(ok, "aura run --help failed: {err}");
assert!(out.contains("document-first"), "run --help names the canonical form: {out}");
fn exec_help_names_both_document_classes() {
let (out, err, ok) = run(&["exec", "--help"]);
assert!(ok, "aura exec --help failed: {err}");
assert!(out.contains("campaign"), "names the campaign document class: {out}");
assert!(out.contains("blueprint"), "names the blueprint document class: {out}");
assert!(out.contains("single run"), "names the blueprint leg's single-run shape: {out}");
}
/// #323: `graph build --help` carries the op-list reference — the op kinds
/// and fields are learnable from the binary, not only from serde refusals.
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
/// joins the roster (ten -> eleven).
#[test]
fn graph_build_help_carries_the_op_reference() {
let (out, err, ok) = run(&["graph", "build", "--help"]);
@@ -72,12 +67,84 @@ fn graph_build_help_carries_the_op_reference() {
r#"{"op":"tap""#,
r#"{"op":"gang""#,
r#"{"op":"doc""#,
r#"{"op":"use""#,
r#"{"op":"name""#,
] {
assert!(out.contains(op), "op reference carries {op}: {out}");
}
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
}
/// #317: `graph introspect --registered` lists every registry label — row
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
/// as prose, not a blank/garbled listing.
#[test]
fn graph_introspect_registered_lists_labels() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
// Empty store: prose, not a blank listing, exit 0.
let out_empty = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out_empty.status.success(), "empty store still exits 0");
assert_eq!(
String::from_utf8_lossy(&out_empty.stdout),
"no labels registered\n",
"the empty-store form reads as prose"
);
// Register + label a described open pattern, then list it.
let pattern = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let bp_path = dir.join("pattern.ops.json");
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
let built = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
child.wait_with_output()
})
.expect("build the pattern");
assert!(built.status.success(), "the pattern builds");
let envelope = dir.join("pattern.bp.json");
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
let reg = Command::new(BIN)
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
.current_dir(&dir)
.output()
.expect("spawn aura register");
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(&reg.stderr));
let out = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out.status.success(), "listing succeeds");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
let fields: Vec<&str> = row.split_whitespace().collect();
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
}
/// #315: the node vocabulary listing carries each type's one-line meaning —
/// no more bare names that teach nothing.
#[test]
@@ -103,18 +170,28 @@ fn graph_introspect_node_head_line_carries_the_meaning() {
assert!(head.contains("moving average"), "head line carries the meaning: {out}");
}
/// #315: the fold vocabulary is introspectable from the binary — name, bind
/// rule, output kind, and meaning per fold (it lived only in source comments).
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
/// `FoldKind` table — lowercase, parseable labels, including the `record`
/// entry that has no `FoldKind` counterpart. Previously this surface printed
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
/// text's own discovery surface described input `--tap` refused.
#[test]
fn graph_introspect_folds_lists_the_fold_vocabulary() {
fn graph_introspect_folds_lists_the_fold_registry_roster() {
let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
assert!(ok, "folds listing failed: {err}");
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 7, "one line per fold kind: {out}");
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}");
assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
assert!(
!out.split_whitespace().any(|w| w == "Mean"),
"no capitalized FoldKind id leaks through: {out}"
);
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
assert!(record.contains("series"), "record row meaning: {record}");
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
}
/// #315: the metric roster carries each metric's one-line meaning beside its
+1 -1
View File
@@ -35,7 +35,7 @@ fn run_measurement(cwd: &Path) {
let bp = cwd.join("measurement.json");
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
let out = Command::new(BIN)
.args(["run", bp.to_str().unwrap()])
.args(["exec", bp.to_str().unwrap()])
.current_dir(cwd)
.output()
.expect("spawn aura run");
+89 -29
View File
@@ -38,13 +38,13 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
#[test]
fn project_run_resolves_demo_node_and_is_bit_identical() {
let dir = built_project();
let a = aura(&["run", "demo_signal.json"], dir);
let a = aura(&["exec", "demo_signal.json"], dir);
assert!(
a.status.success(),
"run failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "demo_signal.json"], dir);
let b = aura(&["exec", "demo_signal.json"], dir);
assert!(b.status.success());
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
}
@@ -52,7 +52,7 @@ fn project_run_resolves_demo_node_and_is_bit_identical() {
#[test]
fn project_run_manifest_carries_provenance() {
let dir = built_project();
let out = aura(&["run", "demo_signal.json"], dir);
let out = aura(&["exec", "demo_signal.json"], dir);
assert!(out.status.success());
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("run report is JSON");
@@ -83,7 +83,7 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
let bp = fixture_dir().join("demo_signal.json");
let out = aura(&["run", bp.to_str().unwrap()], cwd);
let out = aura(&["exec", bp.to_str().unwrap()], cwd);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
@@ -104,31 +104,91 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
/// subdirectory. This is the property that makes `[paths]` project-relative
/// rather than shell-relative (C17).
///
/// #319 vehicle note: the synthetic in-process family builder this test used
/// to drive via bare `sweep --axis` (no `--real`) retired with the quintet —
/// no surviving CLI surface produces a family record without touching a real
/// archive (`exec`'s campaign leg always resolves against `DataServer`), so
/// the vehicle is now a one-cell campaign over the `fresh_project_with_data`
/// synthetic `SYMA` archive (mirrors `exec.rs`'s `seed_blueprint` +
/// `campaign_doc_json_for` recipe), executed from a project subdirectory.
#[test]
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
let dir = built_project();
let sub = dir.join("src");
let (dir, _fixture) = common::fresh_project_with_data();
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).expect("create invocation subdirectory");
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let closed_bp =
format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let reg_out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["graph", "register", &closed_bp, "--name", "proj-anchor-seed"])
.current_dir(&dir)
.output()
.expect("spawn aura graph register");
assert!(reg_out.status.success(), "register failed: {}", String::from_utf8_lossy(&reg_out.stderr));
let reg_text = String::from_utf8_lossy(&reg_out.stdout);
let bp_id = reg_text
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.to_string();
let process_doc = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
std::fs::write(dir.join("anchor.process.json"), process_doc).expect("write process doc");
let proc_out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["process", "register", "anchor.process.json"])
.current_dir(&dir)
.output()
.expect("spawn aura process register");
assert!(proc_out.status.success(), "process register failed: {}", String::from_utf8_lossy(&proc_out.stderr));
let proc_text = String::from_utf8_lossy(&proc_out.stdout);
let proc_id = proc_text
.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string();
// The established `SYMA` window (2024-03..06, fully inside its 2024-01..08
// span) other #319 vehicle campaigns use.
let campaign_doc = format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "run-seam",
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#
);
std::fs::write(dir.join("anchor.campaign.json"), &campaign_doc).expect("write campaign doc");
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"proj-anchor",
])
.args(["exec", "../anchor.campaign.json"])
.current_dir(&sub)
.output()
.expect("spawn aura sweep");
.expect("spawn aura exec");
assert!(
out.status.success(),
"sweep failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
@@ -162,7 +222,7 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
"badcharter fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
let run = aura(&["exec", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
@@ -191,7 +251,7 @@ fn undescribed_vocabulary_entry_refuses_end_to_end() {
"undescribed fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
let run = aura(&["exec", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
@@ -229,7 +289,7 @@ fn restated_name_vocabulary_entry_refuses_end_to_end() {
"restated fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
let run = aura(&["exec", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
@@ -287,7 +347,7 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
set_mtime(&dylib, DYLIB_MTIME);
set_mtime(&src, SOURCE_MTIME);
let out = aura(&["run", "demo_signal.json"], dir);
let out = aura(&["exec", "demo_signal.json"], dir);
// Restore sane mtimes before asserting, so a failed assertion does not
// leave the shared fixture wedged "stale" (or with a future mtime that
@@ -351,7 +411,7 @@ fn nested_nodes_dir() -> PathBuf {
fn data_only_project_runs_and_stamps_commit_only_provenance() {
let dir = dataonly_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "signal.json"])
.args(["exec", "signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -387,7 +447,7 @@ fn data_only_project_runs_and_stamps_commit_only_provenance() {
fn multi_crate_pointer_refuses_end_to_end() {
let dir = multicrate_dir();
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "x.json"])
.args(["exec", "x.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -427,7 +487,7 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
String::from_utf8_lossy(&build.stderr)
);
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "pointer_signal.json"])
.args(["exec", "pointer_signal.json"])
.current_dir(&dir)
.output()
.expect("spawn aura run");
@@ -455,7 +515,7 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
let bp = tmp.join("bp.json");
std::fs::write(&bp, r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"nosuch::Node"}}],"edges":[],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}],"source":"F64"}],"output":[{"node":0,"field":0,"name":"bias"}]}}"#).unwrap();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", bp.to_str().unwrap()])
.args(["exec", bp.to_str().unwrap()])
.current_dir(&tmp)
.output()
.expect("spawn aura");
@@ -479,7 +539,7 @@ fn missing_artifact_refuses_with_build_hint() {
"[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
)
.unwrap();
let out = aura(&["run", "x.json"], &tmp);
let out = aura(&["exec", "x.json"], &tmp);
assert_eq!(out.status.code(), Some(1), "runtime refusal");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cargo build"), "build hint present: {err}");
+130 -39
View File
@@ -1,11 +1,12 @@
//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction
//! — plus the refusal contract. Scaffolded projects live in per-test temp
//! dirs.
//! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the
//! refusal contract. Scaffolded projects live in per-test temp dirs.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
mod common;
fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
@@ -61,17 +62,17 @@ fn init_and_commit(dir: &Path) {
///
/// Documented deviation from the task text: the plan's wording asks this
/// headline to also assert that the run "writes a manifest under `x/runs/`".
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the
/// trace store — it only prints the `RunReport` to stdout; only
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241
/// split this task's file list does not touch (giving `run` a store write
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new`
/// and this test file). There is thus no on-disk manifest for THIS verb to
/// assert against; the assertion below proves that absence directly instead
/// of only asserting it in prose, and the persisted-store property the plan
/// wanted is covered by `data_only_project_sweeps_without_any_build` below,
/// which does assert `proj.join("runs").exists()`.
/// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r`
/// (main.rs): a single-blueprint `aura exec` never touches the trace store —
/// it only prints the `RunReport` to stdout; only exec's campaign leg
/// persists (`env.trace_store()`), a pre-#241 split this task's file list
/// does not touch (giving the blueprint leg a store write would widen scope
/// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test
/// file). There is thus no on-disk manifest for THIS leg to assert against;
/// the assertion below proves that absence directly instead of only
/// asserting it in prose, and the persisted-store property the plan wanted
/// is covered by `data_only_project_execs_a_campaign_without_any_build`
/// below, which does assert `proj.join("runs").exists()`.
#[test]
fn new_scaffolds_and_runs_the_data_only_loop() {
let base = tmp("loop");
@@ -102,13 +103,13 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml");
assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs");
let a = aura(&["run", "blueprints/signal.json"], &proj);
let a = aura(&["exec", "blueprints/signal.json"], &proj);
assert!(
a.status.success(),
"run failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "blueprints/signal.json"], &proj);
let b = aura(&["exec", "blueprints/signal.json"], &proj);
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
let p = &v["manifest"]["project"];
@@ -116,50 +117,140 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
assert!(
!proj.join("runs").exists(),
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \
the task text's \"writes a manifest under x/runs/\" does not hold for this verb"
"a plain single-blueprint `aura exec` must not persist a trace store (only exec's \
campaign leg does) — the task text's \"writes a manifest under x/runs/\" does not \
hold for this leg"
);
let _ = std::fs::remove_dir_all(&base);
}
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's
/// gate) with zero build step — using the scaffolded (closed) starter
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises.
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Writes a tiny, self-contained January-2024 `SYMA` archive into
/// `<proj>/data` and points the scaffolded `Aura.toml`'s `[paths]` table at
/// it. #319: `exec`'s campaign leg (the surviving surface for what `aura
/// sweep --axis` used to do inline) needs a registered instrument, unlike a
/// plain blueprint run — this keeps the scaffolder's "zero host setup"
/// headline intact (no real archive mount) by minting the archive straight
/// into the scaffold instead.
fn seed_synthetic_archive(proj: &Path) {
let data_dir = proj.join("data");
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
common::synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 1));
let toml_path = proj.join("Aura.toml");
let toml = std::fs::read_to_string(&toml_path).expect("read scaffolded Aura.toml");
let toml = toml.replacen("runs = \"runs\"\n", "runs = \"runs\"\ndata = \"data\"\n", 1);
std::fs::write(&toml_path, toml).expect("point Aura.toml at the synthetic archive");
}
/// The id extracted from a `registered <kind> <id> (<path>)` line (#194: bare
/// id, no `content:` prefix — tolerant of the pre-#194 prefix all the same).
fn registered_id(stdout: &str, kind: &str) -> String {
let needle = format!("registered {kind} ");
stdout
.lines()
.find(|l| l.starts_with(&needle))
.unwrap_or_else(|| panic!("no \"{needle}\" line: {stdout}"))
.trim_start_matches(&needle)
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, one-axis campaign document over `bp_id`/`proc_id` — the
/// surviving surface for what `aura sweep --axis` used to do inline over the
/// scaffolded starter's bound `fast.length` (#246: a bound param is a default
/// an axis overrides). Windowed fully inside `seed_synthetic_archive`'s
/// January-2024 `SYMA` archive.
fn one_axis_campaign_doc(bp_id: &str, proc_id: &str, axis_name: &str, axis_values: &str) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "quickstart",
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745599999 }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "{axis_name}": {{ "kind": "I64", "values": [{axis_values}] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 1,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#
)
}
/// #241 headline: a fresh data-only project executes a small campaign — #319's
/// surviving surface for the retired `aura sweep` — with zero build step,
/// using the scaffolded (closed) starter blueprint and the exact axis the
/// scaffolded CLAUDE.md advertises. Registering the blueprint straight (`aura
/// graph register`, not a sweep side-effect) and seeding a tiny synthetic
/// archive (`seed_synthetic_archive`) keeps the loop free of any host data
/// mount, matching the pre-retirement headline.
///
/// #246: the closed starter IS the sweep target — a bound param is a default
/// an axis overrides.
/// #246: the closed starter IS the campaign axis target — a bound param is a
/// default an axis overrides.
#[test]
fn data_only_project_sweeps_without_any_build() {
fn data_only_project_execs_a_campaign_without_any_build() {
let base = tmp("sweep");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
&proj,
);
seed_synthetic_archive(&proj);
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let bp_id = registered_id(&String::from_utf8_lossy(&reg.stdout), "blueprint");
std::fs::write(proj.join("quickstart.process.json"), SWEEP_ONLY_PROCESS_DOC)
.expect("write process doc");
let preg = aura(&["process", "register", "quickstart.process.json"], &proj);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4");
std::fs::write(proj.join("quickstart.campaign.json"), &doc).expect("write campaign doc");
let out = aura(&["exec", "quickstart.campaign.json"], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(proj.join("runs").exists(), "store must land inside the project");
let _ = std::fs::remove_dir_all(&base);
}
/// Property (#246): the scaffold's sweep quickstart genuinely re-opens the
/// Property (#246): the scaffold's campaign quickstart genuinely re-opens the
/// bound `fast.length` param — the persisted family has exactly one member
/// per axis value, not a single collapsed member. A silent regression where
/// the bound-param override is dropped (member always built from the
/// blueprint's own default) would still exit 0 and still create a `runs`
/// store, so `data_only_project_sweeps_without_any_build` above cannot catch
/// it; only the member count can.
/// store, so `data_only_project_execs_a_campaign_without_any_build` above
/// cannot catch it; only the member count can.
#[test]
fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
fn data_only_project_campaign_over_the_starter_opens_one_member_per_axis_value() {
let base = tmp("sweep-members");
let new = aura(&["new", "scratch"], &base);
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
&proj,
);
seed_synthetic_archive(&proj);
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let bp_id = registered_id(&String::from_utf8_lossy(&reg.stdout), "blueprint");
std::fs::write(proj.join("members.process.json"), SWEEP_ONLY_PROCESS_DOC)
.expect("write process doc");
let preg = aura(&["process", "register", "members.process.json"], &proj);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4, 8");
std::fs::write(proj.join("members.campaign.json"), &doc).expect("write campaign doc");
let out = aura(&["exec", "members.campaign.json"], &proj);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let fams = aura(&["runs", "families"], &proj);
assert!(fams.status.success(), "runs families exit: {:?}", fams.status);
@@ -301,10 +392,10 @@ fn new_outside_a_work_tree_leaves_a_resolvable_head() {
// ...so the first quickstart run stamps a commit into the manifest, not
// an empty provenance block.
let run = aura(&["run", "blueprints/signal.json"], &proj);
let run = aura(&["exec", "blueprints/signal.json"], &proj);
assert!(
run.status.success(),
"run failed: {}",
"exec failed: {}",
String::from_utf8_lossy(&run.stderr)
);
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
+81 -43
View File
@@ -149,72 +149,110 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>))
}
/// Acceptance box 1 (#235): a project-authored node with an OPEN param is
/// discoverable as a sweep axis, sweeps over real data into a persisted family,
/// and that family reproduces bit-identically — the defining research loop of a
/// genuine external project ("sweep my OWN node's knob"), proven end to end.
/// discoverable as an axis, execs over real data (a campaign — #319: the
/// surviving surface for what `aura sweep --axis` used to do inline) into a
/// persisted family, and that family reproduces bit-identically — the
/// defining research loop of a genuine external project ("vary my OWN node's
/// knob"), proven end to end.
#[test]
fn project_node_open_param_sweeps_and_reproduces() {
let (dir, _g) = fresh_project();
// (a) `--list-axes` discovers the PROJECT node's own open knob, followed by
// the fixture's three BOUND params as `default=`-lines (#246: every bound
// param is an equally re-openable `--axis`, listed regardless of how many
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
// no data, so the discoverability half runs on every host. The wrapped
// name is `<blueprint>.<node>.<param>`.
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
// (a) `graph introspect --params` discovers the PROJECT node's own open
// knob, followed by the fixture's three BOUND params as `default=`-lines
// (#246: every bound param is an equally re-openable axis, listed
// regardless of how many knobs happen to be open alongside it). NOT
// gated: enumerating axes needs no data, so the discoverability half runs
// on every host. The name is RAW `<node>.<param>` (#328: the blueprint
// name stays out of axis paths).
let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]);
assert!(
axes.status.success(),
"--list-axes stderr: {}",
"graph introspect --params stderr: {}",
String::from_utf8_lossy(&axes.stderr)
);
assert_eq!(
String::from_utf8_lossy(&axes.stdout),
"scaled_signal.gain.factor:F64\n\
scaled_signal.fast.length:I64 default=2\n\
scaled_signal.slow.length:I64 default=4\n\
scaled_signal.bias.scale:F64 default=0.5\n",
"gain.factor:F64\n\
fast.length:I64 default=2\n\
slow.length:I64 default=4\n\
bias.scale:F64 default=0.5\n",
"the project node's own OPEN param is the one open axis, alongside the \
fixture's bound defaults; stderr: {}",
String::from_utf8_lossy(&axes.stderr)
);
// The real-data sweep/reproduce legs are archive-gated.
// The real-data campaign/reproduce legs are archive-gated.
if !local_data_present() {
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
return;
}
// (b) sweep the project node's knob over real GER40 data -> two members.
let sweep = aura_in(
dir,
&[
"sweep",
"blueprints/scaled_open.json",
"--real",
"GER40",
"--from",
GER40_FROM_MS,
"--to",
GER40_TO_MS,
"--axis",
"scaled_signal.gain.factor=0.5,1.0",
"--name",
"knob",
],
// (b) exec a campaign over the project node's knob, real GER40 data ->
// two members. Register the blueprint straight (not a sweep side-effect)
// and a minimal sweep-only process, then reference both by content id.
let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]);
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(&reg.stderr));
let reg_out = String::from_utf8_lossy(&reg.stdout).into_owned();
let bp_id = reg_out
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("blueprint id")
.to_string();
std::fs::write(dir.join("knob-reproduce.process.json"), SWEEP_ONLY_PROCESS)
.expect("write process doc");
let preg = aura_in(dir, &["process", "register", "knob-reproduce.process.json"]);
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
let preg_out = String::from_utf8_lossy(&preg.stdout).into_owned();
let proc_id = preg_out
.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("process id")
.to_string();
let campaign = format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "knob-reproduce",
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
"axes": {{ "gain.factor": {{ "kind": "F64", "values": [0.5, 1.0] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = GER40_FROM_MS,
to = GER40_TO_MS,
bp = bp_id,
proc = proc_id,
);
std::fs::write(dir.join("knob-reproduce.campaign.json"), &campaign).expect("write campaign doc");
let exec = aura_in(dir, &["exec", "knob-reproduce.campaign.json"]);
assert!(
sweep.status.success(),
"sweep stderr: {}",
String::from_utf8_lossy(&sweep.stderr)
exec.status.success(),
"exec stderr: {}",
String::from_utf8_lossy(&exec.stderr)
);
let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().collect();
let stdout = String::from_utf8_lossy(&exec.stdout).into_owned();
let lines: Vec<&str> = stdout.lines().filter(|l| l.starts_with(r#"{"family_id":"#)).collect();
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
// Each member's manifest carries the swept PROJECT-node param binding under
// its wrapped name — the load-bearing proof that it is MY node's knob that
// varied across the family, not some incidental std axis.
// Each member's manifest carries the swept PROJECT-node param binding
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
// real/campaign route's own `manifest.params` mints raw too now) — the
// load-bearing proof that it is MY node's knob that varied across the
// family, not some incidental std axis.
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
assert_eq!(
@@ -227,7 +265,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
.expect("manifest.params is an array");
let bound = params
.iter()
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
.find(|p| p[0].as_str() == Some("gain.factor"))
.and_then(|p| p[1]["F64"].as_f64());
assert_eq!(
bound,
@@ -322,7 +360,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
);
std::fs::write(dir.join("knob.campaign.json"), &campaign).expect("write campaign doc");
let run = aura_in(dir, &["campaign", "run", "knob.campaign.json"]);
let run = aura_in(dir, &["exec", "knob.campaign.json"]);
let out = format!(
"{}{}",
String::from_utf8_lossy(&run.stdout),
+135 -130
View File
@@ -319,34 +319,9 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
]);
// Seed one real, content-addressed blueprint into the project's own
// store via a real sweep over its bound params, which the axes reopen
// as overridable defaults (#246) (mirrors `project_load.rs`'s anchor
// test).
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (sweep_out, sweep_code) = run_code_in(
&dir,
&[
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"campaign-ref-seed",
],
);
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
.expect("blueprints dir")
.next()
.expect("one stored blueprint")
.expect("dir entry")
.path()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned();
// store via `aura graph register` (#319 — the retired `sweep`
// side-effect seeding this used to ride).
let bp_id = seed_blueprint(&dir, "campaign-ref-seed");
// Register a valid AND executable process into the same project store:
// this test's OK campaign must pass all three validate tiers, and
@@ -377,10 +352,11 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
.trim_start_matches("content:")
.to_string();
// "fast.length": the axis name is the RAW composite's `param_space` name.
// "fast.length": the axis name is the RAW composite's `param_space` name
// the SAME namespace `aura sweep --axis` itself binds against now (#328).
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
// "sma_signal." prefix `aura sweep --axis` binds against.
// sweep's `wrap_r`-wrapped axis probe, so it never carries a wrap prefix
// to begin with.
let campaign = format!(
r#"{{
"format_version": 1,
@@ -440,32 +416,8 @@ fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit
]);
// Seed one real, content-addressed blueprint (mirrors the content_id
// sibling test above).
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (sweep_out, sweep_code) = run_code_in(
&dir,
&[
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"campaign-identity-seed",
],
);
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
.expect("blueprints dir")
.next()
.expect("one stored blueprint")
.expect("dir entry")
.path()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned();
// sibling test above; #319 — the retired `sweep` side-effect seeding).
let bp_id = seed_blueprint(&dir, "campaign-identity-seed");
let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json"));
// The identity id of the SAME stored bytes — `--content-id FILE
@@ -710,7 +662,7 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() {
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"),
"stdout/stderr: {out}"
);
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
@@ -996,7 +948,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
write_doc(&dir, "draft.campaign.json", draft);
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
assert_eq!(code, Some(0));
assert!(out.contains("open slot: process.ref (required, content id of a process document)"));
assert!(out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"));
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
}
@@ -1022,7 +974,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
"strategy `ref: null` placeholder not enumerated: {out}"
);
assert!(
out.contains("open slot: process.ref (required, content id of a process document)"),
out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"),
"process `ref: {{}}` placeholder not enumerated: {out}"
);
}
@@ -1240,7 +1192,7 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
assert!(
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"),
"stdout/stderr: {out}"
);
assert!(
@@ -1277,35 +1229,26 @@ fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
);
}
/// Seed one blueprint (its params bound, reopened by the sweep axes per
/// #246) into the built demo project's store via a real sweep and return
/// its content id (the referential test's recipe).
/// Seed one blueprint into the built demo project's store via `aura graph
/// register` and return its content id (the referential test's recipe).
/// `#319` retired the `sweep`-side-effect seeding this used to ride (its
/// `--axis`/reopen mechanics played no role in the returned content id,
/// which is a pure function of the loaded blueprint's canonical bytes);
/// `name` labels the registered id for readability/uniqueness across call
/// sites, mirroring the old per-call sweep family name.
fn seed_blueprint(dir: &Path, name: &str) -> String {
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (out, code) = run_code_in(
dir,
&[
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
name,
],
);
assert_eq!(code, Some(0), "seed sweep failed: {out}");
std::fs::read_dir(dir.join("runs").join("blueprints"))
.expect("blueprints dir")
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
assert_eq!(code, Some(0), "seed register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("one stored blueprint")
.expect("dir entry")
.path()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Register `doc` as a process document in the project store; returns its id.
@@ -1493,7 +1436,7 @@ const WF_PROCESS_DOC: &str = r#"{
fn campaign_run_outside_project_refuses() {
let dir = temp_cwd("campaign-run-outside-project");
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
let (out, code) = run_code_in(&dir, &["campaign", "run", "c.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "c.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
assert!(
@@ -1507,7 +1450,7 @@ fn campaign_run_outside_project_refuses() {
#[test]
fn campaign_run_bogus_target_refuses() {
let (dir, _fixture) = fresh_project();
let (out, code) = run_code_in(&dir, &["campaign", "run", "no-such-target"]);
let (out, code) = run_code_in(&dir, &["exec", "no-such-target"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"),
@@ -1520,7 +1463,7 @@ fn campaign_run_bogus_target_refuses() {
fn campaign_run_unknown_id_refuses() {
let (dir, _fixture) = fresh_project();
let id = "0".repeat(64);
let (out, code) = run_code_in(&dir, &["campaign", "run", &id]);
let (out, code) = run_code_in(&dir, &["exec", &id]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains(&format!("no campaign {id} in the project store")),
@@ -1638,7 +1581,7 @@ fn campaign_run_refuses_mc_before_walk_forward() {
let bp_id = seed_blueprint(&dir, "campaign-run-mcwf-seed");
let proc_id = register_process_doc(&dir, "mcwf.process.json", MC_BEFORE_WF_PROCESS_DOC);
write_doc(&dir, "mcwf.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "mcwf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "mcwf.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
assert!(
@@ -1688,7 +1631,7 @@ fn campaign_run_refuses_a_non_terminal_selection_free_sweep() {
let proc_id =
register_process_doc(&dir, "selfree.process.json", SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC);
write_doc(&dir, "selfree.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "selfree.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "selfree.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains(
@@ -1802,7 +1745,7 @@ fn campaign_run_refuses_a_grid_stage_not_immediately_before_walk_forward() {
"gridgate.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridgate.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gridgate.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
assert!(
@@ -1855,7 +1798,7 @@ fn campaign_run_synthetic_e2e_grid_then_wf_persists_no_sweep_family() {
"",
),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridwf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gridwf.campaign.json"]);
assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out
@@ -1917,7 +1860,7 @@ fn campaign_run_synthetic_e2e_cell_order_is_document_order_under_parallel_instru
for k in ["1", "2"] {
let (out, code) = run_code_in(
&dir,
&["campaign", "run", "cellorder.campaign.json", "--parallel-instruments", k],
&["exec", "cellorder.campaign.json", "--parallel-instruments", k],
);
assert_eq!(code, Some(0), "K={k}: campaign run failed: {out}");
let line = out
@@ -1978,7 +1921,7 @@ fn campaign_run_synthetic_e2e_parallel_instruments_contains_a_real_per_cell_faul
let (out, code) = run_code_in(
&dir,
&["campaign", "run", "parfault.campaign.json", "--parallel-instruments", "2"],
&["exec", "parfault.campaign.json", "--parallel-instruments", "2"],
);
assert_eq!(code, Some(3), "one contained fault, one success: {out}");
let line = out
@@ -2033,7 +1976,7 @@ fn campaign_run_synthetic_e2e_gate_emptied_cell_carries_the_note_marker() {
write_doc(&dir, "gateempty.campaign.json", &doc);
let (out, code) =
run_code_in(&dir, &["campaign", "run", "gateempty.campaign.json"]);
run_code_in(&dir, &["exec", "gateempty.campaign.json"]);
assert_eq!(code, Some(0), "a zero-survivor cell is a valid result, not a fault: {out}");
assert!(
out.contains("aura: note: cell "),
@@ -2065,7 +2008,7 @@ fn campaign_run_refuses_single_instrument_generalize() {
let bp_id = seed_blueprint(&dir, "campaign-run-gen1-seed");
let proc_id = register_process_doc(&dir, "gen1.process.json", GENERALIZE_PROCESS_DOC);
write_doc(&dir, "gen1.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "gen1.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gen1.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("std::generalize needs at least 2"),
@@ -2126,7 +2069,7 @@ fn campaign_run_refuses_generalize_non_r_metric() {
}}"#
);
write_doc(&dir, "genpip.campaign.json", &campaign_doc);
let (out, code) = run_code_in(&dir, &["campaign", "run", "genpip.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "genpip.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
// #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable
// R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is
@@ -2190,7 +2133,7 @@ fn process_validate_permits_wf_after_mc_but_campaign_run_refuses_it() {
let bp_id = seed_blueprint(&dir, "campaign-run-wfmc-seed");
let proc_id = register_process_doc(&dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
write_doc(&dir, "wfmc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "wfmc.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "wfmc.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("std::walk_forward cannot follow std::monte_carlo"),
@@ -2399,7 +2342,7 @@ fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() {
let bp_id = seed_blueprint(&dir, "campaign-run-zeromc-seed");
let proc_id = register_process_doc(&dir, "zeromc.process.json", ZERO_RESAMPLES_MC_PROCESS_DOC);
write_doc(&dir, "zeromc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "zeromc.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "zeromc.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("process stage 1: monte_carlo resamples must be > 0"),
@@ -2434,7 +2377,7 @@ fn campaign_run_refuses_unknown_tap_at_validate() {
"badtap.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "badtap.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "badtap.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
assert!(
@@ -2477,7 +2420,7 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() {
"taps.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "taps.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "taps.campaign.json"]);
assert_eq!(code, Some(3), "stdout/stderr: {out}");
assert!(
!out.contains("unknown tap"),
@@ -2547,7 +2490,7 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
"\"family_table\", \"selection_report\"",
),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "wf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "wf.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -2724,7 +2667,7 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_pooled_oos_bootstrap() {
// Both runs share the store (the cost-e2e sibling pattern) — the record
// line is read from each run's own stdout, so no isolation is needed.
let pooled = |doc: &str| -> serde_json::Value {
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
let (out, code) = run_code_in(&dir, &["exec", doc]);
assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out
.lines()
@@ -2801,7 +2744,7 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap() {
write_doc(&dir, "net_ps.campaign.json", &with_cost);
let per_survivor = |doc: &str| -> Vec<(u64, serde_json::Value)> {
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
let (out, code) = run_code_in(&dir, &["exec", doc]);
assert_eq!(code, Some(0), "campaign run failed: {out}");
let line = out
.lines()
@@ -2863,7 +2806,7 @@ fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() {
(1709251200000, 1717199999999),
);
write_doc(&dir, "percost.campaign.json", &doc);
let (out, code) = run_code_in(&dir, &["campaign", "run", "percost.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "percost.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
// Member emit lines carry report.manifest; group the stamped resolved
@@ -2924,7 +2867,7 @@ fn campaign_run_synthetic_e2e_vol_tf_regime_realizes_and_stamps() {
);
assert_ne!(with_risk, base, "replacen must actually match the seed field");
write_doc(&dir, "voltf.campaign.json", &with_risk);
let (out, code) = run_code_in(&dir, &["campaign", "run", "voltf.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "voltf.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
let record_line = out
@@ -3049,7 +2992,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
write_doc(&dir, "close.campaign.json", &base);
write_doc(&dir, "open.campaign.json", &rebound);
let (out_close, code_close) = run_code_in(&dir, &["campaign", "run", "close.campaign.json"]);
let (out_close, code_close) = run_code_in(&dir, &["exec", "close.campaign.json"]);
if code_close == Some(3)
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
{
@@ -3057,7 +3000,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
return;
}
assert_eq!(code_close, Some(0), "stdout/stderr: {out_close}");
let (out_open, code_open) = run_code_in(&dir, &["campaign", "run", "open.campaign.json"]);
let (out_open, code_open) = run_code_in(&dir, &["exec", "open.campaign.json"]);
assert_eq!(code_open, Some(0), "stdout/stderr: {out_open}");
let first_member = |out: &str| -> serde_json::Value {
@@ -3112,7 +3055,7 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
);
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "regime.campaign.json", &with_regime);
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "regime.campaign.json"]);
if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
@@ -3143,6 +3086,68 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
}
}
/// Property (#338, the `campaign_run_real_e2e_non_default_regime_stamps_its_own_stop`
/// precedent above, `Fixed` edition): a campaign document's `RiskRegime::Fixed`
/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually
/// stamped into each emitted member's manifest as `stop_distance` — making the
/// shipped `FixedStop` composite campaign-reachable end to end, not merely
/// intrinsically valid.
#[test]
fn campaign_run_real_e2e_fixed_regime_stamps_its_own_distance() {
let (dir, _fixture) = fresh_project();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir.clone()),
ScratchPath::File(dir.join("fixed_regime.process.json")),
ScratchPath::File(dir.join("fixed_regime.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "campaign-run-fixed-regime-seed");
let proc_id = register_process_doc(&dir, "fixed_regime.process.json", SWEEP_ONLY_PROCESS_DOC);
let base = campaign_doc_json(
&bp_id,
&proc_id,
(1725148800000, 1727740799999),
"",
"\"family_table\"",
);
let with_regime = base.replacen(
"\"seed\": 7,",
"\"seed\": 7,\n \"risk\": [ { \"fixed\": { \"distance\": 12.0 } } ],",
1,
);
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "fixed_regime.campaign.json", &with_regime);
let (out, code) = run_code_in(&dir, &["exec", "fixed_regime.campaign.json"]);
if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{
eprintln!("skip: no local GER40 data for the fixed-regime campaign e2e");
return;
}
assert_eq!(code, Some(0), "stdout/stderr: {out}");
let member_lines: Vec<&str> =
out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect();
assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}");
for line in &member_lines {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
let params = v["report"]["manifest"]["params"]
.as_array()
.expect("manifest.params is an array");
let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name));
assert_eq!(
get("stop_distance").and_then(|p| p[1]["F64"].as_f64()),
Some(12.0),
"the campaign's own fixed distance is stamped, not the R_SMA vol-stop default: {line}"
);
assert!(
get("stop_length").is_none() && get("stop_k").is_none(),
"a Fixed stop stamps no vol knobs: {line}"
);
}
}
/// Property (#210 T3/T4 — per-cell regime resolution, not a shared last-write):
/// a campaign with TWO distinct non-default risk regimes stamps EACH regime's
/// OWN stop into ITS OWN cell's members, never the other regime's values and
@@ -3182,7 +3187,7 @@ fn campaign_run_real_e2e_two_regimes_each_stamp_their_own_stop() {
);
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "tworegimes.campaign.json", &with_regimes);
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "tworegimes.campaign.json"]);
if code == Some(3)
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
@@ -3269,7 +3274,7 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
);
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "regime-persist.campaign.json", &with_regime);
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "regime-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3349,7 +3354,7 @@ fn campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "cost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "cost-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "cost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3444,7 +3449,7 @@ fn campaign_run_real_e2e_net_r_equity_tap_persists_the_cost_adjusted_curve() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "net-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "net-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "net-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3558,7 +3563,7 @@ fn campaign_run_real_e2e_vol_slippage_cost_persists_without_drift_alarm() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "volcost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "volcost-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "volcost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3647,7 +3652,7 @@ fn campaign_persist_two_regimes_land_in_distinct_trace_dirs() {
);
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "tworegimes-persist.campaign.json", &with_regimes);
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "tworegimes-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3725,7 +3730,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
"",
),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "mixedtap.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "mixedtap.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -3841,7 +3846,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
// (a) the cost-less baseline: net == gross, and net_r_equity skips with
// the remedy notice while equity still persists.
let (out_gross, code_gross) = run_code_in(&dir, &["campaign", "run", "costless.campaign.json"]);
let (out_gross, code_gross) = run_code_in(&dir, &["exec", "costless.campaign.json"]);
if code_gross == Some(3)
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
{
@@ -3863,7 +3868,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
// (b) the costed run: gross identical member-for-member, net strictly
// below gross, no skip notice, net_r_equity persisted on disk.
let (out_net, code_net) = run_code_in(&dir, &["campaign", "run", "costnet.campaign.json"]);
let (out_net, code_net) = run_code_in(&dir, &["exec", "costnet.campaign.json"]);
assert_eq!(code_net, Some(0), "costed run: {out_net}");
assert!(
!out_net.contains("skipped"),
@@ -3962,7 +3967,7 @@ fn campaign_run_real_e2e_carry_cost_persists_without_drift_alarm() {
);
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
write_doc(&dir, "carrycost-persist.campaign.json", &with_cost);
let (out, code) = run_code_in(&dir, &["campaign", "run", "carrycost-persist.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "carrycost-persist.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -4034,7 +4039,7 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
"mc.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1725148800000, 1727740799999), "", ""),
);
let (out, code) = run_code_in(&dir, &["campaign", "run", "mc.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "mc.campaign.json"]);
// Skip on a data-less machine: the member-data refusal, never a panic.
if code == Some(3)
@@ -4105,7 +4110,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
let proc_id = register_process_doc(&dir, "mc2.process.json", MC_PROCESS_DOC);
write_doc(&dir, "mc2.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (file_out, file_code) = run_code_in(&dir, &["campaign", "run", "mc2.campaign.json"]);
let (file_out, file_code) = run_code_in(&dir, &["exec", "mc2.campaign.json"]);
assert_eq!(file_code, Some(3), "stdout/stderr: {file_out}");
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
@@ -4120,7 +4125,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
.expect("id")
.trim_start_matches("content:")
.to_string();
let (id_out, id_code) = run_code_in(&dir, &["campaign", "run", &id]);
let (id_out, id_code) = run_code_in(&dir, &["exec", &id]);
assert_eq!(id_code, Some(3), "stdout/stderr: {id_out}");
let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line");
@@ -4168,8 +4173,8 @@ fn campaign_run_tolerates_content_prefix_on_target() {
.trim_start_matches("content:")
.to_string();
let (bare_out, _) = run_code_in(&dir, &["campaign", "run", &id]);
let (pfx_out, _) = run_code_in(&dir, &["campaign", "run", &format!("content:{id}")]);
let (bare_out, _) = run_code_in(&dir, &["exec", &id]);
let (pfx_out, _) = run_code_in(&dir, &["exec", &format!("content:{id}")]);
let pfx_line = pfx_out.lines().find(|l| l.starts_with("aura: ")).expect("prefixed refusal line");
assert!(
@@ -4221,7 +4226,7 @@ fn campaign_run_accepts_a_graph_register_seeded_strategy() {
let proc_id = register_process_doc(&dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
write_doc(&dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(&dir, &["campaign", "run", "onramp.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "onramp.campaign.json"]);
assert_eq!(code, Some(3), "stdout/stderr: {out}");
assert!(
out.contains("no recorded geometry") || out.contains("no data for instrument"),
@@ -4250,7 +4255,7 @@ fn campaign_run_invalid_file_refuses_before_touching_store() {
let bad_path = write_doc(&dir, "bad.campaign.json", &bad);
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone()), ScratchPath::File(bad_path)]);
let (out, code) = run_code_in(&dir, &["campaign", "run", "bad.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "bad.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("aura: campaign document invalid:"),
@@ -4303,7 +4308,7 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
}}"#,
);
write_doc(&dir, "gap.campaign.json", &doc);
let (out, code) = run_code_in(&dir, &["campaign", "run", "gap.campaign.json"]);
let (out, code) = run_code_in(&dir, &["exec", "gap.campaign.json"]);
assert_eq!(code, Some(3), "stdout/stderr: {out}");
let record_line = out
@@ -4345,7 +4350,7 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
/// clap refuses it as a usage error (exit 2) before any target resolution.
#[test]
fn campaign_run_rejects_a_zero_parallel_instruments_bound() {
let (out, code) = run_code(&["campaign", "run", "unused", "--parallel-instruments", "0"]);
let (out, code) = run_code(&["exec", "unused", "--parallel-instruments", "0"]);
assert_eq!(code, Some(2), "clap usage error expected, got: {out}");
// Domain prose, not clap's raw NonZeroUsize wording ("number would be
// zero for non-zero type") — the fieldtest friction finding.
+83 -1
View File
@@ -34,7 +34,7 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -56,3 +56,85 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
assert_eq!(trace["tap"], "fast_tap");
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
}
/// #310: the measurement arm honours the `--tap` selector — a `last`
/// fold lands one summary row instead of the full series.
#[test]
fn measurement_run_honours_the_tap_selector() {
let cwd = temp_cwd("selector-last");
let bp_path = cwd.join("measurement.json");
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
let got = rows[0].as_f64().expect("f64 row");
let expected = (1.0097_f64 + 1.0092) / 2.0;
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
}
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
/// normalizes there) instead of refusing at the same root-name choke point
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
/// property that actually matters — the escaped directory is never created.
#[test]
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
let cwd = temp_cwd("bad-root-name");
let mut v: serde_json::Value =
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
v["blueprint"]["name"] = serde_json::json!("../x");
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
let bp_path = cwd.join("bad-root-name.json");
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(
out.status.code(),
Some(2),
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(r#"blueprint name "../x" is invalid"#),
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
);
assert!(
out.stdout.is_empty(),
"a refused run must not print a report: {:?}",
String::from_utf8_lossy(&out.stdout)
);
// The property that actually matters: the escaped write target (one level
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
// was never created — the gate fires before `begin_run` ever touches disk.
assert!(
!cwd.join("runs/x").exists(),
"the escaped trace directory must never be created"
);
assert!(
!cwd.join("runs/traces").exists(),
"no trace directory of any shape is written on a refused run"
);
}
@@ -1,8 +1,8 @@
//! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the
//! established binding-refusal register — never a process panic. Both cases
//! are user mistakes on the hand-authored single-run path, so each deserves a
//! diagnosis, not a stack trace.
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 2, the
//! argv-named blueprint-content class (C14) — never a process panic. Both
//! cases are user mistakes on the hand-authored single-run path, so each
//! deserves a diagnosis, not a stack trace.
//!
//! Two independent unrunnable shapes, each pinned as its own minimal,
//! self-contained blueprint (inlined here — no shared fixture file):
@@ -38,7 +38,7 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
std::fs::write(&bp_path, bp_json).expect("write blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -51,16 +51,18 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
!stderr.to_lowercase().contains("panic"),
"{what}: expected a clean refusal, got a PANIC:\n{stderr}"
);
// The clean-refusal register: `aura: <msg>` on stderr, exit 1 — exactly
// what the binding refusals in `run_signal_r` already emit.
// The clean-refusal register: `aura: <msg>` on stderr — the prefix every
// refusal class (C14) shares regardless of exit code.
assert!(
stderr.contains("aura:"),
"{what}: refusal must carry the `aura:` prefix:\n{stderr}"
);
// C14 class 2: fault in argv-named content (#297) — both callers of this
// helper name a compile-time / exposed-output content fault.
assert_eq!(
out.status.code(),
Some(1),
"{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}"
Some(2),
"{what}: a refusal exits 2 (blueprint-content fault, not 101 panic, not 1 env/data fault):\n{stderr}"
);
}
+223 -5
View File
@@ -53,7 +53,7 @@ fn single_run_persists_a_declared_tap_series() {
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -100,7 +100,7 @@ fn tap_free_run_writes_no_trace_store() {
let cwd = temp_cwd("tap-free-writes-nothing");
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(BIN)
.args(["run", &bp])
.args(["exec", &bp])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -124,6 +124,21 @@ fn duplicate_tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
}
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
/// plan and the record-all default can be compared file-by-file (#310).
fn two_tap_blueprint_json() -> String {
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
v["blueprint"]["taps"] = serde_json::json!([
{"name": "fast_tap", "from": {"node": 0, "field": 0}},
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
]);
serde_json::to_string(&v).expect("re-serialize two-tap blueprint")
}
/// Property: **a blueprint declaring two taps under the same name is refused
/// on the single-run path with a named error and exit code 1, before any
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
@@ -137,12 +152,14 @@ fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
// C14 class 2: fault in argv-named content (#297)
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("dup") && stderr.contains("more than once"),
@@ -163,12 +180,14 @@ fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
// C14 class 1: environment fault (#297)
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
@@ -195,7 +214,7 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
.expect("make run dir read-only");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
@@ -205,6 +224,8 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
.expect("restore run dir permissions");
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
// C14 class 1: environment fault (#297)
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
@@ -212,3 +233,200 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
);
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
}
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
/// fold instead of the record-all default — the trace store holds ONE
/// summary row whose value is the mean of the full SMA(2) series, and
/// `index.json` lists exactly the subscribed tap.
#[test]
fn run_tap_selector_persists_a_fold_summary_row() {
let cwd = temp_cwd("tap-selector-mean");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
assert_eq!(trace["tap"], "fast_tap");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
.collect();
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
let got = rows[0].as_f64().expect("f64 row");
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
}
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
/// record-all default — the selector replaces the default without
/// changing what `record` itself writes (C1 determinism across runs).
#[test]
fn run_explicit_record_plan_matches_the_record_all_default() {
let cwd_a = temp_cwd("record-default");
let cwd_b = temp_cwd("record-explicit");
for cwd in [&cwd_a, &cwd_b] {
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
.expect("write two-tap blueprint");
}
let run = |cwd: &std::path::Path, extra: &[&str]| {
let mut args = vec!["exec", "two_tap.json"];
args.extend_from_slice(extra);
let out = Command::new(BIN)
.args(&args)
.current_dir(cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
};
run(&cwd_a, &[]);
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
for file in ["fast_tap.json", "slow_tap.json"] {
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
}
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
};
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
}
/// #310: an unknown fold label is refused through the registry's
/// roster-enumerating refusal, before any store write.
#[test]
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
let cwd = temp_cwd("tap-selector-unknown-label");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unknown fold label must refuse");
// C14 class 2: fault in argv-named content (#297)
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("medain") && stderr.contains("mean"),
"refusal must name the label and enumerate the roster: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #310/#333: a selection naming a tap the blueprint does not declare is
/// refused typed (UndeclaredTap), before any store write, and the refusal
/// enumerates the blueprint's declared taps (the same way the unknown-fold
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
/// both `fast_tap` and `slow_tap` must be named so the author does not have
/// to reopen the blueprint JSON.
#[test]
fn run_tap_selector_refuses_an_undeclared_tap() {
let cwd = temp_cwd("tap-selector-undeclared");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an undeclared tap must refuse");
// C14 class 2: fault in argv-named content (#297)
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
assert!(
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
"refusal must enumerate the blueprint's declared taps: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
/// gets no such note; `slow_tap` (left unlisted) does.
#[test]
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
let cwd = temp_cwd("tap-selector-unbound-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
"stderr must note the unbound declared tap: {stderr}"
);
assert!(
!stderr.contains("\"fast_tap\" unbound"),
"the subscribed tap must not be noted as unbound: {stderr}"
);
}
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
/// unbound — nothing is skipped, so the note must never appear.
#[test]
fn run_with_no_tap_flag_emits_no_unbound_note() {
let cwd = temp_cwd("tap-selector-default-no-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("unbound this run"),
"the record-all default must never note a skipped tap: {stderr}"
);
}
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
/// before anything runs.
#[test]
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
let cwd = temp_cwd("tap-selector-usage");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
for args in [
vec!["--tap", "fast_tapmean"],
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
] {
let mut full = vec!["exec", bp_path.to_str().expect("utf-8 path")];
full.extend(args);
let out = Command::new(BIN)
.args(&full)
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
assert!(!cwd.join("runs").exists());
}
}
+3 -3
View File
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
let scale = g.add(match knobs {
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::builder(1).named("stop_k"),
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::configured(1).named("stop_k"),
});
g.feed(price, [delay.input("series"), sub.input("lhs")]);
g.connect(delay.output("value"), sub.input("rhs"));
@@ -174,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
let entry = g.input_role("entry_price");
let stop = g.input_role("stop_price");
let agg = g.add(CostSum::builder(n));
let agg = g.add(CostSum::configured(n));
// Per-role geometry fan targets, collected across all nodes, fed once per role.
let mut closed_t = Vec::with_capacity(n);
+4
View File
@@ -7,6 +7,10 @@ publish.workspace = true
[dependencies]
serde = { workspace = true }
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
# already a workspace dependency of four other crates (see docs/specs/
# construction-args.md § Dependency note).
chrono-tz = { version = "0.10", default-features = false }
[dev-dependencies]
serde_json = { workspace = true }
+64 -4
View File
@@ -1,7 +1,37 @@
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the
//! project descriptor (src/project.rs) can stamp it. Runs per consuming build:
//! the host binary and a project cdylib each recompile aura-core under their
//! own toolchain, which is exactly what makes the two stamps comparable.
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION`, and a
//! deterministic fingerprint of aura-core's own sources into
//! `AURA_CORE_FINGERPRINT`, so the project descriptor (src/project.rs) can
//! stamp both. Runs per consuming build: the host binary and a project
//! cdylib each recompile aura-core under their own toolchain / from their own
//! checkout, which is exactly what makes the two stamps comparable (C30: the
//! load seam commits to a build-identity fingerprint, never the frozen crate
//! version).
use std::path::{Path, PathBuf};
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0100_0000_01b3;
fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 {
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
/// Recursively collect every `*.rs` file under `dir`.
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
fn main() {
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
@@ -13,4 +43,34 @@ fn main() {
"cargo:rustc-env=AURA_RUSTC_VERSION={}",
String::from_utf8_lossy(&out.stdout).trim()
);
// Deterministic staleness fingerprint: hash (relative path + contents) of
// every source file under src/, in sorted order. Not a security hash —
// FNV-1a is dependency-free and plenty for detecting a stale build. This
// is *source-level* identity only, an accepted C30 limit: the consuming
// build resolves its own lockfile and features, so shared-dependency
// types riding the Rust tier (e.g. chrono-tz's) are NOT guarded by this
// stamp — the seam refuses staleness of aura-core itself, not the full
// link graph.
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rs_files(&src_dir, &mut files);
files.sort();
let mut hash = FNV_OFFSET_BASIS;
for path in &files {
// The walk starts at src_dir, so every path strips; a silent fallback
// here would make the fingerprint checkout-dependent.
let rel = path.strip_prefix(&src_dir).expect("walked file lies outside src/");
let contents = std::fs::read(path).expect("read aura-core source file");
// Prefix-free record: path NUL length-prefixed contents (paths never
// contain NUL, the length pins the content boundary).
hash = fnv1a(rel.to_string_lossy().as_bytes(), hash);
hash = fnv1a(&[0], hash);
hash = fnv1a(&(contents.len() as u64).to_le_bytes(), hash);
hash = fnv1a(&contents, hash);
}
println!("cargo:rustc-env=AURA_CORE_FINGERPRINT={hash:016x}");
println!("cargo:rerun-if-changed=src");
}
+3 -2
View File
@@ -44,8 +44,9 @@ pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use node::{
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder,
};
pub use scalar::{Scalar, ScalarKind, Timestamp};
pub use series_fold::SeriesFold;
+368 -2
View File
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
instance_name: Option<String>,
schema: NodeSchema,
bound: Vec<BoundParam>,
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
schema: NodeSchema,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
}
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
/// not yet constructible. `schema` carries only the doc line (empty
/// inputs/output/params — the real signature forms inside `make`, once
/// `try_args` has parsed values to hand it); `build` on a pending builder
/// panics (the `bind` panic-contract twin) — the data path always goes
/// through `try_args` first, so no pending builder ever reaches `build`.
pub fn pending(
name: &'static str,
doc: &'static str,
specs: &'static [ArgSpec],
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
) -> Self {
Self {
name,
instance_name: None,
schema: NodeSchema { doc, ..Default::default() },
bound: Vec::new(),
args: ArgsState::Pending { specs, make },
build: Box::new(move |_| {
panic!(
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
call try_args first"
)
}),
}
}
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
/// configured builder (`args: Configured{..}`) with this builder's
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
/// (uniform op application, #157's try_bind precedent); `Plain` +
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
/// first unknown arg name, then a duplicate, then the first spec-order
/// missing arg, then per-kind parse failures (`BadValue`).
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
let (specs, make) = match &self.args {
ArgsState::Plain | ArgsState::Configured { .. } => {
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
}
ArgsState::Pending { specs, make } => (*specs, *make),
};
for (name, _) in raw {
if !specs.iter().any(|s| s.name == name.as_str()) {
return Err(ArgOpError::UnknownArg(name.clone()));
}
}
for i in 0..raw.len() {
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
}
}
for spec in specs {
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
return Err(ArgOpError::MissingArg(spec.name.to_string()));
}
}
let mut values = Vec::with_capacity(specs.len());
let mut accepted = Vec::with_capacity(specs.len());
for spec in specs {
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
arg: spec.name.to_string(),
kind: spec.kind,
got: raw_val.clone(),
})?;
values.push((spec.name.to_string(), value));
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
}
let mut built = make(&values);
built.instance_name = self.instance_name;
built.args = ArgsState::Configured { values: accepted };
Ok(built)
}
/// The declared construction-arg specs (a view into the pending recipe);
/// empty unless this builder `is_pending()`.
pub fn arg_specs(&self) -> &[ArgSpec] {
match &self.args {
ArgsState::Pending { specs, .. } => specs,
_ => &[],
}
}
/// The accepted, verbatim construction-arg pairs (the serialize surface);
/// empty unless this builder was configured via `try_args`.
pub fn construction_args(&self) -> &[ConstructionArg] {
match &self.args {
ArgsState::Configured { values } => values,
_ => &[],
}
}
/// True for an arg-bearing recipe not yet configured via `try_args`.
pub fn is_pending(&self) -> bool {
matches!(self.args, ArgsState::Pending { .. })
}
/// Set this node instance's explicit name. Must be non-empty: the name forms
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
/// panic contract (and its pinned messages); `try_bind` reports the same three
/// conditions as values.
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
/// follow-up) does, refusing a ganged member's raw path at the use seam
/// before it silently de-fuses the gang.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BindOpError {
/// No still-open param has this name.
@@ -333,6 +438,141 @@ pub enum BindOpError {
AmbiguousParam(String),
/// The value's kind does not equal the param's declared kind.
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
/// `param` (the full qualified path) is a member of the gang named
/// `gang` — binding it directly would freeze that one member while the
/// gang's public knob keeps driving its siblings. Bind the gang's own
/// public knob instead (accepted residue: unbindable at the use seam,
/// #317).
AlreadyGanged { param: String, gang: String },
}
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
/// are bootstrap metadata, never streamed, so the two closedness axes stay
/// separate types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArgKind {
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
Tz,
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
TimeOfDay,
/// A plain positive decimal count (>= 1, no leading zeros).
Count,
}
impl ArgKind {
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
/// the canonical form (spec §Closedness) — there is no normalization
/// layer, so a near-miss string refuses rather than being silently
/// rewritten (content ids stay input-variance-free by refusal).
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
// no information of its own — `try_args` is the only caller and always maps
// it to `ArgOpError::BadValue`, which is where the real detail lives.
#[allow(clippy::result_unit_err)]
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
match self {
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
ArgKind::TimeOfDay => {
let bytes = raw.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return Err(());
}
let (h, m) = (&raw[0..2], &raw[3..5]);
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
let hour: u32 = h.parse().map_err(|_| ())?;
let minute: u32 = m.parse().map_err(|_| ())?;
if hour > 23 || minute > 59 {
return Err(());
}
Ok(ArgValue::TimeOfDay { hour, minute })
}
ArgKind::Count => {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
if raw.len() > 1 && raw.starts_with('0') {
return Err(()); // leading zero: not the canonical form
}
let n: usize = raw.parse().map_err(|_| ())?;
if n == 0 {
return Err(()); // Count is >= 1
}
Ok(ArgValue::Count(n))
}
}
}
/// The static, per-kind hint line (introspection surface, C29) — a closed
/// table keyed only by `ArgKind`, never per-entry freetext.
pub fn hint(&self) -> &'static str {
match self {
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
ArgKind::TimeOfDay => "local wall-clock HH:MM",
ArgKind::Count => "positive integer count",
}
}
}
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ArgSpec {
pub name: &'static str,
pub kind: ArgKind,
}
/// A parsed, validated construction-arg value (in-memory only; documents carry
/// the canonical string form in [`ConstructionArg`]).
#[derive(Clone, Debug, PartialEq)]
pub enum ArgValue {
Tz(chrono_tz::Tz),
TimeOfDay { hour: u32, minute: u32 },
Count(usize),
}
/// A consumed construction arg — the id-bearing, serialized twin of
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
/// (never re-normalized — see `ArgKind::parse`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConstructionArg {
pub name: String,
pub value: String,
}
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
/// for the data-level construction surface.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArgOpError {
/// Args were given, but this type declares none (a `Plain` builder).
NotArgBearing,
/// A given arg name is not among the declared `ArgSpec`s.
UnknownArg(String),
/// The same arg name was given more than once.
DuplicateArg(String),
/// A declared arg has no value in the given set.
MissingArg(String),
/// A given value failed its declared kind's strict-form parse.
BadValue { arg: String, kind: ArgKind, got: String },
}
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
enum ArgsState {
/// No construction args declared (every existing node, unaffected).
Plain,
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
/// parsed value set into the real, configured builder.
Pending {
specs: &'static [ArgSpec],
#[allow(clippy::type_complexity)]
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
},
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
/// pairs — the render/serialize surface (`construction_args`).
Configured { values: Vec<ConstructionArg> },
}
/// A node's declared interface: its inputs (in order) and its output record — an
@@ -835,6 +1075,132 @@ mod tests {
fn doc_gate_accepts_a_meaning_line() {
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
}
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
const DEMO_ARG_SPECS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// A minimal `make`: the fixture does not need `values` to shape a real
/// schema (that's `Session::make`'s job, Task 2) — it only proves
/// `try_args` reaches `make` with parsed values and carries the result
/// forward as `Configured`.
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let _ = values;
PrimitiveBuilder::new(
"Demo",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|_| Box::new(Bare),
)
}
fn demo_pending() -> PrimitiveBuilder {
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
}
#[test]
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
assert_eq!(
plain.try_args(&[("x".into(), "1".into())]).err(),
Some(ArgOpError::NotArgBearing),
);
}
#[test]
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
let plain = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|_| Box::new(Bare),
);
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
assert_eq!(after.schema().doc, "plain doc");
assert!(after.construction_args().is_empty());
}
#[test]
fn try_args_unknown_arg_names_the_first_unknown() {
assert_eq!(
demo_pending()
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
.err(),
Some(ArgOpError::UnknownArg("nope".into())),
);
}
#[test]
fn try_args_duplicate_arg_names_the_repeated_name() {
assert_eq!(
demo_pending()
.try_args(&[
("tz".into(), "Europe/Berlin".into()),
("tz".into(), "Europe/Berlin".into()),
("open".into(), "09:30".into()),
])
.err(),
Some(ArgOpError::DuplicateArg("tz".into())),
);
}
#[test]
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
assert_eq!(
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
Some(ArgOpError::MissingArg("open".into())),
);
}
#[test]
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
assert_eq!(
demo_pending()
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
.err(),
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
);
}
/// Strict form IS the canonical form (spec §Closedness): no normalization
/// layer, so a near-miss string refuses rather than being rewritten.
#[test]
fn arg_kind_parse_strict_form_refusals_and_accepts() {
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
assert!(ArgKind::Count.parse("2").is_ok());
}
#[test]
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
let configured = demo_pending()
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
.expect("a full valid set configures");
assert!(!configured.is_pending());
assert_eq!(
configured.construction_args(),
&[
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
ConstructionArg { name: "open".into(), value: "09:30".into() },
],
);
}
#[test]
fn pending_arg_specs_are_visible_before_configuration() {
let pending = demo_pending();
assert!(pending.is_pending());
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
assert!(pending.construction_args().is_empty());
}
#[test]
#[should_panic(expected = "unconfigured")]
fn pending_build_panics() {
let _ = demo_pending().build(&[]);
}
}
#[cfg(test)]
+21 -8
View File
@@ -2,7 +2,7 @@
//!
//! A research project compiles to a cdylib exporting ONE symbol,
//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers:
//! a **C tier** (`magic`, `descriptor_version`, the version stamps, the
//! a **C tier** (`magic`, `descriptor_version`, the build stamps, the
//! namespace — all C-compatible field types) the host validates BEFORE
//! trusting anything, and a **Rust tier** (the vocabulary resolver and the
//! enumerable type-id list) the host touches only after both stamps match.
@@ -23,8 +23,11 @@ pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0";
/// The compiling rustc's `--version` line (via build.rs).
pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION");
/// This aura-core's crate version.
pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Build-identity fingerprint of aura-core's own sources (via build.rs),
/// per ledger contract C30: the load seam commits to a source-derived
/// fingerprint, never the frozen crate version (a fixed 0.1.0 under the
/// no-semver discipline would disarm the handshake it's meant to guard).
pub const CORE_FINGERPRINT: &str = env!("AURA_CORE_FINGERPRINT");
/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes.
/// No NUL convention needed; readable without trusting the Rust ABI.
@@ -63,7 +66,7 @@ pub struct ProjectDescriptor {
pub magic: u64,
pub descriptor_version: u32,
pub rustc_version: StrSlice,
pub aura_core_version: StrSlice,
pub aura_core_fingerprint: StrSlice,
pub namespace: StrSlice,
// ---- Rust tier: only after both stamps match ----
pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>,
@@ -86,8 +89,8 @@ macro_rules! aura_project {
rustc_version: $crate::project::StrSlice::new(
$crate::project::RUSTC_VERSION,
),
aura_core_version: $crate::project::StrSlice::new(
$crate::project::CORE_VERSION,
aura_core_fingerprint: $crate::project::StrSlice::new(
$crate::project::CORE_FINGERPRINT,
),
namespace: $crate::project::StrSlice::new($ns),
vocabulary: $vocab,
@@ -117,7 +120,17 @@ mod tests {
#[test]
fn stamps_are_baked_and_nonempty() {
assert!(RUSTC_VERSION.starts_with("rustc "));
assert!(!CORE_VERSION.is_empty());
assert!(!CORE_FINGERPRINT.is_empty());
}
/// C30/#348: the stamp is a source-derived hash, never a constant that
/// would disarm the load-seam refusal — pin its mechanical shape (the
/// 16-hex FNV-1a rendering) and that it is not the crate version.
#[test]
fn core_fingerprint_is_a_source_hash_not_the_crate_version() {
assert_eq!(CORE_FINGERPRINT.len(), 16);
assert!(CORE_FINGERPRINT.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(CORE_FINGERPRINT, env!("CARGO_PKG_VERSION"));
}
#[test]
@@ -152,7 +165,7 @@ mod tests {
assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION);
assert_eq!(unsafe { d.namespace.as_str() }, Some("t"));
assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION));
assert_eq!(unsafe { d.aura_core_version.as_str() }, Some(CORE_VERSION));
assert_eq!(unsafe { d.aura_core_fingerprint.as_str() }, Some(CORE_FINGERPRINT));
assert!((d.vocabulary)("nope").is_none());
assert_eq!((d.type_ids)(), &["t::A"]);
}
+272 -8
View File
@@ -15,7 +15,8 @@
//! C23) and no external dependency (C16).
use aura_core::{
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
@@ -194,6 +195,38 @@ pub struct GangMember {
pub name: String,
}
/// Deterministic shape gate for authored composite render names (#331) —
/// shape only, never content judgement (C29 discipline): non-empty, no path
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
/// exists because the name keys a trace directory on disk unsanitized
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
/// blueprint-envelope root-name intake — never at store read-back (C29:
/// registered artifacts are never retroactively invalidated), and never on
/// the Rust builder API (`GraphBuilder::new`, role-2 native authoring).
pub fn name_gate(name: &str) -> Result<(), NameGateFault> {
if name.is_empty() {
return Err(NameGateFault::Empty);
}
if name.contains('/') || name.contains('\\') {
return Err(NameGateFault::ContainsSeparator);
}
if name == "." || name == ".." {
return Err(NameGateFault::DotSegment);
}
Ok(())
}
/// The [`name_gate`] shape violation — WHY the name was refused; the caller
/// already holds the offending name (WHAT) to build a message from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameGateFault {
Empty,
ContainsSeparator,
DotSegment,
}
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
/// interior edges (local indices), input roles (role `r` fans into the interior
@@ -240,6 +273,37 @@ impl Composite {
pub fn doc(&self) -> Option<&str> {
self.doc.as_deref()
}
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
/// interior — nodes, edges, roles, output — is untouched; only `name()`
/// changes, so a fetched, registered subgraph renders (and path-prefixes
/// its `param_space()`, via `collect_params`'s composite arm) under the
/// caller-chosen instance identifier instead of its own authored name.
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
self.name = name.into();
self
}
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
/// same underlying mechanic every bind goes through
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
/// nested composite frames by node/composite-name prefix — the segmentation
/// `reopen_in` (#246) also walks, but binding an open param instead of
/// unbinding a bound one. A path matching no node at any level is
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
/// param lives there); a path that reaches a primitive but whose leaf
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
/// own fault to carry the full qualified path in place of the bare leaf
/// name, so every fault out of this walk names the same thing: the path
/// the caller wrote. A path landing on a GANGED member's own param is
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
/// it directly would silently de-fuse the gang (the member frozen while
/// the gang's public knob keeps driving its siblings); the gang's own
/// public knob stays unbindable at the use seam (accepted residue). On
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
/// posture `reopen` uses.
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
Ok(self)
}
/// Install declared measurement taps (the output-side twin of `input_roles`).
/// Fluent, mirroring `with_doc`. Empty by default.
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
@@ -297,6 +361,24 @@ impl Composite {
out
}
/// Every declared measurement tap across the whole blueprint, depth-first
/// through nested composites — the taps-as-data twin of `param_space()`
/// (#337, the positive discovery view #333's refusal roster deliberately
/// deferred). Each entry is `(tap name, source wire, column kind)`, the
/// wire rendered `<node>.<field>` in the LOCAL frame the tap was declared
/// in. Unlike a param's path, neither the tap's own name nor its wire's
/// node identifier is prefixed by an enclosing composite: a tap hoists
/// BARE to the flat graph (`inline_composite` keeps `tap.name` verbatim),
/// so this view renders exactly the load-bearing name `compile` keeps.
/// Bounds-total like `derive_signature`: a structurally-invalid wire (out
/// of a producer's output arity) yields no row rather than a panic — the
/// real gate is `compile`'s own `resolve_tap_wire`.
pub fn declared_taps(&self) -> Vec<(String, String, ScalarKind)> {
let mut out = Vec::new();
collect_taps(self, &mut out);
out
}
/// Re-open ONE bound param at its `param_space()`-style path (#246): the
/// param returns to the open surface at the slot `collect_params` order
/// dictates; its bound value is forgotten by this value (the authored
@@ -510,7 +592,8 @@ pub enum ReopenError {
/// One entry of the aggregated BOUND param surface (#246): the path-qualified
/// twin of [`ParamSpec`] carrying the bound value — the default a sweep axis
/// may override, and what `--list-axes` renders as `default=`.
/// may override, and what `aura graph introspect --params` renders as
/// `default=`.
#[derive(Debug, Clone, PartialEq)]
pub struct BoundSpec {
pub name: String,
@@ -1066,6 +1149,31 @@ fn collect_params(items: &[BlueprintNode], gangs: &[Gang], prefix: &str, out: &m
}
}
/// Recursive walk for `Composite::declared_taps` (#337): this level's OWN
/// declared taps first (resolved against this level's OWN `nodes` — a tap's
/// `from` wire is always local to the frame it was declared in), then
/// recurse into every nested composite's own tap list. No prefix threading
/// (unlike `collect_params`): a tap's name and its wire's node identifier
/// stay bare at every depth, mirroring `inline_composite`'s own hoist.
fn collect_taps(c: &Composite, out: &mut Vec<(String, String, ScalarKind)>) {
for tap in &c.taps {
if let Some(item) = c.nodes.get(tap.from.node) {
let node_name = match item {
BlueprintNode::Primitive(b) => b.node_name(),
BlueprintNode::Composite(inner) => inner.name().to_string(),
};
if let Some(field) = item.signature().output.get(tap.from.field) {
out.push((tap.name.clone(), format!("{node_name}.{}", field.name), field.kind));
}
}
}
for item in &c.nodes {
if let BlueprintNode::Composite(inner) = item {
collect_taps(inner, out);
}
}
}
/// Recursive mutable walk for `Composite::reopen` (#246): mirrors
/// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns
/// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns
@@ -1097,6 +1205,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
hits
}
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
/// `full_path` is the whole original path (named in every fault this
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
/// its (node, original-pos) is checked against every gang member; a hit
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
/// the member out from under its gang's public knob (review finding,
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
/// `BlueprintNode`s by value rather than mutating through a reference.
fn bind_path_in(
items: &mut Vec<BlueprintNode>,
gangs: &[Gang],
full_path: &str,
rest: &str,
value: Scalar,
) -> Result<(), BindOpError> {
// The prefix this frame has already consumed off `full_path` (composite
// names and up) — used to qualify a gang's public name the same way
// `collect_params` does, so the refusal below names the SAME address
// `param_space()` would show for that gang.
let prefix = &full_path[..full_path.len() - rest.len()];
for i in 0..items.len() {
let child_rest = match &items[i] {
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
};
let Some(child_rest) = child_rest else { continue };
let child_rest = child_rest.to_string();
// Gang guard (#317 follow-up review finding): a raw path landing on a
// GANGED member's own param must not silently freeze it while the
// gang's public knob keeps driving its siblings — refuse by
// identifier before ever reaching `try_bind`. The gang's own public
// knob staying unbindable at the use seam is accepted residue.
if let BlueprintNode::Primitive(b) = &items[i]
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
{
return Err(BindOpError::AlreadyGanged {
param: full_path.to_string(),
gang: format!("{prefix}{}", g.name),
});
}
let item = items.remove(i);
return match item {
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
Ok(b2) => {
items.insert(i, BlueprintNode::Primitive(b2));
Ok(())
}
Err(e) => Err(qualify_bind_error(e, full_path)),
},
BlueprintNode::Composite(mut c) => {
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
items.insert(i, BlueprintNode::Composite(c));
r
}
};
}
// no node at any level matched `rest`'s leading segment: no open param
// lives at this path.
Err(BindOpError::UnknownParam(full_path.to_string()))
}
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
/// visibility past the one primitive it was called on).
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
match e {
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
BindOpError::KindMismatch { expected, got, .. } => {
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
}
// `try_bind` never raises this one (it has no gang awareness) — the
// gang guard above constructs it directly, already fully qualified.
// Kept here only so this match stays total over `BindOpError`.
BindOpError::AlreadyGanged { gang, .. } => {
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
}
}
}
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
@@ -1401,6 +1595,19 @@ mod tests {
use aura_strategy::Bias;
use std::sync::mpsc;
/// `name_gate` (#331) unit table: an ordinary name passes; an empty
/// name, one containing either path separator, and the two dot segments
/// each fault with their own fault variant.
#[test]
fn name_gate_accepts_ordinary_names_and_refuses_shape_violations() {
assert_eq!(name_gate("ny_momentum"), Ok(()));
assert_eq!(name_gate(""), Err(NameGateFault::Empty));
assert_eq!(name_gate("a/b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("a\\b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("."), Err(NameGateFault::DotSegment));
assert_eq!(name_gate(".."), Err(NameGateFault::DotSegment));
}
/// One knob fanning into two sibling open params passes the gate; the
/// value carries the gang table.
#[test]
@@ -1703,7 +1910,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -1742,7 +1949,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -3115,7 +3322,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
@@ -3213,7 +3420,8 @@ mod tests {
}
/// #246: bound_param_space mirrors param_space's path qualification and
/// carries the bound value (the default the CLI renders in --list-axes).
/// carries the bound value (the default the CLI renders in
/// `aura graph introspect --params`).
#[test]
fn bound_param_space_is_path_qualified_with_values() {
use aura_strategy::Bias;
@@ -3323,7 +3531,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
// fan fast_slow's output into both LinComb terms so every interior slot
// is wired (the totality check, cycle 0040); param order is unaffected.
vec![
@@ -3383,7 +3591,7 @@ mod tests {
use aura_std::{LinComb, Sma};
let bp = Composite::new(
"root",
vec![Sma::builder().into(), LinComb::builder(2).into()],
vec![Sma::builder().into(), LinComb::configured(2).into()],
vec![],
vec![],
vec![], // output
@@ -3789,4 +3997,60 @@ mod tests {
// the inner Sub lowered to flat node 0; the hoisted tap points at it
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "inner_d".into(), node: 0, field: 0 }]);
}
/// The build-free discovery twin (#337) of the compile-time hoist above:
/// `declared_taps()` finds the SAME nested-composite tap without ever
/// compiling — its wire is rendered in the LOCAL frame it was declared in
/// (`sub.value`, the un-named Sub's default node name + its own output
/// field), never composite-path-prefixed (unlike a param's path).
#[test]
fn declared_taps_finds_a_nested_composites_tap_without_compiling() {
let inner = Composite::new(
"inner",
vec![Sub::builder().into()],
vec![],
vec![Role {
name: "x".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
source: None,
}],
vec![OutField { node: 0, field: 0, name: "o".into() }],
)
.with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]);
let root = Composite::new(
"root",
vec![BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "a".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
assert_eq!(
root.declared_taps(),
vec![("inner_d".to_string(), "sub.value".to_string(), ScalarKind::F64)]
);
}
/// Bounds-total (mirrors `derive_signature`): a tap wire naming a field
/// beyond its producer's output arity yields no row rather than a panic —
/// the real gate stays `compile`'s own `TapWireOutOfRange`.
#[test]
fn declared_taps_is_bounds_total_over_an_out_of_range_wire() {
let bp = Composite::new(
"m",
vec![Sub::builder().into()],
vec![],
vec![Role {
name: "a".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![],
)
.with_taps(vec![Tap { name: "bad".into(), from: TapWire { node: 0, field: 9 } }]);
assert_eq!(bp.declared_taps(), Vec::new(), "an out-of-range wire yields no row, not a panic");
}
}
+307 -26
View File
@@ -7,18 +7,29 @@
//! closure and the declared schema are re-derived on load from the injected
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
//! carries no node logic (C17) and references a closed vocabulary (C24).
//!
//! Construction args (#271) are structural, id-bearing data — like `bound`
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
//! `PrimitiveData.args` untouched.
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder};
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
/// The format version the loader understands. Bumped only by a load-bearing
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
/// discipline activates at the first external ship — which consciously
/// freezes v1 (gangs included).
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
/// The CEILING format version this build's loader understands (and the
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
/// 2026-07-10): while the project is unshipped every document lives in-repo
/// and reader/writer change atomically, so the Tier-2 bump discipline
/// activates at the first external ship.
///
/// #271 (data-driven version): the writer no longer emits a single fixed
/// version. A document emits `1` when args-free (byte-identical to every
/// pre-#271 document — content ids stable, C18) and `2` the moment any
/// primitive, at any nesting depth, carries construction `args` — the
/// must-understand signal a pre-#271 loader needs. The loader accepts the
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
/// Top-level envelope: the version is read before the payload is interpreted.
#[derive(serde::Serialize, serde::Deserialize)]
@@ -61,24 +72,64 @@ pub enum NodeData {
}
/// A primitive node as data: its compiled-in type identity, optional instance
/// name, and bound params. The schema + build closure are re-derived on load.
/// name, its accepted construction args (#271), and bound params. The schema
/// + build closure are re-derived on load.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PrimitiveData {
#[serde(rename = "type")]
pub type_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The consumed `(name, value)` construction-arg pairs (#271) — the
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
/// (empty) for every args-free primitive, so a document with no args
/// anywhere stays byte-identical to its pre-#271 form.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound: Vec<BoundParam>,
}
/// One serialized construction-arg pair (#271) — the wire twin of
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
/// string, stored verbatim (never re-normalized).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ArgData {
pub name: String,
pub value: String,
}
/// Serializer failure (typed, named).
#[derive(Debug)]
pub enum SerializeError {
Json(serde_json::Error),
/// #341: an arg-bearing (`PrimitiveBuilder::pending`) node reached the
/// serialize seam still unconfigured. Only the Rust `GraphBuilder`/
/// `Composite::new` path can build this state — the data path
/// (`add_node`) always runs `try_args` first — but emitting it as an
/// args-free v1 document would produce a document `blueprint_from_json`
/// can only refuse as `LoadError::BadArg(ArgOpError::MissingArg(..))` at
/// LOAD time; refusing here instead names the fault at its origin.
/// `missing_args` is the pending recipe's full declared `ArgSpec` list
/// (none configured yet, so every declared arg is missing).
PendingBuilder { node_type: String, missing_args: Vec<String> },
}
fn project(c: &Composite) -> CompositeData {
impl std::fmt::Display for SerializeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SerializeError::Json(e) => write!(f, "{e}"),
SerializeError::PendingBuilder { node_type, missing_args } => write!(
f,
"node \"{node_type}\" is an unconfigured arg-bearing builder (missing arg(s): \
{}); call try_args to configure it before it enters a graph",
missing_args.join(", ")
),
}
}
}
fn project(c: &Composite) -> Result<CompositeData, SerializeError> {
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
// bound-param canonicalization below): each gang's members ascending
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
@@ -89,21 +140,30 @@ fn project(c: &Composite) -> CompositeData {
}
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
CompositeData {
Ok(CompositeData {
name: c.name().to_string(),
doc: c.doc().map(str::to_string),
nodes: c.nodes().iter().map(project_node).collect(),
nodes: c.nodes().iter().map(project_node).collect::<Result<Vec<_>, _>>()?,
edges: c.edges().to_vec(),
input_roles: c.input_roles().to_vec(),
output: c.output().to_vec(),
taps: c.taps().to_vec(),
gangs,
}
})
}
fn project_node(n: &BlueprintNode) -> NodeData {
fn project_node(n: &BlueprintNode) -> Result<NodeData, SerializeError> {
match n {
BlueprintNode::Primitive(b) => {
// #341: refuse a still-pending arg-bearing recipe outright — an
// args-free v1 document built from it would only surface its
// fault at load (`LoadError::BadArg(MissingArg)`), never here.
if b.is_pending() {
return Err(SerializeError::PendingBuilder {
node_type: b.label(),
missing_args: b.arg_specs().iter().map(|s| s.name.to_string()).collect(),
});
}
// Canonical: bound params in ascending original-slot order, mirroring
// the loader's re-bind canonicalization, so serialization is
// bind-order-independent (a precondition for content-addressing, #158).
@@ -111,18 +171,52 @@ fn project_node(n: &BlueprintNode) -> NodeData {
// construction once a multi-param node enters the vocabulary.
let mut bound = b.bound_params().to_vec();
bound.sort_by_key(|bp| bp.pos);
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(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}))
}
BlueprintNode::Composite(c) => NodeData::Composite(project(c)),
BlueprintNode::Composite(c) => Ok(NodeData::Composite(project(c)?)),
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
/// Any primitive, at any nesting depth, carries construction args (#271) —
/// the must-understand signal for the data-driven version (spec §Data-driven
/// format version).
fn has_args(b: &CompositeData) -> bool {
b.nodes.iter().any(|n| match n {
NodeData::Primitive(p) => !p.args.is_empty(),
NodeData::Composite(c) => has_args(c),
})
}
/// The version THIS document must declare (#271): `1` for an args-free
/// document (byte-identical to every pre-#271 document, C18) or `2` the
/// moment any primitive anywhere carries args — never the fixed
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
fn document_version(b: &CompositeData) -> u32 {
if has_args(b) {
2
} else {
1
}
}
fn build_doc(c: &Composite) -> 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> {
@@ -133,7 +227,7 @@ fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
serialize_doc(&build_doc(c))
serialize_doc(&build_doc(c)?)
}
/// The identity-canonical form (#171): the canonical document with every
@@ -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
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
let mut doc = build_doc(c);
let mut doc = build_doc(c)?;
strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc)
}
@@ -200,11 +294,16 @@ pub enum LoadError {
/// addition needs finer granularity than a version bump.
UnsupportedVersion { found: u32, supported: u32 },
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
/// vocabulary — unknown node, or a sink node not in #155's set).
UnknownNodeType(String),
/// The document's gangs section fails structural validation against its
/// own nodes (a hand-edited or corrupted document).
Gang(crate::blueprint::CompileError),
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
/// an arg-bearing type serialized/hand-written with an empty `args` —
/// a document naming an arg-bearing type without args must not load.
BadArg(aura_core::ArgOpError),
}
fn reconstruct(
@@ -220,6 +319,15 @@ fn reconstruct(
if let Some(n) = &p.name {
b = b.named(n);
}
// Apply construction args (#271) BEFORE bound params — the
// `try_args` seam. An arg-bearing type with no `args` in the
// document refuses HERE as `MissingArg` (no pending builder
// ever reaches `bind`/enters the built graph); a `Plain` type
// has empty `p.args`, so this is `Ok(self)` unchanged for
// every pre-#271 document.
let args: Vec<(String, String)> =
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
b = b.try_args(&args).map_err(LoadError::BadArg)?;
// Re-apply bound params BY NAME. The effective param vector is
// order-independent (each `bind` computes its slot against the
// shrunk schema); ascending original `pos` is the canonical order.
@@ -251,7 +359,9 @@ pub fn blueprint_from_json(
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
return Err(LoadError::UnsupportedVersion {
found: doc.format_version,
supported: BLUEPRINT_FORMAT_VERSION,
@@ -266,7 +376,8 @@ mod tests {
use crate::blueprint::{Composite, GangMember, OutField, Role};
use crate::harness::{Edge, Target};
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
use aura_market::Session;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
@@ -436,11 +547,14 @@ mod tests {
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
}
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
/// used to be refused (v1 was the only understood version); now that v2 loads
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
#[test]
fn unsupported_version_fails_named() {
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
}
#[test]
@@ -813,4 +927,171 @@ mod tests {
"the gang itself is identity-bearing"
);
}
// ---- construction args (#271) --------------------------------------
// A single-node composite around an arg-configured `Session` (arity-free —
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
Composite::new(
"sess",
vec![Session::configured(9, 30, tz, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
)
}
/// #271 acceptance (engine layer): an args-bearing composite serializes its
/// construction-arg pairs, declares `format_version: 2` (the data-driven
/// must-understand tier, spec §Data-driven format version), and round-trips
/// byte-stably through load -> re-serialize.
#[test]
fn args_round_trip_preserves_pairs_and_version_2() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
let json = blueprint_to_json(&c).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
assert!(
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
"carries the verbatim accepted arg pairs: {json}"
);
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
json,
"args round-trip byte-stably"
);
}
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
/// here under the #271-specific test name the plan names.
#[test]
fn args_free_document_serializes_byte_identical_and_version_1() {
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&signal).expect("serializes");
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
}
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
/// outer composite still trips the outer document's version to 2 — the
/// version walk recurses through `NodeData::Composite`, not just the
/// top-level node list.
#[test]
fn nested_spliced_args_composite_is_version_2() {
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
let outer = Composite::new(
"outer",
vec![crate::blueprint::BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
}
/// #271 (identity is arg-bearing): construction args are structural,
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
/// two builds differing ONLY in one accepted arg value never share an
/// identity JSON, while renaming the composite (a pure debug symbol)
/// still does not affect it.
#[test]
fn identity_json_retains_args() {
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
assert_ne!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&paris).expect("identity-serializes"),
"a differing construction-arg value survives the identity projection"
);
// renaming the composite (a debug symbol) does not affect identity.
let renamed = Composite::new(
"sess_renamed",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
assert_eq!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&renamed).expect("identity-serializes"),
"the composite's own debug name does not affect identity"
);
}
/// #271 (refuse, don't guess): a hand-written document naming an
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
/// must never silently enter the built graph.
#[test]
fn loading_arg_bearing_type_without_args_refuses() {
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
assert!(
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
/// #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;
pub use blueprint::{
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
Tap, TapWire,
};
pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
pub use construction::{replay, GraphSession, Op, OpError};
@@ -88,7 +89,8 @@ pub use walkforward::{
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
// Firing / Timestamp) so a graph builder has one import surface, not two.
pub use aura_core::{
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
Timestamp,
};
#[cfg(test)]
+14 -14
View File
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
fn signal_ops() -> Vec<Op> {
vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
#[test]
fn replayed_construction_compiles_identically_to_graphbuilder() {
// (a) op-script replay through the public construction surface.
let replay_flat = replay("root", signal_ops(), &std_vocabulary)
let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
.expect("op-script resolves")
.compile_with_params(&params())
.expect("replay compiles");
@@ -96,14 +96,14 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
#[test]
fn replay_attributes_eager_fault_to_its_op_index() {
let ops = vec![
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 1 ok
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // 2 fails
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 0 ok
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 1 ok
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, // 3 never runs
];
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
let err = replay("g", ops, &std_vocabulary).err().unwrap();
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
}
@@ -120,15 +120,15 @@ fn replay_attributes_eager_fault_to_its_op_index() {
fn replay_attributes_holistic_fault_to_finalize_step() {
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 3
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 1
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, // 2
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 3
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
// sub.rhs deliberately left unwired -> only finalize can decide totality.
];
let finalize_index = ops.len(); // 6 — the implicit finalize step
let err = replay("g", ops, &std_vocabulary).err().unwrap();
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
"the holistic fault is still attributed to the finalize step, got {err:?}");
}
+1 -1
View File
@@ -110,7 +110,7 @@ fn build_harness(
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(9, 0, Berlin, 15).schema().clone(), // 3
Session::configured(9, 0, Berlin, 15).schema().clone(), // 3
EqConst::builder().schema().clone(), // 4
EqConst::builder().schema().clone(), // 5
And::builder().schema().clone(), // 6
+1 -1
View File
@@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec<f64> {
let sq = g.add(Mul::builder()); // dev * dev
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
let sigma = g.add(Sqrt::builder());
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
let band = g.add(LinComb::configured(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
let upper = g.add(Add::builder()); // mean + k*sigma
let lower = g.add(Sub::builder()); // mean - k*sigma
let gt_hi = g.add(Gt::builder()); // price > upper
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
.schema()
.clone(), // 3
EqConst::builder().schema().clone(), // 4
@@ -248,7 +248,7 @@ pub fn ger40_breakout_blueprint(
);
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let gt = g.add(Gt::builder());
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes));
let session = g.add(Session::configured(open_hour, open_minute, tz, bar_period_minutes));
// The ONLY two params: the EqConst targets, named so the space reads
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
+82 -9
View File
@@ -33,10 +33,17 @@
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind,
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder, Scalar, ScalarKind,
};
use chrono::{TimeZone, Timelike};
/// The declared construction args of a `Session` recipe (spec §Concrete code
/// shapes): the timezone and local open time — structural, non-scalar
/// configuration, never a swept param.
const SESSION_ARGS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
/// midnight), the IANA timezone, and the bar period — baked at construction,
/// never streamed and never a swept param (a timezone is not a scalar).
@@ -60,23 +67,57 @@ impl Session {
}
}
/// The param-generic recipe for a blueprint primitive. The open time,
/// timezone, and period are **structural** config baked into the closure
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
/// declared param list is empty and the `build_fn` ignores its slice (like
/// `Gt` / `Latch`).
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
/// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
/// timezone and open time come through the `args` channel (`try_args`),
/// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
/// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::pending(
"Session",
"bars elapsed since the session open, from the configured open time and timezone",
SESSION_ARGS,
Self::make,
)
}
/// Turn a validated `(tz, open)` value pair into the real signature: one
/// `trigger` input, one `bars_since_open` output, and `period_minutes` as
/// the sole (now scalar) `ParamSpec` — read from the param slice at build.
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let tz = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("tz", ArgValue::Tz(tz)) => Some(*tz),
_ => None,
})
.expect("try_args validated `tz` as ArgKind::Tz before calling make");
let (open_hour, open_minute) = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("open", ArgValue::TimeOfDay { hour, minute }) => Some((*hour, *minute)),
_ => None,
})
.expect("try_args validated `open` as ArgKind::TimeOfDay before calling make");
PrimitiveBuilder::new(
"Session",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
doc: "bars elapsed since the session open, from the configured open time and timezone",
},
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)),
move |p| Box::new(Session::new(open_hour, open_minute, tz, p[0].i64())),
)
}
/// Rust-path convenience — the same recipe as the data path (twin
/// identity): `builder().try_args([tz, open]) + .bind("period_minutes", …)`.
pub fn configured(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
Self::builder()
.try_args(&[("tz".to_string(), tz.name().to_string()), ("open".to_string(), format!("{open_hour:02}:{open_minute:02}"))])
.expect("configured: a valid chrono_tz::Tz and in-range hour/minute always parse")
.bind("period_minutes", Scalar::i64(period_minutes))
}
}
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
@@ -244,4 +285,36 @@ mod tests {
let inputs = trigger_input();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
/// Twin identity (spec §aura-market): `configured` is exactly
/// `builder().try_args([tz, open]).bind("period_minutes", …)` — same
/// accepted args, same declared signature, whichever path authored it.
#[test]
fn session_configured_twin_equals_builder_try_args() {
let via_configured = Session::configured(9, 30, Berlin, 15);
let via_try_args = Session::builder()
.try_args(&[("tz".into(), "Europe/Berlin".into()), ("open".into(), "09:30".into())])
.expect("valid tz/open configure")
.bind("period_minutes", Scalar::i64(15));
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
assert_eq!(via_configured.schema(), via_try_args.schema());
}
/// Behaviour re-anchor (#271 Task 2): the DST-aware headline property
/// (local 09:45 reads bar 3 in both CEST summer and CET winter) holds
/// identically when the node is built through `configured` instead of
/// `Session::new` directly.
#[test]
fn session_configured_behaviour_unchanged() {
let mut node = Session::configured(9, 0, Berlin, 15).build(&[]);
let mut inputs = trigger_input();
let summer = Berlin.with_ymd_and_hms(2024, 7, 1, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(summer))).map(|r| r[0].i64()), Some(3));
let winter = Berlin.with_ymd_and_hms(2024, 1, 2, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(winter))).map(|r| r[0].i64()), Some(3));
}
}
+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 —
/// a sibling of the runs store, like `blueprints/`.
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
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
/// the Empty fault ("carries no doc").
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
/// before it enters a new composition (fail fast on a doc-less fetched
/// entry) — the one walk, two call sites (register's write-path gate, use's
/// read-path gate), never a second implementation.
pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
let fault = match c.doc.as_deref() {
None => Some(aura_core::DocGateFault::Empty),
Some(d) => aura_core::doc_gate(&c.name, d).err(),
@@ -300,7 +421,18 @@ pub enum RefFault {
StrategyNotFound(String),
IdentityUnmatched(String),
StrategyUnloadable { id: String, error: String },
AxisNotInParamSpace { strategy: String, axis: String },
AxisNotInParamSpace {
strategy: String,
axis: String,
/// #328 translation aid: stripping ONE leading segment off `axis`
/// (the wrapped `<blueprint>.<node>.<param>` shape a stale transcript
/// might quote) and finding that remainder in the strategy's
/// `param_space()` or `bound_param_space()` — `Some` iff the strip-
/// then-hit lands, computed at fault-construction time so the prose
/// layer never re-derives it. `None` when no leading segment strips
/// to a hit (today's prose stays unchanged, no speculation).
raw_candidate: Option<String>,
},
AxisKindMismatch { strategy: String, axis: String },
/// An open param of the resolved strategy is bound by no campaign axis —
/// the executor's every-open-knob-required rule, mirrored at validate
@@ -402,10 +534,22 @@ impl Registry {
});
}
}
None => faults.push(RefFault::AxisNotInParamSpace {
strategy: label.clone(),
axis: axis.clone(),
}),
None => {
// #328 did-you-mean: strip one leading segment off the
// rejected axis (the wrapped-name shape a stale
// transcript might quote) and check whether the
// remainder is a legal raw axis here — never
// speculating when it isn't.
let raw_candidate = axis.split_once('.').map(|(_, rest)| rest).filter(|stripped| {
space.iter().any(|p| p.name == *stripped)
|| bound.iter().any(|b| b.name == *stripped)
}).map(str::to_string);
faults.push(RefFault::AxisNotInParamSpace {
strategy: label.clone(),
axis: axis.clone(),
raw_candidate,
})
}
}
}
// The reverse direction: every open param must be bound by some
@@ -938,6 +1082,14 @@ pub enum RegistryError {
/// the store without a gate-passing doc. Registered artifacts are never
/// retroactively invalidated — the gate guards the write path only.
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
/// Label sidecar (#317): a `put_blueprint_label` name failing the
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
/// control characters). By-identifier, naming the violated rule.
BadLabel { name: String, rule: &'static str },
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
/// labels a blueprint the store does not have.
UnknownBlueprint(String),
}
impl fmt::Display for RegistryError {
@@ -978,6 +1130,12 @@ impl fmt::Display for RegistryError {
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]
fn document_stores_round_trip_by_content_id() {
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);
// A minimal fixture composite with one OPEN param, built from a
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
// plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
// risk_executor*) all route through `LinComb`, whose builder needs a
// structural arity argument and is therefore deliberately absent from
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
// — they cannot round-trip through `blueprint_from_json` with this
// resolver, so this fixture stands in as the "real, open-param,
// vocabulary-loadable composite" the test needs.
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
// independent of that construction channel, so it stands in as the
// "real, open-param, vocabulary-loadable composite" the test needs.
let composite = Composite::new(
"fixture",
vec![aura_strategy::Bias::builder().named("b").into()],
@@ -1990,6 +2274,98 @@ mod tests {
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
}
/// #328: a rejected axis shaped like a stale wrapped transcript (one
/// bogus leading segment glued in front of a real raw param name) carries
/// that real name as `AxisNotInParamSpace::raw_candidate` — computed at
/// fault-construction time in `validate_campaign_refs`, never re-derived
/// by the prose layer.
#[test]
fn axis_not_in_param_space_carries_a_raw_candidate_when_strippable() {
use aura_engine::blueprint_to_json;
let reg = Registry::open(temp_family_dir("axis_raw_candidate_hit"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let composite = bias_fixture();
let real = composite.param_space().first().expect("fixture has an open param").clone();
let real_name = real.name.clone();
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
let bp_id = aura_research::content_id_of(&blueprint_json);
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let proc_id = reg.put_process(process).expect("seed process");
let wrapped_axis = format!("graph.{real_name}");
let campaign_text = format!(
concat!(
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
),
bp = bp_id,
axis = wrapped_axis,
proc = proc_id,
);
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
let hit = faults
.iter()
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == &wrapped_axis));
match hit {
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
assert_eq!(raw_candidate.as_deref(), Some(real_name.as_str()));
}
other => panic!("expected AxisNotInParamSpace for {wrapped_axis:?}, got {other:?}"),
}
}
/// #328 (negative): a rejected axis whose stripped remainder ALSO misses
/// the param space carries no `raw_candidate` — the fault never
/// speculates when stripping one leading segment lands nowhere.
#[test]
fn axis_not_in_param_space_carries_no_candidate_when_stripped_remainder_also_misses() {
use aura_engine::blueprint_to_json;
let reg = Registry::open(temp_family_dir("axis_raw_candidate_miss"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let composite = bias_fixture();
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
let bp_id = aura_research::content_id_of(&blueprint_json);
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let proc_id = reg.put_process(process).expect("seed process");
let bogus_axis = "bogus.alsobogus";
let campaign_text = format!(
concat!(
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
),
bp = bp_id,
axis = bogus_axis,
proc = proc_id,
);
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
let hit = faults
.iter()
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == bogus_axis));
match hit {
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
assert_eq!(raw_candidate, &None);
}
other => panic!("expected AxisNotInParamSpace for {bogus_axis:?}, got {other:?}"),
}
}
/// Property (#191): the identity-index sidecar is a last-line-wins cache
/// over identity id → content id — a missing file reads as empty (no
/// error, since the scan is the truth path), a later append for the same
+5
View File
@@ -31,6 +31,11 @@ pub enum FamilyKind {
Sweep,
MonteCarlo,
WalkForward,
/// Dead machinery since the #319 sugar retirement (C18): no producer
/// mints a merged cross-instrument family anymore — the campaign-run
/// record's `generalizations[]` carries the data. The variant stays for
/// store read-back of historically minted families (never retroactively
/// invalidated, C29).
CrossInstrument,
}
+111 -13
View File
@@ -513,16 +513,17 @@ pub struct Axis {
}
/// A protective-stop regime: a serializable, content-addressable mirror of the
/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is
/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive
/// variant (it runs as a composite today but is not yet campaign-reachable).
/// Externally tagged so adding `Fixed` is additive — no content-id churn on
/// stored `Vol` regimes.
/// runtime `StopRule` structural axis (C10/C20). `Fixed{distance}` (#338) makes
/// the shipped `FixedStop` composite campaign-reachable, beside the vol-stop
/// family — `distance` is the same price-unit knob `FixedStop`'s own `distance`
/// param carries. Externally tagged so each variant was additive on arrival —
/// no content-id churn on stored `Vol`/`VolTf` regimes.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime {
Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, length: i64, k: f64 },
Fixed { distance: f64 },
}
/// One component of the campaign's cost model (#234): a closed, additive
@@ -973,6 +974,14 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
faults.push(DocFault::BadRegime { index: i });
}
}
RiskRegime::Fixed { distance } => {
// Mirrors `FixedStop::new`'s own `distance > 0.0` assert
// (`aura-strategy`) — refused gracefully here instead of
// reaching that assert and panicking.
if *distance <= 0.0 || distance.is_nan() {
faults.push(DocFault::BadRegime { index: i });
}
}
}
}
for (i, c) in doc.cost.iter().enumerate() {
@@ -1102,7 +1111,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
SlotKind::Strings => "list of strings",
SlotKind::Windows => "list of { from_ms, to_ms }",
SlotKind::Ref => "one of { content_id } | { identity_id }",
SlotKind::ContentRef => "content id of a process document",
SlotKind::ContentRef => "{ content_id }: content id of a process document",
SlotKind::Axes => "map: param name -> { kind, values }",
SlotKind::EmitKinds => "list of: family_table | selection_report",
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
@@ -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"))) {
// deliberately narrower than the strategy-ref hint: validate_campaign
// refuses an identity_id process ref, so the guide must not offer it
slots.push(open("process.ref", "required, content id of a process document"));
// refuses an identity_id process ref, so the guide must not offer it.
// The tagged { content_id } shape is stated explicitly (#329) so the
// hint cannot be misread as accepting a bare id string.
slots.push(open("process.ref", "required, { content_id }: content id of a process document"));
}
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
slots.push(open("seed", "required, non-negative integer"));
}
if v.get("presentation").is_none() {
slots.push(open(
match v.get("presentation") {
None => slots.push(open(
"presentation",
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
));
)),
Some(p) => {
if p.get("persist_taps").and_then(|x| x.as_array()).is_none() {
slots.push(open(
"presentation.persist_taps",
format!(
"required, list of: {}",
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
));
}
if p.get("emit").and_then(|x| x.as_array()).is_none() {
slots.push(open(
"presentation.emit",
format!("required, list of: {}", emit_vocabulary().join(" | ")),
));
}
}
}
Ok(slots)
}
@@ -1647,6 +1675,18 @@ mod tests {
assert_eq!(back, r);
}
/// #338: the third variant's wire form follows the same externally-tagged
/// convention as `vol`/`vol_tf` — `fixed` snake_case tag, one named field
/// (the `FixedStop` composite's own `distance` param).
#[test]
fn risk_regime_round_trips_as_externally_tagged_fixed() {
let r = RiskRegime::Fixed { distance: 10.0 };
let j = serde_json::to_string(&r).unwrap();
assert_eq!(j, r#"{"fixed":{"distance":10.0}}"#);
let back: RiskRegime = serde_json::from_str(&j).unwrap();
assert_eq!(back, r);
}
#[test]
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
@@ -1705,6 +1745,26 @@ mod tests {
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
}
/// #338 (harvest audit item 11 extends the NaN branch): a non-positive OR
/// NaN fixed-stop distance must be refused as a graceful
/// `DocFault::BadRegime` at the doc tier — otherwise it reaches
/// `FixedStop::new`'s `distance > 0.0` assert and panics instead of a
/// validation error (the `validate_campaign_flags_non_positive_period_minutes_regime`
/// precedent above, `Fixed` edition).
#[test]
fn validate_campaign_flags_non_positive_fixed_distance_regime() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.risk = vec![
RiskRegime::Fixed { distance: 10.0 }, // 0: valid
RiskRegime::Fixed { distance: 0.0 }, // 1: distance <= 0
RiskRegime::Fixed { distance: f64::NAN }, // 2: NaN (unreachable via JSON; defensive)
];
let faults = validate_campaign(&doc);
assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}");
assert!(faults.contains(&DocFault::BadRegime { index: 2 }), "{faults:?}");
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
}
#[test]
fn validate_campaign_accepts_a_valid_risk_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
@@ -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
/// 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).
@@ -2374,6 +2464,14 @@ mod tests {
label.contains("content"),
"process_ref describe must still name the content-id form: {label}"
);
// #329: the label must state the TAGGED shape ({ content_id: ... }) the
// parser actually requires, not read like a bare id string — mirroring
// the `{ content_id } | { identity_id }` bracket notation std::strategy
// already uses, narrowed to the one key process.ref accepts.
assert!(
label.contains("{ content_id }"),
"process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {label}"
);
let strategy = describe_block("std::strategy").expect("strategy describable");
let strat_ref =
@@ -2430,7 +2528,7 @@ mod tests {
"presentation": { "persist_taps": [], "emit": [] } }"#;
let slots = open_slots_campaign(draft).unwrap();
assert!(slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"));
&& s.hint == "required, { content_id }: content id of a process document"));
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
@@ -2466,7 +2564,7 @@ mod tests {
// ...and the `{}` process ref reports the narrower content-id-only hint.
assert!(
slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"),
&& s.hint == "required, { content_id }: content id of a process document"),
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
);
}
+117
View File
@@ -45,6 +45,76 @@ pub fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool {
wrapped == raw || wrapped_to_raw_axis(wrapped) == raw
}
/// (a) #328: the explicit `--axis` acceptance predicate, shared by BOTH
/// intake routes (`validate_and_register_axes`'s real route and the synthetic
/// sweep-verb intake ahead of `run_blueprint_sweep`). RAW is checked FIRST and
/// independently — a legal RAW name (an open param's [`wrapped_to_raw_axis`]
/// suffix, or a bound param's own already-raw name, #203) is accepted
/// outright, so a pathological name that is both raw-legal and wrapped-exact
/// (e.g. an unwrapped param) resolves as raw, never as a translation refusal.
/// Only once that fails is a WRAPPED hit considered (an exact `param_space()`
/// name, or one leading segment stripped off `name` landing on a bound
/// param): that is a translation refusal naming the raw candidate, never a
/// silent alias. Anything matching neither namespace is [`AxisIntake::Unknown`]
/// — the caller's own unmatched-axis prose applies, unchanged.
///
/// Deliberately NOT built on [`raw_matches_wrapped`]: that predicate's own
/// equality branch also matches an EXACT wrapped name (see its doc comment),
/// so using it as the acceptance gate would silently accept the retired form
/// instead of refusing it.
#[derive(Debug, PartialEq, Eq)]
pub enum AxisIntake {
/// A legal RAW name (`aura graph introspect --params`'s own namespace,
/// #328) — accept as-is.
Raw,
/// A retired WRAPPED name; the carried string is its translated RAW
/// candidate — never applied silently, only ever surfaced in a refusal.
WrappedRetired(String),
/// Neither namespace: the caller's own unknown-axis prose applies.
Unknown,
}
/// Classify one `--axis` name against `wrapped_open` (the WRAPPED open-param
/// probe, `blueprint_axis_probe(..).param_space()`) and `raw_bound` (the
/// strategy's own `bound_param_space()` names — already RAW, #203). See
/// [`AxisIntake`] for the precedence.
pub fn classify_axis_intake(
name: &str,
wrapped_open: &[ParamSpec],
raw_bound: &HashSet<String>,
) -> AxisIntake {
let is_raw = wrapped_open.iter().any(|p| wrapped_to_raw_axis(&p.name) == name)
|| raw_bound.contains(name);
if is_raw {
return AxisIntake::Raw;
}
let is_wrapped = wrapped_open.iter().any(|p| p.name == name)
|| raw_bound.iter().any(|b| name.split_once('.').map(|(_, rest)| rest) == Some(b.as_str()));
if is_wrapped {
AxisIntake::WrappedRetired(wrapped_to_raw_axis(name).to_string())
} else {
AxisIntake::Unknown
}
}
/// Translate one RAW axis name onto the ONE wrapped `space` slot it
/// suffix-matches ([`raw_matches_wrapped`]), falling back to `raw` unchanged
/// when no wrapped slot matches — the caller's own downstream name
/// resolution (e.g. the sweep terminal's `UnknownKnob`) surfaces that case,
/// never panicked here. #328: the shared translation `blueprint_sweep_family`
/// established for its `SweepBinder::axis` call, extracted so
/// `blueprint_sweep_over`/`validate_axis_grid` (the walkforward synthetic
/// route's own binder calls, which key by the same exact wrapped
/// `param_space()` name) can translate a RAW `--axis` name too instead of
/// requiring the retired wrapped form.
pub fn wrapped_name_of(raw: &str, space: &[ParamSpec]) -> String {
space
.iter()
.find(|p| raw_matches_wrapped(raw, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| raw.to_string())
}
/// Suffix-join each raw campaign axis onto exactly one wrapped param
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
/// node segment yields raw), then require every wrapped slot to be covered.
@@ -139,6 +209,53 @@ mod tests {
ParamSpec { name: name.to_string(), kind: ScalarKind::I64 }
}
#[test]
/// #328: a legal RAW name — either an open param's raw suffix or a bound
/// param's own (already-raw) name — classifies `Raw`, accepted as-is.
fn classify_axis_intake_accepts_a_raw_open_or_bound_name() {
let wrapped_open = vec![spec("sma_signal.fast.length")];
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
assert_eq!(classify_axis_intake("fast.length", &wrapped_open, &raw_bound), AxisIntake::Raw);
assert_eq!(classify_axis_intake("bias.scale", &wrapped_open, &raw_bound), AxisIntake::Raw);
}
#[test]
/// #328: a WRAPPED name — the exact `param_space()` string, or a bound
/// param prefixed by one wrap segment — classifies `WrappedRetired`,
/// carrying its raw candidate, never accepted silently.
fn classify_axis_intake_flags_a_wrapped_name_with_its_raw_candidate() {
let wrapped_open = vec![spec("sma_signal.fast.length")];
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
assert_eq!(
classify_axis_intake("sma_signal.fast.length", &wrapped_open, &raw_bound),
AxisIntake::WrappedRetired("fast.length".to_string())
);
assert_eq!(
classify_axis_intake("sma_signal.bias.scale", &wrapped_open, &raw_bound),
AxisIntake::WrappedRetired("bias.scale".to_string())
);
}
#[test]
/// #328: a name matching neither namespace is `Unknown` — the caller's own
/// unmatched-axis prose applies, unchanged by this predicate.
fn classify_axis_intake_reports_unknown_for_neither_space() {
let wrapped_open = vec![spec("sma_signal.fast.length")];
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
assert_eq!(classify_axis_intake("nope", &wrapped_open, &raw_bound), AxisIntake::Unknown);
}
#[test]
/// #328: raw is checked FIRST — an unwrapped (no-dot) param whose raw name
/// happens to equal its own wrapped `param_space()` string (a root-level
/// knob with no node-path prefix) resolves as `Raw`, never misclassified
/// as a wrapped-exact hit needing translation.
fn classify_axis_intake_prefers_raw_when_a_name_is_both() {
let wrapped_open = vec![spec("length")]; // no dot: raw == wrapped
let raw_bound: HashSet<String> = HashSet::new();
assert_eq!(classify_axis_intake("length", &wrapped_open, &raw_bound), AxisIntake::Raw);
}
#[test]
/// The only shape treated as a direct store address is a bare 64-char
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex)
+10 -105
View File
@@ -1,8 +1,8 @@
//! Coverage reporting: the single interior-gap-month walk, shared by a
//! campaign cell's coverage annotation
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s
//! archive-wide report — two independent walk implementations before this
//! module existed (#295 dedup).
//! The interior-gap-month walk behind a campaign cell's coverage annotation
//! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura
//! data coverage`'s archive-wide report (#295 dedup: two independent walk
//! implementations before this module existed) before that verb retired in
//! favor of per-cell fault isolation (#272, #273).
/// `"YYYY-MM"` rendering of a `(year, month)` pair.
pub fn fmt_year_month((y, m): (u16, u8)) -> String {
@@ -18,11 +18,11 @@ pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) {
/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
/// is a flat list an aggregate counts directly. `months` is assumed sorted
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk
/// implementation (#295): both `DefaultMemberRunner::window_coverage`
/// (a swept cell's own window) and [`data_coverage_report`] (the full
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead
/// of maintaining independent copies.
/// ([`aura_ingest::list_m1_months`]'s own contract). `DefaultMemberRunner::
/// window_coverage` (a swept cell's own window) is the sole caller since
/// `aura data coverage`'s archive-wide report retired (#273); the walk stays
/// its own function because it was single-sourced with that verb before then
/// (#295).
pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1);
@@ -40,68 +40,6 @@ pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<S
out
}
/// A Unix-ms window that decodes (via `unix_ms_to_year_month`) to year/month
/// bounds — 0001-01 and 9999-01, exact epoch-ms for those UTC instants, not
/// an approximation — comfortably outside any realistic archive month, so an
/// unbounded, archive-wide [`interior_gap_months`] walk never ms-filters out
/// a real gap. Safely inside `chrono`'s valid calendar range, so the
/// conversion inside `interior_gap_months` never panics.
pub const FULL_ARCHIVE_WINDOW_MS: (i64, i64) = (-62_135_596_800_000, 253_370_764_800_000);
/// `aura data coverage <SYMBOL>`'s pure report body (#264): render `symbol`'s
/// coverage report from its sorted `(year, month)` file list — one `span:`
/// line framing the first/last present month, then either a `no gaps` line
/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a
/// ten-month hole is one line, not ten). `Err` exactly when `months` is
/// empty — no archive file exists for `symbol` at all (the caller's "unknown
/// symbol" refusal, stderr + exit 1). The gap detection itself is
/// [`interior_gap_months`] over the full archive span
/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into
/// contiguous ranges is presentation-only regrouping, done here since it
/// stays byte-identical to the pre-dedup report.
pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result<Vec<String>, String> {
let Some(&first) = months.first() else {
return Err(format!("no archive files found for symbol \"{symbol}\""));
};
let last = *months.last().expect("non-empty checked above");
let mut lines =
vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))];
let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS);
if gap_months.is_empty() {
lines.push(format!("{symbol} no gaps"));
} else {
for (from, to) in collapse_contiguous_year_months(&gap_months) {
lines.push(format!("{symbol} missing: {from}..{to}"));
}
}
Ok(lines)
}
/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into
/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to
/// one pair, not ten. Presentation-only regrouping: the gap WALK itself
/// already ran in `interior_gap_months`.
fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for m in months {
let ym = parse_year_month(m);
match out.last_mut() {
Some((_, to)) if next_year_month(parse_year_month(to)) == ym => {
*to = m.clone();
}
_ => out.push((m.clone(), m.clone())),
}
}
out
}
/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape
/// [`interior_gap_months`] always emits.
fn parse_year_month(s: &str) -> (u16, u8) {
let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM");
(y.parse().expect("YYYY digits"), m.parse().expect("MM digits"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -125,37 +63,4 @@ mod tests {
"both interior gap months must be named, in order"
);
}
/// An unknown symbol (no archive files at all — an empty month list)
/// refuses rather than reporting a bogus empty-span coverage (#264): the
/// caller eprintln's the message and exits 1.
#[test]
fn data_coverage_report_refuses_an_unknown_symbol() {
let err = data_coverage_report("GHOST", &[]).unwrap_err();
assert!(err.contains("GHOST"), "names the unknown symbol: {err}");
}
/// A symbol with a fully contiguous file index reports its span plus an
/// explicit `no gaps` line — never a bare span with no gap-status line at
/// all, which would leave "no gaps" indistinguishable from "gaps not yet
/// checked" (#264).
#[test]
fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() {
let months = [(2024, 1), (2024, 2), (2024, 3)];
let lines = data_coverage_report("SYMA", &months).expect("known symbol");
assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]);
}
/// The Copper failure shape itself: one interior gap collapses to a single
/// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not
/// one line per missing month (#264).
#[test]
fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() {
let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)];
let lines = data_coverage_report("GAPSYM", &months).expect("known symbol");
assert_eq!(
lines,
vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"]
);
}
}
+52 -690
View File
@@ -1,60 +1,24 @@
//! Family assembly and orchestration (#295).
//!
//! The blueprint sweep / walk-forward / Monte-Carlo family builders — pure
//! member-run recipes driven off a [`DataSource`] (the shared synthetic/real
//! data provider every family builder threads, plus the `--select` objective
//! [`Selection`] walk-forward resolves its winner under) — together with the
//! winner-selection (`select_winner`) and axis-grid validation
//! (`validate_axis_grid`) machinery they share. No `aura` binary is needed to
//! drive a family: the shell (`aura-cli`) wraps these builders with
//! persistence + stdout rendering (`run_blueprint_sweep`,
//! `run_blueprint_walkforward`, `run_blueprint_mc`), which stay in the shell,
//! along with the `--select`/`--real` argv grammar (`parse_select`,
//! `select_rule_of`) and the `topology_hash`/`content_id` naming primitive
//! (inlined here instead, mirroring `member::run_signal_r`'s own copy — the
//! CLI shell still needs its own for call sites outside any family builder).
//! The blueprint sweep / walk-forward / Monte-Carlo family builders that used
//! to live here are retired (#319 — the research-verb quintet's own
//! machinery; the campaign document path, `aura-campaign::exec`, is the
//! surviving family-execution surface, with its own independent axis/window/
//! winner-selection machinery). What remains is the shared [`DataSource`]
//! data provider — the synthetic/real price-source abstraction
//! `aura_runner::reproduce` drives its own bit-identical re-derivation
//! over — and its synthetic stream primitives (`showcase_prices`,
//! `walkforward_prices`, `walkforward_window_source`, `synthetic_walk_sources`).
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use aura_campaign::{catch_member_panic, member_fault_prose, MemberFault};
use aura_composites::StopRule;
use aura_core::{Cell, ParamSpec, Scalar, Timestamp};
use aura_engine::{
blueprint_from_json, walk_forward, window_of, BindError, Composite, FamilySelection,
RollMode, RunManifest, SyntheticSpec, VecSource, WindowBounds, WindowRoller,
};
use aura_registry::{
optimize_deflated, optimize_plateau, PlateauMode, DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
};
use aura_backtest::{
monte_carlo, McFamily, RunMetrics, RunReport, SweepFamily, SweepPoint, WalkForwardResult,
WindowRun, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS,
};
use aura_core::{Scalar, Timestamp};
use aura_engine::{window_of, SyntheticSpec, VecSource};
use aura_backtest::{WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS};
use crate::binding::ResolvedBinding;
use crate::member::{
blueprint_axis_probe, blueprint_axis_probe_reopened, no_real_data, override_paths,
pip_or_refuse, probe_window, reopen_all, run_blueprint_member, wrapped_bound_overrides_of,
SYNTHETIC_PIP_SIZE,
};
use crate::member::{no_real_data, pip_or_refuse, probe_window, SYNTHETIC_PIP_SIZE};
use crate::project::Env;
use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH};
/// The demo default stop regime, shared by every family builder in this module.
const DEFAULT_STOP: StopRule = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
/// The in-sample winner-selection objective for walk-forward's per-window IS
/// refit (cycle 0077). `Argmax` is the bare-best pick deflated for trials
/// (#144, the default); `Plateau` argmaxes the neighbourhood-smoothed surface
/// instead (opt-in via `--select`). The CLI shell's `--select` grammar
/// (`parse_select`) and campaign-rule mapping (`select_rule_of`) build this
/// value; they stay in the shell since only this type crosses the boundary.
#[derive(Clone, Copy)]
pub enum Selection {
Argmax,
Plateau(PlateauMode),
}
use crate::RunnerError;
/// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising
/// again) used as `DataSource::Synthetic`'s full-window stream for the built-in
@@ -103,19 +67,18 @@ pub enum DataChoice {
Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
}
/// The source provider threaded into the family builders: synthetic built-in
/// streams, or real M1 close bars from the data-server archive. Replaces the
/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come
/// from one place (Fork B/D/F).
/// The source provider `aura_runner::reproduce` threads through its
/// re-derivation: synthetic built-in streams, or real M1 close bars from the
/// data-server archive. Replaces the hardcoded `VecSource` so a member's
/// source, pip, window, and roller sizes come from one place (Fork B/D/F).
///
/// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed
/// series: the full-window consumers (`full_window` / `run_sources`, used by
/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers
/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar
/// series: the full-window consumers (`full_window` / `run_sources`) draw the
/// 18-bar `showcase_prices()`, while the windowed consumers
/// (`windowed_sources` / `wf_window_sizes`) draw the 60-bar
/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two
/// faces never reach one consumer (a family is either full-window or windowed), so
/// the split is invisible per call site but real across the type — read both
/// family builders to see it whole.
/// faces never reach one consumer (full-window vs. windowed reproduction), so
/// the split is invisible per call site but real across the type.
pub enum DataSource {
Synthetic,
Real {
@@ -128,19 +91,20 @@ pub enum DataSource {
}
impl DataSource {
/// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol
/// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G),
/// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses.
pub fn from_choice(choice: DataChoice, env: &Env) -> DataSource {
/// Build a provider from a parsed choice, or refuse (#297: returned, not
/// printed) on a symbol with no recorded geometry / absent data — both
/// BEFORE any member runs (Fork C/G), via the same `pip_or_refuse` /
/// `no_real_data` helpers `open_real_source` uses.
pub fn from_choice(choice: DataChoice, env: &Env) -> Result<DataSource, RunnerError> {
match choice {
DataChoice::Synthetic => DataSource::Synthetic,
DataChoice::Synthetic => Ok(DataSource::Synthetic),
DataChoice::Real { symbol, from_ms, to_ms } => {
let server = Arc::new(data_server::DataServer::new(env.data_path()));
let pip = pip_or_refuse(&server, &symbol, env);
let pip = pip_or_refuse(&server, &symbol, env)?;
if !server.has_symbol(&symbol) {
no_real_data(&symbol, env);
return Err(no_real_data(&symbol, env));
}
DataSource::Real { server, symbol, from_ms, to_ms, pip }
Ok(DataSource::Real { server, symbol, from_ms, to_ms, pip })
}
}
}
@@ -155,11 +119,11 @@ impl DataSource {
/// The full run window, probed once. Synthetic: the showcase span. Real:
/// `probe_window` drains a separate single-pass probe source for first/last ts
/// (the same helper `open_real_source` uses for its manifest window).
pub fn full_window(&self, env: &Env) -> (Timestamp, Timestamp) {
pub fn full_window(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> {
match self {
DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
window_of(&s).expect("non-empty showcase stream")
Ok(window_of(&s).expect("non-empty showcase stream"))
}
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms, env)
@@ -173,11 +137,11 @@ impl DataSource {
/// longer built-in stream (byte-unchanged from the retired pre-`DataSource`
/// `walkforward_family`, which derived its span the same way). Real: the same
/// probed `--from..--to` window as `full_window`.
pub fn wf_full_span(&self, env: &Env) -> (Timestamp, Timestamp) {
pub fn wf_full_span(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> {
match self {
DataSource::Synthetic => {
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(walkforward_prices()))];
window_of(&s).expect("non-empty walkforward stream")
Ok(window_of(&s).expect("non-empty walkforward stream"))
}
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
probe_window(server, symbol, *from_ms, *to_ms, env)
@@ -188,12 +152,14 @@ impl DataSource {
/// A fresh full-window source set per member (single-pass): the synthetic
/// showcase close stream, or one real source per resolved binding column
/// in canonical order (callers guard the synthetic arm to `{close}`).
pub fn run_sources(&self, env: &Env, fields: &[aura_ingest::M1Field]) -> Vec<Box<dyn aura_engine::Source>> {
pub fn run_sources(
&self, env: &Env, fields: &[aura_ingest::M1Field],
) -> Result<Vec<Box<dyn aura_engine::Source>>, RunnerError> {
match self {
DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))],
DataSource::Synthetic => Ok(vec![Box::new(VecSource::new(showcase_prices()))]),
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields)
.unwrap_or_else(|| no_real_data(symbol, env))
.ok_or_else(|| no_real_data(symbol, env))
}
}
}
@@ -203,12 +169,12 @@ impl DataSource {
/// binding column over the ns-native window.
pub fn windowed_sources(
&self, from: Timestamp, to: Timestamp, env: &Env, fields: &[aura_ingest::M1Field],
) -> Vec<Box<dyn aura_engine::Source>> {
) -> Result<Vec<Box<dyn aura_engine::Source>>, RunnerError> {
match self {
DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))],
DataSource::Synthetic => Ok(vec![Box::new(walkforward_window_source(from, to))]),
DataSource::Real { server, symbol, .. } => {
aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields)
.unwrap_or_else(|| no_real_data(symbol, env))
.ok_or_else(|| no_real_data(symbol, env))
}
}
}
@@ -223,618 +189,14 @@ impl DataSource {
}
}
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
/// originate on these paths and the instrument context is genuinely inert.
/// Named once so every such call site states its intent by reference instead
/// of repeating the justifying comment.
const NO_INSTRUMENT_CONTEXT: &str = "";
/// The winner-selection objective for walk-forward's per-window IS refit — the
/// deflation-aware SQN variant (#144 default). Named once so `select_winner`
/// and `blueprint_walkforward_family` (the only two family-builder-side call
/// sites) cannot drift apart on the token; the CLI shell's campaign-sugar
/// bridge keeps its own copy of this token (main.rs `WINNER_SELECTION_METRIC`)
/// since it is not itself a family builder.
const WINNER_SELECTION_METRIC: &str = "sqn_normalized";
/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward
/// winner selection. Recorded on each winner's manifest (so `overfit_probability`
/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The
/// resample count and block length are the shared `aura_registry::DEFLATION_*`.
const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
/// Renders a [`BindError`] as one-line prose in `member::override_paths`' sibling
/// register above (#247) — never the raw Rust `Debug` struct name
/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep
/// terminal actually raises past `override_paths`' own pre-flight (which
/// already rejects an unresolvable axis name as prose before either call
/// site below ever reaches the terminal). `UnknownKnob` already carries a
/// fully-prosed message string (wrapped from `override_paths`' own
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
/// the walkforward path's rejection reaches stderr as bare prose too.
pub fn render_bind_error(e: &BindError) -> String {
match e {
BindError::MissingKnob(name) => format!(
"axis {name}: an open param with no axis and no bound default — \
bind it with `--axis {name}=<value>` see `aura sweep <bp> --list-axes`"
),
BindError::KindMismatch { knob, expected, got } => format!(
"axis {knob}: expected {expected:?}, supplied {got:?} — \
see `aura sweep <bp> --list-axes`"
),
BindError::UnknownKnob(msg) => msg.clone(),
BindError::DuplicateBinding(name) => format!(
"axis {name}: bound twice — each param takes exactly one axis — \
see `aura sweep <bp> --list-axes`"
),
BindError::EmptyAxis(name) => format!(
"axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`"
),
BindError::EmptyRange(name) => format!(
"axis {name}: the named range is empty — give it at least one value"
),
// A blueprint defect, not an axis usage error: the point passed name
// resolution but its bootstrap failed. Prose frame with the compile
// detail explicitly labelled as internal — per-variant prose for
// CompileError belongs to the graph-build surface, not this boundary.
BindError::Compile(e) => format!(
"the resolved axis point failed to bootstrap — the blueprint is \
defective at this point, re-validate it with `aura graph build` \
(internal detail: {e:?})"
),
}
}
/// Resolve the in-sample winner under the chosen selection objective. `Argmax`
/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid
/// surface — it needs the grid lattice, so a sweep with no lattice (a future random
/// walk-forward producer) is refused rather than silently argmaxed. The metric is
/// always known at the call sites, so a metric error is unreachable (`expect`); the
/// only fallible outcome is the plateau-without-lattice refusal, returned as
/// `Err(message)` for the caller to print and exit 2.
pub fn select_winner(
family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>,
) -> Result<(SweepPoint, FamilySelection), String> {
match select {
Selection::Argmax => Ok(optimize_deflated(
family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED,
).expect("walk-forward metrics are known")),
Selection::Plateau(mode) => match lattice {
Some(lens) => Ok(optimize_plateau(family, lens, metric, mode)
.expect("walk-forward metrics are known")),
None => Err(
"--select plateau requires a grid sweep; a random sweep has no parameter lattice"
.to_string(),
),
},
}
}
/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal
/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all
/// run BEFORE this closure is invoked per grid point, so its body never influences the
/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to
/// satisfy the terminal, at O(1) cost per point instead of a full member run. Reused
/// (#278) as the discarded slot value for a member run [`catch_member_panic`]
/// contains: the caller always resolves the captured fault and exits before this
/// value is ever rendered or persisted, so its content is equally irrelevant there.
fn axis_grid_probe_report() -> RunReport {
RunReport {
manifest: RunManifest {
commit: String::new(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "wf-axis-preflight-placeholder".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
}
}
/// The LOWEST enumeration index's captured member-panic message, found by
/// walking `family.points` — guaranteed by `assemble_sweep`'s own
/// `collect()` to be in grid-enumeration order regardless of which worker
/// finished first (C1) — for the first point whose params match a captured
/// fault. Thread-order-independent by construction (mirrors the lowest-
/// index-fault convention `aura-campaign::exec::run_members` establishes for
/// the identical #272 seam), without needing a separate numeric-index
/// side-channel: the returned family already carries the points in order.
fn lowest_point_fault(family: &SweepFamily, faults: Mutex<Vec<(Vec<Cell>, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.points
.iter()
.find_map(|pt| captured.iter().find(|(p, _)| *p == pt.params).map(|(_, m)| m.clone()))
}
/// The LOWEST seed's captured member-panic message (#278), found by walking
/// `family.draws` — `monte_carlo`'s own contract guarantees seed-**input**
/// order regardless of thread completion (C1) — for the first draw whose
/// seed matches a captured fault. Thread-order-independent by construction,
/// mirroring [`lowest_point_fault`]'s and `lowest_window_fault`'s identical
/// convention one level over (per-point sweep / per-window walk-forward).
fn lowest_seed_fault(family: &McFamily, faults: Mutex<Vec<(u64, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
family
.draws
.iter()
.find_map(|d| captured.iter().find(|(s, _)| *s == d.seed).map(|(_, m)| m.clone()))
}
/// Render + exit(3) on a contained member panic (#278: the deliberate
/// failed-cells exit, C14, never the raw 101 an uncontained panic would
/// yield) — reuses `aura_campaign::member_fault_prose`'s established
/// "a member panicked: …" wording rather than re-wording it, and the
/// `aura: warning: ` class marker (#278 decision) the CLI's own `diag`
/// macro would emit, hand-written here since that macro is presentation-
/// crate-private (#295 boundary) and this fires from `aura-runner`.
fn exit_on_member_panic(msg: &str) -> ! {
eprintln!("aura: warning: {}", member_fault_prose(&MemberFault::Panic(msg.to_string())));
std::process::exit(3);
}
/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any
/// member (#253). Reuses the SAME strict, erroring axis-name check
/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two
/// paths cannot drift to differently-worded rejections) to derive the #246 override
/// set, then drives the sweep terminal's own `resolve_axes`/arity/kind checks
/// (`SweepBinder::sweep_with_lattice`) with [`axis_grid_probe_report`] standing in for
/// the run closure — no data access, no sim engine tick. Axis resolution is
/// window-agnostic, so the caller derives or passes no window at all.
fn validate_axis_grid(
doc: &str, axes: &[(String, Vec<Scalar>)], raw_space: &[ParamSpec], probe_signal: &Composite,
env: &Env,
) -> Result<(), BindError> {
let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ())
}
/// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it
/// keeps the shape of the retired `r_sma_sweep_family` demo builder (#159), with three
/// deviations. (1) The signal source is
/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is
/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3)
/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs):
/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a
/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message
/// string — a named error, never a panic. An axis naming a bound param re-opens it
/// (#246: bound value = default); an axis matching neither space is refused by
/// `override_paths` before any run. Every member manifest carries the shared
/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired
/// mirror's default (no-trace) arm.
pub fn blueprint_sweep_family(
doc: &str,
axes: &[(String, Vec<Scalar>)],
data: &DataSource,
env: &Env,
) -> Result<SweepFamily, String> {
// Identity + binding read the AUTHORED doc, raw (no override re-open):
// topology and the resolved role plan are properties of the document, not
// of any one sweep's axis choices.
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at
// `aura_research`.
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// Strict binding resolution (name defaults — the verb path carries no
// campaign overrides): the family's open plan and wrap plan in one value.
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
}
let pip = data.pip_size();
let window = data.full_window(env);
// The un-reopened wrapped OPEN space (#246): derives the override set (which
// named axes re-open a bound param) before the real, reopened probe is built —
// probe and per-member reloads must re-open identically so points resolve
// against one space. A name matching neither space is the error here.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
// The doc is parse-validated at the dispatch boundary (with file-path context),
// so every reload here is infallible: the builder has a single error contract —
// the `BindError` returned by the sweep terminal — and no hidden process exit.
// Member reloads re-open the SAME override set derived above, so every member
// resolves its axes against the identical (reopened) param space the probe used.
let reload = |d: &str| {
reopen_all(
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
&overrides,
)
};
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
// sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked.
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
// otherwise unwind straight through this sweep to an uncaught exit 101 —
// contained the same way the real/campaign path contains it, via
// `catch_member_panic` + a per-point capture, resolved to the lowest
// enumeration index's message after the sweep joins (thread-order-
// independent, C1).
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let family = binder
.sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs.
match catch_member_panic(|| {
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})
// render the sweep terminal's BindError to prose (#247), the fn's String error
// contract — never the raw Debug struct.
.map_err(|e| render_bind_error(&e))?;
if let Some(msg) = lowest_point_fault(&family, faults) {
exit_on_member_panic(&msg);
}
Ok(family)
}
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
/// `[from,to]` — the windowed, lattice-carrying twin of
/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select
/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the
/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis
/// naming a bound param re-opens it (#246: bound value = default, same
/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is
/// its walk-forward in-sample twin); an axis matching neither space is refused
/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired
/// "fully bound; nothing to sweep" refusal) before any member runs.
///
/// The returned `Option<String>` (#278) is this fn's OWN resolved member-panic
/// capture: this fn's nested sweep runs inside `blueprint_walkforward_family`'s
/// per-window parallel closure, so a contained member panic here must NOT
/// `eprintln!`+`exit` on the spot (that would race across windows, the exact
/// `#177` duplicated-rejection class the dispatch-boundary axis pre-flight
/// above already avoids) — captured internally, resolved to the lowest-index
/// message via [`lowest_point_fault`] before returning, for the caller to push
/// onto its own per-window fault list only after every window has joined.
pub fn blueprint_sweep_over(
doc: &str, axes: &[(String, Vec<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
env: &Env, binding: &ResolvedBinding,
) -> Result<(SweepFamily, Vec<usize>, Option<String>), BindError> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let pip = data.pip_size();
let probe_signal = reload(doc);
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set (which named axes re-open a bound param) before
// the reopened probe is built — probe and per-member reloads must re-open
// identically so points resolve against one space, exactly like
// `blueprint_sweep_family`.
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
}
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let (family, lattice) = binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty in-sample window");
match catch_member_panic(|| {
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})?;
let fault = lowest_point_fault(&family, faults);
Ok((family, lattice, fault))
}
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
/// blueprint. The reduce-mode member
/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching
/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides`
/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and
/// resolved `space`/`params` against — the OOS reload must re-open it too, or a
/// bound-param axis's winner point (kind-checked against the REOPENED space) fails
/// `bootstrap_with_cells`'s arity check against this still-closed reload.
#[allow(clippy::too_many_arguments)]
pub fn run_oos_blueprint(
doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp,
topo: &str, data: &DataSource, env: &Env, binding: &ResolvedBinding,
overrides: &[String],
) -> (Vec<(Timestamp, f64)>, RunReport) {
let reload = reopen_all(
blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
overrides,
);
let pip = data.pip_size();
let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty out-of-sample window");
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT);
(Vec::new(), report)
}
/// A discarded placeholder [`WindowRun`] for a window whose IS refit or OOS
/// run was contained after a member panic (#278): `chosen_params` matches
/// `space`'s arity with inert zero cells (the `assemble_walk_forward` arity
/// invariant), `oos_equity` is empty (an empty segment leaves the stitched
/// curve unbroken, same convention as a genuinely equity-less window), and
/// the report reuses [`axis_grid_probe_report`]'s zero-cost placeholder.
/// Never rendered: the caller always resolves the captured window fault and
/// exits before touching a faulted window's run.
fn placeholder_window_run(space: &[ParamSpec]) -> WindowRun {
WindowRun {
chosen_params: space.iter().map(|_| Cell::from_i64(0)).collect(),
oos_equity: Vec::new(),
oos_report: axis_grid_probe_report(),
}
}
/// The LOWEST roll-order window's captured member-panic message (#278),
/// found by walking `result.windows` — guaranteed by `assemble_walk_forward`
/// to be in roll order regardless of thread completion (C1) — for the first
/// window whose bounds match a captured fault. Thread-order-independent by
/// construction, mirroring [`lowest_point_fault`]'s identical convention one
/// level down (per-point within one window's IS sweep) and
/// `aura-campaign::exec`'s own lowest-index walk-forward fault attribution.
fn lowest_window_fault(result: &WalkForwardResult, faults: Mutex<Vec<(WindowBounds, String)>>) -> Option<String> {
let captured = faults.into_inner().expect("fault capture lock");
result
.windows
.iter()
.find_map(|w| captured.iter().find(|(b, _)| *b == w.bounds).map(|(_, m)| m.clone()))
}
/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the
/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the
/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`;
/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors
/// (a bad `--axis`) `exit(2)` with the sweep terminal's message.
pub fn blueprint_walkforward_family(
doc: &str, axes: &[(String, Vec<Scalar>)], data: &DataSource, select: Selection,
env: &Env,
) -> WalkForwardResult {
let span = data.wf_full_span(env);
let (is_len, oos_len, step) = data.wf_window_sizes();
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
Ok(r) => r,
Err(e) => {
eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}");
std::process::exit(2);
}
};
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
// load: derives the override set ONCE for the whole family — every per-window
// sweep AND the OOS reload re-open the SAME set, mirroring
// `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant
// (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis
// matching neither space is simply not an override here — the strict check + its
// established error message stay single-sourced in `blueprint_sweep_over`'s own
// pre-flight call below, so this derivation cannot double-validate with a
// differently-worded rejection.
let axis_names: Vec<String> = axes.iter().map(|(n, _)| n.clone()).collect();
let raw_space = blueprint_axis_probe(doc, env).param_space();
let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal);
let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space();
// Strict binding resolution, once per family; refusal is the established
// `aura: ` + exit-1 register (the roller's usage refusals stay exit 2).
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(probe_signal.name(), &binding));
std::process::exit(1);
}
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
// (which resolves its axes a single time before any member runs). `walk_forward` fans
// the per-window closure out across the windows in parallel, so a `BindError` raised
// *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one
// exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without
// running a single member — no IS window (or a second roller) is needed here at all.
if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) {
eprintln!("aura: {}", render_bind_error(&e));
std::process::exit(2);
}
// #278: `blueprint_sweep_over`'s inner sweep and `run_oos_blueprint`'s single
// member run both drive `run_blueprint_member` bare, so either could
// otherwise unwind an uncaught member-compile panic straight through this
// window closure. Both are contained (`blueprint_sweep_over` resolves its
// own capture and returns it; `run_oos_blueprint` via `catch_member_panic`
// directly) and captured PER WINDOW here rather than printed on the spot —
// this closure runs across windows in parallel (`walk_forward`), so an
// inline `eprintln!`+`exit` would race the same way the axis pre-flight
// above was written to avoid (#177). The lowest roll-order window's fault
// is resolved once, after every window has joined.
let window_faults: Mutex<Vec<(WindowBounds, String)>> = Mutex::new(Vec::new());
let result = walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice, is_fault) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
.expect("axes validated in the dispatch-boundary pre-flight");
if let Some(msg) = is_fault {
window_faults.lock().expect("fault capture lock").push((w, msg));
return placeholder_window_run(&space);
}
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
};
match catch_member_panic(|| {
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides)
}) {
Ok((oos_equity, mut oos_report)) => {
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
}
Err(msg) => {
window_faults.lock().expect("fault capture lock").push((w, msg));
placeholder_window_run(&space)
}
}
});
if let Some(msg) = lowest_window_fault(&result, window_faults) {
exit_on_member_panic(&msg);
}
result
}
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s
/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the
/// `aura mc <blueprint.json>` persist path AND the reproduce MonteCarlo branch, so the
/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded
/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades.
/// A fresh seeded synthetic price walk for one Monte-Carlo draw (a distinct
/// realization per seed). A FIXED `SyntheticSpec` shared by the reproduce
/// MonteCarlo branch (`aura_runner::reproduce`, its only remaining
/// production caller — #319 retired the family builder that used to mint
/// such a family), so the seed->walk reconstruction is bit-exact (C1).
/// Length 60 comfortably warms the loaded r-sma graph (SMA slow=4 + the
/// len-3 vol stop) so draws produce differing trades.
pub fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> {
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
vec![Box::new(spec.source(seed))]
}
/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed
/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The
/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free
/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling
/// [`blueprint_sweep_family`]: the IO wrapper `run_blueprint_mc` renders it to stderr +
/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw
/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce
/// re-runs), so reproduction is bit-identical (C1); every member carries the shared
/// `topology_hash`.
pub fn blueprint_mc_family(
doc: &str, n_seeds: u64, data: &DataSource, env: &Env,
) -> Result<McFamily, String> {
let reload = |d: &str| {
blueprint_from_json(d, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
};
let probe_signal = reload(doc);
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
);
// Strict binding resolution (name defaults — mc's synthetic family binds
// no campaign overrides); the exit-free Err contract of this builder.
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
if !binding.close_only() {
// MC draws ALWAYS run the seeded synthetic close walk (real-data mc
// routes through the campaign sugar and never reaches this builder).
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
}
let pip = data.pip_size();
// probe the wrapped param_space (the same probe the sweep resolves against);
// MC needs it empty. `blueprint_axis_probe` is the single source of that wrap.
let space = blueprint_axis_probe(doc, env).param_space();
if !space.is_empty() {
// Exit-free like blueprint_sweep_family: the builder's single error contract is this
// returned message (no hidden process exit), so the rejection is unit-testable; the IO
// wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary.
return Err(format!(
"mc requires a closed blueprint (no free parameters); {} free knob(s) — \
bind them or use `aura sweep --axis`",
space.len()
));
}
// Closed blueprint -> an empty base point (as `aura run <blueprint.json>`); the MC
// draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws
// to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1),
// deterministic in seed-input order. Each draw
// re-runs the shared reduce-mode member path over its own seeded synthetic walk.
let seeds: Vec<u64> = (1..=n_seeds).collect();
let base_point: Vec<Scalar> = Vec::new();
// #278: `run_blueprint_member` ran bare here, the one family builder without
// the #272 fault boundary its sweep/walk-forward siblings gained in 51096a3
// — a member-compile panic (e.g. an `Sma::new` length assert) would otherwise
// unwind straight through `monte_carlo`'s `run_indexed` to an uncaught exit
// 101. Contained the same way: `catch_member_panic` + a per-seed capture,
// resolved to the LOWEST seed's message after the join (thread-order-
// independent, C1) via `lowest_seed_fault`.
let faults: Mutex<Vec<(u64, String)>> = Mutex::new(Vec::new());
let family = monte_carlo(&base_point, &seeds, |seed, _base| {
let sources = synthetic_walk_sources(seed);
let window = window_of(&sources).expect("non-empty synthetic walk");
match catch_member_panic(|| {
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((seed, msg));
axis_grid_probe_report()
}
}
});
// The captured fault must win BEFORE the vacuous-mc guard below: a faulted
// draw's placeholder report is metrics-identical across every faulted seed,
// so a run with >= 2 faulted seeds (or one faulted + one real draw sharing
// its placeholder's zero metrics) could otherwise trip the vacuous refusal
// instead of surfacing the real member fault.
if let Some(msg) = lowest_seed_fault(&family, faults) {
exit_on_member_panic(&msg);
}
// Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's
// metrics are bit-identical to the first, no seed reached a distinguishable realization —
// the strategy never warmed over the fixed synthetic walk (e.g. a lookback as deep as the
// walk is long), so the "distribution" is a single point masquerading as a family: a wrong
// result with no error. Compare `metrics`, not the whole `RunReport` — the manifest's
// `seed` differs per draw by construction, so a whole-report compare could never detect the
// collapse; the metrics are the realization the seed is meant to move. A single-draw MC
// (n == 1) is trivially "all identical" and is NOT this cross-seed condition, so it passes.
if family.draws.len() >= 2
&& family
.draws
.iter()
.all(|d| d.report.metrics == family.draws[0].report.metrics)
{
return Err(
"mc is vacuous: every seed produced an identical result — the strategy never warmed \
over the synthetic walk, so no seed reached a distinguishable realization; use a \
shallower-lookback blueprint or a longer walk"
.to_string(),
);
}
Ok(family)
}
+4 -4
View File
@@ -31,10 +31,10 @@ pub use tap_plan::{
pub use tap_recorder::TapRecorder;
/// A refusal a library function reports instead of exiting the process
/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place
/// that maps it back to the identical stderr bytes + exit code, so the
/// binary's observable behaviour stays byte-unchanged (#295, spec §Error
/// handling).
/// itself. The shell (`aura-cli`'s `exit_on_runner_error`, shared by the
/// dispatch arms) maps it back to the stderr bytes + exit code; the class
/// follows the C14 partition — argv-named content 2, environment/data/IO 1
/// (#295/#297).
#[derive(Debug)]
pub struct RunnerError {
pub exit_code: i32,
+58 -27
View File
@@ -10,11 +10,12 @@
use std::collections::BTreeMap;
use aura_core::{Scalar, Timestamp};
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
use aura_engine::{CompileError, Composite, Harness, MeasurementReport, RunManifest};
use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData};
use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, RunData};
use crate::project::Env;
use crate::tap_plan::{bind_tap_plan, TapPlan};
use crate::RunnerError;
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit`
@@ -51,6 +52,31 @@ pub fn measurement_manifest(
}
}
/// #339 item 3 (a #317 follow-up): `CompileError::UnboundRootRole { role }`
/// carries a flat root-role index — meaningless to a caller who authored a
/// NAMED open role (`{"op":"input","role":"price"}`). `role_names` is
/// `signal.input_roles()`'s own names, read before `compile_with_params`
/// consumes `signal` (mirrors `member::compile_error_prose`'s pre-consumption
/// `names` capture for its `ParamKindMismatch` prose). Every other
/// `CompileError` variant keeps the existing Debug fallback deliberately —
/// they ARE reachable on this direct-compile path (a hand-authored
/// measurement envelope with an out-of-range declared-tap wire reaches
/// `TapWireOutOfRange` here exactly as `run_signal_r`'s own compile call
/// does, see `run_refuses_unrunnable_blueprint.rs`), so the fallback stays
/// total rather than partial; only `UnboundRootRole` gets dedicated prose
/// above.
fn compile_error_prose(e: &CompileError, role_names: &[String]) -> String {
let CompileError::UnboundRootRole { role } = e else {
return format!("this blueprint does not compile to a runnable harness: {e:?}");
};
let name = role_names.get(*role).map(String::as_str).unwrap_or("<unknown>");
format!(
"this blueprint does not compile to a runnable harness: root role \"{name}\" is \
unbound it is declared open (an `input` role) but there is no enclosing graph \
to wire it when run standalone"
)
}
/// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the
/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27).
/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this
@@ -60,7 +86,7 @@ pub fn measurement_manifest(
pub fn run_measurement(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan,
) -> MeasurementReport {
) -> Result<(MeasurementReport, Vec<String>), RunnerError> {
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
// `topology_hash` helper is the same primitive, kept single-sourced at
@@ -69,31 +95,34 @@ pub fn run_measurement(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed
let run_name = signal.name().to_string();
// C14 class 2: fault in argv-named content (#297)
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
.map_err(|m| RunnerError { exit_code: 2, message: m })?;
// C14 class 2: fault in argv-named content (#297)
if matches!(data, RunData::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding));
std::process::exit(1);
return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) });
}
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
let defaults = wrapped_bound_defaults(&signal);
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
// #339 item 3: `signal`'s own root-role names, captured before
// `compile_with_params` consumes it below — the bare-tap path compiles
// the signal DIRECTLY (unlike the bias/strategy arm's `wrap_r` nesting),
// so an unbound open root role surfaces here as `CompileError::
// UnboundRootRole { role }`, a flat index with no name attached at the
// engine boundary. `role_names` lets `compile_error_prose` resolve it.
let role_names: Vec<String> = signal.input_roles().iter().map(|r| r.name.clone()).collect();
let defaults = raw_bound_defaults(&signal);
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding)?;
// Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks.
let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| {
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
std::process::exit(1);
});
// C14 class 2: fault in argv-named content (#297)
let mut flat = signal.compile_with_params(params)
.map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &role_names) })?;
// 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);
});
// 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 mut h = Harness::bootstrap(flat).expect("valid measurement harness");
h.run_bound(key_supply(&binding, sources))
@@ -108,11 +137,10 @@ pub fn run_measurement(
// Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
let tap_names: Vec<String> = bound.declared_names().to_vec();
bound.finish(&manifest).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
MeasurementReport { manifest, taps: tap_names }
let skipped = bound.skipped().to_vec();
bound.finish(&manifest)
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
Ok((MeasurementReport { manifest, taps: tap_names }, skipped))
}
#[cfg(test)]
@@ -165,7 +193,9 @@ mod tests {
let (env, root) = temp_project_env("fold");
let mut plan = TapPlan::empty();
plan.subscribe("fast_tap", TapSubscription::named("mean"));
let report = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan);
let (report, _skipped) =
run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan)
.expect("measurement run succeeds");
assert_eq!(report.taps, vec!["fast_tap".to_string()]);
let text = std::fs::read_to_string(
@@ -192,8 +222,9 @@ mod tests {
let (env, root) = temp_project_env("tapfree");
let doc = include_str!("../../aura-cli/examples/r_sma.json");
let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads");
let report =
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
let (report, _skipped) =
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all())
.expect("measurement run succeeds");
assert!(report.taps.is_empty(), "no declared taps");
assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry");
}
+409 -155
View File
@@ -15,8 +15,8 @@ use std::sync::{mpsc, Arc, LazyLock};
use aura_composites::{cost_graph, risk_executor, StopRule};
use aura_core::{zip_params, Cell, Firing, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
use aura_engine::{
blueprint_from_json, window_of, BlueprintNode, Composite, GraphBuilder, Harness, RunManifest,
VecSource,
blueprint_from_json, window_of, BlueprintNode, CompileError, Composite, GraphBuilder, Harness,
RunManifest, VecSource,
};
use aura_backtest::{
summarize, summarize_r, RunMetrics, RunReport, SimBroker, PM_FIELD_NAMES, PM_RECORD_KINDS,
@@ -24,10 +24,13 @@ use aura_backtest::{
use aura_std::{GatedRecorder, LinComb, Recorder, RollingMax, RollingMin, SeriesReducer, Sub};
use aura_strategy::{cost_port, GEOMETRY_WIDTH};
use aura_campaign::MemberFault;
use crate::binding::ResolvedBinding;
use crate::project::Env;
use crate::tap_plan::{bind_tap_plan, TapPlan};
use crate::translate::{R_SMA_STOP_LENGTH, R_SMA_STOP_K};
use crate::RunnerError;
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`) — `sim_optimal_manifest`'s `RunManifest.commit`
@@ -74,7 +77,7 @@ pub fn sim_optimal_manifest(
defaults: Vec::new(),
window,
seed,
broker: format!("sim-optimal(pip_size={pip_size})"),
broker: sim_optimal_broker_label(pip_size),
selection: None,
instrument: None,
topology_hash: None,
@@ -82,28 +85,34 @@ pub fn sim_optimal_manifest(
}
}
/// The wrap-prefixed BOUND-param defaults of `signal` (#249): every
/// `bound_param_space()` entry as a `(<signal.name()>.<param>, value)` pair, in
/// `bound_param_space()` order — the `RunManifest.defaults` field. Must be read
/// off `signal` BEFORE it is consumed by `wrap_r`/reopening: an axis-reopened
/// bound param has already left `bound_param_space()` by construction
/// (`Composite::reopen` forgets the bound value), so a caller that reopens
/// overrides before calling this naturally excludes them — no separate filter
/// needed. Same prefixing rule as `wrapped_bound_names`, kept separate because
/// that helper discards the value this one needs.
pub fn wrapped_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> {
let prefix = format!("{}.", signal.name());
signal
.bound_param_space()
.into_iter()
.map(|b| (format!("{prefix}{}", b.name), b.value))
.collect()
/// The RAW-namespace BOUND-param defaults of `signal` (#249/#328): every
/// `bound_param_space()` entry as a `(<param>, value)` pair, already RAW
/// (`bound_param_space()`'s own path-qualified name, #203 — no wrap-prefix
/// concatenation), in `bound_param_space()` order — the `RunManifest.defaults`
/// field. Must be read off `signal` BEFORE it is consumed by
/// `wrap_r`/reopening: an axis-reopened bound param has already left
/// `bound_param_space()` by construction (`Composite::reopen` forgets the
/// bound value), so a caller that reopens overrides before calling this
/// naturally excludes them — no separate filter needed. Contrast
/// `wrapped_bound_names`, which DOES wrap-prefix (it matches against the
/// WRAPPED open `param_space()`, a different coordinate system than this
/// manifest-recording helper needs).
pub fn raw_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> {
signal.bound_param_space().into_iter().map(|b| (b.name, b.value)).collect()
}
/// The plain sim-optimal broker label (no RiskExecutor branch): shared by
/// `sim_optimal_manifest` and (#299) `reproduce_family_in`'s forward-built
/// guard comparison, so the two cannot drift.
pub(crate) fn sim_optimal_broker_label(pip_size: f64) -> String {
format!("sim-optimal(pip_size={pip_size})")
}
/// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor
/// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report
/// it (#132). Shared by the single run and the sweep so the two cannot drift.
fn r_sma_broker_label(pip_size: f64) -> String {
/// it (#132). Shared by the single run and the sweep (#299: and `reproduce_family_in`'s
/// forward-built guard comparison) so they cannot drift.
pub(crate) fn r_sma_broker_label(pip_size: f64) -> String {
format!("sim-optimal+risk-executor(pip_size={pip_size})")
}
@@ -121,46 +130,51 @@ pub fn r_sma_prices() -> Vec<(Timestamp, Scalar)> {
.collect()
}
/// No-local-data refusal — stderr + exit(1).
pub fn no_real_data(symbol: &str, env: &Env) -> ! {
eprintln!("aura: no local data for symbol '{symbol}' at {}", env.data_path());
std::process::exit(1)
/// No-local-data refusal (#297: returned, not printed — the CLI/library
/// boundary prints `aura: {message}` and exits with `exit_code`).
pub fn no_real_data(symbol: &str, env: &Env) -> RunnerError {
RunnerError {
exit_code: 1,
message: format!("no local data for symbol '{symbol}' at {}", env.data_path()),
}
}
/// Empty-in-window refusal — stderr + exit(1). Distinct from `no_real_data`:
/// used only inside `probe_window`, whose callers have already proven the
/// symbol present via `has_symbol` — an empty probe result there is a fact
/// about the requested `--from`/`--to` window, not about symbol absence, so
/// it must not reuse the symbol-absence message (#242).
fn no_data_in_window(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, env: &Env) -> ! {
/// Empty-in-window refusal (#297: returned, not printed). Distinct from
/// `no_real_data`: used only inside `probe_window`, whose callers have already
/// proven the symbol present via `has_symbol` — an empty probe result there is
/// a fact about the requested `--from`/`--to` window, not about symbol
/// absence, so it must not reuse the symbol-absence message (#242).
fn no_data_in_window(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, env: &Env) -> RunnerError {
let bound = |b: Option<i64>| b.map_or_else(|| "unbounded".to_string(), |v| v.to_string());
eprintln!(
"aura: no data for symbol '{symbol}' in the requested window [{}, {}] at {}",
bound(from_ms),
bound(to_ms),
env.data_path()
);
std::process::exit(1)
RunnerError {
exit_code: 1,
message: format!(
"no data for symbol '{symbol}' in the requested window [{}, {}] at {}",
bound(from_ms),
bound(to_ms),
env.data_path()
),
}
}
/// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse
/// (stderr + exit 1) when the symbol has no recorded geometry — the single home of
/// the guessed-pip refusal, shared by `open_real_source` and the CLI shell's
/// `DataSource::from_choice`. Reads the symbol's geometry metadata (not bar data)
/// before the run source opens, so an instrument with no recorded geometry
/// refuses without a guessed pip; the pip is the provider's recorded value,
/// honest by construction.
pub fn pip_or_refuse(server: &Arc<data_server::DataServer>, symbol: &str, env: &Env) -> f64 {
/// (#297: returned, not printed) when the symbol has no recorded geometry — the
/// single home of the guessed-pip refusal, shared by `open_real_source` and
/// reproduce's `DataSource::from_choice` chain (`family.rs`). Reads the symbol's geometry metadata
/// (not bar data) before the run source opens, so an instrument with no
/// recorded geometry refuses without a guessed pip; the pip is the provider's
/// recorded value, honest by construction.
pub fn pip_or_refuse(server: &Arc<data_server::DataServer>, symbol: &str, env: &Env) -> Result<f64, RunnerError> {
match aura_ingest::instrument_geometry(server, symbol) {
Some(geo) => geo.pip_size,
None => {
eprintln!(
"aura: no recorded geometry for symbol '{symbol}' at {} — \
Some(geo) => Ok(geo.pip_size),
None => Err(RunnerError {
exit_code: 1,
message: format!(
"no recorded geometry for symbol '{symbol}' at {} — \
refusing to run a real instrument with a guessed pip",
env.data_path()
);
std::process::exit(1);
}
),
}),
}
}
@@ -180,9 +194,9 @@ pub fn probe_window(
from_ms: Option<i64>,
to_ms: Option<i64>,
env: &Env,
) -> (Timestamp, Timestamp) {
) -> Result<(Timestamp, Timestamp), RunnerError> {
aura_ingest::archive_extent(server, std::path::Path::new(&env.data_path()), symbol, from_ms, to_ms)
.unwrap_or_else(|| no_data_in_window(symbol, from_ms, to_ms, env))
.ok_or_else(|| no_data_in_window(symbol, from_ms, to_ms, env))
}
/// Open the real M1 sources for a recorded symbol over an optional window — one
@@ -190,27 +204,28 @@ pub fn probe_window(
/// sources paired with the manifest `window` and the per-instrument `pip_size`.
/// Single home of the real-source construction the single-run handlers share — the
/// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and
/// the run-source open (each refusal an stderr + exit 1). Pre-data refusals keep the
/// pip honest by construction. Used by `resolve_run_data`.
/// the run-source open (each refusal returned as a `RunnerError`, #297). Pre-data
/// refusals keep the pip honest by construction. Used by `resolve_run_data`.
#[allow(clippy::type_complexity)]
fn open_real_source(
symbol: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
env: &Env,
fields: &[aura_ingest::M1Field],
) -> (Vec<Box<dyn aura_engine::Source>>, (Timestamp, Timestamp), f64) {
) -> Result<(Vec<Box<dyn aura_engine::Source>>, (Timestamp, Timestamp), f64), RunnerError> {
// Per-instrument pip from the recorded sidecar; resolved BEFORE bar-data access
// so an instrument with no geometry refuses without touching the archive.
let server = Arc::new(data_server::DataServer::new(env.data_path()));
let pip = pip_or_refuse(&server, symbol, env);
let pip = pip_or_refuse(&server, symbol, env)?;
if !server.has_symbol(symbol) {
no_real_data(symbol, env);
return Err(no_real_data(symbol, env));
}
// Manifest window: drain a separate probe (single-pass Source) for first/last ts.
let window = probe_window(&server, symbol, from_ms, to_ms, env);
let window = probe_window(&server, symbol, from_ms, to_ms, env)?;
let sources = aura_ingest::open_columns(&server, symbol, from_ms, to_ms, fields)
.unwrap_or_else(|| no_real_data(symbol, env));
(sources, window, pip)
.ok_or_else(|| no_real_data(symbol, env))?;
Ok((sources, window, pip))
}
/// Short-horizon realized-range window for vol-scaled slippage. Deliberately
@@ -327,7 +342,7 @@ pub fn wrap_r(
if !reduce {
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
let r_equity = g.add(
LinComb::builder(2)
LinComb::configured(2)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0)),
);
@@ -411,7 +426,7 @@ pub fn wrap_r(
// net_r_equity = cum_realized_r + unrealized_r Σcum_cost_in_r
// Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1).
let net_eq = g.add(
LinComb::builder(4)
LinComb::configured(4)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0))
.bind("weights[2]", Scalar::f64(-1.0))
@@ -455,17 +470,20 @@ pub fn resolve_run_data(
data: &RunData,
env: &Env,
binding: &ResolvedBinding,
) -> (
Vec<Box<dyn aura_engine::Source>>,
(Timestamp, Timestamp),
f64,
) {
) -> Result<
(
Vec<Box<dyn aura_engine::Source>>,
(Timestamp, Timestamp),
f64,
),
RunnerError,
> {
match data {
RunData::Synthetic => {
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(r_sma_prices()))];
let window = window_of(&sources).expect("non-empty synthetic stream");
(sources, window, SYNTHETIC_PIP_SIZE)
Ok((sources, window, SYNTHETIC_PIP_SIZE))
}
RunData::Real { symbol, from, to } => {
open_real_source(symbol, *from, *to, env, &binding.columns())
@@ -473,60 +491,133 @@ pub fn resolve_run_data(
}
}
/// The variant-name string for a `ScalarKind`, matching the wire/CLI spelling
/// used across the codebase (`F64`/`I64`/`Bool`/`Timestamp`).
fn scalar_kind_name(kind: ScalarKind) -> &'static str {
match kind {
ScalarKind::F64 => "F64",
ScalarKind::I64 => "I64",
ScalarKind::Bool => "Bool",
ScalarKind::Timestamp => "Timestamp",
}
}
/// Bug 3 (#319 fieldtest cycle 2): an `--override` value whose lexed kind
/// does not match the param's declared kind (e.g. the bare literal `2` for
/// an F64 param) reaches this compile boundary as a raw
/// `CompileError::ParamKindMismatch { slot, expected, got }` — `slot` is a
/// flat param-space index, meaningless to a caller. `names`/`params` are the
/// SAME order `compile_with_params` was called with (built from `signal`
/// before it was consumed by `wrap_r`, `C11`: composites inline, preserving
/// order), so `slot` indexes both. This names the param path and both kinds,
/// mirroring the campaign leg's own kind-fault prose
/// (`ref_fault_prose`'s `AxisKindMismatch` arm, `research_docs.rs`). Every
/// other `CompileError` variant keeps the existing Debug fallback — out of
/// this bug's scope, and `names`/`params` only reconstruct the one variant
/// that carries an injected value.
fn compile_error_prose(e: &CompileError, names: &[String], params: &[Scalar]) -> String {
let CompileError::ParamKindMismatch { slot, expected, got } = e else {
return format!("this blueprint does not compile to a runnable harness: {e:?}");
};
let path = names.get(*slot).map(String::as_str).unwrap_or("<unknown>");
let example = match (expected, params.get(*slot)) {
(ScalarKind::F64, Some(Scalar::I64(v))) => format!("{v}.0"),
(ScalarKind::F64, _) => "a decimal literal, e.g. 2.0".to_string(),
(ScalarKind::I64, _) => "a bare integer literal, e.g. 2".to_string(),
(ScalarKind::Bool, _) => "true or false".to_string(),
(ScalarKind::Timestamp, _) => "an integer epoch-ns literal".to_string(),
};
format!(
"--override {path}: expects {}, got {} — write {example}",
scalar_kind_name(*expected),
scalar_kind_name(*got)
)
}
/// Run a signal blueprint through the R scaffolding: hash the signal,
/// wrap it (broker + equity/exposure/R sinks), compile with `params`, bootstrap,
/// run over `data`, and build the RunReport (manifest carries topology_hash).
/// The single construction+run path shared by the `aura run <blueprint.json>` CLI
/// arm and its bit-identical test.
///
/// `topo`: `None` computes `topology_hash` inline from `signal` as always
/// (every existing caller); `Some(hash)` overrides it with the caller's own
/// **reference-semantics** hash (#343, revised) — the exec blueprint leg's
/// `--override` branch passes the loaded base document's content id, computed
/// BEFORE `reopen_all`, so the manifest reaching every consumer inside this
/// function (the record line AND the trace-store persistence below) carries
/// the base document's id, never the reopened topology's own, exactly as the
/// campaign leg's `run_blueprint_member` has always taken its `topo` as a
/// caller-supplied reference (`runner.rs`'s `&cell.strategy_id`).
#[allow(clippy::type_complexity)]
pub fn run_signal_r(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan,
) -> RunReport {
plan: TapPlan, topo: Option<&str>,
) -> Result<(RunReport, Vec<String>), RunnerError> {
// #297 fieldtest finding B1 (milestone-safe-to-embed): mirror the CLI's
// own `exec_blueprint_leg` exposes-neither guard (`aura-cli/src/main.rs`,
// `has_bias`/`has_tap`) IN the library, before `wrap_r` below wires a
// "bias" output that does not exist — an embedding host driving
// `run_signal_r` directly (never through the CLI shell) must see this
// same typed refusal instead of `wrap_r`'s wiring `.expect` panicking
// with `UnknownOutPort { name: "bias" }` and killing the host. Same
// detection (`output()`/`taps()`), same prose family as the CLI's,
// minus its own "aura: " print prefix — C14 class 2 (argv-named content).
if !signal.output().iter().any(|o| o.name == "bias") && signal.taps().is_empty() {
return Err(RunnerError {
exit_code: 2,
message: "exec needs either a `bias` output (a strategy) or ≥1 declared tap \
(a measurement); this blueprint exposes neither"
.to_string(),
});
}
// topology_hash's own two-line body, inlined: `content_id_of` over the
// canonical (#164) blueprint JSON — the CLI shell's `topology_hash`
// helper is the same primitive, kept single-sourced at `aura_research`.
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed
// Skipped when the caller already supplies a reference-semantics hash
// (`topo: Some(..)`) — no need to hash a signal whose own topology_hash
// will be overridden anyway.
let topo: String = match topo {
Some(t) => t.to_string(),
None => aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
), // before signal is consumed
};
let run_name = signal.name().to_string(); // before signal is consumed by `wrap_r`
// The default binding (name defaults; `aura run` carries no campaign
// overrides). Refusals are the established `aura: ` + exit-1 register.
// overrides). Refusals propagate as a `RunnerError` for the CLI to
// print and exit on, never a process exit inside the library (#297).
// C14 class 2: fault in argv-named content (#297)
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
.map_err(|m| RunnerError { exit_code: 2, message: m })?;
// C14 class 2: fault in argv-named content (#297)
if matches!(data, RunData::Synthetic) && !binding.close_only() {
eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding));
std::process::exit(1);
return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) });
}
let names: Vec<String> = signal
.param_space()
.iter()
.map(|p| p.name.clone())
.collect();
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
// The req tap (r_equity recorder) is wired but not persisted on this path; keep the
// receiver alive so the sink's sends do not fail, but do not drain it.
let (tx_req, _rx_req) = mpsc::channel();
let (sources, window, pip_size) = resolve_run_data(&data, env, &binding);
let (sources, window, pip_size) = resolve_run_data(&data, env, &binding)?;
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 }, false, pip_size, &binding, None);
let mut flat = wrapped.compile_with_params(params).unwrap_or_else(|e| {
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
std::process::exit(1);
});
// C14 class 2: fault in argv-named content (#297)
let mut flat = wrapped.compile_with_params(params)
.map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &names, params) })?;
// Bind each declared tap per the plan's subscription, before bootstrap
// (#283): typed refusals for bad plans, record consumers hold their
// streaming writer in-graph, folds accumulate O(1), live closures run
// inline. Dedup stays caller-owned per TapBindError::DuplicateBind's doc.
let bound = bind_tap_plan(&mut flat, plan, env, &run_name).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
// 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 mut h = Harness::bootstrap(flat).expect("valid r-sma harness");
// `sources` were opened via `resolve_run_data(&data, env, &binding)` against
// this SAME `binding`, and `key_supply` keys them by that binding's own role
@@ -548,14 +639,13 @@ pub fn run_signal_r(
manifest.project = env.provenance();
let mut metrics = summarize(&aura_engine::f64_field(&eq_rows, 0), &aura_engine::f64_field(&ex_rows, 0));
metrics.r = Some(summarize_r(&r_rows, &[]));
let skipped = bound.skipped().to_vec();
// Close the tap plan: drain the ≤1-message channels, write fold rows,
// then `index.json` — nothing was buffered during the run (#283). A
// tap-free (or nothing-persisting) plan wrote nothing at all.
bound.finish(&manifest).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
RunReport { manifest, metrics }
bound.finish(&manifest)
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
Ok((RunReport { manifest, metrics }, skipped))
}
/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
@@ -579,8 +669,8 @@ pub fn run_blueprint_member(
binding: &ResolvedBinding,
cost: &[aura_research::CostSpec],
instrument: &str,
) -> RunReport {
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
) -> Result<RunReport, MemberFault> {
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
@@ -590,11 +680,13 @@ pub fn run_blueprint_member(
// its sender is wired but unread here (the r_equity tap precedent above).
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel();
let cost_leg = (!cost.is_empty()).then(|| CostLeg {
nodes: crate::translate::cost_nodes_for(cost, instrument),
tx_cost,
tx_net,
});
let cost_leg = if cost.is_empty() {
None
} else {
let nodes = crate::translate::cost_nodes_for(cost, instrument)
.map_err(|f| MemberFault::Bind(f.message()))?;
Some(CostLeg { nodes, tx_cost, tx_net })
};
let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding, cost_leg)
.bootstrap_with_cells(point)
.expect("member bootstraps (point kind-checked against param_space)");
@@ -608,9 +700,10 @@ pub fn run_blueprint_member(
h.run_bound(key_supply(binding, sources))
.expect("sources opened against `binding` key-match that binding's own roles by construction");
let mut named = zip_params(space, point); // by-name params for the manifest record
// `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant,
// which stamps no vol knobs. The campaign/single-run paths only pass `Vol`
// or `VolTf`, so the `Fixed` arm is inert here.
// One arm's knobs per member (#233 Vol/VolTf, #338 Fixed) — the manifest
// stamp `stop_rule_from_params` reads back (`translate.rs`), so a
// campaign's `RiskRegime::Fixed` cell reproduces its own distance, not
// the baked default vol-stop.
match stop {
StopRule::Vol { length, k } => {
named.push(("stop_length".to_string(), Scalar::i64(length)));
@@ -621,7 +714,9 @@ pub fn run_blueprint_member(
named.push(("stop_length".to_string(), Scalar::i64(length)));
named.push(("stop_k".to_string(), Scalar::f64(k)));
}
StopRule::Fixed(_) => {}
StopRule::Fixed(distance) => {
named.push(("stop_distance".to_string(), Scalar::f64(distance)));
}
}
// Stamp the cost model the member ran under, beside the stop knobs (#234,
// the #233 pattern): one `cost[k].<knob>` param per component, in
@@ -633,7 +728,8 @@ pub fn run_blueprint_member(
// reads this stamp back via `translate::cost_specs_from_params` (the #233
// stop-regime pattern), so a costed family reproduces net, not gross.
for (k, spec) in cost.iter().enumerate() {
let (knob, v) = crate::translate::cost_knob(spec, instrument);
let (knob, v) = crate::translate::cost_knob(spec, instrument)
.map_err(|f| MemberFault::Bind(f.message()))?;
named.push((format!("cost[{k}].{knob}"), Scalar::f64(v)));
}
let mut manifest = sim_optimal_manifest(named, window, seed, pip);
@@ -654,15 +750,16 @@ pub fn run_blueprint_member(
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, &cost_rows));
RunReport { manifest, metrics: m }
Ok(RunReport { manifest, metrics: m })
}
/// The exact wrapped probe the loaded-blueprint sweep resolves its axes
/// against: the loaded signal wrapped in the r-sma scaffolding (stop bound,
/// reduce, no cost), taps discarded. `param_space()` on it is the axis
/// namespace `--axis` binds; `.axis()` consumes it to seed a sweep. Single
/// source for the sweep terminal, the MC closed-check, AND `--list-axes`, so
/// the listed names track the swept names by construction (incl. across #159's
/// source for the sweep terminal, the MC closed-check, AND
/// `aura graph introspect --params`, so the listed names track the swept
/// names by construction (incl. across #159's
/// harness retirement). The reload is infallible under the SAME
/// dispatch-boundary contract the callers already rely on: the doc is
/// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]`
@@ -675,7 +772,8 @@ pub fn blueprint_axis_probe(doc: &str, env: &Env) -> Composite {
/// The axis probe with a #246 override set re-opened on the strategy BEFORE
/// wrapping — probe and per-member reloads must re-open identically so points
/// resolve against one space. The empty-set form is byte-equal to the old
/// probe (mc/run closed-checks and the open `--list-axes` lines read that).
/// probe (mc/run closed-checks and the open `aura graph introspect --params`
/// lines read that).
pub fn blueprint_axis_probe_reopened(doc: &str, env: &Env, overrides: &[String]) -> Composite {
let signal = blueprint_from_json(doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
@@ -706,55 +804,72 @@ pub fn wrapped_bound_names(signal: &Composite) -> HashSet<String> {
.collect()
}
/// The override subset of `names` (#246): every WRAPPED-coordinate name
/// missing the un-reopened wrapped OPEN space but naming a BOUND param of the
/// strategy — returned in STRATEGY coordinates (the wrap prefix
/// `<signal.name()>.` stripped) for `Composite::reopen`. Names matching
/// neither space are skipped here; the caller decides whether that is an
/// error (sweep boundary) or falls through to its existing resolution
/// errors. The silent variant `reproduce_family_in` uses over the RECORDED
/// manifest param names (the sweep boundary uses the stricter
/// `override_paths`, which errors on an unmatched name instead). Contrast
/// `axes::raw_bound_overrides_of`, whose `names` are already RAW (a campaign
/// document's own namespace, #203) and needs no such stripping.
/// The override subset of `names` (#246): every name missing the un-reopened
/// wrapped OPEN space but naming a BOUND param of the strategy — returned in
/// STRATEGY coordinates (the wrap segment stripped) for `Composite::reopen`.
/// Names matching neither space are skipped here; the caller decides whether
/// that is an error (sweep boundary) or falls through to its existing
/// resolution errors. The silent variant `reproduce_family_in`/`runner.rs`
/// use over the RECORDED manifest param names (the sweep boundary uses the
/// stricter `override_paths`, which errors on an unmatched name instead).
///
/// #328: matches via [`crate::axes::raw_matches_wrapped`], so `names` may be
/// WRAPPED (every route this cycle leaves untouched) OR RAW (a `Sweep` family
/// minted by `blueprint_sweep_family`, whose manifest now records the raw
/// campaign namespace, #328) — `reproduce_family_in` reproduces families from
/// either route generically, so this helper must tolerate both shapes rather
/// than assume one. Contrast `axes::raw_bound_overrides_of`, whose `names` are
/// ALWAYS RAW (a campaign document's own namespace, #203) by contract.
pub fn wrapped_bound_overrides_of(
names: &[String],
open_space: &[ParamSpec],
signal: &Composite,
) -> Vec<String> {
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
let prefix = format!("{}.", signal.name());
let bound = wrapped_bound_names(signal);
names
.iter()
.filter(|n| !open.contains(n.as_str()) && bound.contains(*n))
.map(|n| n[prefix.len()..].to_string())
.filter(|n| !open_space.iter().any(|p| crate::axes::raw_matches_wrapped(n, &p.name)))
.filter_map(|n| {
bound
.iter()
.find(|b| crate::axes::raw_matches_wrapped(n, b))
.map(|b| crate::axes::wrapped_to_raw_axis(b).to_string())
})
.collect()
}
/// The sweep-boundary variant (#246): like `wrapped_bound_overrides_of`, but
/// an axis matching NEITHER the open nor the bound space is the error — the
/// honest replacement of the retired "fully bound; nothing to sweep" refusal.
///
/// #328: matches via [`crate::axes::raw_matches_wrapped`] rather than exact
/// wrapped-name equality, so a RAW incoming name (`fast.length`) resolves
/// against the WRAPPED open/bound space exactly like an exact wrapped hit
/// (`sma_signal.fast.length`) would — the one shared suffix convention
/// `axes.rs` defines, reused rather than re-derived here. This is the
/// resolution mechanism, not an acceptance gate (contrast the CLI's own
/// `--axis` intake, which refuses a wrapped name before this ever runs on the
/// sweep-verb route); callers whose axes are not intake-filtered this cycle
/// (the walk-forward routes) keep accepting either form unchanged.
pub fn override_paths(
axes: &[(String, Vec<Scalar>)],
open_space: &[ParamSpec],
signal: &Composite,
) -> Result<Vec<String>, String> {
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
let prefix = format!("{}.", signal.name());
let bound = wrapped_bound_names(signal);
let mut overrides = Vec::new();
for (name, _) in axes {
if open.contains(name.as_str()) {
if open_space.iter().any(|p| crate::axes::raw_matches_wrapped(name, &p.name)) {
continue;
}
if bound.contains(name) {
overrides.push(name[prefix.len()..].to_string());
} else {
return Err(format!(
"axis {name}: names no param of this blueprint (open or bound) — \
see `aura sweep <bp> --list-axes`"
));
match bound.iter().find(|b| crate::axes::raw_matches_wrapped(name, b)) {
Some(b) => overrides.push(crate::axes::wrapped_to_raw_axis(b).to_string()),
None => {
return Err(format!(
"axis {name}: names no param of this blueprint (open or bound) — \
see `aura graph introspect --params <bp>`"
));
}
}
}
Ok(overrides)
@@ -1030,10 +1145,12 @@ mod tests {
.expect("the price role resolves");
let report_a = run_blueprint_member(
signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40",
);
)
.expect("no cost model: nothing to refuse");
let report_b = run_blueprint_member(
r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40",
);
)
.expect("no cost model: nothing to refuse");
// Guard: the run must have actually traded, else the invariant is vacuous.
assert!(
report_a.metrics.total_pips.abs() > 0.0,
@@ -1145,8 +1262,10 @@ mod tests {
let env = Env::std();
let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_breakout.json"), &|t| std_vocabulary(t))
.expect("shipped r_breakout example loads");
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
let (via_file, _) = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None)
.expect("r_breakout example runs");
let (via_carve, _) = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None)
.expect("carved r_breakout signal runs");
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
}
@@ -1298,15 +1417,18 @@ mod tests {
let env = Env::std();
let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_meanrev.json"), &|t| std_vocabulary(t))
.expect("shipped r_meanrev example loads");
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
let via_carve = run_signal_r(
let (via_file, _) = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None)
.expect("r_meanrev example runs");
let (via_carve, _) = run_signal_r(
r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)),
&[],
RunData::Synthetic,
0,
&env,
TapPlan::record_all(),
);
None,
)
.expect("carved r_meanrev signal runs");
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
}
@@ -1496,8 +1618,9 @@ mod tests {
// different but equally non-flat synthetic fixture. Every assertion
// below is computed from the actual run (never a fixture-specific
// literal), so the substitution preserves the property under test.
let sources = || resolve_run_data(&RunData::Synthetic, &env, &binding).0;
let (_, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding);
let sources = || resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves").0;
let (_, window, pip) =
resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves");
const CPT: f64 = 0.0005; // price units; the synthetic stream trades near 1.0
let run = |cost: &[aura_research::CostSpec]| {
run_blueprint_member(
@@ -1515,6 +1638,7 @@ mod tests {
cost,
"GER40",
)
.expect("GER40-keyed cost fixture resolves")
};
let gross = run(&[]);
let netted = run(&[aura_research::CostSpec::Constant {
@@ -1583,6 +1707,39 @@ mod tests {
);
}
#[test]
/// Review fix (#297 fork 3, item 1): the worker-channel seam a live sweep
/// observes — `run_blueprint_member` given a cost model whose per-instrument
/// map lacks the requested instrument refuses `Err(MemberFault::Bind(msg))`
/// with `msg` byte-identical to `CostKnobFault::message()` (the
/// `cost_knob_fault_message_is_byte_pinned` pin in `translate.rs`, one level
/// up the call chain) — never a panic that `catch_unwind` would instead
/// record as `MemberFault::Panic`. The fault fires before the harness is
/// even bootstrapped, so `sources`/`window` are minimal placeholders.
fn run_blueprint_member_propagates_the_cost_knob_fault_as_a_bind_message() {
let env = Env::std();
let signal = r_meanrev_signal(Some(3), Some(0.0));
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::Vol { length: 3, k: 2.0 };
let window = (Timestamp(0), Timestamp(0));
let cost = [aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::PerInstrument(std::collections::BTreeMap::from(
[("EURUSD".to_string(), 2.0)],
)),
}];
let err = match run_blueprint_member(
signal, &[], &[], Vec::new(), window, 0, 1.0, "topo", &env, stop, &binding, &cost, "GER40",
) {
Err(e) => e,
Ok(_) => panic!("GER40 is not in the map — the run must refuse, not succeed"),
};
let expected =
crate::translate::CostKnobFault { knob: "cost_per_trade", instrument: "GER40".to_string() }
.message();
assert_eq!(err, MemberFault::Bind(expected));
}
/// #262 write-side: `run_blueprint_member` given `StopRule::VolTf` actually
/// bootstraps and runs the member (the `risk_executor`/`VolTfStop` arm,
/// end-to-end over a real synthetic realization, not a hand-built value)
@@ -1601,7 +1758,8 @@ mod tests {
// See `run_blueprint_member_joins_a_constant_cost_model_into_net_metrics`'s
// note: `RunData::Synthetic` + `resolve_run_data` stands in for the
// CLI-only `DataSource::Synthetic` this test used before the #295 move.
let (sources, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding);
let (sources, window, pip) =
resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves");
let run = run_blueprint_member(
reload(),
&[],
@@ -1616,7 +1774,8 @@ mod tests {
&binding,
&[],
"GER40",
);
)
.expect("no cost model: nothing to refuse");
assert!(
run.manifest.params.contains(&("stop_period_minutes".to_string(), Scalar::i64(60))),
"manifest must stamp stop_period_minutes: {:?}",
@@ -1634,6 +1793,51 @@ mod tests {
);
}
/// #338 write-side: `run_blueprint_member` given `StopRule::Fixed` actually
/// bootstraps and runs the member (the `risk_executor`/`FixedStop` arm,
/// end-to-end over a real synthetic realization) and stamps `stop_distance`
/// into the manifest under the key `stop_rule_from_params`'s round-trip
/// reads back — the `run_blueprint_member_stamps_the_vol_tf_stop_knobs`
/// precedent above, `Fixed` edition.
#[test]
fn run_blueprint_member_stamps_the_fixed_stop_distance() {
let env = Env::std();
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
let space = blueprint_axis_probe(&doc, &env).param_space();
let binding = crate::binding::resolve_binding("fixedstamp", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves");
let stop = StopRule::Fixed(10.0);
let (sources, window, pip) =
resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves");
let run = run_blueprint_member(
reload(),
&[],
&space,
sources,
window,
0,
pip,
"topo",
&env,
stop,
&binding,
&[],
"GER40",
)
.expect("no cost model: nothing to refuse");
assert!(
run.manifest.params.contains(&("stop_distance".to_string(), Scalar::f64(10.0))),
"manifest must stamp stop_distance: {:?}",
run.manifest.params
);
assert!(
!run.manifest.params.iter().any(|(k, _)| k == "stop_length" || k == "stop_k"),
"a Fixed stop stamps no vol knobs: {:?}",
run.manifest.params
);
}
/// A fresh project root so a run's `runs/` lands in a temp dir.
fn temp_project_env(name: &str) -> (Env, PathBuf) {
let root = std::env::temp_dir()
@@ -1677,7 +1881,8 @@ mod tests {
fn fold_and_live_plans_agree_with_the_recorded_series() {
// Run A: record (the charting question).
let (env_a, root_a) = temp_project_env("record");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all());
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run A records");
let series = read_tap_json(&root_a);
let ts: Vec<i64> =
series["ts"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
@@ -1689,7 +1894,8 @@ mod tests {
let (env_b, root_b) = temp_project_env("fold");
let mut plan_b = TapPlan::record_all();
plan_b.subscribe("fast_tap", TapSubscription::named("mean"));
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b);
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b, None)
.expect("run B folds");
let row = read_tap_json(&root_b);
let mean = vals.iter().sum::<f64>() / vals.len() as f64; // sequential — FoldState's order
assert_eq!(row["ts"], serde_json::json!([*ts.last().unwrap()]));
@@ -1705,7 +1911,8 @@ mod tests {
let _ = live_tx.send((ts.0, cell.f64()));
}),
);
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_c, plan_c);
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_c, plan_c, None)
.expect("run C runs live-only");
let got: Vec<(i64, f64)> = live_rx.try_iter().collect();
let want: Vec<(i64, f64)> = ts.iter().copied().zip(vals.iter().copied()).collect();
assert_eq!(got, want, "the live closure saw exactly the recorded series (C1)");
@@ -1728,9 +1935,14 @@ mod tests {
Ok(_) => panic!("unknown tap must be refused"),
};
assert!(
matches!(err, TapPlanError::UnknownTap { ref name } if name == "no_such_tap"),
matches!(err, TapPlanError::UnknownTap { ref name, .. } if name == "no_such_tap"),
"{err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("fast_tap"),
"unknown-tap refusal enumerates the declared taps (fixture declares only fast_tap): {msg}"
);
// Unknown label — the refusal enumerates the roster.
let mut flat2 = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
@@ -1751,8 +1963,10 @@ mod tests {
fn two_identical_record_runs_produce_byte_identical_tap_files() {
let (env_a, root_a) = temp_project_env("det-a");
let (env_b, root_b) = temp_project_env("det-b");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all());
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all());
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run a records");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all(), None)
.expect("run b records");
let a = std::fs::read(
root_a.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
@@ -1763,4 +1977,44 @@ mod tests {
.expect("run b trace");
assert_eq!(a, b, "same input, same bytes (the index carries env provenance; the tap file must not)");
}
/// #297: a refusal returns through the Result seam — the embedding
/// process survives to read it (the #283 fieldtest scenario that used
/// to kill the host).
#[test]
fn run_signal_r_refuses_in_process_instead_of_killing_the_host() {
let (env, _root) = temp_project_env("in-process-refusal");
let mut plan = TapPlan::record_all();
plan.subscribe("fast_tap", TapSubscription::named("median"));
let err = run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan, None)
.unwrap_err();
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("unknown fold"), "{}", err.message);
}
/// The #297 promise at the exposes-neither seam (fieldtest
/// milestone-safe-to-embed, finding B1): a blueprint exposing NEITHER a
/// `bias` output NOR a declared tap is not runnable on this leg, and the
/// library must say so as a returned `RunnerError` — C14 class 2
/// (argv-named content, the CLI guard's own prose family) — never via
/// `wrap_r`'s wiring `.expect` (`UnknownOutPort { name: "bias" }`,
/// member.rs:443) panicking and killing the embedding host. The CLI's
/// shell-side pre-validation (`exec_blueprint_leg`) must not be the only
/// guard on the seam the milestone promises is kill-free.
#[test]
fn run_signal_r_refuses_an_exposes_neither_blueprint_in_process() {
let (env, _root) = temp_project_env("exposes-neither");
// Minimal exposes-neither signal: one role, one node, no exposed
// output, no declared tap.
let mut g = GraphBuilder::new("blind_signal");
let s = g.add(Sub::builder());
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [s.input("lhs"), s.input("rhs")]);
let blind = g.build().expect("an exposes-neither composite still builds");
let err = run_signal_r(blind, &[], RunData::Synthetic, 0, &env, TapPlan::empty(), None)
.unwrap_err();
assert_eq!(err.exit_code, 2, "argv-named content (C14): {}", err.message);
assert!(err.message.contains("bias"), "{}", err.message);
assert!(err.message.contains("exposes neither"), "{}", err.message);
}
}
+57 -11
View File
@@ -10,7 +10,7 @@
use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate};
use aura_core::project::{
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
CORE_FINGERPRINT, ProjectDescriptor, RUSTC_VERSION, StrSlice,
};
use aura_engine::ProjectProvenance;
use aura_registry::{Registry, TraceStore};
@@ -378,7 +378,7 @@ fn validate_c_tier(
magic: u64,
descriptor_version: u32,
rustc_version: StrSlice,
aura_core_version: StrSlice,
aura_core_fingerprint: StrSlice,
namespace: StrSlice,
dylib_path: &Path,
) -> Result<String, ProjectError> {
@@ -401,13 +401,13 @@ fn validate_c_tier(
host: RUSTC_VERSION.to_string(),
});
}
let dylib_core = unsafe { aura_core_version.as_str() }
let dylib_core = unsafe { aura_core_fingerprint.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_core != CORE_VERSION {
if dylib_core != CORE_FINGERPRINT {
return Err(ProjectError::Incompatible {
what: "aura-core version",
what: "aura-core build",
dylib: dylib_core.to_string(),
host: CORE_VERSION.to_string(),
host: CORE_FINGERPRINT.to_string(),
});
}
unsafe { namespace.as_str() }
@@ -580,13 +580,13 @@ fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectErro
let magic = unsafe { (*desc_ptr).magic };
let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
let rustc_version = unsafe { (*desc_ptr).rustc_version };
let aura_core_version = unsafe { (*desc_ptr).aura_core_version };
let aura_core_fingerprint = unsafe { (*desc_ptr).aura_core_fingerprint };
let namespace_stamp = unsafe { (*desc_ptr).namespace };
let namespace = validate_c_tier(
magic,
descriptor_version,
rustc_version,
aura_core_version,
aura_core_fingerprint,
namespace_stamp,
&dylib_path,
)?;
@@ -806,7 +806,7 @@ mod tests {
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
StrSlice::new(CORE_VERSION),
StrSlice::new(CORE_FINGERPRINT),
)
}
@@ -857,7 +857,7 @@ mod tests {
}
#[test]
fn validate_c_tier_rejects_aura_core_version_mismatch() {
fn validate_c_tier_rejects_aura_core_build_mismatch() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_core = StrSlice::new("9.9.9-fake");
@@ -865,10 +865,56 @@ mod tests {
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "aura-core version", .. }
ProjectError::Incompatible { what: "aura-core build", .. }
));
}
/// #348 / C30: the aura-core stamp compared at the load seam is a
/// build-identity fingerprint of aura-core's *sources*, not the frozen
/// crate version. Under C30's no-semver discipline the workspace version
/// is pinned at "0.1.0", so every stale pre-transition dylib stamps
/// exactly that string — a host whose own stamp were still the crate
/// version would wave any of them through (the disarmed handshake this
/// pin re-arms). The host side of the comparison must be a
/// source-derived fingerprint (never the frozen version string), the
/// refusal must echo both stamps, and its prose must name the rebuild
/// fix. The accept side — identical stamps still load — stays pinned by
/// `validate_c_tier_accepts_matching_stamps` above.
#[test]
fn validate_c_tier_rejects_the_frozen_crate_version_as_aura_core_stamp() {
let path = PathBuf::from("/tmp/x.so");
// What every stale, pre-fingerprint dylib carries as its aura-core
// stamp: the frozen workspace crate version.
let stale_stamp = StrSlice::new("0.1.0");
let err = validate_c_tier(
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
stale_stamp,
StrSlice::new("demo"),
&path,
)
.unwrap_err();
let ProjectError::Incompatible { dylib, host, .. } = &err else {
panic!("expected Incompatible, got: {err}");
};
assert_eq!(dylib.as_str(), "0.1.0", "the refusal echoes the dylib's stale stamp");
assert_ne!(
host.as_str(),
"0.1.0",
"the host's stamp is a source-derived fingerprint, not the frozen crate version"
);
// Version-bump-proof twin of the literal above: whatever the crate
// version becomes, it must never be the host stamp again.
assert_ne!(
host.as_str(),
env!("CARGO_PKG_VERSION"),
"the host's stamp reverted to the crate version"
);
let msg = err.to_string();
assert!(msg.contains("rebuild"), "the refusal names the fix: {msg}");
}
/// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
/// than dereferencing it, same as the standalone `StrSlice::as_str` test
/// in aura-core — here exercised through the loader's own refusal path.
+248 -31
View File
@@ -1,14 +1,18 @@
//! Bit-identical reproduction (`aura reproduce`) — #295.
//!
//! A library function here reports a refusal as a returned
//! [`crate::RunnerError`] rather than ending the process. The shell dispatch
//! arm (`aura-cli`'s `dispatch_reproduce`) is the single place that prints
//! the error's message to stderr and calls `std::process::exit` on its code,
//! keeping the observable stderr/exit bytes unchanged (C18).
//! Both outcomes here are values, not process control flow: success returns a
//! [`ReproduceReport`] the caller inspects (`ReproduceReport::all_identical`);
//! a refusal returns a [`crate::RunnerError`] — neither ends the process. The
//! shell dispatch arm (`aura-cli`'s `dispatch_reproduce`) is the single place
//! that renders the report and prints a refusal's message to stderr, calling
//! `std::process::exit` on its code, keeping the observable stderr/exit bytes
//! unchanged (C18); a library embedder (a World program) reads the returned
//! report directly instead.
use std::collections::BTreeMap;
use std::sync::mpsc;
use aura_core::Scalar;
use aura_engine::{blueprint_from_json, window_of};
use aura_registry::{group_families, Family, FamilyKind, Registry};
use aura_backtest::point_from_params;
@@ -22,10 +26,18 @@ use crate::RunnerError;
/// The outcome of reproducing one persisted family: per member, whether its re-run
/// metrics are bit-identical to the stored metrics (C1).
#[derive(Debug)]
pub struct ReproduceReport {
pub outcomes: Vec<(String, bool)>,
}
impl ReproduceReport {
/// True iff every member reproduced bit-identically (C1).
pub fn all_identical(&self) -> bool {
self.outcomes.iter().all(|(_, identical)| *identical)
}
}
/// Look up a persisted family by id, or refuse (exit code 1: unknown id / registry
/// load failure) — the single place `reproduce_family` and `reproduce_family_in`
/// resolve a family, so the two exit-1 error phrasings can't drift out of sync
@@ -67,11 +79,35 @@ pub fn load_family(reg: &Registry, id: &str) -> Result<Family, RunnerError> {
/// Re-derive every member of a persisted sweep family from the content-addressed store
/// and compare to the stored result, against an explicit registry (testable seam).
///
/// Refusal contract: an identity/pip mismatch (below) refuses class 2 — this is the
/// explicit-source seam, so a caller-given `data` that contradicts the member
/// manifests is argv-named content (C14). `reproduce_family`, the simple/derived
/// path, hits the same guard at class 1 instead (Fork 6, #299: on that path `data`
/// is derived FROM the family's own manifest, so a mismatch there is data drift, not
/// a caller error). A WINDOW guard is deliberately absent: no reproduce path takes a
/// caller window (WF and real-sweep re-runs load stored.manifest.window; MonteCarlo
/// derives its walk from manifest.seed; synthetic uses the full window).
pub fn reproduce_family_in(
reg: &Registry,
id: &str,
data: &DataSource,
env: &Env,
) -> Result<ReproduceReport, RunnerError> {
reproduce_family_with_class(reg, id, data, env, 2)
}
/// The shared reproduce loop (#299 Fork 6): `mismatch_class` is the exit code the
/// identity/pip guard refuses with — context-borne, not a property of the guard
/// itself. `reproduce_family_in` (explicit-source seam) passes 2; `reproduce_family`
/// (derived-source seam) passes 1. Guard messages are identical either way; only the
/// class varies.
fn reproduce_family_with_class(
reg: &Registry,
id: &str,
data: &DataSource,
env: &Env,
mismatch_class: i32,
) -> Result<ReproduceReport, RunnerError> {
let family = load_family(reg, id)?;
let pip = data.pip_size();
@@ -82,6 +118,52 @@ pub fn reproduce_family_in(
exit_code: 1,
message: "family member has no topology_hash; not a generated run".to_string(),
})?;
// Identity/geometry guard (#299): a source whose identity or pip
// contradicts the member manifests makes divergence mechanical. The
// class is context-borne (Fork 6, see this fn's doc): caller error on
// the explicit-source seam, data drift on the derived seam.
if let Some(recorded) = stored.manifest.instrument.as_deref() {
match data {
DataSource::Synthetic => {
return Err(RunnerError {
exit_code: mismatch_class,
message: format!(
"reproduce source mismatch: family member was recorded over instrument '{recorded}', the source is synthetic"
),
});
}
DataSource::Real { symbol, .. } if symbol != recorded => {
return Err(RunnerError {
exit_code: mismatch_class,
message: format!(
"reproduce source mismatch: family member was recorded over instrument '{recorded}', the source names '{symbol}'"
),
});
}
_ => {}
}
}
// pip has no manifest field of its own: forward-build the label the
// FAMILY MINT stamps for this pip (the r_sma/risk-executor variant —
// `run_blueprint_member` unconditionally stamps that label) and
// compare against the recorded one. The plain-arm branch below
// tolerates a stored record shaped like a single-run stamp (legacy or
// otherwise), not a variant any live re-run of THIS family would
// itself produce.
let expected = if stored.manifest.broker.contains("+risk-executor") {
crate::member::r_sma_broker_label(pip)
} else {
crate::member::sim_optimal_broker_label(pip)
};
if expected != stored.manifest.broker {
return Err(RunnerError {
exit_code: mismatch_class,
message: format!(
"reproduce broker/pip mismatch: manifest recorded '{}', the source's pip renders '{expected}'",
stored.manifest.broker
),
});
}
let doc = reg
.get_blueprint(&hash)
.map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })?
@@ -133,6 +215,9 @@ pub fn reproduce_family_in(
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
// The member's binding, re-derived from the stored blueprint's own
// input roles (name defaults — family manifests carry no overrides).
// Deliberately class 1, unchanged by #297: prose identical to the
// now-class-2 `run_signal_r` twins, but a stored artifact being
// reproduced is not argv-named content (C14 partition).
let binding = crate::binding::resolve_binding(&hash, reload()?.input_roles(), &BTreeMap::new())
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
@@ -142,7 +227,31 @@ pub fn reproduce_family_in(
});
}
let space = crate::member::wrap_r(reload()?, tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding, None).param_space();
let point = point_from_params(&space, &stored.manifest.params)
// #328: `stored.manifest.params` is RAW on every mint route now (the
// synthetic sweep family and, since this cycle's tidy fix, the
// real/campaign route too) — but a family minted before this fix may
// still carry WRAPPED names on disk (C29: no retroactive rewrite of a
// registered artifact), so `reproduce_family_in` reproduces either
// shape generically: each recorded name is translated onto its
// matching WRAPPED `space` slot (`raw_matches_wrapped`, tolerant of
// both shapes) before `point_from_params`, which still keys by the
// exact wrapped name. Non-axis stamps (`stop_length`, `cost[k].<knob>`)
// carry no wrap segment either way and translate to themselves (no
// `space` hit).
let wrapped_params: Vec<(String, Scalar)> = stored
.manifest
.params
.iter()
.map(|(n, v)| {
let wrapped = space
.iter()
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| n.clone());
(wrapped, *v)
})
.collect();
let point = point_from_params(&space, &wrapped_params)
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
// A MonteCarlo member carries no tuning params (the params-join is empty), so its
// reproduce line would print a BLANK member label; the seed IS its realization
@@ -174,7 +283,7 @@ pub fn reproduce_family_in(
// stored window bounds; the winner params come from the shared
// manifest->cells recovery below (as Sweep members do).
let (from, to) = stored.manifest.window;
let s = data.windowed_sources(from, to, env, &binding.columns());
let s = data.windowed_sources(from, to, env, &binding.columns())?;
let w = window_of(&s).expect("non-empty OOS window");
(s, w)
}
@@ -187,9 +296,11 @@ pub fn reproduce_family_in(
_ => match data {
DataSource::Real { .. } => {
let (from, to) = stored.manifest.window;
(data.windowed_sources(from, to, env, &binding.columns()), (from, to))
(data.windowed_sources(from, to, env, &binding.columns())?, (from, to))
}
DataSource::Synthetic => {
(data.run_sources(env, &binding.columns())?, data.full_window(env)?)
}
DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)),
},
};
let rerun = run_blueprint_member(
@@ -209,7 +320,8 @@ pub fn reproduce_family_in(
// construction (`cost_specs_from_params`), so the instrument is
// inert; the fallback is never resolved against a map.
stored.manifest.instrument.as_deref().unwrap_or(""),
);
)
.expect("re-run cost specs are stamp-derived scalars; the instrument-inert fallback never misses");
outcomes.push((label, rerun.metrics == stored.metrics));
}
Ok(ReproduceReport { outcomes })
@@ -222,13 +334,17 @@ pub fn reproduce_family_in(
/// re-derive a real-data family over the wrong stream; see `reproduce_family_in`'s
/// per-member window loader for how the reconstructed source is actually used.
///
/// Prints its own per-member + summary lines to stdout (unchanged from the
/// pre-#295 shell function — this is the report's normal output, not an error
/// path); only the final "not every member reproduced" outcome is a refusal,
/// returned with an EMPTY message (there is no accompanying stderr line today,
/// so the shell dispatch arm must not synthesize one either — see
/// `dispatch_reproduce`).
pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> {
/// Returns the report as a value (#299): divergence is data the caller inspects
/// (`ReproduceReport::all_identical`), not a process exit — this library never
/// prints and never exits. The CLI's `dispatch_reproduce` renders the report and
/// owns the exit; a library embedder (a World program) reads `report.outcomes`
/// directly.
///
/// This is the derived-source seam (Fork 6, #299): `data` is reconstructed FROM
/// the family's own manifest, not caller-given, so the identity/pip guard inside
/// the shared loop refuses class 1 here (data drift within the stored family),
/// not class 2 (`reproduce_family_in`'s explicit-source class — see its doc).
pub fn reproduce_family(id: &str, env: &Env) -> Result<ReproduceReport, RunnerError> {
let reg = env.registry();
let family = load_family(&reg, id)?;
// Reconstruct the DataSource the family was minted over: `None` instrument
@@ -238,21 +354,10 @@ pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> {
let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) {
None => DataSource::Synthetic,
Some(symbol) => {
DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)
DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)?
}
};
let rep = reproduce_family_in(&reg, id, &data, env)?;
let total = rep.outcomes.len();
let ok = rep.outcomes.iter().filter(|(_, b)| *b).count();
for (label, identical) in &rep.outcomes {
let verdict = if *identical { "bit-identical" } else { "DIVERGED" };
println!("{id} member {label} reproduced: {verdict}");
}
println!("reproduced {ok}/{total} members bit-identically");
if ok != total {
return Err(RunnerError { exit_code: 1, message: String::new() });
}
Ok(())
reproduce_family_with_class(&reg, id, &data, env, 1)
}
#[cfg(test)]
@@ -262,7 +367,9 @@ mod tests {
use aura_engine::RunManifest;
use aura_registry::{FamilyKind, Registry};
use super::load_family;
use super::{load_family, reproduce_family_in, ReproduceReport};
use crate::family::DataSource;
use crate::project::Env;
/// A registry over a fresh per-test directory (the #258 tag-keyed pattern:
/// fixed name under the build-tree tmp anchor, pre-create wipe).
@@ -293,6 +400,116 @@ mod tests {
}
}
/// A persisted member carrying a `topology_hash` (#299 guard tests): the
/// identity/pip guards fire BEFORE the blueprint fetch, so a seeded record
/// needs a `topology_hash` to clear the earlier "not a generated run"
/// check, but the hash itself never resolves against the store.
fn stamped_report(instrument: Option<&str>, broker: &str) -> RunReport {
RunReport {
manifest: RunManifest {
topology_hash: Some("deadbeef".to_string()),
instrument: instrument.map(str::to_string),
broker: broker.to_string(),
..minimal_report().manifest
},
..minimal_report()
}
}
/// The instrument guard (#299): a family member recorded over a real
/// instrument refuses class 2 — before any blueprint fetch — when the
/// caller-given source is `Synthetic`, naming the recorded instrument in
/// the message.
#[test]
fn reproduce_family_in_refuses_synthetic_source_for_a_real_instrument_member() {
let reg = temp_registry("guard-synthetic-for-real");
reg.append_family(
"f",
FamilyKind::Sweep,
&[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")],
)
.expect("append family");
let err = reproduce_family_in(&reg, "f", &DataSource::Synthetic, &Env::std())
.expect_err("a synthetic source must not silently reproduce a real-instrument member");
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("GER40"), "message: {}", err.message);
assert!(err.message.contains("synthetic"), "message: {}", err.message);
}
/// The instrument guard's Real-vs-Real twin: a caller-given `Real` source
/// naming a DIFFERENT symbol than the member's recorded instrument refuses
/// class 2, naming both sides.
#[test]
fn reproduce_family_in_refuses_a_real_source_naming_the_wrong_symbol() {
let reg = temp_registry("guard-wrong-symbol");
reg.append_family(
"f",
FamilyKind::Sweep,
&[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")],
)
.expect("append family");
// A hand-built `DataSource::Real` fixture: `DataServer::new` merely
// scans its base path (a no-op on a missing directory), so the guard
// — which never touches the server — is exercised without any real
// archive on disk.
let server = std::sync::Arc::new(data_server::DataServer::new("/nonexistent-aura-test-path"));
let data = DataSource::Real {
server,
symbol: "US500".to_string(),
from_ms: None,
to_ms: None,
pip: 1.0,
};
let err = reproduce_family_in(&reg, "f", &data, &Env::std())
.expect_err("a real source naming the wrong symbol must not silently reproduce");
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("GER40"), "message: {}", err.message);
assert!(err.message.contains("US500"), "message: {}", err.message);
}
/// The broker/pip guard (#299): the recorded manifest carries no pip
/// field of its own — the pip survives only inside the broker label — so
/// the guard forward-builds the label the family mint stamps for this pip
/// (tolerating a plain-shape stored record generically, not asserting a
/// live re-run would itself produce it — #299) and refuses
/// class 2 on a label mismatch, naming both labels.
#[test]
fn reproduce_family_in_refuses_a_pip_mismatch_via_the_broker_label() {
let reg = temp_registry("guard-pip-mismatch");
// Recorded under a pip the actual synthetic run (0.0001, `SYNTHETIC_PIP_SIZE`)
// does not match: the label the re-run would forward-build diverges from
// the stored one, so the guard must fire before any re-run.
reg.append_family(
"f",
FamilyKind::Sweep,
&[stamped_report(None, "sim-optimal(pip_size=0.0002)")],
)
.expect("append family");
let err = reproduce_family_in(&reg, "f", &DataSource::Synthetic, &Env::std())
.expect_err("a pip mismatch forward-built via the broker label must refuse");
assert_eq!(err.exit_code, 2);
assert!(err.message.contains("sim-optimal(pip_size=0.0002)"), "message: {}", err.message);
assert!(err.message.contains("sim-optimal(pip_size=0.0001)"), "message: {}", err.message);
}
/// `all_identical` is true iff every outcome reproduced bit-identically.
#[test]
fn all_identical_reflects_every_outcome() {
let all_ok = ReproduceReport {
outcomes: vec![("a".to_string(), true), ("b".to_string(), true)],
};
assert!(all_ok.all_identical());
let one_diverged = ReproduceReport {
outcomes: vec![("a".to_string(), true), ("b".to_string(), false)],
};
assert!(!one_diverged.all_identical());
}
/// One id vocabulary across enumeration and reproduction (C18, #298): the
/// family-identity string a consumer lifts off the registry's own member
/// enumeration (`FamilyRunRecord.family`) resolves through the reproduce
+46 -8
View File
@@ -116,6 +116,21 @@ impl MemberRunner for DefaultMemberRunner<'_> {
.param_space();
let point = bind_axes(&space, &cell.strategy_id, params)?;
let signal = reopen_all(signal, &overrides);
// manifest.params records RAW names (#328: the real/campaign route was
// the one mint left wrapped after the synthetic route's own switch) —
// a name-only reshaping of `space` (order/kind untouched) fed to
// `run_blueprint_member`, which only zips it against `point` BY
// POSITION (`zip_params`) — never re-resolves by name — so renaming
// here is purely the manifest's own namespace, with no effect on
// member resolution (`space` itself stays wrapped for `bind_axes`
// above, which still keys by the exact wrapped `param_space()` name).
let manifest_space: Vec<aura_core::ParamSpec> = space
.iter()
.map(|p| aura_core::ParamSpec {
name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(),
kind: p.kind,
})
.collect();
// The member's resolved input binding (campaign data.bindings
// overrides win over name defaults). A refusal is a member fault,
@@ -173,7 +188,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
let mut report = run_blueprint_member(
signal,
&point,
&space,
&manifest_space,
sources,
(from, to),
0,
@@ -184,7 +199,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
&binding,
&self.cost,
&cell.instrument,
);
)?;
report.manifest.instrument = Some(cell.instrument.clone());
Ok(report)
}
@@ -490,7 +505,28 @@ pub fn persist_campaign_traces(
// is the member report's own `manifest.window` — already
// epoch-ns (`run_blueprint_member` stamped the post-seam
// bounds), so no second ms->ns crossing here.
let point = point_from_params(&space, &member_report.manifest.params)?;
//
// #328: `member_report.manifest.params` is RAW (this cycle's tidy
// fix put the real/campaign mint on the raw frame too), but
// `point_from_params` still keys by the exact WRAPPED `space`
// name — translate each recorded name onto its matching wrapped
// slot first (`raw_matches_wrapped`, tolerant of either shape, the
// same `reproduce_family_in` recipe), never re-resolving by a
// renamed identity.
let wrapped_params: Vec<(String, Scalar)> = member_report
.manifest
.params
.iter()
.map(|(n, v)| {
let wrapped = space
.iter()
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| n.clone());
(wrapped, *v)
})
.collect();
let point = point_from_params(&space, &wrapped_params)?;
let (from, to) = member_report.manifest.window;
let no_data = || {
format!(
@@ -543,11 +579,13 @@ pub fn persist_campaign_traces(
// not just on a real divergence.
let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, rx_net) = mpsc::channel();
let cost_leg = (!campaign.cost.is_empty()).then(|| CostLeg {
nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument),
tx_cost,
tx_net,
});
let cost_leg = if campaign.cost.is_empty() {
None
} else {
let nodes = cost_nodes_for(&campaign.cost, &cell_rec.instrument)
.map_err(|f| f.message())?;
Some(CostLeg { nodes, tx_cost, tx_net })
};
let mut h =
wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg)
.bootstrap_with_cells(&point)
+125 -13
View File
@@ -161,17 +161,21 @@ impl FoldRegistry {
}),
});
for (label, doc, fold) in [
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum),
("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean),
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max),
("count", "number of warm rows (any kind; i64 row); one row at the last warm ts", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
(
"mean",
"arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
FoldKind::Mean,
),
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
(
"first",
"first warm value, at its own timestamp (any kind; kind-preserving row)",
FoldKind::First,
),
("last", "last warm value (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 {
label,
@@ -213,11 +217,12 @@ impl FoldRegistry {
}
/// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal
/// store fault. Entry points map every variant to the established
/// `aura: ` + exit-1 refusal register via `Display`.
/// store fault. Entry points wrap every variant into a returned
/// `RunnerError` (#297) — prose via `Display`, class via `exit_class`
/// (content faults 2, store I/O 1, the C14 partition).
pub enum TapPlanError {
/// The plan names a tap the blueprint does not declare.
UnknownTap { name: String },
UnknownTap { name: String, declared: Vec<String> },
/// The plan names a label the registry does not carry.
UnknownLabel { label: String, roster: Vec<&'static str> },
/// The entry's bind rule rejects the tap's column kind.
@@ -237,8 +242,12 @@ pub enum TapPlanError {
impl fmt::Display for TapPlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TapPlanError::UnknownTap { name } => {
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap")
TapPlanError::UnknownTap { name, declared } => {
write!(
f,
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
declared.join(", ")
)
}
TapPlanError::UnknownLabel { label, roster } => {
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
@@ -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
/// schema: every binding names a schema param of the right kind; every
/// schema param is bound.
@@ -340,6 +368,10 @@ pub struct BoundTaps {
persisted: Vec<String>,
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
/// Declared taps that resolved to no subscription this run (#297): the
/// caller-printed "unbound" note migrates to the CLI, this is the data
/// it prints from.
skipped: Vec<String>,
}
impl BoundTaps {
@@ -348,6 +380,12 @@ impl BoundTaps {
&self.declared
}
/// Declared taps that resolved to no subscription this run (#297) — the
/// data the caller's "unbound" note prints from.
pub fn skipped(&self) -> &[String] {
&self.skipped
}
/// Drain the ≤1-message-per-tap channels and close the run: record
/// outcomes first (declared order), then fold rows written through the
/// same streamer, then `index.json` last. Any fault returns before the
@@ -402,7 +440,10 @@ pub fn bind_tap_plan(
// Unknown-tap guard: every plan name must be declared.
for name in plan.by_name.keys() {
if !declared_taps.iter().any(|t| &t.name == name) {
return Err(TapPlanError::UnknownTap { name: name.clone() });
return Err(TapPlanError::UnknownTap {
name: name.clone(),
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
});
}
}
@@ -413,6 +454,7 @@ pub fn bind_tap_plan(
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
}
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for tap in &declared_taps {
let kind = flat.signatures[tap.node].output[tap.field].kind;
let sub = match plan.by_name.remove(&tap.name) {
@@ -422,7 +464,18 @@ pub fn bind_tap_plan(
Some((label, params)) => {
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 {
@@ -454,6 +507,7 @@ pub fn bind_tap_plan(
persisted: Vec::new(),
rows: Vec::new(),
outcomes: Vec::new(),
skipped,
};
for (name, kind, sub) in resolved {
let node: Box<dyn Node> = match sub {
@@ -504,6 +558,64 @@ mod tests {
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
}
/// C29 entry seam for the registry roster: every entry ships a gate-clean
/// one-line meaning, and the bind/output prose inside it matches the
/// entry's executable rules — the drift-pin the retired aura-std
/// `fold_vocabulary` table carried, moved here with the surface (#332).
#[test]
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
let r = FoldRegistry::core();
for entry in r.entries.values() {
aura_core::doc_gate(entry.label, entry.doc)
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
if entry.doc.contains("f64 taps") {
assert!(
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
"{} claims f64-only but binds wider",
entry.label
);
} else {
assert!(
entry.doc.contains("any kind"),
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
entry.label,
entry.doc
);
assert!(
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
"{} claims any-kind but refuses a kind",
entry.label
);
}
if entry.doc.contains("i64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
"{} claims an i64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("f64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
"{} claims an f64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("kind-preserving row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
"{} claims kind-preserving but outputs otherwise",
entry.label
);
} else {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
"{}: doc names no row kind, so it must be the series entry",
entry.label
);
}
}
}
#[test]
fn unknown_label_refusal_enumerates_the_roster() {
let r = FoldRegistry::core();
+112 -24
View File
@@ -4,10 +4,11 @@
//! the cost model) must still round-trip through a manifest, so a stored
//! member (family, reproduce) re-derives bit-identically. These translators
//! are the single source for each such write<->read pair — the manifest
//! `stop_length`/`stop_k`/`stop_period_minutes` <-> [`StopRule`] binding, and
//! the `cost[k].<knob>` <-> [`aura_research::CostSpec`] binding — so the two
//! halves of each pair cannot drift out of sync between the run/campaign
//! paths and reproduce/persist.
//! `stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance` <->
//! [`StopRule`] binding, and the `cost[k].<knob>` <->
//! [`aura_research::CostSpec`] binding — so the two halves of each pair
//! cannot drift out of sync between the run/campaign paths and
//! reproduce/persist.
use aura_composites::StopRule;
use aura_core::{PrimitiveBuilder, Scalar};
@@ -23,11 +24,14 @@ pub const R_SMA_STOP_LENGTH: i64 = 3;
pub const R_SMA_STOP_K: f64 = 2.0;
/// Re-derive the `StopRule` a member was minted under from its manifest params
/// (`stop_length`/`stop_k`/`stop_period_minutes`, stamped by `run_blueprint_member`):
/// if `stop_period_minutes` is present alongside `stop_length`/`stop_k`, this
/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default
/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233
/// members), mirroring [`stop_rule_for_regime`]'s `None` arm for the same
/// (`stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance`, stamped by
/// `run_blueprint_member`): if `stop_period_minutes` is present alongside
/// `stop_length`/`stop_k`, this re-derives `VolTf`; `stop_length`/`stop_k`
/// alone re-derives `Vol`; `stop_distance` alone (#338) re-derives `Fixed` —
/// the three stamps are mutually exclusive (`run_blueprint_member` stamps
/// exactly one arm's knobs per member). Falls back to the default vol-stop
/// regime when the manifest carries no stop knobs at all (pre-#233 members),
/// mirroring [`stop_rule_for_regime`]'s `None` arm for the same
/// one-directional widening `point_from_params` already applies to missing
/// manifest params.
pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
@@ -35,11 +39,13 @@ pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64());
let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64());
let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64());
match (period_minutes, length, k) {
(Some(period_minutes), Some(length), Some(k)) => {
let distance = params.iter().find(|(n, _)| n == "stop_distance").map(|(_, s)| s.as_f64());
match (period_minutes, length, k, distance) {
(Some(period_minutes), Some(length), Some(k), _) => {
StopRule::VolTf { period_minutes, length, k }
}
(None, Some(length), Some(k)) => StopRule::Vol { length, k },
(None, Some(length), Some(k), _) => StopRule::Vol { length, k },
(None, None, None, Some(distance)) => StopRule::Fixed(distance),
_ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
}
}
@@ -94,6 +100,7 @@ pub fn cost_specs_from_params(
/// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own
/// params. Single-sourced so the persist-side re-run structurally cannot
/// diverge from the run-side binding again (the #219 divergence class).
/// `Fixed { distance }` (#338) binds the shipped `FixedStop` composite.
pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_composites::StopRule {
match regime {
None => aura_composites::StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
@@ -103,6 +110,7 @@ pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_c
Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => {
aura_composites::StopRule::VolTf { period_minutes, length, k }
}
Some(aura_research::RiskRegime::Fixed { distance }) => aura_composites::StopRule::Fixed(distance),
}
}
@@ -113,12 +121,15 @@ pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_c
/// cannot diverge from the run-side cost model (the #219 divergence class,
/// cost edition). The bound knob names are the builders' own `ParamSpec` names
/// — the `CostSpec` serde vocabulary conforms to them.
pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Vec<PrimitiveBuilder> {
pub fn cost_nodes_for(
specs: &[aura_research::CostSpec],
instrument: &str,
) -> Result<Vec<PrimitiveBuilder>, CostKnobFault> {
specs
.iter()
.map(|s| {
let (knob, v) = cost_knob(s, instrument);
match s {
let (knob, v) = cost_knob(s, instrument)?;
Ok(match s {
aura_research::CostSpec::Constant { .. } => {
ConstantCost::builder().bind(knob, Scalar::f64(v))
}
@@ -128,29 +139,47 @@ pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Ve
aura_research::CostSpec::Carry { .. } => {
CarryCost::builder().bind(knob, Scalar::f64(v))
}
}
})
})
.collect()
}
/// The fault `cost_knob` refuses with: the document named an instrument the
/// value map has no entry for (unreachable after intrinsic validation —
/// kept as a loud refusal, never a silent 0-charge).
#[derive(Debug)]
pub struct CostKnobFault {
pub knob: &'static str,
pub instrument: String,
}
impl CostKnobFault {
/// The exact prose the retired exit register printed (byte-identical).
pub fn message(&self) -> String {
format!("cost {}: no entry for instrument {}", self.knob, self.instrument)
}
}
/// The one CostSpec-variant -> (knob name, value) mapping, shared by
/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp:
/// the stamp key must equal the bind key for reproduce to re-derive a
/// `CostSpec` from a stored manifest, so both sites read the name off this
/// single function rather than each carrying its own literal.
pub fn cost_knob(spec: &aura_research::CostSpec, instrument: &str) -> (&'static str, f64) {
pub fn cost_knob(
spec: &aura_research::CostSpec,
instrument: &str,
) -> Result<(&'static str, f64), CostKnobFault> {
let (knob, value) = match spec {
aura_research::CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade),
aura_research::CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult),
aura_research::CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle),
};
let v = value.resolve(instrument).unwrap_or_else(|| {
match value.resolve(instrument) {
Some(v) => Ok((knob, v)),
// Unreachable after intrinsic validation (map keys ≡ instruments);
// refuse loudly rather than charging 0 silently if it ever surfaces.
eprintln!("aura: cost {knob}: no entry for instrument {instrument}");
std::process::exit(1);
});
(knob, v)
None => Err(CostKnobFault { knob, instrument: instrument.to_string() }),
}
}
#[cfg(test)]
@@ -178,6 +207,15 @@ mod tests {
assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 });
}
#[test]
/// #338 round-trip: a manifest carrying ONLY `stop_distance` (no vol
/// knobs) re-derives `StopRule::Fixed` — the `stop_rule_from_params_round_trips_the_vol_tf_stamp`
/// precedent above, `Fixed` edition.
fn stop_rule_from_params_round_trips_the_fixed_distance_stamp() {
let fixed = vec![("stop_distance".to_string(), Scalar::f64(10.0))];
assert_eq!(stop_rule_from_params(&fixed), StopRule::Fixed(10.0));
}
#[test]
/// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to
/// `StopRule::VolTf` field-for-field — the resolve-side half of the
@@ -193,6 +231,17 @@ mod tests {
);
}
#[test]
/// #338: `stop_rule_for_regime` binds `RiskRegime::Fixed` to
/// `StopRule::Fixed` — the resolve-side half of the write/resolve pair the
/// manifest round-trip test above covers from the stamp side (the
/// `stop_rule_for_regime_binds_vol_tf_field_for_field` precedent, `Fixed`
/// edition).
fn stop_rule_for_regime_binds_fixed_distance() {
let regime = Some(aura_research::RiskRegime::Fixed { distance: 10.0 });
assert_eq!(stop_rule_for_regime(regime), aura_composites::StopRule::Fixed(10.0));
}
#[test]
/// #234: the one CostSpec -> builder binding maps each component to its
/// shipped cost node with the knob BOUND — a bound component adds no open
@@ -212,14 +261,53 @@ mod tests {
},
],
"GER40",
);
)
.expect("every knob resolves against the GER40-keyed fixture");
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
assert!(
nodes.iter().all(|n| n.params().is_empty()),
"every component must be fully bound (no open param leaks into param_space)"
);
assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes");
assert!(
cost_nodes_for(&[], "GER40").expect("empty model resolves").is_empty(),
"an empty model binds no nodes"
);
}
#[test]
/// Review fix (#297 fork 3): `CostKnobFault::message()` is the exact prose
/// the retired exit register printed byte-identically — pinned against a
/// concrete fixture so a future edit to the format string is caught here,
/// not only at the call sites that propagate it as `MemberFault::Bind`.
fn cost_knob_fault_message_is_byte_pinned() {
let fault = CostKnobFault { knob: "cost_per_trade", instrument: "XYZ".to_string() };
assert_eq!(fault.message(), "cost cost_per_trade: no entry for instrument XYZ");
}
#[test]
/// Review fix (#297 fork 3): `cost_knob`/`cost_nodes_for` refuse (never
/// silently charge 0) when a `CostSpec`'s per-instrument map lacks the
/// requested instrument — the `PerInstrument` counterpart of
/// `cost_nodes_for_maps_each_component_to_its_bound_builder`'s all-resolve
/// case above, exercising the `None` arm of `CostValue::resolve`.
fn cost_knob_and_cost_nodes_for_refuse_an_uncovered_instrument() {
let spec = aura_research::CostSpec::Constant {
cost_per_trade: aura_research::CostValue::PerInstrument(std::collections::BTreeMap::from([(
"GER40".to_string(),
2.0,
)])),
};
let err = cost_knob(&spec, "EURUSD").expect_err("EURUSD is not in the map");
assert_eq!(err.knob, "cost_per_trade");
assert_eq!(err.instrument, "EURUSD");
let err = match cost_nodes_for(std::slice::from_ref(&spec), "EURUSD") {
Err(f) => f,
Ok(_) => panic!("cost_nodes_for must propagate the same fault"),
};
assert_eq!(err.knob, "cost_per_trade");
assert_eq!(err.instrument, "EURUSD");
}
#[test]
-7
View File
@@ -7,13 +7,6 @@ publish.workspace = true
[dependencies]
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]
aura-strategy = { path = "../aura-strategy" }
+1 -1
View File
@@ -74,6 +74,6 @@ pub use sma::Sma;
pub use sqrt::Sqrt;
pub use sub::Sub;
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 when::When;
+50 -9
View File
@@ -7,10 +7,15 @@
//! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19).
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, 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
/// construction parameters that configure the node and fix its arity
/// (`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)] }
}
/// The param-generic recipe for a blueprint primitive. The `arity` is topology
/// (fixed per blueprint, C19), taken as a builder arg; only the weight *values*
/// are injected, slot by slot, through `LinComb::new` (the single sizing gate).
pub fn builder(arity: usize) -> PrimitiveBuilder {
/// Roster factory: zero-arg, arg-bearing. `arity` is topology (fixed per
/// blueprint, C19), now taken through the `args` channel instead of a
/// Rust-side parameter.
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)
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
.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 {
@@ -149,7 +180,7 @@ mod tests {
#[test]
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();
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
}
@@ -157,7 +188,7 @@ mod tests {
#[test]
fn chained_bind_reconstructs_positional_vector() {
// 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[0]", Scalar::f64(0.5));
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()));
// 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!(
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["weights[1]"],
@@ -187,4 +218,14 @@ mod tests {
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()));
}
/// 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 }],
);
// 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[0].name, "weights[0]");
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
/// 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
@@ -190,47 +164,6 @@ mod tests {
use aura_core::AnyColumn;
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.
fn run_f64_fold(fold: FoldKind, values: &[f64]) -> Vec<(Timestamp, Vec<Scalar>)> {
let (tx, rx) = mpsc::channel();
+1
View File
@@ -29,6 +29,7 @@ impl CarryCost {
"CarryCost",
Vec::new(),
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())),
)
}
@@ -28,6 +28,7 @@ impl ConstantCost {
"ConstantCost",
Vec::new(),
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())),
)
}
+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
/// extra inputs, the standard 3-field cost output, the given params, and a build
/// closure. The single home for the cost-node schema shape.
/// extra inputs, the standard 3-field cost output, the given params, a
/// 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(
name: &'static str,
extra_inputs: Vec<PortSpec>,
params: Vec<ParamSpec>,
doc: &'static str,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> PrimitiveBuilder {
let mut inputs = geometry_input_ports();
inputs.extend(extra_inputs);
PrimitiveBuilder::new(
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,
)
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params, doc }, build)
}
#[cfg(test)]
@@ -501,6 +495,7 @@ mod tests {
"Probe",
extra.clone(),
params.clone(),
"cost-model node: probe factor for the builder-assembly test",
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
);
let schema = builder.schema();
@@ -537,6 +532,21 @@ mod tests {
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]
fn producer_and_aggregator_share_the_triple() {
// 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();
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
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());
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> =
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
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.
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
/// (`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
@@ -32,10 +38,29 @@ impl CostSum {
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
}
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19),
/// captured by the build closure (no per-build params). The input names are a
/// lockstep contract with the connect side (`cost[k].<field>`).
pub fn builder(n_costs: usize) -> PrimitiveBuilder {
/// Roster factory: zero-arg, arg-bearing. `n_costs` is topology (fixed per
/// blueprint, C19), now taken through the `args` channel instead of a
/// Rust-side parameter.
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);
for k in 0..n_costs {
for field in COST_FIELD_NAMES {
@@ -63,6 +88,13 @@ impl CostSum {
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 {
@@ -140,7 +172,7 @@ mod tests {
#[test]
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();
assert_eq!(
names,
@@ -161,4 +193,15 @@ mod tests {
fn new_panics_on_zero() {
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",
volatility_input_ports(),
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())),
)
}
+32 -19
View File
@@ -8,22 +8,27 @@
//! takes it as an injected resolver, and a project `cdylib` later supplies its
//! own (C16) through the same seam.
//!
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are
//! resolvable. Builders that take structural construction arguments
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`)
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id`
//! Scope (#155, extended #271): the roster resolves any type whose factory is
//! `Type::builder()` with zero RUST-side arguments — this now includes
//! arg-bearing types (`Session`, `LinComb`, `CostSum`), whose `builder()` is
//! itself zero-arg and returns a *pending* `PrimitiveBuilder`, configured
//! 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`)
//! rather than guessing. Serialising structural-axis construction args is a
//! later, additive extension (#156/C20).
//! rather than guessing.
use aura_backtest::PositionManagement;
use aura_market::{Resample, SessionFrankfurt};
use aura_market::{Resample, Session, SessionFrankfurt};
use aura_std::{
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, Max, Min, Mul, RollingMax,
RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, LinComb, Max, Min, Mul,
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;
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
@@ -67,6 +72,7 @@ std_vocabulary_roster! {
"CarryCost" => CarryCost,
"Const" => Const,
"ConstantCost" => ConstantCost,
"CostSum" => CostSum,
"CumSum" => CumSum,
"Delay" => Delay,
"Div" => Div,
@@ -75,6 +81,7 @@ std_vocabulary_roster! {
"FixedStop" => FixedStop,
"Gt" => Gt,
"Latch" => Latch,
"LinComb" => LinComb,
"LongOnly" => LongOnly,
"Max" => Max,
"Min" => Min,
@@ -85,6 +92,7 @@ std_vocabulary_roster! {
"RollingMin" => RollingMin,
"Scale" => Scale,
"Select" => Select,
"Session" => Session,
"SessionFrankfurt" => SessionFrankfurt,
"Sign" => Sign,
"Sizer" => Sizer,
@@ -103,8 +111,11 @@ mod tests {
/// 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
/// agreement even though the match arms and the list share the same
/// (mistyped) literal; construction-arg nodes and sinks stay absent so the
/// loader fails cleanly rather than guessing.
/// (mistyped) literal; an unresolvable id and a sink stay absent so the
/// 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]
fn std_vocabulary_resolves_known_and_rejects_unknown() {
for &type_id in std_vocabulary_types() {
@@ -116,9 +127,9 @@ mod tests {
"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("LinComb").is_none());
assert!(std_vocabulary("LinComb").is_some());
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
/// all — is not catchable here (no enumeration of zero-arg builders
/// 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]
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
// 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(&"nope"));
// count guard: pins the roster at exactly 33 entries
assert_eq!(std_vocabulary_types().len(), 33);
// count guard: pins the roster at exactly 36 entries
assert_eq!(std_vocabulary_types().len(), 36);
}
}
@@ -185,7 +185,6 @@ fn the_shell_defines_no_domain_modules_and_no_lib_target() {
"render.rs",
"research_docs.rs",
"scaffold.rs",
"verb_sugar.rs",
],
"C28 (#295): the shell holds argv/dispatch, argv->document translation, \
and presentation only a new module needs a library home"
+254 -53
View File
@@ -10,7 +10,7 @@ JSON artifact kinds you author headlessly along that arc:
through `aura process validate|introspect|register|show`;
3. a **campaign document** (role 6b) — experiment intent (instruments ×
windows × strategy × axes × process) through `aura campaign
validate|introspect|register|show|run|runs`.
validate|introspect|register|runs|show`, executed by `aura exec` (#319).
Each section below is a worked, verified example — every command shown was
run against this repo and the output is transcribed, not invented. The *why*
@@ -39,9 +39,11 @@ the same shape every node in `aura-std` already follows.
scaffolds a **data-only** project (`docs/project-layout.md`, "A project
repo (two tiers)") — a strategy over the std vocabulary is a
blueprint/campaign document, not Rust: the scaffold ships **one** closed
`signal.json` starter that serves both verbs`aura run` uses its bound
values as-is, `aura sweep --axis <bp>.fast.length=2,4,8` overrides them
(bound = default, not fixed); `--list-axes` lists the open knobs and the
`signal.json` starter that serves both legs of `exec` (#319) — `aura exec
signal.json` uses its bound values as-is, `aura exec signal.json --override
fast.length=8` reopens one for that run, and a campaign document's
`strategies[].axes` reopens one across a family (bound = default, not
fixed); `aura graph introspect --params` lists the open knobs and the
bound defaults alike, so every override target is discoverable.
- **A project-specific *native* node type** — the moment §0's three-part
pattern is actually needed — lives in an attached **node crate**:
@@ -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)
and appends its path to the project's `Aura.toml [nodes]` section.
- **A block promoted to universal** — reused across projects and folded into
the engine itself — lives in `crates/aura-std/src/`, unprefixed, and is
rostered in `crates/aura-std/src/vocabulary.rs` instead of a node crate's
the engine itself — lives in `crates/aura-std/` (or its sibling domain
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
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
emits a starter pair — see the worked example below). Add one match arm
to `vocabulary()` and one entry to `type_ids()`'s slice.
- **Std-side** (only when promoting a node into `aura-std` itself): one
line in the `std_vocabulary_roster!` macro invocation in
[`crates/aura-std/src/vocabulary.rs`](../crates/aura-std/src/vocabulary.rs)
- **Std-side** (only when promoting a node into the shipped std
vocabulary): one line in the `std_vocabulary_roster!` macro invocation in
[`crates/aura-vocabulary/src/lib.rs`](../crates/aura-vocabulary/src/lib.rs)
`"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
`LoadError::UnknownNodeType` naming the missing id, and the type is simply
absent from `aura graph introspect --vocabulary` — never a silent partial
@@ -221,20 +229,37 @@ $ aura graph build < smacross.json > blueprint.json
Nodes are referenced by an **identifier** (given by `add`, see below); ports
are dotted `<identifier>.<port>` on both sides of a wire.
### The nine ops
### The eleven ops
| op | JSON shape | does |
|---|---|---|
| `name` | `{"op":"name","name":<str>}` | set the composite's **render name**, script-level and at-most-once (a second `name` op refuses: `a script names its blueprint at most once`). The name must be a single path segment (non-empty, no `/`, `\`, `.` or `..`) since it keys a trace directory (`traces/<name>/`) at run time. Omitting the op keeps the CLI's own default, `"graph"`. |
| `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — 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`. |
| `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. |
| `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>?,"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). |
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura run` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a sweep leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura exec` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a campaign member run leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
| `gang` | `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` | Fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space and `as` replaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound). |
Omitting `name` is fine for a single strategy, but it has three live
consequences once a project has more than one: every unnamed store document
is indistinguishable by name (`"graph"` again), every unnamed strategy's
default tap recording lands in the same shared `traces/graph/` directory (a
second unnamed strategy's run overwrites the first's), 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
the first and third of these — giving each blueprint its own distinct name
stops *different* blueprints from colliding with each other. It does NOT by
itself dissolve the second: two runs of the *identical*, now uniquely-named
blueprint still share one `traces/<name>/` directory, so a later run still
overwrites an earlier one's trace — naming separates blueprints from each
other, not a blueprint's own repeated runs from each other (that trace-dir
question is tracked separately, not resolved here).
Value forms are the typed-tag representations used everywhere in this
family of artifacts:
@@ -267,9 +292,10 @@ corpus's own example, verified below; byte-identical to the on-disk
A `bind` in an `add` op pins that param to a value and removes it from the
**open** param space (`--params`): a bound param is a **default** (#246) — a
run uses it as-is, while any campaign axis or `aura sweep --axis` naming it
re-opens it for that family and binds it per cell. `--list-axes` lists it
after the open knobs as `<name>:<KIND> default=<value>`. `bind` is for a
plain `exec` uses it as-is, while a campaign axis naming it re-opens it for
that family (bound per cell) and `exec`'s own `--override NODE.PARAM=VALUE`
(#319) re-opens it for that one execution. `graph introspect --params` lists
it after the open knobs as `<name>:<KIND> default=<value>`. `bind` is for a
value the strategy carries by default; leave a param unbound, as all three
are here, to make binding it mandatory for every sweep.
@@ -277,7 +303,7 @@ A param therefore has three states: **open** (an axis every sweep MUST bind),
**bound** (a default any axis MAY override, #246), and **ganged** (open, but
fused with its siblings under ONE public knob declared by a `gang` op; the
member addresses are unbindable and only the gang's own single-segment name —
wrapped like any knob, e.g. `graph.channel_length` — appears in `--list-axes`).
e.g. `channel_length` — appears in `graph introspect --params`).
### Worked example: declaring a measurement tap
@@ -306,13 +332,96 @@ bias scaling) under the name `spread`:
The built blueprint carries a `taps` array naming the resolved wire
(`{"name":"spread","from":{"node":<sub's index>,"field":0}}` — the name is
addressed, the index is resolved *for* you). A single `aura run <blueprint>`
addressed, the index is resolved *for* you). A single `aura exec <blueprint>`
then constructs a recorder at that point and persists the `spread` series as a
`ColumnarTrace` in the run's trace store, chartable by its name. A tap is inert
in a sweep (no per-cell recorder) — it is a single-run observation surface.
in a campaign member run (no per-cell recorder) — it is a single-run
observation surface (or, per campaign, what a nominee's `persist_taps`
re-run records, §3).
(`sub.value` is tapped here purely to illustrate; any interior output field is
a legal tap source, and a producer needs no other consumer to be tapped.)
### Worked example: a `LinComb` op-script
`LinComb` is **arg-bearing** (#271): `graph introspect --node LinComb` shows
only the `arity` arg and the pending note until `args` supplies it — the
ports (`term[0]`, `term[1]`, …) and params (`weights[0]`, `weights[1]`, …)
form only once `arity` is real, one pair per term. A minimal weighted blend
of two sources:
```json
[
{"op": "source", "role": "a", "kind": "F64"},
{"op": "source", "role": "b", "kind": "F64"},
{"op": "add", "type": "LinComb", "name": "combo",
"args": {"arity": "2"}, "bind": {"weights[0]": {"F64": 0.7}}},
{"op": "feed", "role": "a", "into": ["combo.term[0]"]},
{"op": "feed", "role": "b", "into": ["combo.term[1]"]},
{"op": "expose", "from": "combo.value", "as": "blend"}
]
```
`args` fixes the arity BEFORE `bind`/`feed` can address `term[i]`/`weights[i]`
at all — the same closed-args-then-bind order every arg-bearing type follows
(§0/#271). `weights[0]` is bound to `0.7` here, so it becomes a default
(#246); the unbound `weights[1]` stays a required axis, appearing in `graph
introspect --params` as `combo.weights[1]:F64` (alongside
`combo.weights[0]:F64 default=0.7`).
### Worked example: wiring a cost model (`ConstantCost``CostSum`)
`CostSum`'s `cost[k].<field>` inputs (C10) are meaningless fed from a bare
source: they read the co-temporal record a real `PositionManagement` node
emits (`closed`, `open`, `entry_price`, `stop_price`). Every node in that
chain — `Bias`, `FixedStop`, `Sizer`, `PositionManagement`, `ConstantCost`,
`CostSum` — sits in the closed vocabulary, so the whole cost-model wiring is
honestly expressible by hand, one `add`/`connect` pair at a time, mirroring
the Rust `risk_executor` + `cost_graph` composite-builders (`aura-composites`)
field-for-field:
```json
[
{"op": "source", "role": "price", "kind": "F64"},
{"op": "source", "role": "signal", "kind": "F64"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
{"op": "feed", "role": "signal", "into": ["bias.signal"]},
{"op": "add", "type": "FixedStop", "name": "stop", "bind": {"distance": {"F64": 0.01}}},
{"op": "add", "type": "PositionManagement", "name": "pm"},
{"op": "feed", "role": "price", "into": ["stop.price", "pm.price"]},
{"op": "add", "type": "Sizer", "name": "sizer", "bind": {"risk_budget": {"F64": 1.0}}},
{"op": "connect", "from": "bias.bias", "to": "sizer.bias"},
{"op": "connect", "from": "stop.stop_distance", "to": "sizer.stop_distance"},
{"op": "connect", "from": "bias.bias", "to": "pm.bias"},
{"op": "connect", "from": "stop.stop_distance", "to": "pm.stop_distance"},
{"op": "connect", "from": "sizer.size", "to": "pm.size"},
{"op": "add", "type": "ConstantCost", "name": "cc", "bind": {"cost_per_trade": {"F64": 0.5}}},
{"op": "connect", "from": "pm.closed_this_cycle", "to": "cc.closed"},
{"op": "connect", "from": "pm.open", "to": "cc.open"},
{"op": "connect", "from": "pm.entry_price", "to": "cc.entry_price"},
{"op": "connect", "from": "pm.stop_price", "to": "cc.stop_price"},
{"op": "add", "type": "CostSum", "name": "costsum", "args": {"n_costs": "1"}},
{"op": "connect", "from": "cc.cost_in_r", "to": "costsum.cost[0].cost_in_r"},
{"op": "connect", "from": "cc.cum_cost_in_r", "to": "costsum.cost[0].cum_cost_in_r"},
{"op": "connect", "from": "cc.open_cost_in_r", "to": "costsum.cost[0].open_cost_in_r"},
{"op": "expose", "from": "pm.realized_r", "as": "realized_r"},
{"op": "expose", "from": "costsum.cost_in_r", "as": "cost_in_r"}
]
```
`CostSum`'s port names ARE the `cost[k].<field>` shape already
(`cost[0].cost_in_r`, …) — `cost_port` is baked into `CostSum::make`'s schema
itself (#271), no composite-level renaming needed; a second factor is
`args: {"n_costs": "2"}` plus a second cost-node block wired into
`cost[1].*`, and `CostSum` per-field-sums whatever arity it was given (#341
item 4). This closes the "CostSum reachable but pathless" gap: the ports
genuinely connect at the op-script level, using only closed-vocabulary
nodes. It is a standalone wiring reference, though, not the production
attachment path — a campaign strategy's real cost model still comes from
the campaign document's `cost:` block (C10, `cost_nodes_for`), wrapped
around the user's bias-only blueprint entirely in Rust at run time; a
user's op-script-authored blueprint never embeds `PositionManagement` /
cost nodes itself.
### Session anchoring: the `SessionFrankfurt` preset
To anchor logic to the trading session, the closed vocabulary ships a
@@ -337,10 +446,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
summer (CEST) and winter (CET).
The index is derived from the clock (`ctx.now()`), never counted from trigger
firings — so the trigger's cadence only sets how *often* the node emits, not
what it emits. Feeding a denser stream (e.g. a raw m1 `price`) is equally
valid: the node fires once per input tick and reports the same
`period_minutes`-bar index throughout that bar. The once-per-bar wiring above
is the convention when you want exactly one emission per completed bar; it is
not a correctness requirement.
Wire it like any node —
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
**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
```
@@ -363,6 +504,9 @@ SMA
param length:I64 (bind {"I64": <v>})
$ aura graph introspect --unwired < partial.json # open slots of a partial op-script
sub.rhs:F64
$ aura graph introspect --taps tapped.json # one row per declared tap: name, source wire, kind
fast_ma fast.value F64
spread sub.value F64
$ aura graph introspect --content-id smacross.json # SHA-256 of the canonical form
597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876dafba9a
$ aura graph introspect --content-id smacross.json --identity-id # + debug-name-blind identity id, combinable
@@ -374,23 +518,42 @@ slow.length:I64
bias.scale:F64
```
These printed names are the **raw param-space namespace**: op-script params
and a campaign document's `strategies[].axes` keys (§3) share this one raw
form. There is a third, *wrapped* surface: the dissolved `aura sweep
<blueprint> --axis` CLI (glossary `sweep`) accepts only the names `aura
sweep <blueprint> --list-axes` prints — the raw name prefixed with the
root-composite instance name, `graph.<param>` — never the raw form; the
campaign document the sweep sugar generates still stores the raw form
(#210):
These printed names are the **one** axis namespace (#328): op-script params
and a campaign document's `strategies[].axes` keys (§3) speak the same raw
`<node>.<param>` form as `graph introspect --params` prints (open params
bare, bound params trailing `default=<value>`) — every discovered name is
verbatim legal as a document axis key, bound params included (#246's
re-open contract), and as the token `exec --override NODE.PARAM=VALUE` (#319)
takes on the single-run leg:
```json
"strategies": [ { "ref": { "content_id": "…" },
"axes": { "fast.length": { "kind": "I64", "values": [3, 5, 7, 9] },
"bias.scale": { "kind": "F64", "values": [0.25, 0.5] } } } ]
```
A single execution reopens the same raw name directly, with no document at all:
```
raw (--params, campaign axes): fast.length
wrapped (--list-axes, --axis): graph.fast.length
$ aura exec smacross.json --override fast.length=5 # single run: raw works
```
The older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`,
what pre-#328 transcripts and the retired `--list-axes` output used to print)
is retired from the surface; naming it in a campaign document refuses with a
translation pointer to the raw candidate, the one remaining intake seam:
```
$ aura campaign validate wrapped.json # a document quoting an old transcript
aura: campaign references do not resolve:
strategy f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240:
axis "graph.fast.length" is not in the param space; axis names are raw
node.param paths — did you mean "fast.length"?
```
A ganged knob's raw address has one path segment less than a member address
would — it sits at the composite's own level, like a role name (e.g.
`channel_length`, not `channel_hi.length`) — and wraps identically (`graph.channel_length`).
`channel_length`, not `channel_hi.length`).
`--content-id`, `--identity-id`, `--params`, and `graph register` all accept
**either** shape: the raw op-script array or an already-built `#155`
@@ -405,6 +568,13 @@ registered blueprint 597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876da
The printed content id is the address a campaign document's `strategies[].ref`
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)
A process document is a named, versionable pipeline of **std stage blocks**
@@ -633,9 +803,9 @@ campaign document valid (executable): pipeline shape and static guards pass
```
$ aura campaign register mra_3_campaign_full_v2.json
registered campaign 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd (…/runs/campaigns/42ed…json)
$ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd
{"family_id":"42edebd2-0-GER40-w0-s0-0","report":{...}}
{"family_id":"42edebd2-0-GER40-w0-s0-0-r1","report":{...}}
$ aura exec 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd
{"family_id":"42edebd2-0-GER40-w0-r0-s0-0","report":{...}}
{"family_id":"42edebd2-0-GER40-w0-r1-s0-0","report":{...}}
{"campaign_run":{"campaign":"42edebd2…","process":"cd9127…","run":0,"seed":42,
"cells":[{"strategy":"597d719b…","instrument":"GER40","window_ms":[...],
@@ -654,32 +824,63 @@ $ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b6
```
With two stop regimes every (instrument, window) cell runs twice — the four
cells above are the "4 cell(s)" the validate summary counted. The second
regime's family ids and trace dirs carry the `-r1` ordinal suffix (the
default/first regime stays unsuffixed), each cell record names its regime,
and generalization is keyed per regime — regimes are compared, never pooled.
cells above are the "4 cell(s)" the validate summary counted. The regime
ordinal is an INFIX segment, `-r{k}`, right after the window segment
(`{id8}-{ordinal}-{instrument}-w{k}-r{k}-s{seed}-{member}`) — the
default/first regime carries `-r0` explicitly, never left unmarked — each
cell record names its regime, and generalization is keyed per regime —
regimes are compared, never pooled.
`aura campaign run` is register-then-run sugar for a `.json` file, but the
`aura exec` (#319) is register-then-run sugar for a `.json` file, but the
canonical address is always the content id — running a bare file the first
time registers it implicitly. `aura campaign runs` lists stored
realizations; `aura campaign runs <id>` dumps the bare stored record(s) (not
the `{"campaign_run": …}` emit wrapper above). If `presentation.persist_taps`
is non-empty, the run also persists the named taps under
`runs/traces/<trace_name>/…`, chartable with `aura chart`. The
`sweep`/`walkforward --trace` analog persists every member's taps as a
family charted the same way, by the handle the run prints (members keyed
`<cell>/<member>`) — also by the `--trace <NAME>` you chose, when that name
uniquely names one recorded run (`aura chart <NAME>` resolves it against the
stored campaign documents; a name reused across runs refuses rather than
guessing which one you mean).
is non-empty, the run also persists every member's taps under
`runs/traces/<trace_name>/…` — the deterministic `{campaign8}-{run}` handle
the `campaign_run` record's `trace_name` field prints, never a user-chosen
name — chartable with `aura chart <trace_name>` (members keyed
`<cell>/<member>`); `aura chart` also resolves a bare campaign id/name
against the stored campaign documents when no literal trace directory
matches, refusing rather than guessing when the name is ambiguous across
recorded runs.
Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any
cell runs (invalid document, missing project, unresolvable strategy) exits 1;
a run that **completes with one or more failed cells** exits 3 — the run record
and every healthy cell persist, and the failed cells are named on stderr
(#272). A **gate-emptied cell** — a `std::gate` stage that filters out every
member — is a *successful* cell, not a failed one: the gate legitimately
answered "no survivors", so the run still exits 0, the cell's realization is
recorded as truncated at that stage, and an informational `aura:`-prefixed
note names the cell on stderr. Exit 3 is reserved for cells that could not be
evaluated (faults), never for an empty-but-valid result.
(#272). A directly-run blueprint (`aura exec <blueprint>.json`, no campaign)
has no cells at all: its own refusals — a binding mismatch, a synthetic-data
mismatch, a compile error, a tap-bind content fault (unknown fold/tap,
duplicate) — are the content of the argv-named file and exit 2 like a usage
error; only its environment refusals (a tap-trace store write failure)
exit 1. The no-data family (no local data, no data in the requested window,
no recorded geometry) belongs to the real-data paths: on `reproduce` it
exits 1, while inside a campaign the same fault is contained per cell
(#272) — the run completes and exits 3, naming the failed cells (#297;
the full partition lives in
[C14](design/contracts/c14-headless-two-faces.md)). `aura
reproduce`'s identity/pip guard (a stored member's recorded instrument or
broker/pip label contradicting the source it re-derives against) is
likewise class 1 on the plain verb — its source is derived from the
family's own manifest, so a mismatch there is data drift, not a caller
error (Fork 6, #299).
The class boundary is FORM vs VALUE, not "environment/data/IO vs everything
else": an `--override` value that is well-formed but out of the node's own
domain (e.g. a negative length) is not caught at the compile/bind seam above
— it panics inside the node's own constructor at bootstrap. `aura exec`
catches that panic at the dispatch boundary (`catch_member_panic`) and
renders it as a runtime-class refusal, exit 1 — the same class an
environment/data fault gets, and the same class the campaign leg gives the
identical value as a per-cell fault (#272). A form fault the surface detects
before the node ever runs (kind mismatch, unknown param, compile error)
stays class 2 argv-content; a well-formed value the node itself refuses in
its own domain is class 1, regardless of which leg hit it.
A **gate-emptied cell** — a `std::gate` stage that filters out every member —
is a *successful* cell, not a failed one: the gate legitimately answered "no
survivors", so the run still exits 0, the cell's realization is recorded as
truncated at that stage, and an informational `aura:`-prefixed note names the
cell on stderr. Exit 3 is reserved for cells that could not be evaluated
(faults), never for an empty-but-valid result.
+7
View File
@@ -201,6 +201,13 @@ the repo, not session memory, is the source of truth.
optional gated document `description`); descriptions never influence
execution or identity ids, and registered artifacts are never
retroactively invalidated.
- **[C30 — Stability discipline: artifacts are stable, code is movable](contracts/c30-stability-discipline.md)** —
No Rust-API stability before 1.0 (no semver, no shims): node crates ride
the engine checkout as path deps, external embeddings pin a rev, and the
cdylib handshake refuses non-identical aura-core builds (build-identity
stamp, #348). The compatibility promise attaches to the artifact plane
(C18/C29/invariant 8); no surface may claim "stable API" while this
contract stands.
---
@@ -23,3 +23,7 @@ never line-ordered. Duplicate campaign instruments are refused at both the
validate tier and the executor's preflight: the per-cell family name embeds
the raw instrument string, so uniqueness is what keeps concurrent appends from
racing one name's run-index assignment.
**Current-state cross-command-path example (superseded by the #319 sugar
retirement, 2026-07-25):** "e.g. a swept member's `sqn` vs the same cell
re-run under `aura generalize`".
+3 -2
View File
@@ -5,8 +5,9 @@ loop that reaches a unique state after each input tick. Same input (incl. seed)
→ bit-identical run. Two backtests are fully disjoint and run concurrently
without locking. The bit-identity is *per run*: one backtest of given (inputs,
seed) reproduces byte-for-byte. It does **not** extend to a *derived metric*
recomputed for the same params by two *different command paths* (e.g. a swept
member's `sqn` vs the same cell re-run under `aura generalize`), which may differ
recomputed for the same params by two *different command paths* (e.g. a
campaign cell's `sqn` within its sweep stage vs the same params re-run
through `exec`'s blueprint leg with `--override`, #319), which may differ
by floating-point reassociation (≤1 ULP) because the two paths accumulate the
same logical reduction in a different operation order (IEEE-754 non-associativity).
C1 governs the determinism of a single run, not the cross-command bit-identity of
+5 -4
View File
@@ -284,10 +284,11 @@ with like; costed families reproduce bit-identically (incl. `Carry`).
curves; a cost-less doc requesting it keeps a remedy-naming skip notice. There are
no `--cost-*` run-path flags (removed #221/#234); cost travels in the document.
**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol}` axis is
realized at the campaign-document level as `CampaignDoc.risk: [RiskRegime]`
(`aura-research`, variants `Vol{length, k}` and `VolTf{period_minutes, length, k}`
(#262), the fixed-stop rule additive when needed) — a kept-separate matrix axis,
**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol, VolTf}`
axis is realized at the campaign-document level as `CampaignDoc.risk:
[RiskRegime]` (`aura-research`, variants `Vol{length, k}`,
`VolTf{period_minutes, length, k}` (#262), and `Fixed{distance}` (#338, binding
the shipped `FixedStop` composite)) — a kept-separate matrix axis,
peer of instruments and windows. The executor keys the nominee map by `(strategy,
window, regime)`, so `generalize` aggregates *within* a regime, never across.
Regimes are **compared** at presentation, never argmax-**selected** across (a
@@ -34,3 +34,15 @@ walk-forward families (axes 24 above) build their members over **one** shared
`Arc<DataServer>` (one `FileCache`), so a window is parsed once and every member's
`M1FieldSource` borrows the same cached `Arc<[M1Parsed]>` chunks
(`crates/aura-ingest/src/lib.rs:316`). The single-pass *parse* cost stays tracked as #95.
**Current-state bound-override + identity-anchor clause (superseded by the
#319 sugar retirement, 2026-07-25):** "so axis 1 (param-sweep) may name a
bound param — the family boundary re-opens it on the probe and on every
member reload, and the axis binds it per cell; `run`/`mc` still require
every param resolved (a truly open param refuses). Identity is untouched:
`content_id_of` (`crates/aura-research/src/lib.rs`) and `topology_hash`
(`crates/aura-cli/src/main.rs`) read the authored document, never a
re-opened probe, and each member manifest records its per-cell bindings
(#246, ratified 2026-07-12; the restriction this amends — axes bind only
open knobs — was an implementation consequence of `bind()` shrinking the
param surface, not a recorded decision)."
+19 -7
View File
@@ -5,13 +5,25 @@ RNG-seed) → deterministic run → metrics`. Parameters are typed, ranged, runt
values injected at graph build — no recompile per param-set; the optimizer sees a
generic vector of typed ranges. A **bound blueprint param is that param's
default**: "open" means *must be bound by an axis*, "bound" means *default,
overridable by an axis*, so axis 1 (param-sweep) may name a bound param — the
family boundary re-opens it on the probe and on every member reload, and the axis
binds it per cell; `run`/`mc` still require every param resolved (a truly open
param refuses). Identity is untouched: `content_id_of`
(`crates/aura-research/src/lib.rs`) and `topology_hash`
(`crates/aura-cli/src/main.rs`) read the authored document, never a re-opened
probe, and each member manifest records its per-cell bindings (#246, ratified
overridable by an axis*, so a campaign axis (param-sweep) may name a bound
param — the family boundary re-opens it on the probe and on every member
reload, and the axis binds it per cell; `exec`'s blueprint leg still requires
every param resolved (a truly open param refuses), and its own `--override
NODE.PARAM=VALUE` (#246 residue, #319) reopens exactly the named bound param
for that one execution — the single-member analogue of a campaign axis. The
reopened execution's `topology_hash` stays the base document's own — reference
semantics, not the reopened topology's (#343, revised; full clause in
[C24](c24-blueprint-data.md)'s "Reproduction identity"). Identity is untouched: `content_id_of` (`crates/aura-research/src/lib.rs`) is
the one hashing primitive, read directly off the authored document by
`aura_runner::member`'s `run_signal_r`/`run_blueprint_member` — where the
manifest's `topology_hash` is computed inline by default (`run_signal_r`'s
`topo: Option<&str>` parameter's `None` arm, every caller but one; #319 Task 9
retired the CLI-side wrapper that used to duplicate this one call), or
supplied by the caller as a ready reference-semantics hash (`Some`, #343
revised — `exec`'s override branch passes the loaded base document's own id,
computed before `reopen_all`, so the SAME value reaches both the record line
and the trace-store persistence) — never a re-opened probe either way, and
each member manifest records its per-cell bindings (#246, ratified
2026-07-12; the restriction this amends — axes bind only open knobs — was an
implementation consequence of `bind()` shrinking the param surface, not a recorded
decision). Raw data is shared read-only across sims via `Arc<[T]>` (the
@@ -22,7 +22,8 @@ A research project compiles to an external cdylib exporting one symbol,
`AURA_PROJECT`, a two-tier `#[repr(C)]` `ProjectDescriptor`
(`aura-core::project`, `crates/aura-core/src/project.rs`, emitted by the
`aura_project!` macro). A **C tier**`magic` (`AURAPROJ`),
`descriptor_version`, the rustc-version and aura-core-version stamps, the
`descriptor_version`, the rustc-version and aura-core build-fingerprint
stamps ([C30](c30-stability-discipline.md)), the
namespace, all C-compatible field types — is validated **before** any Rust-ABI
field is touched; a **Rust tier** — the vocabulary resolver
`fn(&str) -> Option<PrimitiveBuilder>` and the enumerable type-id list — is read
@@ -37,7 +38,7 @@ via `cargo metadata` (debug default, `--release` opt-in) and loads it
**load-and-hold** — the `Library` is leaked, never unloaded, so its `'static`
strings and fn-pointers stay valid (`aura-runner::project::load`,
`crates/aura-runner/src/project.rs`). `validate_c_tier` checks the four stamps
front-to-back; a mismatch of either version stamp refuses with exit 1 naming
front-to-back; a mismatch of either stamp refuses with exit 1 naming
both sides (`ProjectError::Incompatible`). The project vocabulary is
charter-checked at load (`check_charter`): non-empty namespace, every id
`<ns>::`-prefixed, no duplicate, no collision against the std vocabulary,
@@ -27,3 +27,27 @@ every hand-rolled usage line reads `Usage: aura <verb> …` (#179, cycle 0101);
refusal diagnostics stay unprefixed (diagnostics are not usage lines). The
machine-first help surface (JSON/manifest help, stdin op-scripts) stays on the
#157/C21 track, not this cycle (settled as human/GNU convention compliance).
**Current-state "Dual grammar" section (superseded by the #319 sugar
retirement, 2026-07-25):** "The four dual-grammar subcommands
(run/sweep/walkforward/mc) keep both grammars under one token via an
optional `[blueprint]` positional plus a post-parse `is_file()` dispatch; the
execution layer is unchanged (arg-plumbing via thin `*_from` adapters). The
machine-first help surface (JSON/manifest help, stdin op-scripts) is
deferred to the #157 / C21 track, distinct from this human/GNU-convention
compliance."
**Current-state "Single document grammar" section's first-draft routing
sentence (superseded by the #319 sugar retirement, 2026-07-25):** "one verb,
`exec <target>`, dispatches on `is_blueprint_file` (a first-positional
naming an existing `.json` file selects the loaded-blueprint leg; anything
else — a campaign file or a registered content id — the campaign leg)." This
undersold the routing by one layer: the code always also peeked the file's
top-level `"kind"` field (`is_campaign_document_file`) to tell a campaign
document ending in `.json` from a blueprint file — both being ordinary
readable `.json` files `is_blueprint_file` alone cannot distinguish — and,
after an independent review found a malformed campaign file was silently
misrouted to the blueprint leg's own JSON-parse error prose, gained a
neutral not-valid-JSON refusal ahead of the leg choice (review Minor-3,
2026-07-25's fix cycle). The current contract's "Single document grammar"
section names this real routing.
+101 -6
View File
@@ -59,12 +59,107 @@ caller branches on the failure class without parsing stderr.
in `crates/aura-cli/src/main.rs`, threaded from the run registry —
[C18](c18-registry.md)).
**Dual grammar.** The four dual-grammar subcommands (run/sweep/walkforward/mc)
keep both grammars under one token via an optional `[blueprint]` positional plus
a post-parse `is_file()` dispatch; the execution layer is unchanged (arg-plumbing
via thin `*_from` adapters). The machine-first help surface (JSON/manifest help,
stdin op-scripts) is deferred to the #157 / [C21](c21-world.md) track, distinct
from this human/GNU-convention compliance.
**`aura-runner`'s single-run refusals now honor this same partition (#297,
2026-07-26).** Before this cycle, every refusal inside `aura-runner`'s
single-run verb paths (`aura exec <blueprint.json>`, both the bias-output and
bare-measurement legs) exited 1 uniformly — a `std::process::exit(1)` baked
into the library itself, bypassing the partition above rather than
instantiating it. The #297 conversion turns every such site into a returned
`RunnerError { exit_code, message }` the CLI prints and exits on; the class
is now assigned by the same criterion as the rest of this partition — is the
fault in the content of what argv named, or in the environment the command
needs? The binding refusal, the synthetic/binding mismatch, the blueprint
compile error, and tap-bind content faults (unknown fold, unknown tap,
duplicate bind) are argv-named-content faults and exit 2; the no-local-data /
no-data-in-window / no-recorded-geometry refusals and a tap-trace store I/O
failure (`bound.finish`) are environment faults and stay at 1. `cost_knob`
(via `aura-runner::translate::cost_nodes_for`) has no single-run production
caller at all — its two production callers are both worker-side: reached
from inside a sweep worker (`run_blueprint_member`) it surfaces as a per-cell
`MemberFault::Bind` — never a process exit (the campaign contract
[C28](c28-stratification.md) already guaranteed); reached from
`persist_campaign_traces`'s C1 drift-alarm re-run it propagates as a `String`
through that function's existing channel, exiting 1 via the campaign run
summary (`campaign_run.rs`'s `?` after the run record is already persisted).
**The class-1 set is not only environment/data/IO.** It also covers the exec
blueprint leg's bootstrap domain-refusal path: an `--override` value that is
well-formed but out of a node's own domain (e.g. a negative length) is not
caught at the compile/bind seam above — it panics inside the node's own
constructor at bootstrap. `aura exec` catches that panic at the dispatch
boundary (`catch_member_panic`, `crates/aura-cli/src/main.rs`) and renders it
as a runtime-class refusal, exit 1 — the same class the campaign leg gives
the identical value as a per-cell fault (#272). The boundary is FORM vs
VALUE: a form fault the surface detects before the node ever runs (kind
mismatch, unknown param, compile error) is class 2 argv-content; a
well-formed value the node itself refuses in its own domain is class 1,
regardless of which leg hit it.
**The reproduce guard's classes are context-borne (#299, 2026-07-26).**
Reproducing a stored family refuses up front when the effective data
source's identity or pip/broker geometry contradicts the member manifests
(divergence would be mechanical — refusing beats reporting a false
`DIVERGED`). The refusal prose is one; the class is carried by who named
the source: the explicit-source library seam (`reproduce_family_in`)
refuses class 2 (the caller named it), while the plain verb's derived
path — the source is reconstructed from the manifest — refuses the same
drift as class 1 (environment/data: a re-ingested sidecar, a stored label
outside the known shapes; nobody named the faulting input).
**Single document grammar (#319, 2026-07-25).** The five dual-grammar
subcommands (run/sweep/walkforward/mc/generalize) that used to keep two
grammars under one token are retired; one verb, `exec <target>`, dispatches
on `is_blueprint_file` layered with a "kind"-field peek
(`classify_exec_document_file`): a first-positional naming an existing
`.json` file that parses as JSON and carries no top-level `"kind"` selects
the loaded-blueprint leg; a file carrying `"kind"`, or a target that is not a
readable `.json` file at all (a registered content id), selects the campaign
leg. A file that does not parse as JSON at all refuses neutrally (exit 2)
before either leg's own document-shape validation runs, rather than
silently choosing a leg (review Minor-3, 2026-07-25). The execution layer
this collapses onto is unchanged in kind (arg-plumbing via thin adapters).
The machine-first help surface (JSON/manifest help, stdin op-scripts) is
deferred to the #157 / [C21](c21-world.md) track, distinct from this
human/GNU-convention compliance.
**`exec`'s target-classification exit partition is pinned, precisely scoped
(#342 item 5).** The partition below governs exec's TARGET-CLASSIFICATION
refusals at the routing seam — the shape-peek (`classify_exec_document_file`)
that decides which leg, if any, a target reaches, strictly before either
leg's own document-shape validation ever runs. A target fault at this seam
splits along the same usage/runtime line the exit-code partition above
already draws, keyed on whether a *real, existing document's content* is
being judged (usage, exit 2 — the content of an argv-named file is itself
part of the argument) or the target simply fails to resolve to any recorded
state at all (runtime, exit 1 — the needed environment/recorded state is
missing). Missing-state faults (exit 1): a target naming no readable file and
no plausible content id (`'{target}' is neither a readable .json file nor a
64-hex content id`), and a well-formed-but-unregistered 64-hex content id
(`no campaign {id} in the project store`) — no file or store entry exists to
have content in either case. File-content faults at the routing seam (exit
2): a file that does not parse as JSON at all, a valid-JSON op-script array
(not a document exec can execute at all — it names the `graph build`
escalation instead), and a kind-bearing document whose `"kind"` is not
`"campaign"` (e.g. a process document handed to `exec`) — all three are a
real, readable file whose content the ROUTING seam itself cannot classify
into a leg. The wrong-kind case is the one realignment this pin made (#342
item 5): it shipped at exit 1 (mirroring the campaign leg's own missing-state
refusals) until this pass, despite being a routing-seam content fault like
its op-script and not-valid-JSON siblings, not a missing-state fault like the
file-vs-id cases — realigned to exit 2 to match its true class, the one
sanctioned behaviour change of this pin.
Once a target classifies as campaign or blueprint, it falls under that leg's
**own** established validation partition, not this one — and the two legs'
content-validation exits differ today, named honestly rather than harmonized:
a `kind:"campaign"` document whose *content* then fails the campaign
doc-tier gate (e.g. an empty axis) exits 1, runtime-class, per the
established campaign contract (pinned by
`campaign_run_invalid_file_refuses_before_touching_store`), while the
blueprint leg's own content faults (a blueprint envelope that fails to parse
or build) exit 2, usage-class. No behaviour changes here — only this pin's
scope is named precisely: it is the routing seam's partition, not a
uniform content-validation rule across both legs.
**Two artifact classes, two redundancy budgets** (#249, ratified 2026-07-13).
The data layer the programmatic face emits is a public interface read raw (by
@@ -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 node `Session` (`crates/aura-market/src/session.rs`) emits
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct, over a
baked Frankfurt open), and takes no scalar params; the zero-arg
`SessionFrankfurt` roster preset (#261) exposes it with a single
`period_minutes` knob. This is a deliberate narrowing pinned in the node's own
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct), over a
timezone/open time that is now data-authorable via the typed construction-args
channel (`Session` roster-reaches `std_vocabulary` as an arg-bearing type,
#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)`
subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which
never match), and nothing consumes a `session_open_ts: timestamp`. The two
@@ -271,3 +271,27 @@ and both callers are untouched; maintenance is lazy-only — put-time indexing
was rejected because it would need a roster-free doc-level identity function
whose equivalence to the loaded-composite path no green test ratifies (decision
log: #191 comments).
**Current-state `runs.jsonl`/`families.jsonl` CLI clauses (superseded by the
#319 sugar retirement, 2026-07-25):** "No live producer writes it today —
sweep / walk-forward / mc persist to the family store and `aura run` does not
persist — so the flat lib API is retained but selectively live: `rank_by`
backs `aura runs family … rank`, `optimize` backs walk-forward's in-sample
step (`aura-campaign` and the registry's own selection helper both call it),
while `append`/`load` remain public API with no in-tree caller (a latent
surface for external consumers)." … "CLI: `aura runs families`, `aura runs
family <id> [rank <metric>]`; `aura sweep`/`walkforward`/`mc` persist via
`append_family` with an optional `--name`."
**Current-state "Cross-instrument generalization" paragraph (superseded by
the #319 sugar retirement, 2026-07-25):** "`FamilyKind::CrossInstrument`:
`aura generalize` runs one candidate across an instrument list and persists
the M per-instrument runs via `append_family`, each member self-identifying
through `RunManifest.instrument`. The generalization score (worst-case R
floor + sign-agreement + per-instrument breakdown) is a **recomputable
aggregate** over those members, not a persisted family-level record."
**Current-state "The campaign executor" opening sentence (superseded by the
#319 sugar retirement, 2026-07-25):** "`aura campaign run <file|content-id>`
executes a campaign (a file is register-then-run sugar; the content id is
canonical): a zero-fault referential gate, then the process pipeline."
+39 -18
View File
@@ -39,9 +39,10 @@ by aura. The experiments-&-results plane is the run registry in `aura-registry`
- `runs.jsonl` — the append-only flat store, one `RunReport` per line
(`RunManifest` + `RunMetrics`), with a typed read-path (`load`) and best-first
ranking (`rank_by`/`optimize`). No live producer writes it today — sweep /
walk-forward / mc persist to the family store and `aura run` does not persist —
so the flat lib API is retained but selectively live: `rank_by` backs
ranking (`rank_by`/`optimize`). No live producer writes it today — the campaign
executor (`exec`'s campaign leg, `aura-campaign`, #319) persists to the family
store instead, and `exec`'s blueprint leg (single run) does not persist — so
the flat lib API is retained but selectively live: `rank_by` backs
`aura runs family … rank`, `optimize` backs walk-forward's in-sample step
(`aura-campaign` and the registry's own selection helper both call it), while
`append`/`load` remain public API with no in-tree caller (a latent surface for
@@ -49,14 +50,16 @@ by aura. The experiments-&-results plane is the run registry in `aura-registry`
- `families.jsonl` — the family store. A sweep / Monte-Carlo / walk-forward /
cross-instrument run persists as a *set of related records*, each a
`FamilyRunRecord` (a `RunReport` stamped with `family` + `run` + `kind` +
`ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`).
`group_families` re-derives a family from the stored links (re-listable /
rankable as a unit — C21). The user-facing `family_id = "{family}-{run}"` handle
is **derived** from the stored `family` name plus a per-name `run` index
(numeric max+1 — not a content hash, so re-running the same family mints a fresh
id). CLI: `aura runs families`, `aura runs family <id> [rank <metric>]`;
`aura sweep`/`walkforward`/`mc` persist via `append_family` with an optional
`--name`.
`ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`,
the last now dead — below). `group_families` re-derives a family from the
stored links (re-listable / rankable as a unit — C21). The user-facing
`family_id = "{family}-{run}"` handle is **derived** from the stored `family`
name plus a per-name `run` index (numeric max+1 — not a content hash, so
re-running the same family mints a fresh id). CLI: `aura runs families`,
`aura runs family <id> [rank <metric>]`; the campaign executor persists per-cell
sweep/walk-forward families via `append_family` under a deterministic,
campaign-derived name — no user-supplied `--name` (the retired quintet's
naming flag, #319).
- `campaign_runs.jsonl` — one thin `CampaignRunRecord` per campaign run (below),
over untouched family records.
- `blueprints/<hash>.json`, `processes/`, `campaigns/` — the content-addressed
@@ -109,12 +112,13 @@ the byte-exact `topology_hash` keeps every debug role untouched (introspection-o
the blueprint does not use leaves the id byte-stable. `--content-id` and
`--identity-id` are combinable.
**Cross-instrument generalization.** `FamilyKind::CrossInstrument`: `aura generalize`
runs one candidate across an instrument list and persists the M per-instrument runs
via `append_family`, each member self-identifying through `RunManifest.instrument`.
The generalization score (worst-case R floor + sign-agreement + per-instrument
breakdown) is a **recomputable aggregate** over those members, not a persisted
family-level record.
**Cross-instrument generalization (retired standalone family, #319).** The
`FamilyKind::CrossInstrument` variant is now dead: the standalone `aura
generalize` verb that used to run one candidate across an instrument list and
persist the M per-instrument runs via `append_family` is retired. The
surviving cross-instrument grading is `std::generalize`'s campaign-scope
computation (below, "Annotators are terminal") — a **recomputable aggregate**
over the cells' nominees, not a persisted family-level record.
**Research-artifact document stores.** `processes/` and `campaigns/` hold two
document types (C25 roles 5 / 6b): the **process document** (a named
@@ -133,7 +137,24 @@ show <content-id>` prints a registered document's canonical bytes (#300), so the
generate → retrieve → hand-extend → re-register loop needs no direct store
filesystem access.
**The campaign executor.** `aura campaign run <file|content-id>` executes a
**The bound-override coincidence (#246, ratified #328).** A campaign axis naming a
**bound** param of the referenced blueprint is deliberately accepted alongside one
naming an open param: `validate_campaign_refs` checks each axis's name against
`param_space()` **OR** `bound_param_space()` — the bound value stands as the
axis's default, and the axis's own values re-open it for that campaign. There is
no schema flag distinguishing the two cases (no `open`/`bound` marker on the axis
entry); name coincidence against one of the two namespaces IS the mechanism, and
the referenced blueprint is the single source of truth for which namespace a name
falls in. Pinned by
`referential_tier_accepts_a_kind_correct_axis_over_a_bound_param`
(`aura-registry/src/lib.rs`). The single-run analogue — `exec`'s own
`--override NODE.PARAM=VALUE` reopening a bound param for one execution — now
truthfully has the identical hash consequence (#343, revised): both legs
stamp the referenced/loaded document's own id, never the reopened topology's,
exactly as a reopened campaign member's has always been ([C24](c24-blueprint-data.md)'s
"Reproduction identity" carries the full clause).
**The campaign executor.** `aura exec <file|content-id>` (#319) executes a
campaign (a file is register-then-run sugar; the content id is canonical): a
zero-fault referential gate, then the process pipeline. The executable shape is
`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?`
@@ -34,3 +34,16 @@ scaffolding's last data weld — `wrap_r`'s hard-wired `price`←close role and
`M1Field::Close`-only open sites — is retired; a strategy's input roles now bind
archive columns by name (C26). `wrap_r`'s remaining R-scaffolding retirement
stays #159.]
**Current-state "Pre-C24 scaffolding retired" paragraph's family-builder
naming (superseded by the #319 sugar retirement, 2026-07-25):** "The family
builders are now generic and blueprint-driven: `blueprint_sweep_family`,
`blueprint_walkforward_family`, `blueprint_mc_family` (and
`blueprint_sweep_over`) in `crates/aura-runner/src/family.rs`." Those four
functions are themselves retired by #319 (2026-07-25): the campaign executor
(`aura_campaign::exec::execute`) builds every family now, driven by the
process document's stage pipeline; `family.rs` keeps only the synthetic-
stream helpers `aura_runner::reproduce` calls to re-derive a recorded family
(`showcase_prices`, `walkforward_prices`, `walkforward_window_source`,
`synthetic_walk_sources`, `DataSource`/`DataChoice`). The current contract's
"Pre-C24 scaffolding retired" paragraph names this real state.
+14 -7
View File
@@ -71,13 +71,20 @@ rather than a hand-enumerated menu. Persisted experiment *intent* is the campaig
document (C25/C18).
**Pre-C24 scaffolding retired.** The pre-C24 scaffolding — `HarnessKind` and the
per-strategy `*_sweep_family` functions — is **gone**. The family builders are now
generic and blueprint-driven: `blueprint_sweep_family`, `blueprint_walkforward_family`,
`blueprint_mc_family` (and `blueprint_sweep_over`) in
`crates/aura-runner/src/family.rs`. The R-evaluator scaffold `wrap_r`
(defined in `crates/aura-runner/src/member.rs`; imported and called from
`runner.rs`) survives; its last hard data weld — the
hard-wired `price`←close role and the `M1Field::Close`-only open sites — is retired
per-strategy `*_sweep_family` functions — is **gone**. The generic blueprint-driven
family builders that first replaced it (`blueprint_sweep_family`,
`blueprint_walkforward_family`, `blueprint_mc_family`, `blueprint_sweep_over`) are,
in turn, retired by the #319 sugar retirement (2026-07-25): the **campaign
executor** (`aura_campaign::exec::execute`) now builds every family, driven by
the process document's stage pipeline (`std::sweep`/`std::grid`,
`std::walk_forward`, `std::monte_carlo`). `crates/aura-runner/src/family.rs`
retains only the synthetic-stream helpers serving `aura_runner::reproduce`
(`showcase_prices`, `walkforward_prices`, `walkforward_window_source`,
`synthetic_walk_sources`, `DataSource`/`DataChoice`) — reproduction's own
re-derivation path, not a family-minting path. The R-evaluator scaffold
`wrap_r` (defined in `crates/aura-runner/src/member.rs`; imported and called
from `runner.rs`) survives; its last hard data weld — the hard-wired
`price`←close role and the `M1Field::Close`-only open sites — is retired
(a strategy's input roles now bind archive columns by name, C26), and `wrap_r`'s
remaining R-scaffolding retirement is tracked at #159.
@@ -135,3 +135,36 @@ the mean shows the net/duty-cycle level. Deferred refinements: rendering the min
envelope honestly as range bars / OHLC rather than a polyline (#112); a `--width`
budget flag and true intra-bucket min/max ordering for the non-default continuous
x-mode (#110).
**Current-state "Single run" + "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."
+27 -27
View File
@@ -60,31 +60,31 @@ columnar (SoA, C7) `ColumnarTrace` — struct→JSON only — in `aura-engine`
`aura-cli` (`render_chart_html` + the vendored `chart-viewer.js`). The engine stays
headless.
**Single run.** `aura run <blueprint.json>` persists every tap the blueprint
declares to the trace store under the run's own name, on both shapes: a
`bias`-output strategy (`aura-runner::member::run_signal_r`, R-wrapped) and a bare
measurement blueprint with ≥1 declared tap but no `bias`
(`aura-runner::measure::run_measurement`). A tap-free run writes nothing to `runs/`
and its stdout stays byte-identical. The CLI `--trace <NAME>` flag is **retired** on
`run` (and on `mc`): it parses but is refused at dispatch (exit 2) — naming a trace
is the family / campaign path's job, not a per-run flag.
**Single run.** `aura exec <blueprint.json>` (#319) persists every tap the
blueprint declares to the trace store under the run's own 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. `exec` carries no
free-standing `--trace <NAME>` flag — naming a trace is the family / campaign
path's job, not a per-run flag (the repeatable `--tap TAP=FOLD` selector,
#310, only chooses which of the blueprint's own declared taps to subscribe,
never names the trace).
**Families.** `aura sweep|walkforward --real <SYM> … --trace <NAME>` persists *each
member* via the campaign path: the dissolved verb is translated into a
content-addressed process + campaign document whose `presentation.persist_taps`
requests the tap vocabulary, run through the one campaign executor;
`aura-runner::runner::persist_campaign_traces` writes each member under a depth-2
fan-out `runs/traces/<name>/<cell>/<member>/`. Every written member is
independently re-run once in non-reduce trace mode over its own recorded window,
and its re-derived metrics are asserted equal to the recorded member's — a **C1
drift alarm** that refuses (exit 1) rather than persist a silently-wrong trace. The
**synthetic** sweep / walk-forward path refuses `--trace` (exit 2);
**Monte-Carlo is excluded from trace persistence** (`mc --real` refuses
`--name`/`--trace`, exit 2 — the real-data R-bootstrap campaign itself runs, but
records no per-member family traces; the synthetic `--seeds` family's realization
argument, C12, does not carry over to one real series).
`TraceStore::ensure_name_free` makes name resolution a total function, refusing
cross-kind reuse of one name by both a run and a family.
**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 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. `TraceStore::ensure_name_free` makes
name resolution a total function, refusing cross-kind reuse of one name by
both a run and a family.
**Viewer.** `aura chart <name> [--tap <t>] [--panels]` classifies the name on disk
(`TraceStore::name_kind`: top-level `index.json` → a single Run; member subdirs → a
@@ -117,10 +117,10 @@ vocabulary (an SMA-cross → `Bias` strategy), `.gitignore`, and a project `CLAU
The engine also ships example blueprints under
`crates/aura-cli/examples/r_{sma,breakout,channel,meanrev}.json`. Honest gap against
the Guarantee's "populated trace immediately": these shipped blueprints are
*strategies* (a `bias` output, no declared taps), so `aura run` prints summary
*strategies* (a `bias` output, no declared taps), so `aura exec` prints summary
R-metrics to stdout but writes no on-disk trace — a **chartable** trace today comes
from a blueprint that declares taps (single run) or from a `--real … --trace` family
campaign.
from a blueprint that declares taps (single run) or from a campaign document
requesting `presentation.persist_taps`.
**Deferred.** Live sink streams *during* a run are not built — taps are
buffer-then-drain (collected, then written after the run), and there is no local
@@ -9,7 +9,12 @@ entirely (there is no composite in the flat graph). The blueprint's field /
input-role *names* are **non-load-bearing** — the wiring resolves by index, and names
survive at most as **informative debug symbols** (exactly as `FieldSpec.name` already
is, C8), kept for tracing / rendering (C9 graph-as-data) but carrying no run
semantics. The flat graph is then the target of **behaviour-preserving optimisation**
semantics. (Reconciling with the root composite's own render name, #331: that
claim stands unweakened — the name never influences compilation, identity, or
execution semantics — but at *run* time, outside compilation, it 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.) The flat graph is then the target of **behaviour-preserving optimisation**
— any transform that leaves every observable sink trace bit-identical (C1 is the
correctness invariant) — on two levels:
@@ -157,4 +157,100 @@ convert every deliberate template improvement into forced churn of the frozen
fixture — the same cross-purpose coupling that rules out regenerating the
fixture from the scaffolder.
**Current-state "Runs and families are built FROM blueprint-data" section
(superseded by the #319 sugar retirement, 2026-07-25):** "`aura run
<blueprint.json>` loads a serialized **signal** blueprint and emits a
`RunReport` **bit-identical** (C1) to its Rust-built twin; the run scaffolding
(sinks / broker / data) is supplied **at run**, not serialized. Beyond a
single run, the World constructs and orchestrates **families** of harnesses
from topology-data:
- `aura sweep <blueprint.json> --axis <name>=<csv>``FamilyKind::Sweep`. A
sweep needs an **open** blueprint (a fully-bound one has an empty
`param_space`).
- `aura mc <blueprint.json> --seeds N``FamilyKind::MonteCarlo`, each seed a
distinct synthetic price walk drawn disjoint-parallel through the engine
`monte_carlo` seam (invariant 1). MC binds no axis, so it needs a **closed**
blueprint (the sweep's distinction inverted); an open one returns a named
`Err`, rendered exit-2 at the builder boundary — no hidden exit in the pure
builder.
- `aura walkforward <blueprint.json> --axis <name>=<csv> [--select
argmax|plateau:mean|plateau:worst]` → `FamilyKind::WalkForward`:
re-optimizes the loaded blueprint's params over the `--axis` grid on each
24/12/12 IS window, selects the winner by `sqn_normalized`, runs it
out-of-sample. Reduce-mode members are R-measured (`oos_r` the meaningful
summary; stitched pip-equity empty, C10).
The family builders live in `aura-runner::family` (`blueprint_sweep_family` /
`blueprint_mc_family` / `blueprint_walkforward_family`). Every member manifest
carries the **shared** `topology_hash` (one signal topology, only params
vary; `member_key` distinguishes members), and each family stores its
blueprint(s) content-addressed so `aura reproduce` re-derives every member
bit-identically (`aura-runner::reproduce`, C18). The synthetic-walk DGP is
the MC machinery, not trader-grade statistics; a real-data block-bootstrap —
and retiring the `synthetic_walk_sources` `len:60`↔warm-up coupling — rides
#172."
**Current-state "Axis discovery" section (superseded by the #319 sugar
retirement, 2026-07-25):** "`aura sweep <blueprint.json> --list-axes` lists
a loaded blueprint's open sweepable knobs (one `<name>:<kind>` per line,
`param_space()` order) and exits; the printed names are exactly what
`--axis` binds. Every listed name is **mandatory** on `sweep` /
`walkforward` — the blueprint must be fully bound before it runs — so a
subset grid is refused with the missing knob named (`BindError::MissingKnob`,
`aura-engine`) and there is no default; pin a knob you do not want to vary
with a single-value axis. A single `blueprint_axis_probe` (`aura-runner`)
single-sources the wrapped probe for the sweep terminal, the MC closed-check,
and the listing, so **listed == swept by construction** (and stays so across
the harness retirement — the listing tracks whatever the sweep actually
resolves). One raw namespace, `<node>.<param>` (a splice path keeps its
interior path, e.g. `anchor.sess.period_minutes`), is the only user-facing
axis name (#328): `graph introspect --params` and `--list-axes` are
line-identical (open params bare, bound params with `default=`) and both
print exactly what `--axis` binds, on both sweep routes. The wrapped
`<blueprint>.<node>.<param>` form the probe still resolves against
internally is retired from the surface, with a translation refusal naming
the raw candidate on both intake seams (`--axis` and `campaign validate`).
`--trace` on `sweep` / `walkforward` writes per-member traces on the
real-data campaign path (depth-2 fan-out, chartable by the printed family
handle, #224); the synthetic path still refuses (`run` / `mc` refuse
`--trace` outright). The live trace-writer is the campaign
`presentation.persist_taps` (`persist_campaign_traces`,
`aura-runner::runner`)."
**Current-state `name`-op gated-intake clause (superseded by the #319 sugar
retirement, 2026-07-25):** "Gated by a shared shape check (non-empty, single
path segment, no `/`, `\`, `.` or `..`) applied at this op intake **plus
every CLI intake that reads an authored blueprint envelope from a file** —
`register`, `introspect --content-id <FILE>`, the bare graph-file viewer,
`run`, `introspect --params <FILE>`, `sweep --list-axes`, and each of
`sweep`/`walkforward`/`mc`/`generalize`'s file-reading entry points
(synthetic and `--real` alike) — one class of intake, one gate. The
refusal's core sentence (`name_gate_fault_prose`) is byte-uniform across
every one of these sites; only the leading context prefix varies — `run`
and the bare graph-file viewer prepend `aura: <path>:`, while `register`,
the sweep-axis family (`validate_and_register_axes`), and `introspect`
prepend bare `aura: `. Store read-back (`reproduce`, `use` resolution,
`introspect`/`--params` by content id) stays deliberately ungated — C29: a
registered artifact is never retroactively invalidated."
**[C26, 2026-07-10 (#231): the single-price data weld inside the surviving `wrap_r` scaffolding is retired — input roles bind archive columns by name; the wrapper's remaining R-scaffolding retirement stays #159.]** (From the C24 Forbids clause as of that date; the single-price weld was retired at #231/C26, but `wrap_r` itself survives — its full R-scaffolding retirement stays deferred, #159.)
---
> The disjoint-parallel synthetic
> price walks a sweep/MC family draws (`aura_runner::family`) are the family
> machinery, not trader-grade statistics; a real-data block-bootstrap — and
> retiring the `synthetic_walk_sources` `len:60`↔warm-up coupling — rides #172.
(superseded by the #319 sugar retirement, 2026-07-25 — audit-close correction:
no family-minting path draws synthetic walks after the family-builder
retirement; only `reproduce` consumes them)
**"Registered-blueprint splice" clause, build-free-introspection sentence
(amended by the 2026-07-26 harvest, #339 item 4):** "build-free introspection
paths pass `&|_| None`." (The CLI's introspection paths now resolve `use`
refs through the store exactly like `graph build` does, via the shared
`parse_and_resolve_ops` phase — retiring the earlier build-free resolver on
the introspection side; the engine's `subgraph` closure contract itself is
unchanged.)
+169 -67
View File
@@ -70,37 +70,41 @@ loop for the round-trippable vocabulary.
**Out of the round-trippable set** (deliberate; fails clean as `UnknownNodeType`):
recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic
data, C19) and construction-arg builders (`LinComb` / `CostSum` / `SimBroker` /
`Session` — structural-axis args, a C20 concern), additively addable later (#156).
data, C19) and `SimBroker` (a scalar-typed baked value, a narrower gap than the args
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
`RunReport` **bit-identical** (C1) to its Rust-built twin; the run scaffolding (sinks
/ broker / data) is supplied **at run**, not serialized. Beyond a single run, the
World constructs and orchestrates **families** of harnesses from topology-data:
`aura exec <blueprint.json>` loads a serialized **signal** blueprint and runs it
once, emitting a `RunReport` **bit-identical** (C1) to its Rust-built twin (a
no-bias blueprint with ≥1 declared tap runs the measurement leg instead); the
run scaffolding (sinks / broker / data) is supplied **at run**, not serialized.
Beyond a single run, the World constructs and orchestrates **families** from a
**campaign document** (role 6b, [C18](c18-registry.md)/[C25](c25-role-model.md)):
`aura exec <campaign.json|id>` drives the document's `strategies[].axes` through
the `aura-campaign` executor (`aura_campaign::exec::execute`) — one member per
(strategy, instrument, window) cell, the process document's stage pipeline
(`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]?
[std::generalize]?`) deciding what each cell realizes (grid family, gated
survivors, walk-forward roll, MC bootstrap, cross-instrument generalization —
C18's cycle-0107/0108 realizations). The one deliberate argv residue,
`--override NODE.PARAM=VALUE`, reopens a bound param for the execution without
touching the document (blueprint leg: `override_paths` + `reopen_all`; campaign
leg: injected as a single-value axis ahead of `validate_campaign_refs`, refusing
a collision with a document-declared axis).
- `aura sweep <blueprint.json> --axis <name>=<csv>``FamilyKind::Sweep`. A sweep
needs an **open** blueprint (a fully-bound one has an empty `param_space`).
- `aura mc <blueprint.json> --seeds N``FamilyKind::MonteCarlo`, each seed a
distinct synthetic price walk drawn disjoint-parallel through the engine
`monte_carlo` seam (invariant 1). MC binds no axis, so it needs a **closed**
blueprint (the sweep's distinction inverted); an open one returns a named `Err`,
rendered exit-2 at the builder boundary — no hidden exit in the pure builder.
- `aura walkforward <blueprint.json> --axis <name>=<csv> [--select
argmax|plateau:mean|plateau:worst]` → `FamilyKind::WalkForward`: re-optimizes the
loaded blueprint's params over the `--axis` grid on each 24/12/12 IS window,
selects the winner by `sqn_normalized`, runs it out-of-sample. Reduce-mode members
are R-measured (`oos_r` the meaningful summary; stitched pip-equity empty, C10).
The family builders live in `aura-runner::family` (`blueprint_sweep_family` /
`blueprint_mc_family` / `blueprint_walkforward_family`). Every member manifest carries
the **shared** `topology_hash` (one signal topology, only params vary; `member_key`
distinguishes members), and each family stores its blueprint(s) content-addressed so
`aura reproduce` re-derives every member bit-identically (`aura-runner::reproduce`,
C18). The synthetic-walk DGP is the MC machinery, not trader-grade statistics; a
real-data block-bootstrap — and retiring the `synthetic_walk_sources` `len:60`↔warm-up
coupling — rides #172.
Every member manifest carries the **shared** `topology_hash` (one signal
topology, only params vary; `member_key` distinguishes members), and the
executor stores its blueprint(s) content-addressed so `aura reproduce`
re-derives every member bit-identically (C18). The synthetic price walks
(`aura_runner::family::synthetic_walk_sources`) survive the #319 retirement
only as `reproduce`'s re-derivation inputs for historically minted synthetic
families — no family-minting path draws them anymore (the campaign executor
is the one family builder, C20) — and they are not trader-grade statistics; a
real-data block-bootstrap — and retiring the `len:60`↔warm-up coupling —
rides #172.
### Reproduction identity
@@ -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 is introspection-only until a dedup consumer exists.
**`topology_hash` carries reference semantics on every leg (#343, revised).**
`exec`'s `--override NODE.PARAM=VALUE` (above) reopens a bound param before
bootstrap, but the executed run's `topology_hash` stamps the **loaded (or
stored) base document's own content id** — computed before the reopen —
never a transient reopened variant, exactly as the campaign leg's #246
axis-over-bound-param path has always stamped the referenced strategy's id
for a reopened member ([C18](c18-registry.md)'s bound-override coincidence).
The manifest's `params` map carries the variation (the bound value still
moves from `defaults` to `params`, #249); `params` plus the base document
deterministically reconstruct the executed run, byte-identically —
reproduction identity, not the hash, is where the variation lives. A no-op
override (re-binding a param to its existing bound value) therefore changes
nothing about identity: the bare run's and the no-op-override run's
`topology_hash` are equal. An unresolved `topology_hash` (a file-target exec
never registered via `graph register`) is still the honest name of a real,
reconstructible topology, not a dangling reference — store-resolution
(`get_blueprint`) is a lookup convenience, never an identity guarantee.
### Axis discovery
`aura sweep <blueprint.json> --list-axes` lists a loaded blueprint's open sweepable
knobs (one `<name>:<kind>` per line, `param_space()` order) and exits; the printed
names are exactly what `--axis` binds. Every listed name is **mandatory** on `sweep` /
`walkforward` — the blueprint must be fully bound before it runs — so a subset grid is
refused with the missing knob named (`BindError::MissingKnob`, `aura-engine`) and
there is no default; pin a knob you do not want to vary with a single-value axis. A
single `blueprint_axis_probe` (`aura-runner`) single-sources the wrapped probe for the
sweep terminal, the MC closed-check, and the listing, so **listed == swept by
construction** (and stays so across the harness retirement — the listing tracks
whatever the sweep actually resolves). The names are prefixed by the current wrapping
(`sma_signal.fast.length`, the nested-composite prefix), which is why discovery lives
on the sweep verb (it owns the wrapping), not `graph introspect`. `--trace` on
`sweep` / `walkforward` writes per-member traces on the real-data campaign path
(depth-2 fan-out, chartable by the printed family handle, #224); the synthetic path
still refuses (`run` / `mc` refuse `--trace` outright). The live trace-writer is the
campaign `presentation.persist_taps` (`persist_campaign_traces`, `aura-runner::runner`).
`aura graph introspect --params <blueprint.json|id>` (#328) is the one
axis-discovery surface: it lists a loaded blueprint's open knobs (one
`<name>:<kind>` per line, `param_space()` order) followed by its bound knobs
(`<name>:<kind> default=<value>`) — the campaign-axis namespace
`validate_campaign_refs` checks a campaign document's `strategies[].axes`
against. Every open name a campaign document's `axes` varies is **mandatory**:
a subset is refused with the missing knob named (`BindError::MissingKnob`,
`aura-engine`) and there is no default; pin a knob you do not want to vary with
a single-value axis. One raw namespace, `<node>.<param>` (a splice path keeps
its interior path, e.g. `anchor.sess.period_minutes`), is the only user-facing
axis name (#328) — the wrapped `<blueprint>.<node>.<param>` form is retired
from the surface, with a did-you-mean refusal naming the raw candidate at the
one remaining intake seam, `campaign validate` (#319 retired the standalone
`--axis`/`--list-axes` CLI flags this used to also gate). Per-member traces
are the campaign document's `presentation.persist_taps` (closed vocabulary,
C18 cycle-0109) or, on `exec`'s blueprint leg, the repeatable `--tap
TAP=FOLD` selector (#310) over a blueprint's own declared taps (C27); the
live trace-writer is `persist_campaign_traces` (`aura-runner::runner`).
### The construction service (op-script)
@@ -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
eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic**
finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity
(`check_param_namespace_injective`), root-role boundness (`check_root_roles_bound`).
Both cadences call the **same extracted predicates** — no second validator. The
holistic faults read **by-identifier** (#162): `finish()` translates the
index-carrying `CompileError`s (`UnconnectedPort` / `RoleKindMismatch` /
`UnboundRootRole`) into by-identifier `OpError` variants while still *calling* the
unchanged holistic gates. Build-free introspection answers over the closed vocabulary:
`graph introspect --vocabulary | --node <T> | --unwired`. The 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.
(`check_param_namespace_injective`). Both cadences call the **same extracted
predicates** — no second validator. The holistic faults read **by-identifier**
(#162): `finish()` translates the index-carrying `CompileError`s (`UnconnectedPort` /
`RoleKindMismatch`) into by-identifier `OpError` variants while still *calling* the
unchanged holistic gates. **Root-role boundness moved off this list (#317, open
patterns):** `check_root_roles_bound` no longer runs in `finish()` — a `finish()`ed
op-script may still carry an open (unbound) root role, declaring a reusable
pattern's formal parameter; only `compile`/bootstrap (the runnability gate, not a
construction gate) still call it, unchanged. Build-free introspection answers over
the closed vocabulary: `graph introspect --vocabulary | --node <T> | --unwired`. The
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
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"`,
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 role producing a base column of `kind`.
- `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred
from the slots it feeds).
- `add``{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}`
from the slots it feeds) — a reusable **pattern**'s formal parameter, left open
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**
(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
interior input slots.
- `connect``{"op":"connect","from":<port>,"to":<port>}` — wire an interior output
@@ -189,13 +269,26 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
recorded observation point (a `Composite.taps` entry, C27), not a boundary output.
Name-addressed like every other op (no raw index); tap names are their own
namespace (a duplicate refuses). The `finish` gate threads op-declared taps into
the built `Composite` (`.with_taps`); a single `aura run` records each, a sweep
leaves them inert.
the built `Composite` (`.with_taps`); a single `aura exec` (#319) records
each, a campaign member run leaves them inert.
- `gang``{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}`
— fuse two or more sibling params into ONE public knob: the member addresses
leave the sweepable param space and `as` replaces them; the bound or swept
value fans out to every member at bootstrap. Members must share one scalar
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
form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind`
@@ -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:
a new node type, edge semantics, or structural-axis kind) **bumps** `format_version` so
an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`).
**Pre-ship dormancy (#61):** until the first external ship there are no out-of-repo
readers — reader and writer change atomically in one commit — 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 consciously freezes v1, gangs included,
and activates the bump discipline. The per-section required-flag scheme is deferred (no
current Tier-2 section to validate it; recorded on #156).
**Pre-ship dormancy (#61, ended by #271):** until the first external ship there were
no out-of-repo readers — reader and writer changed atomically in one commit — so the
Tier-2 bump discipline lay dormant and structurally-semantic additions (the `gangs`
section) landed as additive-optional fields of v1. #271's construction args are the
first exercised Tier-2 bump (data-driven, next paragraph); v1 remains the version of
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
@@ -0,0 +1,10 @@
# C25 — The role model: nine authoring roles, cut by artifact + surface + iteration cost: history
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp and may be superseded; this file is NOT current truth and NOT a grounding surface. Current contract: [c25-role-model.md](c25-role-model.md).
**Current-state executor-verb-set sentence (owner-minuted 2026-07-21 on #295,
delivered by #300; superseded by the #319 sugar retirement, 2026-07-25):**
"The executor verb set is settled: `run`, the four thin per-verb generators,
and the document verbs validate/introspect/register/show/run/runs — `show`
being #300's read-back addition. The verbs' per-verb identity is re-ratified
(#300 F8 — reduction, not grammar collapse)."
+10 -6
View File
@@ -74,12 +74,14 @@ present but faceless: no addressed verb families yet. Role homes in the project
layout and docs-by-role are open (#192).
**Document-first completion** is the resolved direction on the control-surface
amendment (owner-minuted 2026-07-21 on #295), delivered by #300. The executor verb
set is settled: `run`, the four thin per-verb generators, and the document verbs
validate/introspect/register/show/run/runs — `show` being #300's read-back
addition. The verbs' per-verb identity is re-ratified (#300 F8 — reduction, not
grammar collapse). A typed-protocol host or MCP face remains demand-driven and
unbuilt.
amendment (owner-minuted 2026-07-21 on #295), delivered by #300. The executor
surface is settled again, one reduction further (#319, 2026-07-25): a single
`exec <target>` verb runs both document classes — a campaign (file or content
id) and a signal blueprint (single run) — over the document verbs
validate/introspect/register/show/runs (`show` #300's read-back addition); the
one argv residue is `--override NODE.PARAM=VALUE`, a per-execution bound-param
reopen threading through both `exec` legs. A typed-protocol host or MCP face
remains demand-driven and unbuilt.
## See also
- [C16](c16-engine-project-split.md) — the game-engine analogy this extends to people
@@ -88,3 +90,5 @@ unbuilt.
- [C20](c20-strategy-harness.md), [C21](c21-world.md) — the node/harness/World tiers behind the 6a/6b split
- [C24](c24-blueprint-data.md) — topology-as-data, the strategy designer's artifact
- [C26](c26-input-binding.md) — a role's input contract carried in blueprint data
> History: [c25-role-model.history.md](c25-role-model.history.md)
@@ -0,0 +1,16 @@
# C27 — Declared taps: named measurement points bind sinks run-mode-aware: 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: [c27-declared-taps.md](c27-declared-taps.md).
**Current-state tap-plan sentence (2026-07-21, #283; superseded 2026-07-24 by
the #310 `--tap` selector):** "…and the shared `bind_tap_plan`/`BoundTaps`
pair called by both declared-tap entry points, `run_signal_r`
(`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both
CLI verbs pass a record-all plan."
**Current-state "single CLI verb" clause (superseded by the #319 sugar
retirement, 2026-07-25):** "…both arms of the single CLI verb `aura run`,
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named`
selection data-reachable…"
+38 -3
View File
@@ -42,7 +42,10 @@ input role, which `check_root_roles_bound` rejects ([C26](c26-input-binding.md))
observation is optional, a fed input is mandatory. A declared-but-unbound tap
compiles and runs, its producer evaluating and its output discarded (a no-out-edge
producer is a valid runnable sink — the Kahn sort emits it,
`check_ports_connected` gates only inputs).
`check_ports_connected` gates only inputs). The CLI surfaces this to the human
as a note (`aura: note: declared tap "…" unbound this run`) printed from the
unbound names the entry points return beside their report — never emitted by
the library itself (#297).
**Why.** Observability must be expressible in a hand-authored blueprint — the
measurement-shaped study computes in the graph and surfaces via taps, no throwaway
@@ -68,18 +71,50 @@ and the roster-enumerating refusal — plus a scalar-typed param schema; all cor
entries are param-less today, the seam ships in every entry's build signature),
and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
(`aura-runner::measure`); both CLI verbs pass a record-all plan. The `record`
(`aura-runner::measure`) both arms of the single CLI verb `aura exec` (#319),
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
data-reachable: no flag keeps the record-all default, any flag replaces the
plan entirely (unlisted taps stay unbound/inert). `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). The boundary is thereby
fixed in place: *selecting* a subscription is run-mode authority, exercised
by the run-mode owner — on the one-shot path the CLI invocation itself, a
projection exercising this contract's authority, not a second home for
intent under [C25](c25-role-model.md) — while *adding* a fold stays a Rust
entry (role 2). The campaign/document carrier, and the reconciliation of the
presentation-tap namespace (`persist_taps`) with declared taps it requires,
is deferred to the Measurement-reachable milestone (#312/#327, minuted on
#312). The `record`
consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's
streaming writer in-graph — `initialize` opens (deferred acquisition), `eval`
appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports
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
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`.
The chain-pruning benefit — a sweep paying zero for the study wires behind an
unbound tap — is **deferred to the future DCE cycle** ([C23](c23-graph-compilation.md));
the mechanism ships now, verified sound.
**#337 (2026-07-26 harvest) ships the positive discovery view**:
`Composite::declared_taps()` (`crates/aura-engine/src/blueprint.rs`) walks the
blueprint depth-first collecting `(tap name, source wire, column kind)`, bare
at every depth, bounds-total over an invalid wire; `graph introspect --taps
<FILE|ID>` renders one row per declared tap (a tap-less blueprint is a
stderr-noted listing, exit 0). This closes the **recovery-only** discovery
gap: before #337, the only way to learn a blueprint's declared tap names was
provoking `bind_tap_plan`'s `UnknownTap` refusal roster (#333) — a real name
surfaced only as the side effect of naming a wrong one first. The refusal
roster itself is unchanged and remains the **recovery** half (what to do once
a tap name is already wrong); `--taps` is the **discovery** half (learning the
right names up front).
## See also
- [C26](c26-input-binding.md) — the input-side twin (`input_roles`); `check_root_roles_bound`, the mandatory-input counterpart
- [C23](c23-graph-compilation.md) — compilation/lowering and the deferred DCE cycle the tap design anticipates
+23 -6
View File
@@ -167,12 +167,29 @@ demand-gated, no tracking issue): measurement runs as sweep-family citizens
(report unification, campaign engine generic-over-`M`), until a concrete
family/campaign demand exists.
**Deferred.** ~24 refusal sites inside `aura-runner`'s single-run verb paths still
terminate the process (`std::process::exit`; the #283 tap-plan refusals added four
— typed as `TapPlanError` before the exit, so the eventual conversion is a
mechanical rewrap); their conversion to `RunnerError` propagation is tracked as
**#297** (the campaign path already refuses via `MemberFault`, never a process
exit).
**#297 (closed, 2026-07-26).** `aura-runner` no longer terminates the host
process anywhere: the 14 recon-enumerated `std::process::exit` sites inside
the single-run verb paths (`member.rs` ×8, `measure.rs` ×5, `translate.rs`
×1 — the "~24" this paragraph used to cite predates the #319 dual-grammar
retirement and never updated in lockstep) are now `RunnerError { exit_code,
message }` constructors threaded through a fallible chain; the two entry
points, `run_signal_r` and `run_measurement`, return
`Result<(_, Vec<String>), RunnerError>`, and the CLI's `exec_blueprint_leg`
remaps via one shared `exit_on_runner_error` helper (the `dispatch_reproduce`
pattern). The one hole in "a sweep worker never process-exits" — `cost_knob`,
reachable from `run_blueprint_member` — is closed the same cycle: the
campaign worker maps its fault into the cell's own `MemberFault` channel
(per-cell isolation, this contract's stated campaign guarantee unchanged),
while the campaign trace-persist path — the only other production caller —
reports it through its existing string-error channel into the campaign
summary's exit. Refusal prose is byte-identical everywhere;
only who prints it changed, plus — per the adjudicated C14 partition — the
exit class of refusals whose fault is in the content of what argv named.
What remains printing in the assembly crate is the deliberate note/info
residuum — the fold/trace notes and the traces-persisted line
(`runner.rs`) and the stale-dylib warning (`project.rs`) — benign
diagnostics that print and never exit; migrating them needs a note-channel
design of its own.
## See also
- [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve
+17 -5
View File
@@ -27,13 +27,23 @@ cannot pass its short-name alibi
restatement.
3. **Register seam (data plane).** The registry's public `put_blueprint`
parses the canonical bytes and requires a gate-passing doc on the root
composite and on every named nested composite the gate guards the
store boundary itself, so the register verb, every register-then-run
composite and on **every** nested composite (the walk recurses
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
op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s
twin ([C25](c25-role-model.md): a closed, typed metadata construct).
Process/campaign documents take an additive-optional top-level
`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
vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and
@@ -67,6 +77,8 @@ for whom the binary is the only always-present teacher. Field evidence
execution semantics and document schemas by CAS forensics — the removed
failure class is exactly that forensic recovery.
Rendering of the carried texts on the help/introspection surfaces is #315;
the generated agent bootstrap card is #267; a fold introspection surface
is blocked on #310.
Rendering of the carried texts on the help/introspection surfaces shipped
with #315; the agent bootstrap card was retired as superseded (#267 — the
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).
@@ -0,0 +1,85 @@
# C30 — Stability discipline: artifacts are stable, code is movable
**Guarantee.** The project's compatibility promise attaches to recorded
data, never to Rust API. Before 1.0 the workspace's library surface
promises nothing between commits; every cargo consumer either moves with
the engine checkout (path-dep node crates, staleness refused at the load
handshake) or pins a rev deliberately (external embeddings). The one
handshake guarding the ABI seam compares build identity, never a
hand-maintained version.
1. **No API stability before 1.0.** Signatures, module homes, crate
rosters, and trait shapes move freely, commit over commit — no
deprecation cycles, no compat shims, no semver bumps. The workspace
version (0.1.0) is not a communication channel; nothing downstream
reads it. What a change may *not* silently break is behaviour pinned
by the artifact plane (point 4).
2. **Two consumer classes, two mechanisms.**
- A **native node crate** (scaffolded by `aura nodes new`) consumes
the engine as a cargo *path* dependency into the local engine
checkout — the scaffolder emits exactly this
(`crates/aura-cli/src/scaffold.rs`, template `CARGO_TOML`). Node
crate and engine move together: cargo rebuilds the crate when
aura-core changes, and a dylib from any other engine state is
refused at load (point 3), never trusted.
- An **external embedding** (a World program in its own repo)
consumes the workspace crates as a cargo git dependency pinned to a
rev. The pin names the engine; the embedding's own committed
`Cargo.lock` freezes the rest of the graph — dependencies the
engine itself takes as git-branch references (e.g. the data-server)
resolve at lock time, so rev + lockfile together are the
reproducible-build contract (fieldtest finding, 2026-07-26).
Updating either is a deliberate act that accepts whatever moved,
with the design ledger and commit bodies as the changelog.
3. **The load handshake refuses non-identical builds.** The cdylib
handshake already refuses a rustc mismatch (`RUSTC_VERSION`, emitted
by build.rs). The aura-core stamp must be equally honest: a
build-identity fingerprint derived mechanically from aura-core's
sources, so a node dylib loads only against the aura-core it was
built from — *source-level* identity, an accepted limit: the
consuming build's lockfile and feature selection stay outside the
stamp, so the seam refuses staleness of aura-core itself, never the
full link graph. A hand-maintained version that never moves disarms the
refusal (the pre-C30 state: `CORE_VERSION` frozen at 0.1.0 matched
every stale dylib); a hand-bumped one re-arms it only between bumps —
an implicit ABI promise this contract refuses to make. *(Re-armed by
#348: a source-derived fingerprint replaced the crate-version stamp.)*
4. **The artifact plane is the stable tier.** What was already law,
stated as the positive promise: registered artifacts are never
retroactively invalidated ([C29](c29-self-description.md)); document
format changes carry `format_version` with readers keeping old forms
readable ([C18](c18-registry.md)); deploy artifacts are frozen
([C13](c13-hot-reload-frozen-deploy.md), invariant 8). Compatibility
work happens here: a change that would orphan recorded artifacts is a
design decision for the ledger, never a refactor.
5. **The erosion guard.** No doc, README, or scaffold may describe any
Rust surface as "stable API" while this contract stands. Inviting a
consumer means naming the point-2 mechanism (path or pin), never
promising stillness. Amending this contract is the only way to
promise more.
**Rationale.** #296 recorded the tension: an invited external consumer
(#295 deliberately enlarges the public library surface) versus a working
discipline of behaviour-preserving reshaping ([C28](c28-stratification.md)
relocated whole rosters twice in one pass). The resolution promises where
the project can keep promises — the recorded-data plane, whose guarantees
already exist — and refuses to promise where it cannot. The empirical
ground at decision time: the one real downstream project is data-only (an
`Aura.toml` directory with no `Cargo.toml`, consuming the released binary
and documents), so the only cargo consumers are engine-scaffolded node
crates (path-dep by construction) and the engine's own fieldtest
fixtures. A stability promise to nobody would cost every cycle and inform
no one; hardening toward 1.0 stays available at any time, while stability
promised early is practically irrevocable.
## See also
- [C13](c13-hot-reload-frozen-deploy.md) — frozen deploy artifacts (the promise at the deploy edge)
- [C16](c16-engine-project-split.md) — engine/project split; reuse is cargo-native (invariant 9)
- [C18](c18-registry.md) — the run registry and `format_version` (the artifact plane's mechanics)
- [C28](c28-stratification.md) — the movable crate ladder this contract licenses
- [C29](c29-self-description.md) — registered artifacts never retroactively invalidated
- Decision record: #296 (options weighed, skeptic findings); re-arming the handshake: #348
+37 -19
View File
@@ -11,6 +11,10 @@ Entries are alphabetical.
---
### arg kind
**Avoid:** ArgKind, construction-arg type
The closed vocabulary of construction-arg value shapes (`Tz`/`TimeOfDay`/`Count`, #271) — deliberately NOT a `scalar base type`: args are bootstrap metadata, never streamed, so the two closedness axes stay separate. Each kind's accepted strict form IS its canonical form (an exact IANA name; zero-padded `HH:MM`; a plain positive decimal count, no sign or leading zeros) — there is no normalization layer, so a near-miss string refuses rather than being rewritten. The per-kind forms are deliberately asymmetric (`"03"` refuses as a count while `"09:30"` requires the pad): canonical is each domain's conventional notation — fixed-width for wall-clock times, minimal for numbers.
### atomic sim unit
**Avoid:** atomic unit, sim unit
The primitive `(frozen topology + param-set + data-window + RNG-seed) → deterministic run → metrics` over which the four orchestration axes operate. One frozen-topology unit equals one harness instance.
@@ -29,11 +33,15 @@ A strategy's primary, backtestable DAG output: one signed, bounded `f64 ∈ [-1,
### blueprint
**Avoid:** —
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while `run` uses it as-is.
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while a plain `exec` uses it as-is (#319).
### blueprint label
**Avoid:** —
A registry-level name pointing at a registered blueprint's content id (`graph register --name <label>`, #317) — a mutable, latest-wins pointer over the immutable content-addressed store, not a second identity: re-registering under an existing label *repoints* it (the earlier content id stays reachable by its own id, never invalidated). `graph introspect --registered` lists every label with its content-id prefix and root `doc` line — the discovery surface a `use (op)` reference by name resolves against. Orthogonal to the composite's own **render name** (the `name` field carried in the blueprint bytes, op-settable via `{"op":"name",...}`, #331; defaults to `"graph"` if omitted): the render name keys the run's trace directory (`traces/<name>/`) and a `use`-splice's default instance identifier, while the label is an explicit, separately-set store pointer.
### bootstrap
**Avoid:** —
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null, the `aura mc` R path, and since 0108 the `std::monte_carlo` stage's `stage bootstrap` annotation; context (construction vs annotation) disambiguates.
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null and, since 0108, the `std::monte_carlo` stage's `stage bootstrap` annotation (the campaign executor's one R-bootstrap path, #319); context (construction vs annotation) disambiguates.
### bot
**Avoid:** —
@@ -45,11 +53,11 @@ A downstream consumer node, never part of the strategy: the signal-quality side
### campaign document
**Avoid:** experiment doc, campaign file
The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored, validated, and executed headless (`aura campaign validate|introspect|register|show|run|runs``show` prints a registered document's canonical bytes back, #300), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura campaign run` (v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. `std::sweep`'s own selection group (`metric`+`select`) is optional, all-or-nothing, and permitted only as the pipeline's terminal stage when omitted (a selection-free sweep, #210).
The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored headless (`aura campaign validate|introspect|register|runs|show``show` prints a registered document's canonical bytes back, #300), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura exec` (#319; v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. `std::sweep`'s own selection group (`metric`+`select`) is optional, all-or-nothing, and permitted only as the pipeline's terminal stage when omitted (a selection-free sweep, #210).
### campaign run
**Avoid:** campaign execution record, realized campaign
One execution of a `campaign document` (`aura campaign run <file|content id>`; a file is register-then-run sugar — the content id is the canonical address). Realized once per (strategy, instrument, window) cell in doc order and recorded as a thin `CampaignRunRecord` line in the registry's `campaign_runs.jsonl` — linking the per-stage family ids, gate survivor ordinals, `stage bootstrap` annotations, and (campaign-scope, per strategy × window) `generalizations` entries (`generalization` with `worst_case`/`sign_agreement`/`per_instrument`, plus `winners` params and `missing` instruments on shortfall) over untouched family records, run-counted per campaign id. A cell whose gate leaves no survivors records its realized prefix and the run exits 0: a null result is a valid research result. Deterministic from doc + stores + data (C1): deflation and bootstrap nulls seed from the doc's `seed`. When the document requests `persist_taps`, the record's sparse `trace_name` (`"{campaign8}-{run}"`) points at the TraceStore family holding each nominee cell's persisted taps (0109/#201). NB the stdout emit wraps each record as `{"campaign_run": …}`; the stored `campaign_runs.jsonl` line is the bare record.
One execution of a `campaign document` (`aura exec <file|content id>`, #319; a file is register-then-run sugar — the content id is the canonical address). Realized once per (strategy, instrument, window) cell in doc order and recorded as a thin `CampaignRunRecord` line in the registry's `campaign_runs.jsonl` — linking the per-stage family ids, gate survivor ordinals, `stage bootstrap` annotations, and (campaign-scope, per strategy × window) `generalizations` entries (`generalization` with `worst_case`/`sign_agreement`/`per_instrument`, plus `winners` params and `missing` instruments on shortfall) over untouched family records, run-counted per campaign id. A cell whose gate leaves no survivors records its realized prefix and the run exits 0: a null result is a valid research result. Deterministic from doc + stores + data (C1): deflation and bootstrap nulls seed from the doc's `seed`. When the document requests `persist_taps`, the record's sparse `trace_name` (`"{campaign8}-{run}"`) points at the TraceStore family holding each nominee cell's persisted taps (0109/#201). NB the stdout emit wraps each record as `{"campaign_run": …}`; the stored `campaign_runs.jsonl` line is the bare record.
### cdylib
**Avoid:** —
@@ -63,6 +71,10 @@ The type-erased 64-bit word holding one scalar-base-type value with its kind str
**Avoid:** —
A node that wires a sub-graph and exposes one output (a combined signal, or a strategy) — composition is fractal and acyclic. May carry an optional authored rationale (`doc`, #125) — a non-load-bearing debug symbol shown in the graph tooltip, identity-blind like the name. Also names the multi-column stream a node emits: the **record** a producer's `eval` returns, bundling 1..K base scalar columns (e.g. OHLCV), each bound field-wise by a consumer (C7/C8).
### construction arg
**Avoid:** construction-time param, arg
A typed, closed, non-scalar construction input (`args`, #271) that configures an **arg-bearing** vocabulary type's structural shape — `Session`'s IANA timezone/open, `LinComb`/`CostSum`'s arity — through a second channel beside `bind`, consumed once at construction (`PrimitiveBuilder::try_args`) before any param binds, never streamed, never swept, never bound. Values are closed, load-time-validated strings (see `arg kind`); the accepted pairs are id-bearing (they enter the blueprint's content id, like `bind` values) and survive the identity projection.
### content id
**Avoid:** —
The SHA-256 (hex) of an artifact's byte-canonical form — for a blueprint, `blueprint_to_json` (the byte-exact identity that keys the reproduction store and anchors `aura reproduce`, stamped into a run manifest as the `topology hash`); for a `process document` / `campaign document`, its canonical JSON (keying the sibling registry stores). One primitive, library-hosted in `aura-research` (`content_id_of`; the CLI delegates). Surfaced as `aura graph|process|campaign introspect --content-id`; two artifacts share a content id only when their canonical bytes agree, debug names included — contrast `identity id`.
@@ -77,7 +89,7 @@ A composable downstream **C9 graph of cost nodes**, in **R**, that **approximate
### cross-instrument generalization
**Avoid:** cross-symbol pooling, pooled generalization
The validation read that grades how consistently one *brought* candidate holds across a set of instruments, scored on its weakest one — the across-instrument axis of the anti-false-discovery discipline. Realised by `aura generalize` and, since 0108 (#200), by the `std::generalize` process stage at campaign scope (per (strategy, window) over the cells' nominees across instruments, recorded in the `campaign run`'s `generalizations`); an aggregator (a recomputable family score), never a selector that picks a winner.
The validation read that grades how consistently one *brought* candidate holds across a set of instruments, scored on its weakest one — the across-instrument axis of the anti-false-discovery discipline. Realised by the `std::generalize` process stage at campaign scope (since 0108, #200; per (strategy, window) over the cells' nominees across instruments, recorded in `CampaignRunRecord.generalizations`, executed via `aura exec`, #319) — the standalone `aura generalize` verb that used to persist its own per-instrument family is retired (#319: `FamilyKind::CrossInstrument` is now dead); an aggregator (a recomputable family score), never a selector that picks a winner.
### cycle
**Avoid:** —
@@ -191,7 +203,7 @@ The recorded chance that a deflated sweep winner is noise rather than edge — a
### plateau selection
**Avoid:** plateau-over-peak (as a noun)
A selection objective that argmaxes the neighbourhood-smoothed metric surface (mean or worst-case) rather than the bare in-sample peak, preferring a robust parameter plateau to a lucky spike. Opt-in via `--select plateau:mean|plateau:worst`; the default selection stays a bare argmax.
A selection objective that argmaxes the neighbourhood-smoothed metric surface (mean or worst-case) rather than the bare in-sample peak, preferring a robust parameter plateau to a lucky spike. Opt-in via a process document's `std::sweep`/`std::walk_forward` stage `"select": "plateau:mean"|"plateau:worst"` field; the default selection stays a bare argmax.
### playground
**Avoid:** —
@@ -233,13 +245,15 @@ A node that converts a finer stream to a coarser bar stream, emitting a complete
**Avoid:** risk section
One entry of a campaign document's structural risk axis (`risk`): a
serializable protective-stop regime (variants `vol{length,k}` — per-cycle —
and `vol_tf{period_minutes,length,k}` — per completed time bucket) the matrix
runs every cell under, so cells differ by execution discipline, never by
signal. Absent or empty = one implicit default regime; the regime's stop
defines the risk unit R — in `vol{length,k}` (stop = k·√EMA(Δ², length) over
m1 cycles) `length` only smooths the vol estimator while `k` scales the stop
distance, so the stop's timescale stays one cycle (`vol_tf` sets the stop's
timescale via `period_minutes`).
`vol_tf{period_minutes,length,k}` — per completed time bucket — and
`fixed{distance}` — a constant price-unit distance, binding the shipped
`FixedStop` composite) the matrix runs every cell under, so cells differ by
execution discipline, never by signal. Absent or empty = one implicit default
regime; the regime's stop defines the risk unit R — in `vol{length,k}` (stop =
k·√EMA(Δ², length) over m1 cycles) `length` only smooths the vol estimator
while `k` scales the stop distance, so the stop's timescale stays one cycle
(`vol_tf` sets the stop's timescale via `period_minutes`; `fixed` has no
timescale — the distance never adapts).
### run
**Avoid:** —
@@ -259,7 +273,7 @@ The four streamed scalar kinds — `i64`, `f64`, `bool`, `timestamp` (a newtype
### session node
**Avoid:** session window
A node that exposes session context as a scalar stream so session logic stays inside the stream model. The shipped `Session` / `SessionFrankfurt` node (`aura-std`) emits ONE `i64` field, `bars_since_open` — the count of completed bar-periods since the local (tz-aware, DST-correct) session open, indexed off the just-closed bar's close instant (in-session closes read exact positive multiples: 09:15→1, 09:45→3; pre-open ≤0). `SessionFrankfurt` bakes the Frankfurt 09:00 `Europe/Berlin` open with a single `period_minutes` knob; its `trigger` input only clocks it once per completed bar (the value is ignored). Calendars and instrument specs remain metadata beside the hot path.
A node that exposes session context as a scalar stream so session logic stays inside the stream model. The shipped `Session` / `SessionFrankfurt` node (`aura-market`) emits ONE `i64` field, `bars_since_open` — the count of completed bar-periods since the local (tz-aware, DST-correct) session open, indexed off the just-closed bar's close instant (in-session closes read exact positive multiples: 09:15→1, 09:45→3; pre-open ≤0). `Session` is roster-reachable for ANY IANA timezone/open through the typed **construction arg** channel (#271: `args: {"tz": <IANA name>, "open": "HH:MM"}`, applied before its one scalar param, `period_minutes`); `SessionFrankfurt` stays as baked sugar over the Frankfurt 09:00 `Europe/Berlin` open. Its `trigger` input only clocks it once per completed bar (the value is ignored). Calendars and instrument specs remain metadata beside the hot path.
### sign-agreement
**Avoid:** sign consistency, market breadth
@@ -319,27 +333,31 @@ The harness's structural parameterization — which strategy, instrument(s), bro
### sweep
**Avoid:** param-sweep, parameter sweep
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`--list-axes`) is **required** — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (`--axis name=<one-value>`), there is no default. `aura sweep --axis` takes the `--list-axes`-printed, root-composite-wrapped name (e.g. `graph.fast.length`) — not the raw `param_space` name (`fast.length`) that `graph introspect --params` and a campaign document's axes use; the CLI strips exactly one leading wrapper segment (#210). The `aura sweep` CLI verb is now thin sugar over the `campaign document` path — its blueprint form (`<bp.json> --real`) translates to a generated, content-addressed campaign run through the one executor (#210); the built-in `--strategy` sweep surface was retired by #159 (its hard-wired harnesses removed) — sweep now runs from a blueprint + `--axis`, and a retired `--strategy` token falls to the generic usage error. A ganged pair contributes ONE axis.
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`graph introspect --params`) is **required** on a campaign document's `strategies[].axes` — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (one value in its `values` list), there is no default. One raw namespace, `<node>.<param>` (a splice path keeps its interior path), is the only axis name (#328): `graph introspect --params` and a campaign axis key are line-identical (open params bare, bound params with `default=`) — the older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`) is retired from the surface; naming it in a campaign document gets a did-you-mean toward the raw candidate, the one remaining intake seam (#319 retired the standalone `--axis`/`--list-axes` CLI flags along with the blueprint-form `aura sweep` verb, whose own `--strategy` built-in surface #159 had already retired). Realised as a campaign document's axes (#210), executed through the one `aura exec` executor (#319). A ganged pair contributes ONE axis.
### tap
**Avoid:** probe, monitor, scope (for the observation slot — "probe" is taken by the sweep-terminal `blueprint_axis_probe` sense; the #77 `Recorder``Probe` rename was retired, 2026-07-21)
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's via the record's `trace_name`, a `sweep`/`walkforward --trace` family's via the family handle the run prints (`aura chart <handle>`; its members are keyed `<cell>/<member>` in the chart) — or, equivalently, by the `--trace <NAME>` the user chose, when `NAME` uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing).
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's deterministic `trace_name` (`"{campaign8}-{run}"`, never a user-chosen name since #319) charts its whole family (`aura chart <handle>`; members keyed `<cell>/<member>` in the chart) — or, equivalently, a bare campaign id/name that uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing).
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura run` constructs a recorder at each and persists the series through the trace store; a sweep leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura exec` constructs a recorder at each and persists the series through the trace store; a campaign member run leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both CLI verbs (`aura run`, `aura measure`) pass a record-all plan, so their semantics are unchanged.
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura exec` (#319; `aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura exec` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is emitted at finalize and stamped with the instant of the last contributing (warm) value — `first` alone pins the first contributing instant, and `min`/`max` deliberately do not carry the extremum's instant (ratified #335).
### topology hash
**Avoid:** —
The `content id` of a run's signal blueprint in its run-record role: stamped into the manifest as `topology_hash`, keying the reproduction store and anchoring `aura reproduce` — the same SHA-256 over the same canonical bytes. Distinct from the debug-name-blind `identity id`.
### use (op)
**Avoid:** —
The construction op that splices a **registered** blueprint into the building graph as a nested `composite` under a chosen instance name (`{"op":"use","ref":{"content_id"|"name":…},"name":…,"bind":{…}}`, #317): the reference — a content id, a unique content-id prefix, or a `blueprint label` — resolves CLI-side, before the engine ever sees it; the fetched composite is C29-gated, spliced, and its resolution echoed on stderr (`aura: note:`, the existing benign marker). The emitted blueprint carries the splice inline as an ordinary nested composite — no reference or registry dependency survives into the artifact. An instance's open input roles become its `<instance>.<role>` in-ports, its output boundary fields its `<instance>.<field>` out-fields — the existing nested-composite param-prefix discipline extends unchanged (`<instance>.<node>.<param>`, the raw axis form since #328). Pairs with the `input` op: a pattern meant to be `use`d declares its formal parameters as open `input` roles, left unbound at `finish()` (#317's open-pattern amendment) — only running it standalone requires them bound.
### veto
**Avoid:** risk-manager
The **optional** documented pre-trade-gate seam in the execution chain (`stop-rule → [Veto] → position-management`): position / exposure / notional caps that may reject or scale a trade. A pass-through identity DCE'd away when absent (C19/C23); kept as a named seam — it is what LEAN calls "Risk Management" and nautilus the RiskEngine.
### walk-forward
**Avoid:** —
An orchestration axis: rolling in-sample optimize + out-of-sample test across moving windows, stitched into one out-of-sample verdict plus parameter stability. As with `sweep`, every open knob named by `--list-axes` is **required** on a loaded blueprint — a subset is refused with the missing knob named; pin one with a single-value axis, there is no default. The `aura walkforward <blueprint.json> --real <sym> --axis <name>=<csv> …` CLI verb is thin sugar over the `campaign document` path — translated to a generated campaign (`std::sweep → std::walk_forward`) run through the one executor (#210; blueprint-generic over arbitrary blueprints and axes since #220).
An orchestration axis: rolling in-sample optimize + out-of-sample test across moving windows, stitched into one out-of-sample verdict plus parameter stability. As with `sweep`, every open knob a campaign document's axes name is **required** — a subset is refused with the missing knob named; pin one with a single-value axis, there is no default. Realised as a campaign document's `std::sweep → std::walk_forward` process pipeline (#210; blueprint-generic over arbitrary blueprints and axes since #220), executed through the one `aura exec` executor (#319) — the standalone `aura walkforward` CLI verb is retired.
### World
**Avoid:** —
+14 -4
View File
@@ -91,6 +91,15 @@ deploy, spanning both halves.
> nodes new` scaffolder emits this line; a hand-rolled node crate adds it
> itself.
> **Compatibility across the seam
> ([C30](design/contracts/c30-stability-discipline.md)).** The node crate
> consumes `aura-core` as a *path* dependency into the engine checkout —
> the pair moves together, and cargo rebuilds the crate when the engine
> changes. No Rust API stability is promised before 1.0: an external
> embedding in its own repo instead pins the engine as a git dependency at
> a rev, and the load handshake refuses a dylib built against a different
> aura-core.
## Where reusable nodes live (three tiers)
Everything that plugs into the engine is fractally a `Node`. Reuse is plain
@@ -144,9 +153,10 @@ command sequences.
`ThirdCandleLong` node's `schema` + `eval` in it, against `aura-core`.
3. **Backtest:** an op-script wiring `ger40_lab_nodes::ThirdCandleLong` (§1,
`docs/authoring-guide.md`) is built into `blueprints/third-candle-long.json`
(`aura graph build`); `aura run blueprints/third-candle-long.json --real
GER40 --from 1704067200000 --to 1735689600000` (the window bounds are Unix
milliseconds — here 2024) → the strategy produces a broker-independent, unsized
(`aura graph build`); `aura exec blueprints/third-candle-long.json` (#319)
smoke-runs it over the synthetic stream — a real-data window over `GER40`
between two Unix-millisecond bounds is a one-cell campaign document (step
4) → the strategy produces a broker-independent, unsized
**bias stream** (one signed, bounded `f64 ∈ [-1,+1]` per cycle — sign = direction,
magnitude = optional conviction). A downstream **risk-based executor** (stop-rule →
position-management, in **R**, the protective stop defining 1R) turns the bias into
@@ -165,7 +175,7 @@ command sequences.
Monte-Carlo over seeds, or a structural matrix like "these 10 strategies ×
these 3 instruments × {fixed-stop, vol-stop} risk-executors" — instruments
× windows × strategy × param axes × process, headless-authorable and
registered under `blueprints/`. `aura campaign run <content-id>`
registered under `blueprints/`. `aura exec <content-id>` (#319)
bootstraps the matrix, fans the disjoint sims over all cores (C1), and
writes the comparable runs to `runs/`.
5. **Compose:** "combine it with `momentum-filter` as a weighted sum" → Claude
+122
View File
@@ -0,0 +1,122 @@
# Fieldtest transcript — cycle #317 (composites, the `use` op)
Verbatim command/output capture for the `fieldtests/composites-use/` fixtures.
Binary: `target/release/aura` built from HEAD `4a30222` (release profile).
Project: `./lab` (scaffolded with `aura new lab`; `/runs` gitignored).
Commands using the store were run from inside `lab/`.
## Example 1 — register → --name → discover → use by name → splice
```
$ aura graph build < cu_1_pattern_spread.ops.json
{... "input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}] ...}
# open pattern: input role "price" carries no "source":<kind> (contrast a source role)
$ aura graph introspect --params cu_1_pattern_spread.ops.json
fast.length:I64
slow.length:I64
$ aura graph introspect --unwired < cu_1_pattern_spread.ops.json
# (no output — the price input role feeds fast.series/slow.series, so no open interior slot)
# --- inside lab/ ---
$ aura graph register .../cu_1_pattern_spread.ops.json --name spread_xover
registered blueprint f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1 (.../runs/blueprints/f61659....json)
label "spread_xover" -> f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1
$ aura graph introspect --registered
spread_xover f61659aaa8dd open SMA-crossover spread pattern: fast minus slow over an injected price input role
$ aura graph build < cu_1_consumer.ops.json > payload 2> echo
# stderr (echo): aura: note: use "xover": spread_xover -> f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1
# stdout (payload): {... "nodes":[{"composite":{"name":"xover", ... spliced inline ...}}, {"primitive":{"type":"Bias"...}}] ...}
$ aura graph introspect --params cu_1_consumer.ops.json
xover.fast.length:I64
xover.slow.length:I64
bias.scale:F64
```
## Example 2 — ref-form variety + refusal UX
Store contains labels `ema_smoother` (1c8edf24a1eb) and `spread_xover` (f61659aaa8dd),
plus two unlabelled spread variants `e6982f7d...` and `e8f04011...` (both start with `e`).
```
$ aura graph register .../cu_2_docless.ops.json --name docless
aura: blueprint: composite `graph` carries no doc — a registered composite describes itself (C29); add a doc line (...) before register
# exit 1 (register gates doc-less at register; a doc-less entry never enters the store)
# by full content id -> splices, echoes id
$ aura graph build < cu_2_ref_full_id.ops.json
aura: note: use "sp": e6982f7d...d55d82 -> e6982f7d...d55d82 (exit 0)
# by unique prefix "e69" -> splices, echoes resolved id
$ aura graph build < cu_2_ref_prefix.ops.json
aura: note: use "sp": e69 -> e6982f7d...d55d82 (exit 0)
# by ambiguous prefix "e" -> refuses, enumerates candidates
$ aura graph build < cu_2_ref_ambiguous.ops.json
aura: op 2 (use "sp"): ambiguous id "e": candidates e6982f7d...d55d82, e8f04011...07fcf8 (exit 1)
# by unknown prefix "deadbeef" -> refuses
$ aura graph build < cu_2_ref_unknown_prefix.ops.json
aura: op 2 (use "sp"): no registered blueprint matches content id "deadbeef" (exit 1)
# by unknown label "nope" -> refuses, enumerates labels
$ aura graph build < cu_2_ref_unknown_label.ops.json
aura: op 2 (use "sp"): no registered blueprint labeled "nope" — registered labels: ema_smoother, spread_xover (exit 1)
```
## Example 3 — running an open pattern standalone
Docs (authoring guide `input` row; glossary `use (op)`) say: "only running it
standalone refuses (bootstrap's unbound-root-role gate)."
```
# (a) spread-exposing open pattern -> refuses on the run-SHAPE gate (not the role gate)
$ aura run spread_open.bp.json
aura: `aura run` needs either a `bias` output (a strategy) or ≥1 declared tap (a measurement); this blueprint exposes neither (exit 1)
# (b) bias-exposing open pattern, params OPEN -> refuses on the OPEN-PARAMS gate
$ aura run open_bias.bp.json
aura: run requires a closed blueprint (no free parameters); 3 free knob(s) — bind them or use `aura sweep --axis` (exit 2)
$ aura run open_bias.bp.json --params '[{"I64":2},{"I64":4},{"F64":0.5}]'
aura: run requires a closed blueprint (no free parameters); 3 free knob(s) — ... (exit 2) # --params did not close the knobs
# (c) bias-exposing open pattern, params BOUND, input role "price" OPEN -> RUNS CLEAN (docs say refuse)
$ aura run open_bias_bound.bp.json # from cu_3_open_bias_bound.ops.json
{"manifest":{...,"params":[],"defaults":[["graph.fast.length",{"I64":2}],...]},"metrics":{"total_pips":0.3418...}} (exit 0)
# (d) same, but input role named "quote" (not a column name) -> refuses on the ROLE-COLUMN gate
$ aura run altrole.bp.json
aura: input role "quote" of strategy "graph" binds to no archive column — bindable columns: open, high, low, close (alias: price), spread, volume; or bind it explicitly in the campaign data.bindings block (exit 1)
```
## Example 4 — sweep integration + gang refusal + happy-path run
```
$ aura sweep cu_1_consumer.bp.json --list-axes
graph.xover.fast.length:I64
graph.xover.slow.length:I64
graph.bias.scale:F64
$ aura sweep cu_1_consumer.bp.json --axis graph.xover.fast.length=2,3 \
--axis graph.xover.slow.length=6 --axis graph.bias.scale=0.5 --name cu4_sweep
{"family_id":"cu4_sweep-0","report":{"manifest":{...,"params":[["graph.xover.fast.length",{"I64":2}],["graph.xover.slow.length",{"I64":6}],...]}}}
{"family_id":"cu4_sweep-0","report":{"manifest":{...,"params":[["graph.xover.fast.length",{"I64":3}],...]}}}
# exit 0, 2 members — spliced-instance params sweep through the nesting
# gang a spliced instance's member path -> refuses (documented), but message frames it as a missing param
$ aura graph build < cu_4_gang_instance_member.ops.json
aura: note: use "xover": spread_xover -> f61659...
aura: op 6 (gang): node xover has no param "fast.length" (exit 1)
# happy path: use + splice-time bind -> closed blueprint -> run
$ aura graph build < cu_1_consumer_bound.ops.json > closed.bp.json
$ aura graph introspect --params cu_1_consumer_bound.ops.json # (empty = closed)
$ aura run closed.bp.json
{"manifest":{...,"params":[],"defaults":[["graph.xover.fast.length",{"I64":2}],...]},"metrics":{...}} (exit 0)
$ aura run closed.bp.json --real GER40
{"manifest":{...}} (exit 0)
```
@@ -0,0 +1,9 @@
[
{"op": "doc", "text": "consumer: splice the spread pattern by name, feed price, clamp the spread into a bias"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"name": "spread_xover"}, "name": "xover"},
{"op": "feed", "role": "price", "into": ["xover.price"]},
{"op": "add", "type": "Bias", "name": "bias"},
{"op": "connect", "from": "xover.spread", "to": "bias.signal"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]

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