Compare commits

75 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

closes #331
refs #328, #311

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

refs #283, refs #77
2026-07-21 19:55:30 +02:00
347 changed files with 12589 additions and 1207 deletions
+10
View File
@@ -159,6 +159,16 @@ design decision, not a refactor, and belongs in the ledger.
harness and is an execution viewer / trace explorer (structure before, live
streams during, recorded traces after) — never a scene editor; topology is
grown in Rust + hot-reload, runtime params are UI-tunable.
13. **Self-description: every surface explains itself.** Every closed-
vocabulary entry the binary exposes (nodes, metrics, tap slots, folds,
blocks) carries a one-line meaning, enforced at its entry seam —
compile/unit for engine-shipped entries, load for native node crates,
register for the content-addressed store (documents: an optional gated
`description`). The gate is deterministic string shape (`doc_gate`),
never content judgement: the engine never evaluates description text
(no freetext logic hole, invariant 10), descriptions never influence
execution, determinism (C1), or identity ids, and registered artifacts
are never retroactively invalidated. (C29)
## HTML surfaces
Generated
+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]]
+7 -3
View File
@@ -38,7 +38,7 @@ Invoke it as `aura <command> …` (examples below use the plain name).
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
in a content-addressed store and can be listed, ranked, and reproduced.
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
bound with a comma-separated value list. A **gang** fuses several sibling
knobs into one axis (one value drives all members).
@@ -119,8 +119,12 @@ aura sweep crossover.bp.json --list-axes
```
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
and `gang` (`tap` declares a recorded measurement point on an interior wire —
see the authoring guide). See
`gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
interior wire; `doc` declares the composite's one-line meaning, required at
register — C29; `use` splices a registered blueprint in as a nested composite,
by content id or label; `add` additionally takes an `args` object for
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
non-scalar construction consumed before `bind`; see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics.
+1
View File
@@ -1214,6 +1214,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
@@ -87,7 +87,12 @@ impl PositionManagement {
.collect();
PrimitiveBuilder::new(
"PositionManagement",
NodeSchema { inputs, output, params: vec![] },
NodeSchema {
inputs,
output,
params: vec![],
doc: "turns bias plus protective stop into a managed position in R",
},
|_| Box::new(PositionManagement::new()),
)
}
+1
View File
@@ -68,6 +68,7 @@ impl SimBroker {
],
output: vec![FieldSpec { name: "equity".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "frictionless sim-optimal broker: integrates held exposure times price return into cumulative pip equity",
},
move |_| Box::new(SimBroker::new(pip_size)),
)
@@ -1,9 +1,9 @@
{
"surface": "campaign_heavy",
"metrics": {
"cpu_percent": 2116.0,
"peak_rss_mb": 98.25,
"wall_s": 5.604210818
"cpu_percent": 2152.0,
"peak_rss_mb": 101.3828125,
"wall_s": 5.572580056
},
"fingerprint": "cells=8 c0s0w17 c0s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c0s3b2883 c1s0w17 c1s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c1s3b2883 c2s0w17 c2s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c2s3b2883 c3s0w17 c3s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c3s3b2883 c4s0w17 c4s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c4s3b2883 c5s0w17 c5s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c5s3b2883 c6s0w17 c6s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c6s3b2883 c7s0w17 c7s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c7s3b2883",
"reps": 3,
@@ -12,6 +12,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -1,9 +1,9 @@
{
"surface": "campaign_sweep",
"metrics": {
"cpu_percent": 2170.0,
"peak_rss_mb": 98.21875,
"wall_s": 1.418092216
"cpu_percent": 2190.0,
"peak_rss_mb": 92.203125,
"wall_s": 1.394470036
},
"fingerprint": "cells=8 c0s0w17 c1s0w17 c2s0w17 c3s0w17 c4s0w17 c5s0w17 c6s0w17 c7s0w17",
"reps": 3,
@@ -12,6 +12,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -1,16 +1,16 @@
{
"surface": "cli_fixed_cost",
"metrics": {
"help_ms": 1.5094509999999999,
"run_ms": 3.679743
"help_ms": 1.487171,
"run_ms": 3.376192
},
"fingerprint": "run_line_fnv=792dc4434f8e3dee",
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
"reps": 3,
"host": {
"hostname": "Raki",
"nproc": 24
},
"profile": "release",
"commit": "13d8500",
"date": "2026-07-17"
"commit": "10570b7",
"date": "2026-07-25"
}
@@ -1,8 +1,8 @@
{
"surface": "engine_throughput",
"metrics": {
"bars_per_s": 13591474.080269944,
"wall_s": 0.735755367
"bars_per_s": 14005588.039151624,
"wall_s": 0.714000724
},
"fingerprint": "last=0.417351 max_dd=8.274577 flips=265308 n=10000000",
"reps": 3,
@@ -11,6 +11,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
@@ -1,8 +1,8 @@
{
"surface": "ingest_throughput",
"metrics": {
"bars_per_s": 12912289.748572402,
"wall_s": 0.388838858
"bars_per_s": 13000457.745164338,
"wall_s": 0.386201786
},
"fingerprint": "n=251040 xor=0019885debd3bd5e",
"reps": 3,
@@ -11,6 +11,6 @@
"nproc": 24
},
"profile": "release",
"commit": "d62dede",
"date": "2026-07-17"
"commit": "9c7f60b",
"date": "2026-07-23"
}
+4 -4
View File
@@ -67,8 +67,8 @@ pub const HEAVY_PROCESS_DOC_QUICK: &str = r#"{
/// `crates/aura-cli/examples/r_sma.json` except the second variant's bound
/// lengths (3/6 instead of 2/4), which gives it a distinct content id — two
/// strategies, same axes surface.
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
fn run_in(bin: &Path, dir: &Path, args: &[&str]) -> Result<(String, Option<i32>), String> {
let out = std::process::Command::new(bin)
@@ -106,9 +106,9 @@ fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<Stri
"sweep",
file,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
name,
],
+13
View File
@@ -830,6 +830,19 @@ fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
.unwrap_or_else(|| "member panicked (non-string payload)".to_string())
}
/// The `SilencedPanic` + `catch_unwind` + `panic_message` recipe above
/// (this module's three call sites), exposed for reuse by a member-run caller
/// OUTSIDE this crate that drives a bare member run without going through
/// `execute` (#278: the synthetic blueprint sweep/walk-forward family
/// builders in `aura-runner`, which share this exact bare-member seam but
/// carry no #272 fault boundary of their own). Returns the panic payload's
/// best-effort message on containment; `AssertUnwindSafe` is the same
/// judgement documented on `panic_message` above.
pub fn catch_member_panic<R>(f: impl FnOnce() -> R) -> Result<R, String> {
let _silence = SilencedPanic::enter();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_message)
}
/// Assemble the (CellOutcome, CellRealization) pair for a cell that failed at a
/// stage (#272): the realized stage prefix survives in `stages`, `families`/
/// `selections` carry whatever already-persisted stages produced (empty for a
+2 -1
View File
@@ -13,7 +13,8 @@
mod exec;
pub use exec::{
execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut,
catch_member_panic, execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily,
StageSelectionOut,
};
use std::collections::BTreeMap;
@@ -85,7 +85,7 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
scalar_fields.extend(selection_scalars);
let vocabulary: BTreeSet<String> =
aura_research::metric_vocabulary().iter().map(|s| s.to_string()).collect();
aura_research::metric_vocabulary().iter().map(|m| m.id.to_string()).collect();
let missing_from_vocabulary: Vec<_> = scalar_fields.difference(&vocabulary).collect();
let stale_in_vocabulary: Vec<_> = vocabulary.difference(&scalar_fields).collect();
@@ -128,7 +128,7 @@ fn rankable_roster_is_single_sourced_and_nested() {
}
for name in aura_campaign::PER_MEMBER_METRICS {
assert!(
aura_research::metric_vocabulary().contains(name),
aura_research::metric_vocabulary().iter().any(|m| m.id == *name),
"per-member metric '{name}' missing from the research vocabulary"
);
}
+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" }
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
+5 -5
View File
@@ -315,8 +315,8 @@ fn present_campaign(
for cell in &outcome.record.cells {
for (stage_ix, st) in cell.stages.iter().enumerate() {
if matches!(&st.survivor_ordinals, Some(v) if v.is_empty()) {
eprintln!(
"aura: cell {}/{}/[{}, {}]: gate at stage {stage_ix} left no \
crate::diag::note!(
"cell {}/{}/[{}, {}]: gate at stage {stage_ix} left no \
survivors; cell realization truncated",
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1
);
@@ -333,8 +333,8 @@ fn present_campaign(
if let Some(f) = &cell.fault {
failed += 1;
fail_labels.push(format!("{}: {}", cell.instrument, cell_fault_kind_label(f.kind)));
eprintln!(
"aura: cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues",
crate::diag::warning!(
"cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues",
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1, f.stage, f.detail
);
}
@@ -388,7 +388,7 @@ fn present_campaign(
.expect("campaign run record serializes")
);
eprintln!(
"campaign run {} recorded: {} cells{}",
"aura: campaign run {} recorded: {} cells{}",
outcome.record.run,
outcome.record.cells.len(),
if failed == 0 {
+65
View File
@@ -0,0 +1,65 @@
//! Stable stderr class markers (C14): `aura: note: <text>` for benign
//! diagnostics on a continuing run (exit code unaffected),
//! `aura: warning: <text>` for recorded faults the run survives.
//! Error lines that accompany a non-zero exit — and plain info lines
//! such as the run-record summary — keep the bare `aura:` prefix; for
//! errors, the exit-code partition is the machine contract.
macro_rules! note {
($($arg:tt)*) => {
eprintln!("aura: note: {}", format_args!($($arg)*))
};
}
macro_rules! warning {
($($arg:tt)*) => {
eprintln!("aura: warning: {}", format_args!($($arg)*))
};
}
pub(crate) use {note, warning};
/// The zero-trade note's message text (#313), pluralization-aware — the
/// milestone fieldtest tripped over "all 1 walk-forward windows"
/// (captured: `fieldtests/milestone-stderr-honesty/captured/
/// example2_singular.err`). Pure over the count so the wording is
/// unit-pinned.
fn zero_trade_note_text(n_windows: usize) -> String {
if n_windows == 1 {
"the single walk-forward window recorded zero trades".to_string()
} else {
format!("all {n_windows} walk-forward windows recorded zero trades")
}
}
/// The #313 zero-trade note, shared by both walk-forward paths (the
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
/// place. Takes the per-window trade counts; a no-op unless there is at
/// least one window and every one of them traded zero times.
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
let n_windows = window_trades.len();
if n_windows > 0 && window_trades.all(|n| n == 0) {
note!("{}", zero_trade_note_text(n_windows));
}
}
#[cfg(test)]
mod tests {
use super::zero_trade_note_text;
#[test]
/// #278 fieldtest friction: the single-window form must read as
/// grammatical English while the plural form keeps the exact phrase
/// the e2e pins grep for.
fn zero_trade_note_pluralizes() {
assert_eq!(
zero_trade_note_text(1),
"the single walk-forward window recorded zero trades"
);
assert_eq!(
zero_trade_note_text(3),
"all 3 walk-forward windows recorded zero trades"
);
}
}
+546 -48
View File
@@ -8,22 +8,61 @@ use std::collections::BTreeMap;
use std::path::Path;
use aura_engine::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, name_gate, replay, ArgOpError,
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, NameGateFault, Op,
OpError, Scalar, ScalarKind,
};
use aura_runner::runner::render_value;
use serde::Deserialize;
use crate::research_docs::resolve_id_prefix;
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
// production code; tests still exercise it directly.
#[cfg(test)]
use aura_vocabulary::std_vocabulary;
/// The op-list reference `aura graph build --help` appends (#323): the eleven
/// op kinds with their fields and one worked element each (#331: `name` joins
/// the roster, ten -> eleven). Lives beside [`OpDoc`] so a new op variant is
/// one screen away from its help line.
pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order):
{"op":"source","role":"price","kind":"F64"}
declare a bound root input role of a scalar kind
{"op":"input","role":"price"}
declare an open input role (wired by an enclosing graph)
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
instantiate a node ("name" optional; "bind" maps param -> typed scalar)
{"op":"add","type":"Session","name":"ny","args":{"tz":"America/New_York","open":"09:30"},"bind":{"period_minutes":{"I64":15}}}
an arg-bearing type applies "args" (closed, per-type-declared string
pairs) BEFORE "bind" — see graph introspect --node <T> for its args
{"op":"feed","role":"price","into":["fast.series","slow.series"]}
wire a role into one or more input slots
{"op":"connect","from":"fast.value","to":"sub.lhs"}
wire a node output into an input slot
{"op":"expose","from":"sub.value","as":"bias"}
name a graph output field
{"op":"tap","from":"sub.value","as":"spread"}
declare a recordable tap on a wire (expose's output-side twin)
{"op":"gang","as":"length","into":["fast.length","slow.length"]}
fuse two or more sibling params into one public knob
{"op":"doc","text":"..."}
declare the composite's one-line meaning (C29)
{"op":"use","ref":{"name":"agree"},"name":"gate","bind":{"sma.length":{"I64":9}}}
splice a registered blueprint (by "content_id" or "name") under an
instance name ("bind" path-qualifies the spliced instance's params)
{"op":"name","name":"ny_momentum"}
set the composite's render name, at most once per script (default
"graph" if omitted)
Node types and their ports: aura graph introspect --vocabulary | --node <T>"#;
/// The wire DTO for one construction op — the document's by-identifier shape,
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
#[derive(Debug, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
#[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
enum OpDoc {
Source { role: String, kind: ScalarKind },
Input { role: String },
@@ -34,6 +73,12 @@ enum OpDoc {
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
#[serde(rename = "name", default)]
as_name: Option<String>,
// Construction args (#271) — the typed, closed channel applied BEFORE
// `bind`. A `BTreeMap` (deterministic iteration order into `Op::Add`'s
// `Vec`, mirroring `bind`'s own convention) — absent for every
// args-free type, so an old-style `add` document is unaffected.
#[serde(default)]
args: BTreeMap<String, String>,
#[serde(default)]
bind: BTreeMap<String, Scalar>,
},
@@ -54,37 +99,116 @@ enum OpDoc {
as_name: String,
into: Vec<String>,
},
/// Declare the composite's one-line meaning (C29, #316) — the op-script
/// twin of the builder's `.doc(...)`.
Doc { text: String },
/// Splice a registered blueprint into the building graph as a nested
/// composite (#317) — the DTO the engine's `Op::Use` never sees
/// directly: the CLI resolves `ref` (a store content id, a unique
/// content-id prefix, or a registry name label) to the full content id
/// at conversion time, before the engine ever runs.
Use {
#[serde(rename = "ref")]
r#ref: UseRef,
#[serde(default)]
name: Option<String>,
#[serde(default)]
bind: BTreeMap<String, Scalar>,
},
/// Set the composite's render name (#331) — script-level, at most once;
/// the op-script twin of `replay`'s seeded default name (`"graph"`).
Name { name: String },
}
/// A `use` op's reference (#317) — exactly one of a store content id (full
/// or a unique prefix, #302 semantics) or a registry name label
/// (`graph register --name`, latest-wins).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
enum UseRef {
#[serde(rename = "content_id")]
ContentId(String),
#[serde(rename = "name")]
Name(String),
}
impl OpDoc {
/// The op-kind label for the `op N (kind): cause` message.
fn kind_label(&self) -> &'static str {
/// The op-kind label for the `op N (kind): cause` message. A `use` op
/// carries its instance name (when the author gave one) so a `use`
/// fault reads `use "gate"`, not a bare, undifferentiated `use` — the
/// only kind whose label is not a fixed string (#317).
fn kind_label(&self) -> String {
match self {
OpDoc::Source { .. } => "source",
OpDoc::Input { .. } => "input",
OpDoc::Add { .. } => "add",
OpDoc::Feed { .. } => "feed",
OpDoc::Connect { .. } => "connect",
OpDoc::Expose { .. } => "expose",
OpDoc::Tap { .. } => "tap",
OpDoc::Gang { .. } => "gang",
OpDoc::Source { .. } => "source".to_string(),
OpDoc::Input { .. } => "input".to_string(),
OpDoc::Add { .. } => "add".to_string(),
OpDoc::Feed { .. } => "feed".to_string(),
OpDoc::Connect { .. } => "connect".to_string(),
OpDoc::Expose { .. } => "expose".to_string(),
OpDoc::Tap { .. } => "tap".to_string(),
OpDoc::Gang { .. } => "gang".to_string(),
OpDoc::Doc { .. } => "doc".to_string(),
OpDoc::Use { name: Some(n), .. } => format!("use {n:?}"),
OpDoc::Use { name: None, .. } => "use".to_string(),
OpDoc::Name { .. } => "name".to_string(),
}
}
}
impl From<OpDoc> for Op {
/// Infallible, context-free conversion — used directly only by
/// build-free introspection paths (`introspect --unwired`, #317's
/// spec: "Build-free introspection paths pass a `|_| None` closure"),
/// which never resolve a `use` ref through the registry. A bare
/// `OpDoc::Use` therefore maps its `UseRef` payload verbatim into
/// `ref_id` (unresolved) — that session's `subgraph` closure is always
/// `&|_| None`, so any `use` op there faults `UnknownSubgraph`
/// regardless of the exact `ref_id` text; `graph build`'s real path
/// (`composite_from_str`) never reaches this arm — it resolves and
/// replaces each `Op::Use` before conversion (see `resolve_use_op`).
fn from(d: OpDoc) -> Op {
match d {
OpDoc::Source { role, kind } => Op::Source { role, kind },
OpDoc::Input { role } => Op::Input { role },
OpDoc::Add { type_id, as_name, bind } => {
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
}
OpDoc::Add { type_id, as_name, args, bind } => Op::Add {
type_id,
as_name,
args: args.into_iter().collect(),
bind: bind.into_iter().collect(),
},
OpDoc::Feed { role, into } => Op::Feed { role, into },
OpDoc::Connect { from, to } => Op::Connect { from, to },
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
OpDoc::Doc { text } => Op::Doc { text },
OpDoc::Use { r#ref, name, bind } => Op::Use {
ref_id: match r#ref {
UseRef::ContentId(id) => id,
UseRef::Name(name) => name,
},
name,
bind: bind.into_iter().collect(),
},
OpDoc::Name { name } => Op::Name { name },
}
}
}
/// Phrase one `ArgOpError` (#271) as a human cause, WITHOUT the node prefix —
/// shared by `format_op_error`'s `BadArg` arm (the `add`-op path) and
/// `blueprint_load_prose`'s `BadArg` arm (the blueprint LOAD path), so the two
/// error families cannot phrase the same fault differently. Exhaustive over
/// `ArgOpError`; `BadValue` names the arg, its declared `ArgKind`, AND the
/// closed per-kind `hint()` line (the same hint `introspect --node` shows).
fn arg_op_error_prose(err: &ArgOpError) -> String {
match err {
ArgOpError::NotArgBearing => "takes no construction args".to_string(),
ArgOpError::UnknownArg(a) => format!("has no construction arg {a:?}"),
ArgOpError::DuplicateArg(a) => format!("was given arg {a:?} more than once"),
ArgOpError::MissingArg(a) => format!("is missing required arg {a:?}"),
ArgOpError::BadValue { arg, kind, got } => {
format!("arg {arg:?} expects {kind:?} ({}) — got {got:?}", kind.hint())
}
}
}
@@ -117,10 +241,13 @@ fn format_op_error(e: &OpError) -> String {
BindOpError::KindMismatch { param, expected, got } => {
format!("param {node}.{param} expects {expected:?} but got {got:?}")
}
BindOpError::AlreadyGanged { param, gang } => {
format!("cannot bind {node}.{param} — member of gang {gang:?}")
}
},
OpError::BadArg { node, err } => format!("node {node} {}", arg_op_error_prose(err)),
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"),
OpError::GangKindMismatch { member, expected, got } => {
format!("gang: member `{member}` is {got:?}, expected {expected:?}")
}
@@ -129,18 +256,191 @@ fn format_op_error(e: &OpError) -> String {
}
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::Incomplete(ce) => format!("{ce:?}"),
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
// #317: `graph build`'s real path (`composite_from_str`) resolves and
// fetches every `use` op before replay, so this never fires there —
// but `introspect --unwired` stays build-free/subgraph-free by spec
// (`&|_| None`, see `From<OpDoc> for Op`), so a `use` op in a partial
// document reaches this arm through THAT path, by-identifier on the
// (unresolved) `ref_id` text.
OpError::UnknownSubgraph { ref_id } => {
format!("use: no subgraph for content id {ref_id:?}")
}
OpError::DuplicateName => "a script names its blueprint at most once".to_string(),
OpError::BadName { name, fault } => name_gate_fault_prose(name, fault),
}
}
/// Phrase a `name_gate` shape violation as prose (#331): shared by this
/// module's op-intake `format_op_error` `BadName` arm and the
/// blueprint-envelope intake's root-name gate (`composite_from_authored_text`
/// below) — the shape rule has exactly one seam-independent cause per fault,
/// so both data-borne birth routes for a name read identically.
fn name_gate_fault_prose(name: &str, fault: &NameGateFault) -> String {
let cause = match fault {
NameGateFault::Empty => "must be non-empty",
NameGateFault::ContainsSeparator => "must not contain '/' or '\\'",
NameGateFault::DotSegment => "must not be \".\" or \"..\"",
};
format!("blueprint name {name:?} is invalid: {cause} (a single path segment)")
}
/// Resolve one `use` op's [`UseRef`] to the full store content id (#317):
/// verbatim if it already IS a 64-hex content id, else a unique content-id
/// prefix (#302 semantics, reusing [`resolve_id_prefix`]) or a registry name
/// label (latest-wins, [`aura_registry::Registry::resolve_blueprint_label`]).
/// Returns the full id plus the `<label-or-prefix> -> <full id>` text the
/// resolution echo shows — the bare cause on a miss (unknown label / unknown
/// or ambiguous prefix), for the caller to attribute by op index.
fn resolve_use_ref_id(r: &UseRef, registry: &aura_registry::Registry) -> Result<(String, String), String> {
match r {
UseRef::Name(label) => {
let id = registry.resolve_blueprint_label(label).ok_or_else(|| {
let labels = registry.blueprint_labels();
if labels.is_empty() {
format!("no registered blueprint labeled {label:?} — no labels registered")
} else {
let names = labels.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ");
format!("no registered blueprint labeled {label:?} — registered labels: {names}")
}
})?;
Ok((id.clone(), format!("{label} -> {id}")))
}
UseRef::ContentId(raw) => {
if aura_runner::axes::is_content_id(raw) {
return Ok((raw.clone(), format!("{raw} -> {raw}")));
}
let candidates = registry.list_blueprint_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(raw, &candidates)? {
Some(full) => {
let shown = format!("{raw} -> {full}");
Ok((full, shown))
}
None => Err(format!("no registered blueprint matches content id {raw:?}")),
}
}
}
}
/// The 12-char content-id prefix convention `graph introspect --registered`
/// shows (#317), reused in the use-seam C29 refusal so both surfaces name a
/// blueprint the same shortened way.
fn id_prefix12(id: &str) -> &str {
&id[..id.len().min(12)]
}
/// The use-seam C29 doc-gate refusal cause (#317): re-run
/// [`aura_registry::gate_composite_docs`] — the SAME walk `graph register`
/// gates the write path with — over a FETCHED composite, before it ever
/// reaches the session; names the fetched blueprint's id-prefix and the
/// failing (root or nested — from the consumer's view, everything fetched is
/// "nested") composite by identifier, mirroring `research_docs.rs`'s
/// `BadDescription` phrasing for the same two `DocGateFault` kinds.
fn use_doc_gate_cause(full_id: &str, e: &aura_registry::RegistryError) -> String {
let aura_registry::RegistryError::UndescribedComposite { name, fault } = e else {
// Defensive: `gate_composite_docs` only ever constructs
// `UndescribedComposite`; every other `RegistryError` variant is
// unreachable from this call, but the match stays total rather than
// panicking on a surprise variant.
return format!("registered blueprint {} fails the description gate", id_prefix12(full_id));
};
let rule = match fault {
aura_core::DocGateFault::Empty => format!(
"nested composite {name:?} has no description — re-register it with a \"doc\" op (C29)"
),
aura_core::DocGateFault::RestatesName => {
format!("nested composite {name:?} has a doc that merely restates its name (C29)")
}
};
format!("registered blueprint {} fails the description gate: {rule}", id_prefix12(full_id))
}
/// Resolve, fetch, C29-gate, and echo one `use` op (#317) — the CLI-side
/// half of the store/engine split (the engine never resolves a label, a
/// prefix, or the registry itself). On success, caches the fetched
/// CANONICAL JSON (not the parsed `Composite` — `Composite` is not `Clone`,
/// mirroring the engine's own `use_fixture`-reload test pattern) under the
/// full content id, so the `subgraph` closure handed to `replay` is a pure,
/// registry-free lookup that re-parses on every call. Returns the bare
/// cause on any refusal; the caller attributes it by op index.
fn resolve_use_op(
r#ref: UseRef,
name: Option<String>,
bind: BTreeMap<String, Scalar>,
env: &aura_runner::project::Env,
cache: &mut std::collections::HashMap<String, String>,
) -> Result<Op, String> {
let registry = env.registry();
let (full_id, shown) = resolve_use_ref_id(&r#ref, &registry)?;
let json = registry
.get_blueprint(&full_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no registered blueprint {full_id}"))?;
let doc: BlueprintDoc = serde_json::from_str(&json)
.map_err(|e| format!("registered blueprint {full_id} is not a valid blueprint: {e}"))?;
// C29 at the use seam: gate the DATA form (no vocabulary needed) before
// the composite ever reaches the session.
if let Err(e) = aura_registry::gate_composite_docs(&doc.blueprint) {
return Err(use_doc_gate_cause(&full_id, &e));
}
// Resolve the fetched envelope through the FULL vocabulary here, at DTO
// conversion (review finding, #317 follow-up): deferring this to the
// `subgraph` closure's lazy re-parse let a deserialize/vocab-resolution
// failure fold into `.ok() -> None`, which `replay` then misreports as
// `UnknownSubgraph` — a fetched-but-unloadable blueprint is a runtime
// content fault (exit 1), not a missing reference. `blueprint_load_prose`
// + `unresolved_namespace_hint` are the same house-style pair
// `blueprint_slot_prose`/`composite_from_any` already use over a
// `LoadError`.
if let Err(e) = blueprint_from_json(&json, &|t| env.resolve(t)) {
let mut msg = blueprint_load_prose(&e);
if let Some(hint) = unresolved_namespace_hint(&e, env) {
msg.push_str("");
msg.push_str(&hint);
}
return Err(format!("registered blueprint {} is invalid: {msg}", id_prefix12(&full_id)));
}
let instance = name.clone().unwrap_or_else(|| doc.blueprint.name.clone());
// The resolution echo (C14 benign class): stderr only, so stdout stays
// clean payload (#164's convention, extended to this new note).
crate::diag::note!("use {instance:?}: {shown}");
cache.insert(full_id.clone(), json);
Ok(Op::Use { ref_id: full_id, name, bind: bind.into_iter().collect() })
}
/// Parse a JSON op-list document and replay it through the env's vocabulary into
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
/// past the last op).
/// past the last op). Every `use` op resolves through the registry HERE, before
/// replay (#317): a resolution/doc-gate fault is attributed exactly like any
/// other per-op construction fault (same `op N (kind): cause` shape, exit 1).
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
let labels: Vec<String> = docs.iter().map(OpDoc::kind_label).collect();
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut ops: Vec<Op> = Vec::with_capacity(docs.len());
for (idx, d) in docs.into_iter().enumerate() {
let op = match d {
OpDoc::Use { r#ref, name, bind } => match resolve_use_op(r#ref, name, bind, env, &mut cache) {
Ok(op) => op,
Err(cause) => return Err(format!("op {idx} ({}): {cause}", labels[idx])),
},
other => Op::from(other),
};
ops.push(op);
}
// The injected `subgraph` lookup (#317): a pure, registry-free map read —
// every `use` op's blueprint was already fetched, gated, AND resolved
// (`resolve_use_op`'s eager `blueprint_from_json` check) above; this
// closure only re-parses the cached bytes (`Composite` is not `Clone`).
// The `.ok()` is defensive, not a fallible path in practice: re-parsing
// the SAME cached JSON through the SAME pure resolver cannot fail here
// once it has already succeeded once above (review finding, #317
// follow-up — the failure case now surfaces eagerly, at DTO conversion).
let subgraph = |ref_id: &str| {
cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok())
};
replay("graph", ops, &|t| env.resolve(t), &subgraph).map_err(|(idx, err)| {
let mut cause = format_op_error(&err);
// #244: the op-script path reports an unresolvable namespaced type as
// `OpError::UnknownNodeType`, a different error family than the
@@ -195,7 +495,19 @@ pub fn build_cmd(env: &aura_runner::project::Env) {
pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
let schema = builder.schema();
let mut out = format!("{}\n", builder.label());
// C29 (#315): the head line carries the node's one-line meaning.
let mut out = format!("{}{}\n", builder.label(), schema.doc);
// #271: an arg-bearing (pending) type declares its ArgSpecs but no real
// ports/params yet — those form only once `try_args` runs (`make`). Show
// the arg rows first (they must be supplied before anything else can be
// discovered) plus the one-line note the spec's worked discovery example
// pins.
for spec in builder.arg_specs() {
out.push_str(&format!(" arg {}: {:?} ({})\n", spec.name, spec.kind, spec.kind.hint()));
}
if builder.is_pending() {
out.push_str(" note ports and params form at construction; args are required\n");
}
for port in &schema.inputs {
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
}
@@ -213,7 +525,11 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let resolver = |t: &str| env.resolve(t);
let mut session = GraphSession::new("introspect", &resolver);
// #317: build-free introspection stays subgraph-free by design (spec:
// "Build-free introspection paths pass a `|_| None` closure") — a `use`
// op here always misses (`OpError::UnknownSubgraph`, `From<OpDoc>`'s own
// doc comment), never a registry read.
let mut session = GraphSession::new("introspect", &resolver, &|_: &str| None);
for (i, d) in docs.into_iter().enumerate() {
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
}
@@ -224,6 +540,33 @@ pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<
Ok(out)
}
/// `aura graph introspect --registered` (#317): one row per registry label —
/// `<label> <12-char id prefix> <root doc line>`, latest-wins (the label
/// sidecar's own resolution rule) — the discovery surface the worked
/// acceptance program's last step reads. An empty store is `no labels
/// registered` on stdout, exit 0 (a listing, not a fault); dangling label
/// rows (content id no longer resolves) are already skipped by
/// `blueprint_labels()`.
fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, String> {
let registry = env.registry();
let labels = registry.blueprint_labels();
if labels.is_empty() {
return Ok("no labels registered\n".to_string());
}
let mut out = String::new();
for (name, id) in labels {
let json = registry
.get_blueprint(&id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("label {name:?} names {id}, which is not in the store"))?;
let doc: BlueprintDoc = serde_json::from_str(&json)
.map_err(|e| format!("registered blueprint {id} is not a valid blueprint: {e}"))?;
let root_doc = doc.blueprint.doc.as_deref().unwrap_or("");
out.push_str(&format!("{name} {} {root_doc}\n", id_prefix12(&id)));
}
Ok(out)
}
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
/// group must be set; zero or more than one is the usage error (exit 2). The id
@@ -233,17 +576,46 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
+ cmd.unwired as usize
+ cmd.folds as usize
+ cmd.registered as usize
+ cmd.params.is_some() as usize
+ (cmd.content_id.is_some() || cmd.identity_id) as usize;
if count != 1 {
eprintln!(
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
);
std::process::exit(2);
}
if cmd.vocabulary {
// C29 (#315): each type id carries its schema's one-line meaning —
// bare names teach nothing (What do Latch, When, Select, Bias do?).
// A rostered id that fails to resolve is an invariant breach; better
// loud than a silent C29-incomplete bare row.
for t in env.type_ids() {
println!("{t}");
let b = env.resolve(t).expect("every rostered type id resolves to a builder");
println!("{t:<18} {}", b.schema().doc);
}
} else if cmd.folds {
// The fold vocabulary binds at graph-declared taps (C27), so the
// graph introspect namespace is its discovery surface (#315). #332:
// this renders the fold-REGISTRY roster — the same roster `aura run
// --tap TAP=FOLD` resolves labels against (aura-runner's layered
// `FoldRegistry`) — not the aura-std `FoldKind` table: labels here
// must be exactly what `--tap` accepts (lowercase), including the
// `record` entry that has no `FoldKind` counterpart.
for (label, doc) in aura_runner::FoldRegistry::core().roster() {
println!("{label:<7}{doc}");
}
} else if cmd.registered {
// The label sidecar's discovery surface (#317): one row per
// registered label — nothing to build, so a store-only read, exit 0
// even on an empty store (a listing, not a fault).
match introspect_registered(env) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
} else if let Some(type_id) = cmd.node.as_deref() {
match introspect_node(type_id, env) {
@@ -268,10 +640,12 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
}
}
} else if let Some(target) = cmd.params.as_deref() {
// --params <FILE|ID> (#196): the RAW composite param space — exactly the
// namespace campaign axes are validated against (`validate_campaign_refs`
// checks the raw space; the wrapped `--list-axes` namespace on `aura sweep`
// is the sweep-verb view, not the campaign view).
// --params <FILE|ID> (#196): the RAW composite param space — the one
// namespace campaign axes are validated against
// (`validate_campaign_refs`) and `aura sweep --list-axes` prints
// (#328: the wrapped `<blueprint>.<node>.<param>` form was retired —
// there is exactly one axis namespace now, so the two discovery
// surfaces agree line-for-line, see `params_lines`'s own doc comment).
match params_lines(target, env) {
Ok(s) => print!("{s}"),
Err(m) => {
@@ -300,7 +674,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
std::process::exit(1);
}
};
match composite_from_any(&text, env) {
match composite_from_authored_text(&text, env) {
Ok(c) => c,
Err(m) => {
eprintln!("aura: {m}");
@@ -359,6 +733,7 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
}
}
@@ -469,6 +844,18 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
/// each canonicalized by its own rules.
///
/// **Ungated by design (#331 review finding).** This is the STORE READ-BACK
/// shape: `params_lines`'s content-id fetch (`resolve_blueprint_text`'s
/// non-file branch) — `introspect --params <ID>` and, by the same
/// convention, `validate_campaign_refs`'s already-ungated `blueprint_from_json`
/// call — reads whatever is already sitting in the store, and C29 says a
/// registered artifact is never retroactively invalidated. Freshly authored
/// FILE text (a hand-edited document that has not yet passed through a gated
/// intake) goes through [`composite_from_authored_text`] instead, never here
/// — `params_lines`'s OWN file branch is such a case (#331 delta re-review:
/// it used to call straight through to this fn, missing the gate; fixed by
/// branching on `resolve_blueprint_text`'s file-vs-store flag).
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
@@ -486,12 +873,86 @@ pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) ->
}
}
/// The FILE-intake counterpart of [`composite_from_any`] (#331 review
/// finding): identical parse, plus the blueprint-envelope root-name shape
/// gate. Every call site that builds a `Composite` from text freshly read
/// off disk (`graph register`, `graph introspect --content-id <FILE>`, the
/// bare `aura graph <FILE>` viewer, and `graph introspect --params <FILE>`'s
/// file branch) goes through this wrapper — never the ungated
/// `composite_from_any` — because a hand-edited envelope with
/// `"name":"../x"` would otherwise register/build cleanly and write
/// `traces/../x/` at run time (`trace_store.rs` joins the name unsanitized).
/// Gating the composite's name unconditionally (not just on the Object
/// branch) is harmless: an op-script-built composite's name already passed
/// the op-intake gate (`GraphSession::set_name`), so re-checking it here is
/// redundant, not restrictive — it keeps this wrapper a single shared choke
/// point rather than one that has to re-discriminate the JSON shape. Only
/// the ROOT name is checked: the filesystem seam consumes exclusively the
/// root name. Store read-back (reproduce, `use`-splice resolution from the
/// registry, and `params_lines`'s content-id branch) never reaches this
/// function — C29's "registered artifacts are never retroactively
/// invalidated" stays intact.
///
/// **Deliberate exceptions — direct `gate_authored_root_name` callers
/// (main.rs).** A handful of fresh-FILE intakes reach the same unsanitized
/// `traces/<name>/` seam (or `put_blueprint` the loaded envelope straight
/// into the registry) without going through this wrapper, because each
/// already parses the document its own way and only needs the shared root-
/// name gate bolted on, not the shape-discrimination `composite_from_any`
/// does:
/// - `aura run <blueprint.json>` (`dispatch_run`): envelope-only grammar (no
/// op-script fallback), feeds `signal.name()` into
/// `run_signal_r`/`run_measurement` -> `bind_tap_plan` ->
/// `TraceStore::begin_run`.
/// - `validate_and_register_axes` (shared by `generalize`,
/// `sweep --real`, `walkforward --real`, `mc --real`): `put_blueprint`s
/// the loaded envelope by topology hash before any of those four verbs
/// touches an archive.
/// - `run_blueprint_sweep` / `run_blueprint_walkforward` / `run_blueprint_mc`
/// (the synthetic, no-`--real` family builders): same `put_blueprint`
/// reason, on the routes that bypass `validate_and_register_axes`
/// entirely (#331 cycle-close — these used to plant an ungated envelope
/// in the store; see each fn's own comment).
/// - `list_blueprint_axes` (`aura sweep <FILE> --list-axes`, main.rs): no
/// registry write and no trace directory here, but a shape-violating root
/// name otherwise mangles through `wrapped_to_raw_axis` into a printed,
/// non-bindable axis name instead of refusing (#331 fieldtest finding
/// c331_2e) — the class rule is every CLI intake reading an authored
/// envelope from a file gates the root name, not only the writing ones.
///
/// All of these share the identical `name_gate` + `name_gate_fault_prose`
/// primitives this wrapper uses, so the refusal wording is byte-identical
/// across every authored-file-intake route even though the shape-
/// discrimination step is not shared by them.
pub(crate) fn composite_from_authored_text(
text: &str,
env: &aura_runner::project::Env,
) -> Result<Composite, String> {
let composite = composite_from_any(text, env)?;
gate_authored_root_name(composite.name())?;
Ok(composite)
}
/// The root-name shape gate itself (#331 delta re-review), factored out of
/// [`composite_from_authored_text`] so `dispatch_run`'s narrower-grammar file
/// intake (see that fn's doc comment) can share the exact `name_gate` call and
/// `name_gate_fault_prose` wording without going through the shape-discriminating
/// wrapper.
pub(crate) fn gate_authored_root_name(name: &str) -> Result<(), String> {
name_gate(name).map_err(|fault| name_gate_fault_prose(name, &fault))
}
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
/// id in the project store (the campaign-run target-addressing convention).
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
/// cannot drift apart on what counts as a store address.
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
/// cannot drift apart on what counts as a store address. The `bool` names
/// which branch fired: `true` for a FRESH FILE read, `false` for a STORE
/// (content-id) fetch — `params_lines` (#331 delta re-review) uses it to
/// decide whether the root-name gate applies (a fresh file must gate; a
/// store read-back must not, C29) without re-deriving the FILE-vs-id
/// distinction a second time.
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<(String, bool), String> {
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
// fields stay bare-only.
let target = target
@@ -501,11 +962,12 @@ fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Resu
let path = Path::new(target);
if path.is_file() {
return std::fs::read_to_string(path)
.map(|text| (text, true))
.map_err(|e| format!("cannot read {}: {e}", path.display()));
}
if aura_runner::axes::is_content_id(target) {
return match env.registry().get_blueprint(target) {
Ok(Some(json)) => Ok(json),
Ok(Some(json)) => Ok((json, false)),
Ok(None) => Err(format!("no blueprint {target} in the project store")),
Err(e) => Err(e.to_string()),
};
@@ -517,27 +979,45 @@ fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Resu
/// line per open param of the RAW composite (no harness wrap) — the
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
/// the same form `introspect --node` already uses for param kinds.
/// the same form `introspect --node` already uses for param kinds. Followed
/// by one `{name}:{kind:?} default={value}` line per BOUND param (#328):
/// `bound_param_space()`'s names are already RAW (#203), so no blueprint-name
/// concatenation is needed — line-identical to the reconciled `--list-axes`
/// bound pass (`list_blueprint_axes`, main.rs), same `render_value` lexicon.
/// #331 delta re-review: the FILE target is a FOURTH freshly-authored-file
/// intake this fn's own `composite_from_any` call had missed gating — a FILE
/// routes through [`composite_from_authored_text`] instead; a content id
/// (STORE read-back) keeps the ungated `composite_from_any` (C29).
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
use std::fmt::Write as _;
let text = resolve_blueprint_text(target, env)?;
let (text, is_file) = resolve_blueprint_text(target, env)?;
// Shape-discriminated like file-mode --content-id: an op-script (array)
// builds through the one-build tail, an envelope (object) loads (#202).
let composite = composite_from_any(&text, env)?;
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
let mut out = String::new();
for p in composite.param_space() {
let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
}
for b in composite.bound_param_space() {
let _ = writeln!(out, "{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
}
Ok(out)
}
/// `aura graph register <blueprint.json>` (#196): parse the blueprint through
/// the project vocabulary, canonicalize, content-address, and store — the
/// `process register` pattern, printing the store path so the trail is
/// followable. Prose to stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
match register_blueprint(file, env) {
Ok(line) => println!("{line}"),
/// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
/// #317): parse the blueprint through the project vocabulary, canonicalize,
/// content-address, and store — the `process register` pattern, printing
/// the store path so the trail is followable. With `--name`, additionally
/// labels the stored content id (`aura_registry::Registry::put_blueprint_label`),
/// echoing the repoint when the label already pointed elsewhere. Prose to
/// stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, name: Option<&str>, env: &aura_runner::project::Env) {
match register_blueprint(file, name, env) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
}
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
@@ -545,20 +1025,37 @@ pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
}
}
fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
fn register_blueprint(
file: &Path,
name: Option<&str>,
env: &aura_runner::project::Env,
) -> Result<Vec<String>, String> {
let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
// Shape-discriminated like file-mode --content-id (#202): either shape
// canonicalizes to the envelope form, so the stored id is shape-invariant.
let composite = composite_from_any(&text, env)?;
// Gated (#331 review finding): this text is freshly authored FILE bytes,
// not a store read-back, so `composite_from_authored_text` applies.
let composite = composite_from_authored_text(&text, env)?;
let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?;
let id = crate::content_id(&canonical);
let registry = env.registry();
registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
Ok(format!(
let mut lines = vec![format!(
"registered blueprint {id} ({})",
registry.blueprint_path(&id).display()
))
)];
if let Some(label) = name {
// Resolve BEFORE writing, so the repoint echo compares against the
// pre-write target (#317).
let previous = registry.resolve_blueprint_label(label);
registry.put_blueprint_label(label, &id).map_err(|e| e.to_string())?;
lines.push(match previous {
Some(old) if old != id => format!("label {label:?} -> {id} (was {old})"),
_ => format!("label {label:?} -> {id}"),
});
}
Ok(lines)
}
#[cfg(test)]
@@ -587,7 +1084,8 @@ mod tests {
assert_eq!(docs[1].kind_label(), "add");
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
// both SMA lengths are bound, so the only open param is bias.scale (f64).
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
let composite =
replay("graph", ops, &std_vocabulary, &|_: &str| None).expect("replay resolves");
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
}
File diff suppressed because it is too large Load Diff
+98 -9
View File
@@ -92,9 +92,10 @@ fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
/// vocabulary, but the mode rides the shared introspect struct and answers
/// for both families.
fn print_metric_roster() {
for name in aura_research::metric_vocabulary() {
let rankable = aura_campaign::RANKABLE_METRICS.contains(name);
let gate = aura_campaign::PER_MEMBER_METRICS.contains(name);
for m in aura_research::metric_vocabulary() {
let name = m.id;
let rankable = aura_campaign::RANKABLE_METRICS.contains(&name);
let gate = aura_campaign::PER_MEMBER_METRICS.contains(&name);
// The generalize applicability is the registry's own R-expectancy
// predicate — no fourth roster site (#190/#207).
let generalize = aura_registry::check_r_metric(name).is_ok();
@@ -106,10 +107,12 @@ fn print_metric_roster() {
(true, false) => "rankable",
(false, false) => "annotation",
};
// C29 (#315): the roster carries each metric's one-line meaning, not
// only where it may be used.
if generalize {
println!("{name:<24} {base} | generalize");
println!("{name:<24} {base} | generalize{}", m.doc);
} else {
println!("{name:<24} {base}");
println!("{name:<24} {base}{}", m.doc);
}
}
}
@@ -181,7 +184,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
DocFault::UnknownTap { index, tap } => format!(
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
DocFault::UnknownBindingColumn { role, column } => format!(
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
@@ -190,6 +193,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::ProcessRefMustBeContentId => {
"process.ref: a process is referenced by content id in this version".into()
}
DocFault::BadDescription { subject, fault } => match fault {
aura_core::DocGateFault::Empty => format!(
"description: `{subject}`'s description is empty — omit the \
field or write a one-line meaning (C29)"
),
aura_core::DocGateFault::RestatesName => format!(
"description: `{subject}`'s description merely restates the \
document name (C29)"
),
},
}
}
@@ -281,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),
@@ -361,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> {
for s in schema.slots {
let req = if s.required { "required" } else { "optional" };
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
// C29 (#315): the tap slot's closed value vocabulary carries its
// per-entry meanings, indented under the slot line.
if matches!(s.kind, aura_research::SlotKind::TapKinds) {
for t in aura_research::tap_vocabulary() {
println!(" {:<14} {}", t.id, t.doc);
}
}
}
Ok(())
}
@@ -442,8 +462,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
RefFault::StrategyUnloadable { id, error } => {
format!("strategy {id} cannot be loaded: {error}")
}
RefFault::AxisNotInParamSpace { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
if let Some(candidate) = raw_candidate {
msg.push_str(&format!(
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
));
}
msg
}
RefFault::AxisKindMismatch { strategy, axis } => {
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
@@ -741,6 +767,7 @@ mod tests {
ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
}),
ref_fault_prose(&RefFault::AxisKindMismatch {
strategy: "9f3a".into(),
@@ -761,6 +788,39 @@ mod tests {
}
}
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
/// and verbatim, exactly when `raw_candidate` is present — the document-
/// side refusal seam's mirror of the sweep `--axis` intake translation
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
/// `axis names are raw node.param paths` clause pinned there.
#[test]
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "graph.fast.length".into(),
raw_candidate: Some("fast.length".into()),
});
assert_eq!(
msg,
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
axis names are raw node.param paths — did you mean \"fast.length\"?"
);
}
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
/// leading segment yields no param-space hit, or the axis has no leading
/// segment to strip at all) renders today's prose unchanged — no
/// speculative suggestion.
#[test]
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
strategy: "9f3a".into(),
axis: "nope".into(),
raw_candidate: None,
});
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
}
#[test]
/// #194 (the prefix trap): a doc ref that carries the display `content:`
/// prefix (a copy-paste from register/introspect output) is bare-only in
@@ -816,6 +876,35 @@ mod tests {
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
}
/// C29 (#316): the description-quality fault names the subject and the
/// rule (C29) for both sub-faults — an empty description and one that
/// merely restates the document name — never leaking the Debug variant
/// names of either `DocFault` or the nested `DocGateFault`.
#[test]
fn bad_description_prose_names_subject_and_rule_for_both_faults() {
let empty = doc_fault_prose(&DocFault::BadDescription {
subject: "ger40-momentum".into(),
fault: aura_core::DocGateFault::Empty,
});
assert!(
empty.contains("`ger40-momentum`'s description is empty"),
"subject and emptiness named: {empty}"
);
assert!(empty.contains("C29"), "rule cited: {empty}");
assert!(!empty.contains("BadDescription") && !empty.contains("DocGateFault"), "Debug leak: {empty}");
let restated = doc_fault_prose(&DocFault::BadDescription {
subject: "ger40-momentum".into(),
fault: aura_core::DocGateFault::RestatesName,
});
assert!(
restated.contains("`ger40-momentum`'s description merely restates the document name"),
"subject and restatement named: {restated}"
);
assert!(restated.contains("C29"), "rule cited: {restated}");
assert!(!restated.contains("BadDescription") && !restated.contains("DocGateFault"), "Debug leak: {restated}");
}
/// #260: the instrument-map key-mismatch fault is path-addressed and names
/// both directions — the campaign instruments the map misses AND the map
/// keys naming no campaign instrument — in one line, no Debug leak. The
+61 -11
View File
@@ -152,6 +152,8 @@ impl Scale {
pub fn new(factor: f64) -> Self {
Self { factor, out: [Cell::from_f64(0.0)] }
}
// Replace `doc` below when renaming this sample node — it must keep
// describing the node's own behaviour, not this scaffolding step.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"__NS__::Scale",
@@ -163,6 +165,9 @@ impl Scale {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
// C29: every vocabulary entry ships a one-line meaning — the
// doc field is required (E0063 without it) and gated at load.
doc: "scalar gain: emits the input times the factor param",
},
|p| Box::new(Scale::new(p[0].f64())),
)
@@ -219,7 +224,7 @@ crate is attached to its project via `[nodes]` in the project's `Aura.toml`.
const GITIGNORE_PROJECT: &str = "/runs\n";
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only)
@@ -229,12 +234,21 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
- Run: `aura run blueprints/signal.json` (the starter is closed — all
params bound; bound values are defaults — any `--axis` may override them
(#246))
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
- Sweep: `aura sweep blueprints/signal.json --axis fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes, RAW
`<node>.<param>` names — #328)
- Native nodes: when the project needs its first project-specific node,
`aura nodes new <name>` scaffolds a node crate beside this project and
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
- Topology is data (`blueprints/*.json`); results land in `runs/`.
- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
continuously-tracked target position; a protective stop defines the risk
unit R, and quality metrics are R-based. Entry signals become held state
via the signal-side latch/edge-pulse idiom (see
`aura graph introspect --vocabulary`).
- Data plane: the research verbs are sugar over registered process/campaign
documents — author them directly with `aura process` / `aura campaign`,
growing one from a bare `{}` via `aura campaign introspect --unwired`.
"#;
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
@@ -280,7 +294,7 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> {
Ok(o) if o.status.success() => {
commit_all(&spec.target_dir, "aura new scaffold");
}
_ => eprintln!("aura: warning: git init failed (project created without a repo)"),
_ => crate::diag::warning!("git init failed (project created without a repo)"),
}
Ok(())
}
@@ -321,7 +335,7 @@ pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> {
Ok(o) if o.status.success() => {
commit_all(&spec.target_dir, "aura nodes new scaffold");
}
_ => eprintln!("aura: warning: git init failed (node crate created without a repo)"),
_ => crate::diag::warning!("git init failed (node crate created without a repo)"),
}
Ok(())
}
@@ -354,7 +368,7 @@ fn commit_all(dir: &std::path::Path, message: &str) {
.current_dir(dir)
.output();
if !matches!(add, Ok(o) if o.status.success()) {
eprintln!("aura: warning: git add failed (scaffold created without an initial commit)");
crate::diag::warning!("git add failed (scaffold created without an initial commit)");
return;
}
let commit = std::process::Command::new("git")
@@ -371,8 +385,8 @@ fn commit_all(dir: &std::path::Path, message: &str) {
.current_dir(dir)
.output();
if !matches!(commit, Ok(o) if o.status.success()) {
eprintln!(
"aura: warning: git commit failed (scaffold created without an initial commit)"
crate::diag::warning!(
"git commit failed (scaffold created without an initial commit)"
);
}
}
@@ -446,10 +460,46 @@ mod tests {
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("demo-lab"));
// The CLAUDE.md sweep quickstart targets the closed starter itself
// with the rendered blueprint-name axis prefix (#246: a bound param
// is a default an axis overrides).
// with the RAW axis name (#246: a bound param is a default an axis
// overrides; #328: the axis namespace is raw, no blueprint-name
// prefix).
assert!(claude.contains("blueprints/signal.json"));
assert!(claude.contains("demo_lab_signal.fast.length"));
assert!(claude.contains("--axis fast.length"));
}
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
/// (bias as held target position, protective stop, R) and the document
/// data plane — the two things the 2026-07-22 field agent had to discover
/// forensically.
#[test]
fn project_claude_md_teaches_execution_semantics_and_the_data_plane() {
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
assert!(claude.contains("target position"), "names the held-target model: {claude}");
assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}");
assert!(claude.contains("aura process"), "points at the document layer: {claude}");
assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}");
}
/// #323: the authoring guide's S0 worked literal is byte-identical to the
/// scaffold's `LIB_RS` template (rendered with the guide's `my_lab`
/// namespace). The scaffold e2e tests compile that template for real, so
/// this equality pin is a transitive compile guard — the guide literal
/// can no longer drift silently from the schema (it broke against the
/// required `NodeSchema.doc` once).
#[test]
fn guide_worked_example_matches_the_scaffold_template() {
let guide = include_str!("../../../docs/authoring-guide.md");
let anchor = "### Worked example: `Scale`";
let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..];
let start = after.find("```rust\n").expect("worked example is a rust fence") + 8;
let fence = &after[start..];
let fence = &fence[..fence.find("\n```").expect("fence closes") + 1];
let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..];
let expected = body.replace("__NS__", "my_lab");
assert_eq!(
fence, expected,
"docs/authoring-guide.md S0 literal drifted from the compiled scaffold template"
);
}
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
+7 -4
View File
@@ -60,9 +60,9 @@ fn persist_taps_from(trace: bool) -> Vec<String> {
tap_vocabulary()
.iter()
.filter(|t| {
aura_runner::runner::tap_channel(t) != Some(aura_runner::runner::TapChannel::Net)
aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net)
})
.map(|t| t.to_string())
.map(|t| t.id.to_string())
.collect()
} else {
vec![]
@@ -526,7 +526,7 @@ pub(crate) fn run_walkforward_sugar(
)?;
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
eprintln!("aura: walkforward cell failed at stage {}: {}", f.stage, f.detail);
crate::diag::warning!("walkforward cell failed at stage {}: {}", f.stage, f.detail);
return Ok(1);
}
@@ -549,6 +549,9 @@ pub(crate) fn run_walkforward_sugar(
summary_axes.push("stop_length".to_string());
summary_axes.push("stop_k".to_string());
}
crate::diag::note_zero_trade_windows(
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
);
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes));
// Trace persistence runs LAST (mirroring `present_campaign`'s ordering,
@@ -690,7 +693,7 @@ pub(crate) fn run_mc_sugar(
)?;
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
eprintln!("aura: mc cell failed at stage {}: {}", f.stage, f.detail);
crate::diag::warning!("mc cell failed at stage {}: {}", f.stage, f.detail);
return Ok(1);
}
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,7 @@ impl Identity {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "one-input f64 pass-through",
},
|_| Box::new(Identity::new()),
)
@@ -29,6 +29,7 @@ impl Identity {
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "one-input f64 pass-through",
},
|_| Box::new(Identity::new()),
)
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
+1 -1
View File
@@ -1 +1 @@
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
@@ -0,0 +1 @@
# restated fixture — static project context only (C17); paths only (cycle 0102).
@@ -0,0 +1,15 @@
[package]
name = "restated-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1,83 @@
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
//! pass-through node under the `restated` namespace whose `NodeSchema` doc
//! merely restates its own type id (`DocGateFault::RestatesName`, the fault
//! arm `undescribed-project` does not exercise). The charter is satisfied
//! (prefixed id, list/resolver in sync) so the refusal under test is exactly
//! the doc gate's name-restatement branch.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through whose doc is a no-content restatement of its
/// own name.
pub struct Echo {
out: [Cell; 1],
}
impl Echo {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"restated::Echo",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
// The one deliberate violation this fixture exists for: this
// norm-equals the type id "restated::Echo" once punctuation
// and case are stripped, so it carries no meaning beyond the
// name itself.
doc: "Restated Echo",
},
|_| Box::new(Echo::new()),
)
}
}
impl Default for Echo {
fn default() -> Self {
Self::new()
}
}
impl Node for Echo {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"restated::Echo".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"restated::Echo" => Some(Echo::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["restated::Echo"]
}
aura_core::aura_project! {
namespace: "restated",
vocabulary: vocabulary,
type_ids: type_ids,
}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
@@ -0,0 +1 @@
# undescribed fixture — static project context only (C17); paths only (cycle 0102).
@@ -0,0 +1,15 @@
[package]
name = "undescribed-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1,78 @@
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
//! pass-through node under the `und` namespace whose `NodeSchema` carries the
//! empty-string alibi `doc: ""` — compile-legal (the field is present), but
//! the load gate must refuse it. The charter is satisfied (prefixed id,
//! list/resolver in sync) so the refusal under test is exactly the doc gate.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through with a deliberately empty doc.
pub struct Opaque {
out: [Cell; 1],
}
impl Opaque {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"und::Opaque",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
// The one deliberate violation this fixture exists for.
doc: "",
},
|_| Box::new(Opaque::new()),
)
}
}
impl Default for Opaque {
fn default() -> Self {
Self::new()
}
}
impl Node for Opaque {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"und::Opaque".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"und::Opaque" => Some(Opaque::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["und::Opaque"]
}
aura_core::aura_project! {
namespace: "und",
vocabulary: vocabulary,
type_ids: type_ids,
}
+790 -22
View File
@@ -54,6 +54,77 @@ const SIGNAL_DOC: &str = r#"[
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC with the C29 doc op — the register-reaching variant (a store
/// write requires a described composite; build-only tests keep SIGNAL_DOC).
const SIGNAL_DOC_DESCRIBED: &str = r#"[
{"op":"doc","text":"fast/slow SMA difference clamped into a directional bias"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC with a `doc` op whose text merely restates the op-script
/// composite's name ("graph", the literal `replay("graph", ..)` gives every
/// CLI-built op-script) -- the RestatesName arm of the C29 shape gate,
/// reached via the op-script route rather than a builder-authored blueprint.
const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
{"op":"doc","text":"Graph"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC with a leading `{"op":"name",...}` (#331) — authors a render
/// name distinct from the CLI's own default seed ("graph"), otherwise
/// structurally identical to SIGNAL_DOC.
const SIGNAL_DOC_NAMED: &str = r#"[
{"op":"name","name":"ny_momentum"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// SIGNAL_DOC_NAME_RESTATED with an authored name (#331 spec test 7) instead
/// of the CLI's default seed: the `doc` op's text restates the *authored*
/// name ("ny_momentum") rather than the literal "graph" — the same
/// RestatesName arm of the C29 shape gate must fire against whichever name
/// the composite actually carries, not a hardcoded "graph" comparison.
const SIGNAL_DOC_NAMED_NAME_RESTATED: &str = r#"[
{"op":"name","name":"ny_momentum"},
{"op":"doc","text":"Ny Momentum"},
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
/// never wired — a 0-cover input slot only the holistic finalize gate can
/// reject. The smallest document that is op-valid yet whole-document-invalid.
@@ -74,6 +145,21 @@ fn graph_build_emits_blueprint_for_a_valid_document() {
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
}
/// C29 (#316): the op-script `doc` op's text lands verbatim in the built
/// blueprint's `doc` field -- `graph build` is a pure translation from ops to
/// the canonical envelope, so a doc op is not consumed or reworded on the way
/// through, only threaded (the shape gate is a separate, register-time check;
/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`).
#[test]
fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(ok, "exit success");
assert!(
stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#),
"the doc op's exact text appears in the blueprint's doc field: {stdout}"
);
}
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
@@ -121,8 +207,9 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
33,
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
36,
"the std-only (no project) vocabulary has exactly the roster's 36 entries (#271: \
Session/LinComb/CostSum moved in): {stdout}"
);
}
@@ -134,6 +221,78 @@ fn graph_introspect_node_answers_ports_without_a_build() {
assert!(stdout.contains("value"), "lists the output field: {stdout}");
}
/// #271 acceptance criterion 1: an op-script reaching BOTH new arg-bearing
/// roster entries (a `Session` for any IANA timezone/open, a `LinComb` of any
/// arity — no Rust authored) builds via `aura graph build`; the emitted
/// blueprint is `"version": 2` (the args-bearing tier) and carries the
/// accepted timezone verbatim.
///
/// Deviation from the spec's literal worked example
/// (`docs/specs/construction-args.md` §"The user-facing program"): that JSON
/// wires `ny.bars_since_open` (`Session`'s I64 output) directly into
/// `mix.term[0]` (`LinComb`'s F64 input) — a genuine kind mismatch the
/// engine's eager `connect` gate refuses (I64 != F64), not a property of
/// this feature. This fixture keeps every construction-args property the
/// criterion asks for (Session + args, LinComb + args, version 2, tz
/// carried) but feeds `LinComb` from two same-kind SMAs and exposes
/// `ny.bars_since_open` at the boundary directly (`expose` has no kind
/// constraint) instead of wiring it into the mismatched slot.
#[test]
fn graph_build_accepts_the_session_args_example() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"Session","name":"ny",
"args":{"tz":"America/New_York","open":"09:30"},
"bind":{"period_minutes":{"I64":15}}},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"LinComb","name":"mix","args":{"arity":"2"},
"bind":{"weights[0]":{"F64":1.0},"weights[1]":{"F64":0.25}}},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["ny.trigger","fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"mix.term[0]"},
{"op":"connect","from":"slow.value","to":"mix.term[1]"},
{"op":"connect","from":"mix.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"expose","from":"ny.bars_since_open","as":"session_bar"},
{"op":"doc","text":"NY-session-gated SMA blend"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(ok, "the worked example builds: {stderr}");
assert!(stdout.contains(r#""format_version":2"#), "an args-bearing document is version 2: {stdout}");
assert!(stdout.contains("America/New_York"), "carries the accepted tz verbatim: {stdout}");
}
/// #271: a malformed construction-arg value (a bad IANA tz string) refuses at
/// the `add` op with exit 1, naming the offending arg (not a Debug leak).
#[test]
fn graph_build_bad_tz_is_exit_1_naming_the_arg() {
let doc = r#"[
{"op":"add","type":"Session","name":"ny",
"args":{"tz":"not_a_zone","open":"09:30"}}
]"#;
let (stderr, code) = run_code(&["graph", "build"], doc);
assert_eq!(code, Some(1), "a construction-arg fault is exit 1: {stderr}");
assert!(stderr.contains("tz"), "names the offending arg: {stderr}");
assert!(!stderr.contains("{"), "no Debug-struct leak: {stderr}");
}
/// #271: `graph introspect --node Session` self-describes its declared
/// construction args (name, kind, hint) plus the pending note — the
/// discovery surface an author reads before writing the `args` object.
#[test]
fn graph_introspect_node_session_lists_arg_rows() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "Session"], "");
assert!(ok, "exit success");
assert!(stdout.contains("tz"), "lists the tz arg: {stdout}");
assert!(stdout.contains("open"), "lists the open arg: {stdout}");
assert!(stdout.contains("IANA timezone"), "shows the tz hint: {stdout}");
assert!(
stdout.contains("ports and params form at construction"),
"shows the pending note: {stdout}"
);
}
#[test]
fn graph_introspect_unwired_reports_open_slots() {
let doc = r#"[
@@ -305,6 +464,21 @@ fn graph_build_bad_stdin_content_is_runtime_exit_1() {
assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
}
/// Property (#326): an op-list element carrying an unknown/typo'd key (e.g.
/// `params` where the schema expects `bind`) is refused at parse — not
/// silently dropped. Previously the unrecognized key vanished, the field it
/// meant to set kept its default, and the wrong graph built with zero
/// signal. Same content-fault family as `graph_build_bad_stdin_content_is_
/// runtime_exit_1` (both fire from the same top-level deserialize), so the
/// exit class matches: exit 1, stderr names the offending key.
#[test]
fn graph_build_rejects_an_unknown_op_field() {
let doc = r#"[{"op":"add","type":"Const","params":{"value":{"F64":1.0}}}]"#;
let (stderr, code) = run_code(&["graph", "build"], doc);
assert_eq!(code, Some(1), "unknown op field is a content fault -> exit 1; stderr: {stderr}");
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
}
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
@@ -402,6 +576,24 @@ fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>)
)
}
/// Run `aura <args>` in `dir` with `stdin_doc` piped in; return (stderr, exit
/// code) — the `run_code`/`run_in` cross, for a `use`-seam fault that needs
/// BOTH a project cwd (the label sidecar lives under `<cwd>/runs/`) and a
/// piped op-list.
fn run_code_in(dir: &std::path::Path, args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
let mut child = Command::new(BIN)
.args(args)
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura");
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura");
(String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code())
}
/// The absolute path of a blueprint fixture shipped with this test crate.
fn fixture(name: &str) -> String {
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
@@ -462,18 +654,87 @@ fn graph_register_is_idempotent() {
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
}
/// C29 register seam end-to-end (#316): a doc-less root composite refuses at
/// `aura graph register` naming the composite and the rule (exit 1, C14); a
/// doc-less named nested composite refuses naming the nested name; the
/// described shipped example registers. The gate lives at the store write
/// every register path shares — this pins its verb surface.
#[test]
fn register_refuses_undescribed_composites_end_to_end() {
let dir = temp_cwd("register-doc-gate");
// doc-less root: the shipped example minus its doc member
let text = std::fs::read_to_string(example("r_sma.json")).expect("read example");
let mut v: serde_json::Value = serde_json::from_str(&text).expect("example parses");
v["blueprint"].as_object_mut().expect("blueprint object").remove("doc");
let docless = dir.join("docless.json");
std::fs::write(&docless, serde_json::to_string(&v).unwrap()).expect("write fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", docless.to_str().unwrap()]);
assert_eq!(code, Some(1), "doc-less root refuses: {out} {err}");
assert!(
err.contains("composite `sma_signal` carries no doc"),
"stderr names the composite and the rule: {err}"
);
assert!(err.contains("C29"), "stderr cites the contract: {err}");
// doc-less named nested composite inside a described root
v["blueprint"]["doc"] = serde_json::Value::String("a described root".into());
v["blueprint"]["nodes"]
.as_array_mut()
.expect("nodes array")
.push(serde_json::json!({"composite": {"name": "inner", "nodes": []}}));
let nested = dir.join("nested.json");
std::fs::write(&nested, serde_json::to_string(&v).unwrap()).expect("write fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", nested.to_str().unwrap()]);
assert_eq!(code, Some(1), "doc-less nested refuses: {out} {err}");
assert!(
err.contains("composite `inner` carries no doc"),
"stderr names the nested composite: {err}"
);
// the described shipped example registers
let (out, err, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
assert_eq!(code, Some(0), "described example registers: {out} {err}");
assert!(out.starts_with("registered blueprint "), "prints the register line: {out}");
}
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
/// shows. The open fixture leaves exactly the two SMA lengths unbound
/// (`bias.scale` is bound in the document).
/// lowering order (#328: the one raw axis namespace — `aura sweep
/// --list-axes` prints the identical lines for the same blueprint, see
/// `graph_params_and_sweep_list_axes_are_line_identical` below). The open
/// fixture leaves exactly the two SMA lengths unbound (`bias.scale` is bound
/// in the document).
#[test]
fn graph_params_lists_raw_axis_namespace() {
let dir = temp_cwd("params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
assert_eq!(
stdout, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"the raw open params, then the raw bound param with its default"
);
}
/// Property (#328, cycle-close tidy fix): `graph introspect --params` and
/// `aura sweep --list-axes` are two independently-dispatched discovery
/// surfaces over the SAME raw axis namespace — the open pass shares one
/// wrap-strip convention, the bound pass shares one `bound_param_space()` +
/// `render_value` lexicon (`list_blueprint_axes`, main.rs; `params_lines`,
/// this crate). Pinned here as a same-fixture LINE EQUALITY (not just two
/// independently-asserted shapes) so the duplicated formatting cannot
/// silently drift apart across an edit to either surface.
#[test]
fn graph_params_and_sweep_list_axes_are_line_identical() {
let dir = temp_cwd("params-vs-list-axes");
let bp = fixture("r_sma_open.json");
let (params_out, params_err, params_code) =
run_in(&dir, &["graph", "introspect", "--params", &bp]);
assert_eq!(params_code, Some(0), "graph introspect --params: {params_out} {params_err}");
let (axes_out, axes_err, axes_code) = run_in(&dir, &["sweep", &bp, "--list-axes"]);
assert_eq!(axes_code, Some(0), "sweep --list-axes: {axes_out} {axes_err}");
assert_eq!(
params_out, axes_out,
"the two discovery surfaces must print byte-identical lines for the same blueprint"
);
}
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
@@ -523,7 +784,10 @@ fn graph_params_by_content_id_matches_params_by_file() {
run_in(&dir, &["graph", "introspect", "--params", &bp]);
assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
assert_eq!(
by_id_out, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"the raw open params, then the raw bound param with its default"
);
}
/// #194 (the prefix trap): a `--params <ID>` target may carry the display
@@ -540,7 +804,10 @@ fn graph_params_tolerates_content_prefix_on_target() {
let (pfx_out, pfx_err, pfx_code) =
run_in(&dir, &["graph", "introspect", "--params", &format!("content:{id}")]);
assert_eq!(pfx_code, Some(0), "content:-prefixed --params must resolve: stdout {pfx_out} stderr {pfx_err}");
assert_eq!(pfx_out, "fast.length:I64\nslow.length:I64\n", "same raw axis namespace as the bare id");
assert_eq!(
pfx_out, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"same raw axis namespace as the bare id"
);
}
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
@@ -647,11 +914,12 @@ fn graph_register_rejects_an_unknown_node_type_with_prose() {
#[test]
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
let dir = temp_cwd("register-op-script");
// The op-script form (a JSON array of ops), written to a file.
// The op-script form (a JSON array of ops), written to a file. Register
// requires a described composite (C29), so the described twin is used here.
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
std::fs::write(&op_script, SIGNAL_DOC_DESCRIBED).expect("write op-script fixture");
// Its `graph build` envelope (a JSON object), the shape `register` already accepts.
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
@@ -669,6 +937,147 @@ fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
registered_id(&env_out),
"op-script and its built envelope register to the same content id"
);
// C29: the doc-less op-script form refuses at register like any other
// doc-less composite entering the store.
let docless_script = dir.join("docless-script.json");
std::fs::write(&docless_script, SIGNAL_DOC).expect("write op-script fixture");
let (dl_out, dl_err, dl_code) =
run_in(&dir, &["graph", "register", docless_script.to_str().unwrap()]);
assert_eq!(dl_code, Some(1), "doc-less op-script refuses: {dl_out} {dl_err}");
assert!(dl_err.contains("carries no doc"), "stderr names the rule: {dl_err}");
assert!(dl_err.contains("C29"), "stderr cites the contract: {dl_err}");
}
/// C29: an op-script's `doc` op is not a bare presence checkbox -- its text
/// goes through the same shape gate a builder-authored doc does. A doc that
/// merely restates the composite's name (here "graph", every CLI op-script's
/// literal name) refuses at register exactly like the builder-authored
/// RestatesName case already pinned at the registry-unit layer, but reached
/// end-to-end through the op-script CLI seam this time.
#[test]
fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
let dir = temp_cwd("register-op-script-restated-doc");
let restated_script = dir.join("restated-doc-script.json");
std::fs::write(&restated_script, SIGNAL_DOC_NAME_RESTATED).expect("write op-script fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
assert_eq!(code, Some(1), "name-restating doc refuses: {out} {err}");
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
assert!(err.contains("C29"), "stderr cites the contract: {err}");
}
/// (#331 spec test 7) The C29 restates-name gate checks against the
/// *authored* name, not a hardcoded default: a `doc` op restating the name
/// set by a `name` op ("ny_momentum") refuses at register exactly like the
/// default-name case above (`graph_register_refuses_an_op_script_doc_that_
/// restates_the_composite_name`, which must stay green byte-identical).
#[test]
fn graph_register_refuses_a_doc_that_restates_an_authored_name() {
let dir = temp_cwd("register-op-script-restated-authored-name-doc");
let restated_script = dir.join("restated-authored-name-doc-script.json");
std::fs::write(&restated_script, SIGNAL_DOC_NAMED_NAME_RESTATED)
.expect("write op-script fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
assert_eq!(code, Some(1), "authored-name-restating doc refuses: {out} {err}");
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
assert!(err.contains("C29"), "stderr cites the contract: {err}");
}
/// (#331 spec test 6) `graph build` on an ops doc with a `name` op emits
/// blueprint JSON carrying the authored name, not the CLI's own seeded
/// default (`"graph"`, `replay("graph", ...)` in `composite_from_str`).
#[test]
fn graph_build_emits_the_authored_name_from_a_name_op() {
let (stdout, stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_NAMED);
assert!(ok, "build succeeds: {stderr}");
assert!(stdout.contains("\"name\":\"ny_momentum\""), "authored name carries into the blueprint: {stdout}");
assert!(!stdout.contains("\"name\":\"graph\""), "the default seed name must not leak in: {stdout}");
}
/// (#331 spec test 8) Refusal prose for a duplicate `name` op and a
/// shape-violating name is op-indexed (`op N (name): ...`) — the same
/// by-identifier labeling every other op fault gets.
#[test]
fn graph_build_name_op_refusals_are_op_indexed() {
let twice_named = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"name","name":"a"},
{"op":"name","name":"b"},
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"out"}
]"#;
let (_out, stderr, ok) = run(&["graph", "build"], twice_named);
assert!(!ok, "a second name op refuses");
assert!(stderr.contains("op 3 (name):"), "op-indexed at the second name op: {stderr}");
let slash_named = r#"[{"op":"name","name":"a/b"}]"#;
let (_out2, stderr2, ok2) = run(&["graph", "build"], slash_named);
assert!(!ok2, "a shape-violating name refuses");
assert!(stderr2.contains("op 0 (name):"), "op-indexed at the offending op: {stderr2}");
}
/// (#331 spec test 9, at the binary seam — mirrors
/// `graph_introspect_identity_id_bridges_renamed_op_scripts`'s pattern for
/// the debug-name field): a named and an unnamed build of the SAME
/// structural script share the identity id (the name is stripped as a
/// debug symbol before identity hashing, C23) and differ in the content id
/// (the name is part of the serialized bytes).
#[test]
fn graph_named_and_unnamed_twins_share_identity_id_and_differ_content_id() {
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC_NAMED);
assert!(ok2, "exit success");
assert_eq!(ia.trim(), ib.trim(), "the name is stripped before identity hashing (C23)");
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC_NAMED);
assert!(ok3 && ok4, "exit success");
assert_ne!(ca.trim(), cb.trim(), "the name is part of the serialized bytes -> different content id");
}
/// (#331 spec test 10, the skeptic's bypass route): the op intake
/// (`GraphSession::set_name`) is not the name's only data-borne birth
/// route — a hand-edited blueprint ENVELOPE (not an op-script) can carry a
/// shape-violating root name directly. `aura graph register` on such a file
/// is refused naming the offending name (exit 1, the refusal convention
/// every sibling test in this module asserts), not silently stored under a
/// name that would write `traces/../x/` at run time. Shipped envelope
/// fixtures with plain root names (`doc_blueprint.json`'s "sig",
/// `nested_doc_blueprint.json`'s "root", every op-script's default "graph")
/// stay accepted — the surrounding suite is that regression gate.
///
/// Spec test 10 also names `build` as a gated envelope-intake route
/// alongside `register`. The `graph build` SUBCOMMAND takes only a stdin
/// op-script (`Vec<OpDoc>`, never a JSON object) — it cannot ingest this
/// envelope fixture at all (a shape mismatch fires before any name is ever
/// read). The file-consuming envelope-intake sibling `build` route is
/// `graph introspect --content-id <FILE>` (its own doc comment: "one
/// build, then each requested id…") — the other call site this iteration
/// moved onto the same `composite_from_authored_text` choke point as
/// `register`, so it is the leg exercised here, sharing the identical
/// `name_gate_fault_prose` text.
#[test]
fn graph_build_and_register_refuse_a_hand_crafted_envelope_with_a_bad_root_name() {
let dir = temp_cwd("register-bad-root-name");
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(built, "graph build produces the envelope");
let bad = envelope_bytes.replacen("\"name\":\"graph\"", "\"name\":\"../x\"", 1);
assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}");
let file = dir.join("bad-root-name.json");
std::fs::write(&file, &bad).expect("write envelope fixture");
let (out, err, code) = run_in(&dir, &["graph", "register", file.to_str().unwrap()]);
assert_eq!(code, Some(1), "a shape-violating root name refuses (not a panic): {out} {err}");
assert!(err.contains("../x"), "names the offending root name: {err}");
// The other file-consuming envelope intake (same fixture, same choke
// point) refuses identically.
let (out2, err2, code2) =
run_in(&dir, &["graph", "introspect", "--content-id", file.to_str().unwrap()]);
assert_eq!(code2, Some(1), "the sibling envelope intake refuses too: {out2} {err2}");
assert_eq!(err2, err, "both file-consuming envelope intakes share the identical fault prose");
}
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
@@ -696,6 +1105,35 @@ fn graph_params_accepts_an_op_script_matching_its_envelope() {
assert_eq!(op_out, env_out, "op-script and envelope agree on the raw axis namespace");
}
/// (#331 review finding, the C29 exemption's ratifying pin): the root-name
/// shape gate lives at the CLI's file-consuming envelope intakes (`register`,
/// `introspect --content-id <FILE>`) — never at STORE read-back. A blueprint
/// with a shape-violating root name registered DIRECTLY through the
/// `aura-registry` store API (bypassing the CLI's gate entirely, the way a
/// pre-#331 artifact would already sit in a project's store) must still be
/// introspectable: `introspect --params <ID>` reads the store, not a file, so
/// it must never re-punish an artifact the store already accepted — C29,
/// "registered artifacts are never retroactively invalidated".
#[test]
fn graph_introspect_params_accepts_a_pre_331_store_artifact_with_a_bad_root_name() {
let dir = temp_cwd("introspect-store-readback-bad-root-name");
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
assert!(built, "graph build produces the envelope");
let bad = envelope_bytes.replacen("\"name\":\"graph\"", "\"name\":\"../x\"", 1);
assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}");
// Bypass the CLI's gate entirely: the registry's own store API is the
// simulated pre-#331 write path.
let id = aura_research::content_id_of(&bad);
let registry = aura_registry::Registry::open(dir.join("runs/runs.jsonl"));
registry
.put_blueprint(&id, &bad)
.expect("the registry's own store API has no root-name gate to bypass");
let (out, err, code) = run_in(&dir, &["graph", "introspect", "--params", &id]);
assert_eq!(code, Some(0), "store read-back stays ungated (C29): {out} {err}");
}
/// Property (#226, the render positional closes the #202 on-ramp family):
/// `aura graph <op-script.json>` (the render positional) accepts an op-script
/// document (a JSON array) exactly as it accepts a #155 blueprint envelope (a
@@ -741,7 +1179,10 @@ fn shipped_r_sma_example_is_genuinely_closed() {
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
assert_eq!(
stdout, "fast.length:I64 default=2\nslow.length:I64 default=4\nbias.scale:F64 default=0.5\n",
"zero OPEN params — every knob is now printed as a raw bound default instead"
);
}
/// Property (#159): the open fixture (`tests/fixtures/r_sma_open.json`) is a
@@ -759,8 +1200,8 @@ fn open_r_sma_fixture_lists_its_axis_namespace() {
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "fast.length:I64\nslow.length:I64\n",
"the raw open params, in order, from the public gallery copy"
stdout, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
"the raw open params, then the raw bound param, from the public gallery copy"
);
}
@@ -797,12 +1238,17 @@ fn shipped_r_breakout_example_is_genuinely_closed() {
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
assert_eq!(
stdout,
"delay.lag:I64 default=1\nchannel_hi.length:I64 default=3\nchannel_lo.length:I64 default=3\n",
"zero OPEN params — every knob is now printed as a raw bound default instead"
);
}
/// Property (#159 cut 2): the open fixture (`tests/fixtures/r_breakout_open.json`)
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
/// one knob).
/// one knob) followed by the still-bound `delay.lag` default (#328: bound params
/// are listed too, line-identical to `--list-axes`).
#[test]
fn open_r_breakout_fixture_lists_its_axis_namespace() {
let dir = temp_cwd("r-breakout-example-open-params");
@@ -810,8 +1256,9 @@ fn open_r_breakout_fixture_lists_its_axis_namespace() {
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_breakout_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "channel_length:I64\n",
"the ganged channel knob — one public axis for the two rolling windows (#61)"
stdout, "channel_length:I64\ndelay.lag:I64 default=1\n",
"the ganged channel knob — one public axis for the two rolling windows (#61)\
then the still-bound delay.lag default"
);
}
@@ -840,7 +1287,7 @@ fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() {
let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&["sweep", &fixture("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
&["sweep", &fixture("r_breakout_open.json"), "--axis", "channel_length=3"],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
@@ -862,7 +1309,11 @@ fn shipped_r_meanrev_example_is_genuinely_closed() {
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
assert_eq!(
stdout,
"mean_window.length:I64 default=3\nvar_window.length:I64 default=3\nband.factor:F64 default=2\n",
"zero OPEN params — every knob is now printed as a raw bound default instead"
);
}
/// Property (#159 cut 3): the open fixture (`tests/fixtures/r_meanrev_open.json`)
@@ -900,8 +1351,8 @@ fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() {
&sweep_dir,
&[
"sweep", &fixture("r_meanrev_open.json"),
"--axis", "r_meanrev_signal.window=3",
"--axis", "r_meanrev_signal.band.factor=2.0",
"--axis", "window=3",
"--axis", "band.factor=2.0",
],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
@@ -1157,6 +1608,26 @@ fn graph_build_reports_gang_kind_mismatch_as_prose() {
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
}
/// The `doc` op's duplicate refusal (a second meaning line in one op-script)
/// reads as prose at the binary seam, attributed to the second `doc` op's
/// index -- never the raw `DuplicateDoc` Debug variant name.
#[test]
fn graph_build_reports_duplicate_doc_fault_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"doc","text":"first"},
{"op":"doc","text":"second"}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a duplicate doc fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 2 (doc): a doc op may appear at most once"),
"names the op + cause as prose: {stderr}"
);
assert!(!stderr.contains("DuplicateDoc"), "does not leak the Debug variant name: {stderr}");
}
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
/// register` on a hand-corrupted blueprint whose gangs section fails structural
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
@@ -1398,3 +1869,300 @@ fn tap_authored_via_op_script_runs_and_persists_the_series() {
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
}
}
// ---- `use` / label sidecar / open patterns (#317) ------------------------
/// A small reusable pattern: an open `x` input role feeds an `SMA`, whose
/// value re-exports as `out` — the fixture every `use`-seam e2e test below
/// registers under a label. `sma.length` stays UNBOUND: the pattern's own
/// open param, so a consumer's `--list-axes` sees the bare (unbound) form.
const OPEN_PATTERN_DOC: &str = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
/// Build `doc` via `aura graph build` in `dir` and write the resulting
/// envelope to `dir/<stem>.bp.json`, returning its path.
fn build_envelope_in(dir: &std::path::Path, doc: &str, stem: &str) -> std::path::PathBuf {
let (bytes, _e, ok) = run(&["graph", "build"], doc);
assert!(ok, "the fixture document builds: {doc}");
let path = dir.join(format!("{stem}.bp.json"));
std::fs::write(&path, &bytes).expect("write built envelope");
path
}
/// `graph register --name` (#317): the label sidecar echo on first
/// registration, and the repoint echo (`(was <old-id>)`) on a second
/// registration of DIFFERENT bytes under the SAME label.
#[test]
fn graph_register_name_labels_and_repoints() {
let dir = temp_cwd("register-name");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (out1, err1, code1) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(code1, Some(0), "first register --name: {out1} {err1}");
let id1 = registered_id(&out1);
assert!(
out1.lines().any(|l| l == format!("label \"smooth\" -> {id1}")),
"the plain label echo (no repoint on a fresh label): {out1}"
);
// Different bytes (a bound sma.length), same label -> a repoint.
let doc2 = OPEN_PATTERN_DOC.replace(
r#"{"op":"add","type":"SMA","name":"sma"}"#,
r#"{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":7}}}"#,
);
let bp2 = build_envelope_in(&dir, &doc2, "pattern2");
let (out2, err2, code2) =
run_in(&dir, &["graph", "register", bp2.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(code2, Some(0), "second register --name: {out2} {err2}");
let id2 = registered_id(&out2);
assert_ne!(id1, id2, "the two registrations are distinct content");
assert!(
out2.lines().any(|l| l == format!("label \"smooth\" -> {id2} (was {id1})")),
"the repoint echo names both ids: {out2}"
);
}
/// `use` resolves a registry label (#317): the resolution echo lands on
/// STDERR (`aura: note: use "<instance>": <label> -> <full id>`), stdout
/// stays clean payload — the existing e2e convention this cycle extends.
#[test]
fn graph_build_use_resolves_a_label_and_echoes_the_id() {
let dir = temp_cwd("use-resolves-label");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (reg_out, reg_err, reg_code) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let mut child = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura graph build");
child.stdin.take().unwrap().write_all(consumer.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura graph build");
assert!(out.status.success(), "build succeeds: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
stderr.trim(),
format!("aura: note: use \"trend\": smooth -> {id}"),
"the resolution echo names the instance, the label, and the full id: {stderr}"
);
assert!(stdout.starts_with('{'), "stdout stays clean blueprint payload: {stdout}");
assert!(!stdout.contains("aura: note:"), "the note never leaks onto stdout: {stdout}");
assert!(
stdout.contains(&format!("\"doc\":\"{}\"", "a reusable smoothing pattern")),
"the spliced instance's own doc survives inline: {stdout}"
);
}
/// An unknown `use` label enumerates every registered label (C29's "name the
/// closed set" idiom) and refuses at exit 1 (op-list content fault, #175).
#[test]
fn graph_build_use_unknown_label_enumerates_labels_exit_1() {
let dir = temp_cwd("use-unknown-label");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (reg_out, reg_err, reg_code) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"nope"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let out = std::process::Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
child.wait_with_output()
})
.expect("run aura graph build");
assert_eq!(out.status.code(), Some(1), "unknown label is a content fault -> exit 1");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stdout.is_empty(), "no blueprint emitted on refusal: {stdout}");
assert!(stderr.contains("op 1 (use \"trend\")"), "names the op by index and instance: {stderr}");
assert!(stderr.contains("no registered blueprint labeled \"nope\""), "names the miss: {stderr}");
assert!(stderr.contains("registered labels: smooth"), "enumerates the closed set: {stderr}");
}
/// The empty-store form of the unknown-label refusal: no labels to
/// enumerate reads as prose, not a bare empty list.
#[test]
fn graph_build_use_unknown_label_empty_store_reads_as_prose() {
let dir = temp_cwd("use-unknown-label-empty-store");
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"nope"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let (stderr, code) = run_code_in(&dir, &["graph", "build"], consumer);
assert_eq!(code, Some(1), "still a content fault -> exit 1");
assert!(
stderr.contains("no registered blueprint labeled \"nope\" — no labels registered"),
"the empty-store form reads as prose: {stderr}"
);
}
/// C29 at the `use` seam (#317): a doc-less registered blueprint refuses
/// entering a NEW composition, naming the ref's id-prefix, the failing
/// (here root, "nested" from the consumer's view) composite, and the rule —
/// exit 1. The doc-less entry reaches the store via the RAW file write
/// existing pre-C29 fixtures use (`register_refuses_undescribed_composites_
/// end_to_end`'s technique): `graph register` itself always C29-gates, so a
/// doc-less entry can only reach the store by writing the file directly.
#[test]
fn graph_build_use_docless_source_refuses_with_the_c29_shape_exit_1() {
let dir = temp_cwd("use-docless-source");
// A doc-less pattern: same shape as OPEN_PATTERN_DOC minus the `doc` op.
let docless = r#"[
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":3}}},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let (envelope, _e, built) = run(&["graph", "build"], docless);
assert!(built, "the doc-less pattern still builds (C29 gates register/use, not build)");
let (id_out, _e2, id_ok) = run(&["graph", "introspect", "--content-id"], docless);
assert!(id_ok, "content-id computes without a doc");
let id = id_out.trim().to_string();
let store_dir = dir.join("runs").join("blueprints");
std::fs::create_dir_all(&store_dir).expect("create store dir");
std::fs::write(store_dir.join(format!("{id}.json")), &envelope).expect("raw store write");
let consumer = format!(
r#"[
{{"op":"source","role":"price","kind":"F64"}},
{{"op":"use","ref":{{"content_id":"{id}"}},"name":"gate"}},
{{"op":"feed","role":"price","into":["gate.x"]}},
{{"op":"expose","from":"gate.out","as":"bias"}}
]"#
);
let (stderr, code) = run_code_in(&dir, &["graph", "build"], &consumer);
assert_eq!(code, Some(1), "a doc-less fetched composite refuses -> exit 1: {stderr}");
assert!(stderr.contains("op 1 (use \"gate\")"), "names the op by index and instance: {stderr}");
assert!(stderr.contains(&id[..12]), "names the ref's id-prefix: {stderr}");
assert!(stderr.contains("fails the description gate"), "names the rule: {stderr}");
assert!(
stderr.contains("has no description") && stderr.contains("re-register it with a \"doc\" op"),
"carries the re-register hint: {stderr}"
);
assert!(stderr.contains("C29"), "cites the contract: {stderr}");
}
/// The worked acceptance flow's last leg (spec §Concrete code shapes): a
/// pattern registered with an open param splices under an instance, and
/// `aura sweep --list-axes` on the CONSUMER shows the path-qualified bare
/// (unbound) axis `graph.<instance>.<node>.<param>:<kind>` — the existing
/// nested-composite param-prefix discipline, reached through `use` this
/// time, not a Rust-authored nested composite.
#[test]
fn graph_build_use_end_to_end_axes() {
let dir = temp_cwd("use-end-to-end-axes");
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
let (reg_out, reg_err, reg_code) =
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
let consumer = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
{"op":"feed","role":"price","into":["trend.x"]},
{"op":"expose","from":"trend.out","as":"bias"}
]"#;
let consumer_path = dir.join("consumer.ops.json");
std::fs::write(&consumer_path, consumer).expect("write consumer fixture");
let build = std::process::Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
child.wait_with_output()
})
.expect("run aura graph build");
assert!(build.status.success(), "consumer builds: {}", String::from_utf8_lossy(&build.stderr));
let consumer_bp = dir.join("consumer.bp.json");
std::fs::write(&consumer_bp, &build.stdout).expect("write consumer envelope");
let (axes_out, axes_err, axes_code) =
run_in(&dir, &["sweep", consumer_bp.to_str().unwrap(), "--list-axes"]);
assert_eq!(axes_code, Some(0), "list-axes: {axes_out} {axes_err}");
assert_eq!(
axes_out, "trend.sma.length:I64\n",
"the spliced instance's open param surfaces path-qualified, bare (unbound) RAW form (#328)"
);
}
/// Open patterns (#317 §"Open patterns", acceptance criterion 6): an
/// `input`-role op-script — no bound root role at all — builds cleanly:
/// `finish()` no longer gates root-role boundness, only `compile`/bootstrap
/// do. The agree flow's first half (register is exercised by the other
/// `use`-seam tests above; this pins the build step alone).
#[test]
fn graph_build_accepts_an_open_input_pattern() {
let (stdout, stderr, ok) = run(&["graph", "build"], OPEN_PATTERN_DOC);
assert!(ok, "an input-role pattern builds: {stderr}");
assert!(stdout.contains("\"input_roles\""), "carries the open root role: {stdout}");
assert!(!stdout.contains("\"source\""), "the role carries no bound kind (open, #317): {stdout}");
}
/// Open patterns, the OTHER half: running an open blueprint standalone
/// still refuses — the runnability gate moved nowhere, only `finish()`
/// stopped pre-empting it (#317). `aura run`'s `bias`-output arm re-roots
/// through `wrap_r` (an existing, unrelated nesting mechanism unaffected by
/// this cycle), so a bare-tap (no-`bias`) pattern is used here: its
/// `run_measurement` path compiles the signal directly, hitting
/// `CompileError::UnboundRootRole` at bootstrap — via the EXISTING `{e:?}`
/// Debug rendering (a raw index, not a role name); a name mapping there is
/// left for a follow-up (out of this cycle's scope, per the plan's own
/// escape hatch), so this pins the existing form.
#[test]
fn running_an_open_blueprint_refuses_at_bootstrap() {
let doc = r#"[
{"op":"input","role":"price"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"tap","from":"fast.value","as":"fast_ma"}
]"#;
let dir = temp_cwd("open-pattern-run-refuses");
let (build_out, _e, built) = run(&["graph", "build"], doc);
assert!(built, "an open (Input) root role finishes without root-role boundness (#317)");
let bp = dir.join("open.bp.json");
std::fs::write(&bp, &build_out).expect("write built blueprint");
let (stdout, stderr, code) = run_in(&dir, &["run", bp.to_str().unwrap()]);
assert_eq!(code, Some(1), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}");
assert!(stdout.is_empty(), "no report emitted on a bootstrap refusal: {stdout}");
assert!(
stderr.contains("UnboundRootRole"),
"the runnability gate (compile), unchanged, names the fault: {stderr}"
);
}
@@ -0,0 +1,249 @@
//! End-to-end pins for the self-description surfaces (#315, #323): the help
//! opens with the two-layer concept, the sugar verbs name the document shape
//! they desugar to, every introspection roster carries per-entry meanings,
//! and `graph build --help` carries the op-list reference. Driven over the
//! built binary — the zero-setup surface a reader without repo access gets.
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// Run `aura <args>` (no stdin); return (stdout, stderr, success).
fn run(args: &[&str]) -> (String, String, bool) {
let out = Command::new(BIN).args(args).output().expect("spawn aura");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
/// #315: `aura --help` opens with the concepts paragraph — the two layers,
/// the bias-as-target-position + protective-stop execution model, and how
/// traces come to exist. Each pin sits on one authored line of the explicit
/// `long_about` string, so clap's wrapping cannot split it.
#[test]
fn top_level_help_opens_with_the_two_layer_concept() {
let (out, err, ok) = run(&["--help"]);
assert!(ok, "aura --help failed: {err}");
assert!(out.contains("research verbs"), "names the sugar layer: {out}");
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
assert!(out.contains("introspect --unwired"), "names the bare-{{}} ramp: {out}");
assert!(out.contains("aura chart"), "names the trace consumers: {out}");
}
/// #315: each document-bridged sugar verb's long help names the process
/// shape it desugars to and points at the document layer. `run` (not
/// document-bridged) points at the canonical document-first form instead.
#[test]
fn sugar_verbs_name_their_document_shape() {
for (verb, needle) in [
("sweep", "std::sweep"),
("walkforward", "std::walk_forward"),
("mc", "std::monte_carlo"),
("generalize", "std::generalize"),
] {
let (out, err, ok) = run(&[verb, "--help"]);
assert!(ok, "aura {verb} --help failed: {err}");
assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}");
assert!(out.contains(needle), "{verb} --help names {needle}: {out}");
assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}");
}
let (out, err, ok) = run(&["run", "--help"]);
assert!(ok, "aura run --help failed: {err}");
assert!(out.contains("document-first"), "run --help names the canonical form: {out}");
}
/// #323: `graph build --help` carries the op-list reference — the op kinds
/// and fields are learnable from the binary, not only from serde refusals.
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
/// joins the roster (ten -> eleven).
#[test]
fn graph_build_help_carries_the_op_reference() {
let (out, err, ok) = run(&["graph", "build", "--help"]);
assert!(ok, "aura graph build --help failed: {err}");
for op in [
r#"{"op":"source""#,
r#"{"op":"input""#,
r#"{"op":"add""#,
r#"{"op":"feed""#,
r#"{"op":"connect""#,
r#"{"op":"expose""#,
r#"{"op":"tap""#,
r#"{"op":"gang""#,
r#"{"op":"doc""#,
r#"{"op":"use""#,
r#"{"op":"name""#,
] {
assert!(out.contains(op), "op reference carries {op}: {out}");
}
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
}
/// #317: `graph introspect --registered` lists every registry label — row
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
/// as prose, not a blank/garbled listing.
#[test]
fn graph_introspect_registered_lists_labels() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
// Empty store: prose, not a blank listing, exit 0.
let out_empty = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out_empty.status.success(), "empty store still exits 0");
assert_eq!(
String::from_utf8_lossy(&out_empty.stdout),
"no labels registered\n",
"the empty-store form reads as prose"
);
// Register + label a described open pattern, then list it.
let pattern = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let bp_path = dir.join("pattern.ops.json");
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
let built = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
child.wait_with_output()
})
.expect("build the pattern");
assert!(built.status.success(), "the pattern builds");
let envelope = dir.join("pattern.bp.json");
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
let reg = Command::new(BIN)
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
.current_dir(&dir)
.output()
.expect("spawn aura register");
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(&reg.stderr));
let out = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out.status.success(), "listing succeeds");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
let fields: Vec<&str> = row.split_whitespace().collect();
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
}
/// #315: the node vocabulary listing carries each type's one-line meaning —
/// no more bare names that teach nothing.
#[test]
fn graph_introspect_vocabulary_carries_meanings() {
let (out, err, ok) = run(&["graph", "introspect", "--vocabulary"]);
assert!(ok, "vocabulary listing failed: {err}");
let sma = out
.lines()
.find(|l| l.split_whitespace().next() == Some("SMA"))
.unwrap_or_else(|| panic!("no SMA line: {out}"));
assert!(sma.contains("moving average"), "SMA line carries its meaning: {sma}");
for l in out.lines().filter(|l| !l.trim().is_empty()) {
assert!(l.split_whitespace().count() >= 2, "bare name without meaning: {l}");
}
}
/// #315: `--node <T>`'s head line carries the type's meaning beside its label.
#[test]
fn graph_introspect_node_head_line_carries_the_meaning() {
let (out, err, ok) = run(&["graph", "introspect", "--node", "SMA"]);
assert!(ok, "node introspect failed: {err}");
let head = out.lines().next().unwrap_or("");
assert!(head.contains("moving average"), "head line carries the meaning: {out}");
}
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
/// `FoldKind` table — lowercase, parseable labels, including the `record`
/// entry that has no `FoldKind` counterpart. Previously this surface printed
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
/// text's own discovery surface described input `--tap` refused.
#[test]
fn graph_introspect_folds_lists_the_fold_registry_roster() {
let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
assert!(ok, "folds listing failed: {err}");
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
assert!(
!out.split_whitespace().any(|w| w == "Mean"),
"no capitalized FoldKind id leaks through: {out}"
);
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
assert!(record.contains("series"), "record row meaning: {record}");
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
}
/// #315: the metric roster carries each metric's one-line meaning beside its
/// applicability tags.
#[test]
fn process_introspect_metrics_carries_meanings() {
let (out, err, ok) = run(&["process", "introspect", "--metrics"]);
assert!(ok, "metric roster failed: {err}");
let er = out
.lines()
.find(|l| l.split_whitespace().next() == Some("expectancy_r"))
.unwrap_or_else(|| panic!("no expectancy_r line: {out}"));
assert!(er.contains("mean realized R"), "meaning text on the roster line: {er}");
for l in out.lines().filter(|l| !l.trim().is_empty()) {
assert!(l.contains(""), "roster line without meaning: {l}");
}
}
/// #315: the persisted-tap vocabulary's meanings are readable where the taps
/// are advertised — under the `std::presentation` block's slot listing.
#[test]
fn campaign_introspect_block_presentation_lists_tap_meanings() {
let (out, err, ok) = run(&["campaign", "introspect", "--block", "std::presentation"]);
assert!(ok, "presentation describe failed: {err}");
assert!(out.contains("cumulative pip equity"), "equity tap meaning: {out}");
assert!(out.contains("after the cost model"), "net_r_equity tap meaning: {out}");
}
/// #323: `introspect --unwired` enumerates the optional C29 `description`
/// slot for both document kinds — the authoring side of the gate is
/// advertised where every other slot is.
#[test]
fn introspect_unwired_lists_the_description_slot() {
let dir = std::env::temp_dir().join(format!("aura-selfdesc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bare = dir.join("bare.json");
std::fs::write(&bare, "{}").unwrap();
for family in ["process", "campaign"] {
let (out, err, ok) = run(&[family, "introspect", "--unwired", bare.to_str().unwrap()]);
assert!(ok, "{family} --unwired failed: {err}");
assert!(
out.contains("open slot: description"),
"{family} --unwired lists the description slot: {out}"
);
assert!(out.contains("C29-gated"), "{family} hint names the gate: {out}");
}
let _ = std::fs::remove_dir_all(&dir);
}
+82 -2
View File
@@ -19,6 +19,14 @@ fn badcharter_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project")
}
fn undescribed_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/undescribed-project")
}
fn restated_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/restated-project")
}
fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
@@ -109,9 +117,9 @@ fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
"proj-anchor",
])
@@ -164,6 +172,78 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
}
/// C29 load seam end-to-end (#316): an extension node whose doc fails the
/// shape gate (the empty-string alibi the compiler cannot catch) refuses at
/// load with the entry and the rule named — through the real
/// `aura-cli::project::load` path (libloading + descriptor read), not merely
/// the pure `doc_gate` unit function. The described twin's load is pinned by
/// `project_run_resolves_demo_node_and_is_bit_identical`.
#[test]
fn undescribed_vocabulary_entry_refuses_end_to_end() {
let dir = undescribed_dir();
let build = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the undescribed fixture");
assert!(
build.status.success(),
"undescribed fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
"an undescribed vocabulary entry is a runtime refusal (exit 1), \
not a usage error"
);
let err = String::from_utf8_lossy(&run.stderr);
assert!(
err.contains("vocabulary entry `und::Opaque` has an empty doc"),
"stderr must name the entry and the rule: {err}"
);
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
}
/// C29 load seam end-to-end, the sibling fault arm (#316): a doc that merely
/// restates its own type id (`DocGateFault::RestatesName`) refuses at load
/// exactly like the empty-doc case above, through the same real
/// `aura-cli::project::load` path. `RestatesName`'s rendered prose was
/// previously pinned only against the pure `Display` impl in
/// `aura-runner::project`'s unit tests (never reachable at all through the
/// CLI) — a regression that reordered `doc_gate`'s two fault arms, or that
/// skipped the gate call for this arm specifically, would pass every
/// existing test and still ship a self-description hole for exactly this
/// case: an alibi doc that types-checks but says nothing.
#[test]
fn restated_name_vocabulary_entry_refuses_end_to_end() {
let dir = restated_dir();
let build = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the restated fixture");
assert!(
build.status.success(),
"restated fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
"a name-restating vocabulary entry is a runtime refusal (exit 1), \
not a usage error"
);
let err = String::from_utf8_lossy(&run.stderr);
assert!(
err.contains("vocabulary entry `restated::Echo` has a doc that merely restates its name"),
"stderr must name the entry and the rule: {err}"
);
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
}
/// Force a file's modification time to a fixed instant (explicit mtime, no
/// sleeping — mtime granularity would make a "touch then compare" race).
fn set_mtime(path: &Path, secs_since_epoch: u64) {
+2 -2
View File
@@ -135,7 +135,7 @@ fn data_only_project_sweeps_without_any_build() {
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4"],
&proj,
);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
@@ -157,7 +157,7 @@ fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
let proj = base.join("scratch");
let out = aura(
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4,8"],
&proj,
);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
+82
View File
@@ -211,3 +211,85 @@ fn attached_crate_namespace_resolves_after_build() {
"{}", String::from_utf8_lossy(&out.stdout)
);
}
/// E2E (#278): loading an attached node crate whose `src/` is newer than its
/// built dylib emits exactly one `aura: warning: ` class-marked line naming
/// the stale dylib, and still succeeds (the run proceeds with the existing
/// dylib rather than refusing) — staleness is a continuing-run warning, never
/// a fault. A freshly built dylib (no touched source since) emits none. Real
/// `cargo build` + a real `libloading::Library::new` load, exercising
/// `aura_runner::project::load_crate`'s actual call site rather than the pure
/// `stale_warning` helper alone.
#[test]
fn attached_crate_load_warns_when_source_outruns_the_built_dylib() {
let cwd = temp_cwd("stale-warn");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
let crate_root = cwd.join("lab-nodes");
let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build");
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
// Fresh build, untouched source: no staleness warning.
let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr));
assert!(
!String::from_utf8_lossy(&fresh.stderr).contains("may be stale"),
"a freshly built dylib is not stale: {}",
String::from_utf8_lossy(&fresh.stderr)
);
// Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the
// dylib's just-completed build time) without rebuilding — the exact
// "source outran the dylib, no rebuild yet" shape `stale_warning` guards.
let lib_rs = crate_root.join("src/lib.rs");
let f = std::fs::File::open(&lib_rs).expect("open lib.rs");
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60))
.expect("bump lib.rs mtime past the dylib's build time");
let stale = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(
stale.status.success(),
"staleness is a warning, not a refusal: {}",
String::from_utf8_lossy(&stale.stderr)
);
let stderr = String::from_utf8_lossy(&stale.stderr);
assert_eq!(
stderr.matches("may be stale").count(),
1,
"exactly one staleness warning: {stderr}"
);
assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}");
assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}");
}
/// Property (#316 self-description): the scaffold's own seed node ships a
/// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`)
/// — non-empty and not a bare restatement of the type name. This exact text
/// is what a new extension author copy-pastes as the very first node of
/// their crate; if the scaffold's own doc failed the gate it would teach the
/// wrong pattern (an alibi doc) to every project built from it, and nothing
/// else would catch that — `NodeSchema.doc` is required at compile time
/// (E0063 without it) but its *shape* is never checked by the compiler.
#[test]
fn nodes_new_scaffold_doc_passes_the_doc_gate() {
let cwd = temp_cwd("doc-gate");
assert!(aura(&["new", "lab"], &cwd).status.success());
let proj = cwd.join("lab");
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap();
let doc_line = lib
.lines()
.find(|l| l.trim_start().starts_with("doc:"))
.unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}"));
let doc_text = doc_line
.split_once('"')
.and_then(|(_, rest)| rest.rsplit_once('"'))
.map(|(text, _)| text)
.unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}"));
aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| {
panic!("scaffold's own doc line fails its own gate: {f:?}{doc_text:?}")
});
}
+14 -12
View File
@@ -37,7 +37,7 @@ const GER40_TO_MS: &str = "1727740799999";
/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab`
/// vocabulary namespace — `Scale` is the starter node every node-crate
/// scaffold emits (#183).
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","doc":"fast/slow SMA bias scaled by an extension gain node","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
/// The minimal executable process pipeline (one sweep stage) — the campaign
/// cell's methodology reference.
@@ -160,8 +160,8 @@ fn project_node_open_param_sweeps_and_reproduces() {
// the fixture's three BOUND params as `default=`-lines (#246: every bound
// param is an equally re-openable `--axis`, listed regardless of how many
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
// no data, so the discoverability half runs on every host. The wrapped
// name is `<blueprint>.<node>.<param>`.
// no data, so the discoverability half runs on every host. The name is RAW
// `<node>.<param>` (#328: the blueprint name stays out of axis paths).
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
assert!(
axes.status.success(),
@@ -170,10 +170,10 @@ fn project_node_open_param_sweeps_and_reproduces() {
);
assert_eq!(
String::from_utf8_lossy(&axes.stdout),
"scaled_signal.gain.factor:F64\n\
scaled_signal.fast.length:I64 default=2\n\
scaled_signal.slow.length:I64 default=4\n\
scaled_signal.bias.scale:F64 default=0.5\n",
"gain.factor:F64\n\
fast.length:I64 default=2\n\
slow.length:I64 default=4\n\
bias.scale:F64 default=0.5\n",
"the project node's own OPEN param is the one open axis, alongside the \
fixture's bound defaults; stderr: {}",
String::from_utf8_lossy(&axes.stderr)
@@ -198,7 +198,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
"--to",
GER40_TO_MS,
"--axis",
"scaled_signal.gain.factor=0.5,1.0",
"gain.factor=0.5,1.0",
"--name",
"knob",
],
@@ -212,9 +212,11 @@ fn project_node_open_param_sweeps_and_reproduces() {
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
// Each member's manifest carries the swept PROJECT-node param binding under
// its wrapped name — the load-bearing proof that it is MY node's knob that
// varied across the family, not some incidental std axis.
// Each member's manifest carries the swept PROJECT-node param binding
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
// real/campaign route's own `manifest.params` mints raw too now) — the
// load-bearing proof that it is MY node's knob that varied across the
// family, not some incidental std axis.
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
assert_eq!(
@@ -227,7 +229,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
.expect("manifest.params is an array");
let bound = params
.iter()
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
.find(|p| p[0].as_str() == Some("gain.factor"))
.and_then(|p| p[1]["F64"].as_f64());
assert_eq!(
bound,
+81 -15
View File
@@ -329,9 +329,9 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
"campaign-ref-seed",
],
@@ -377,10 +377,11 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
.trim_start_matches("content:")
.to_string();
// "fast.length": the axis name is the RAW composite's `param_space` name.
// "fast.length": the axis name is the RAW composite's `param_space` name
// the SAME namespace `aura sweep --axis` itself binds against now (#328).
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
// "sma_signal." prefix `aura sweep --axis` binds against.
// sweep's `wrap_r`-wrapped axis probe, so it never carries a wrap prefix
// to begin with.
let campaign = format!(
r#"{{
"format_version": 1,
@@ -448,9 +449,9 @@ fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
"campaign-identity-seed",
],
@@ -871,13 +872,14 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
}
/// #216/#234: bound (non-empty) risk AND cost lists close both optional
/// slots — a complete document with both reports no open slots at all.
/// slots — with the optional C29 description also present (#323), a complete
/// document reports no open slots at all.
#[test]
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
let with_both = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
"\"seed\": 1,\n \"description\": \"a screen with bound risk and cost\",\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
1,
);
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
@@ -995,7 +997,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
write_doc(&dir, "draft.campaign.json", draft);
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
assert_eq!(code, Some(0));
assert!(out.contains("open slot: process.ref (required, content id of a process document)"));
assert!(out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"));
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
}
@@ -1021,7 +1023,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
"strategy `ref: null` placeholder not enumerated: {out}"
);
assert!(
out.contains("open slot: process.ref (required, content id of a process document)"),
out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"),
"process `ref: {{}}` placeholder not enumerated: {out}"
);
}
@@ -1287,9 +1289,9 @@ fn seed_blueprint(dir: &Path, name: &str) -> String {
"sweep",
&closed_bp,
"--axis",
"sma_signal.fast.length=2,4",
"fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"slow.length=8,16",
"--name",
name,
],
@@ -1452,6 +1454,19 @@ const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// A gate no realized sweep member can ever satisfy (`n_trades ge 999999`) —
/// deterministically empties `survivor_ordinals` regardless of the fixture's
/// actual trade counts, driving the #278 zero-survivor NOTE marker.
const GATE_EMPTIES_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "gate-empties",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 999999.0 } ] }
]
}"#;
/// The full annotated shape for the gated e2e: sweep -> gate -> walk_forward
/// -> monte_carlo. The gate (`n_trades ge 0`) passes every member, so
/// walk-forward always has survivors; the mc annotator bootstraps the pooled
@@ -1978,6 +1993,57 @@ fn campaign_run_synthetic_e2e_parallel_instruments_contains_a_real_per_cell_faul
assert_eq!(cells[1]["instrument"].as_str(), Some("SYMB"), "doc order preserved: {line}");
assert!(cells[0]["fault"].is_null(), "SYMA has data in January, its cell succeeds: {line}");
assert!(!cells[1]["fault"].is_null(), "SYMB has no January data, its cell is faulted: {line}");
assert!(
out.contains("aura: warning: cell ("),
"the failed-cell line carries the warning class marker (#278): {out}"
);
}
/// #278 sibling of the warning-class pin above: a gate that empties a cell's
/// survivors (never a fault, #198 decision 8) emits the NOTE class marker,
/// not the bare `aura:` prefix nor `warning:`. `GATE_EMPTIES_PROCESS_DOC`'s
/// impossible threshold guarantees zero survivors regardless of the
/// fixture's actual sweep-member trade counts. Second property pinned on the
/// same invocation: the run's terminal "recorded" summary line — previously
/// printed with NO `aura:` prefix at all — now carries it too, so every
/// stderr line this command emits (per-cell class markers AND the run-level
/// summary) shares the one `aura:`-rooted vocabulary; a regression that
/// reverted just this one un-tagged `eprintln!` back to a bare line would
/// not be caught by the note-marker assertion above.
#[test]
fn campaign_run_synthetic_e2e_gate_emptied_cell_carries_the_note_marker() {
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir.clone()),
ScratchPath::File(dir.join("gateempty.process.json")),
ScratchPath::File(dir.join("gateempty.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "campaign-run-gateempty-seed");
let proc_id =
register_process_doc(&dir, "gateempty.process.json", GATE_EMPTIES_PROCESS_DOC);
let doc = campaign_doc_json_for(
"SYMA",
&bp_id,
&proc_id,
(1704844800000, 1705708800000),
"",
"",
);
write_doc(&dir, "gateempty.campaign.json", &doc);
let (out, code) =
run_code_in(&dir, &["campaign", "run", "gateempty.campaign.json"]);
assert_eq!(code, Some(0), "a zero-survivor cell is a valid result, not a fault: {out}");
assert!(
out.contains("aura: note: cell "),
"the gate-emptied cell line carries the note class marker (#278): {out}"
);
assert!(
out.contains("aura: campaign run "),
"the run-level recorded summary line also carries the aura: prefix (#278): {out}"
);
}
/// The v2 boundary, campaign side: a generalize-bearing process is an
@@ -3683,7 +3749,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
assert!(
out.contains(
"aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
"aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
),
"the unproducible tap gets a named, one-time note with the remedy: {out}"
);
@@ -3786,7 +3852,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
assert_eq!(code_gross, Some(0), "cost-less baseline run: {out_gross}");
assert!(
out_gross.contains(
"aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
"aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
),
"the cost-less run skips the net tap with the remedy: {out_gross}"
);
+82
View File
@@ -56,3 +56,85 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
assert_eq!(trace["tap"], "fast_tap");
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
}
/// #310: the measurement arm honours the `--tap` selector — a `last`
/// fold lands one summary row instead of the full series.
#[test]
fn measurement_run_honours_the_tap_selector() {
let cwd = temp_cwd("selector-last");
let bp_path = cwd.join("measurement.json");
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
let got = rows[0].as_f64().expect("f64 row");
let expected = (1.0097_f64 + 1.0092) / 2.0;
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
}
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
/// normalizes there) instead of refusing at the same root-name choke point
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
/// property that actually matters — the escaped directory is never created.
#[test]
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
let cwd = temp_cwd("bad-root-name");
let mut v: serde_json::Value =
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
v["blueprint"]["name"] = serde_json::json!("../x");
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
let bp_path = cwd.join("bad-root-name.json");
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(
out.status.code(),
Some(2),
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains(r#"blueprint name "../x" is invalid"#),
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
);
assert!(
out.stdout.is_empty(),
"a refused run must not print a report: {:?}",
String::from_utf8_lossy(&out.stdout)
);
// The property that actually matters: the escaped write target (one level
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
// was never created — the gate fires before `begin_run` ever touches disk.
assert!(
!cwd.join("runs/x").exists(),
"the escaped trace directory must never be created"
);
assert!(
!cwd.join("runs/traces").exists(),
"no trace directory of any shape is written on a refused run"
);
}
+43
View File
@@ -0,0 +1,43 @@
//! C29 coverage: every engine-shipped vocabulary entry carries a
//! gate-passing one-line meaning. Blocks, metrics, tap slots, folds, and
//! the std node vocabulary — the compile/unit seam of the self-description
//! contract.
use aura_core::doc_gate;
#[test]
fn every_shipped_vocabulary_entry_passes_the_doc_gate() {
// process + campaign blocks (BlockSchema.doc — the pre-existing model)
for b in aura_research::process_vocabulary()
.iter()
.chain(aura_research::campaign_vocabulary().iter())
{
doc_gate(b.id, b.doc)
.unwrap_or_else(|f| panic!("block {}: {:?}", b.id, f));
}
// metrics
for m in aura_research::metric_vocabulary() {
doc_gate(m.id, m.doc)
.unwrap_or_else(|f| panic!("metric {}: {:?}", m.id, f));
}
// tap slots
for t in aura_research::tap_vocabulary() {
doc_gate(t.id, t.doc)
.unwrap_or_else(|f| panic!("tap {}: {:?}", t.id, f));
}
// tap folds (the #283 registry roster already carries docs)
for (name, doc) in aura_runner::tap_plan::FoldRegistry::core().roster() {
doc_gate(name, doc)
.unwrap_or_else(|f| panic!("fold {}: {:?}", name, f));
}
// std node vocabulary: resolve each shipped type id to its schema
let env = aura_runner::project::Env::std();
for &type_id in aura_vocabulary::std_vocabulary_types() {
let builder = env
.resolve(type_id)
.unwrap_or_else(|| panic!("std type {type_id} must resolve"));
let schema = builder.schema();
doc_gate(type_id, schema.doc)
.unwrap_or_else(|f| panic!("node {type_id}: {:?}", f));
}
}
+270
View File
@@ -124,6 +124,21 @@ fn duplicate_tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
}
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
/// plan and the record-all default can be compared file-by-file (#310).
fn two_tap_blueprint_json() -> String {
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
v["blueprint"]["taps"] = serde_json::json!([
{"name": "fast_tap", "from": {"node": 0, "field": 0}},
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
]);
serde_json::to_string(&v).expect("re-serialize two-tap blueprint")
}
/// Property: **a blueprint declaring two taps under the same name is refused
/// on the single-run path with a named error and exit code 1, before any
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
@@ -150,3 +165,258 @@ fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
);
assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
}
/// The `aura: writing tap traces failed: …` register, pre-run arm: `runs`
/// exists as a FILE, so the trace store's directory creation
/// (`begin_run`) fails before the run — exit 1 through the register.
/// (This register was code-present but untested before #283.)
#[test]
fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
let cwd = temp_cwd("store-root-is-a-file");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
"the tap-trace register must fire: {stderr}"
);
}
/// The same register, deferred arm (#283): the run directory pre-exists
/// read-only, so `begin_run` succeeds (create_dir_all on an existing dir)
/// but the record consumer's deferred open in `initialize` fails; the run
/// COMPLETES, the failure surfaces terminally through the register, and no
/// `index.json` is written (the store's treat-as-absent crash shape).
#[cfg(unix)]
#[test]
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
use std::os::unix::fs::PermissionsExt;
let cwd = temp_cwd("run-dir-read-only");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run_dir = cwd.join("runs/traces/sma_signal");
std::fs::create_dir_all(&run_dir).expect("pre-create run dir");
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
.expect("make run dir read-only");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
// restore permissions FIRST so the next run's temp_cwd cleanup works.
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
.expect("restore run dir permissions");
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("writing tap traces failed"),
"the tap-trace register must fire terminally: {stderr}"
);
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
}
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
/// fold instead of the record-all default — the trace store holds ONE
/// summary row whose value is the mean of the full SMA(2) series, and
/// `index.json` lists exactly the subscribed tap.
#[test]
fn run_tap_selector_persists_a_fold_summary_row() {
let cwd = temp_cwd("tap-selector-mean");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
assert_eq!(trace["tap"], "fast_tap");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
.collect();
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
let got = rows[0].as_f64().expect("f64 row");
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
}
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
/// record-all default — the selector replaces the default without
/// changing what `record` itself writes (C1 determinism across runs).
#[test]
fn run_explicit_record_plan_matches_the_record_all_default() {
let cwd_a = temp_cwd("record-default");
let cwd_b = temp_cwd("record-explicit");
for cwd in [&cwd_a, &cwd_b] {
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
.expect("write two-tap blueprint");
}
let run = |cwd: &std::path::Path, extra: &[&str]| {
let mut args = vec!["run", "two_tap.json"];
args.extend_from_slice(extra);
let out = Command::new(BIN)
.args(&args)
.current_dir(cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
};
run(&cwd_a, &[]);
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
for file in ["fast_tap.json", "slow_tap.json"] {
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
}
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
};
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
}
/// #310: an unknown fold label is refused through the registry's
/// roster-enumerating refusal, before any store write.
#[test]
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
let cwd = temp_cwd("tap-selector-unknown-label");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an unknown fold label must refuse");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("medain") && stderr.contains("mean"),
"refusal must name the label and enumerate the roster: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #310/#333: a selection naming a tap the blueprint does not declare is
/// refused typed (UndeclaredTap), before any store write, and the refusal
/// enumerates the blueprint's declared taps (the same way the unknown-fold
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
/// both `fast_tap` and `slow_tap` must be named so the author does not have
/// to reopen the blueprint JSON.
#[test]
fn run_tap_selector_refuses_an_undeclared_tap() {
let cwd = temp_cwd("tap-selector-undeclared");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(!out.status.success(), "an undeclared tap must refuse");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
assert!(
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
"refusal must enumerate the blueprint's declared taps: {stderr}"
);
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
}
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
/// gets no such note; `slow_tap` (left unlisted) does.
#[test]
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
let cwd = temp_cwd("tap-selector-unbound-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
"stderr must note the unbound declared tap: {stderr}"
);
assert!(
!stderr.contains("\"fast_tap\" unbound"),
"the subscribed tap must not be noted as unbound: {stderr}"
);
}
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
/// unbound — nothing is skipped, so the note must never appear.
#[test]
fn run_with_no_tap_flag_emits_no_unbound_note() {
let cwd = temp_cwd("tap-selector-default-no-note");
let bp_path = cwd.join("two_tap.json");
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
let out = Command::new(BIN)
.args(["run", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("unbound this run"),
"the record-all default must never note a skipped tap: {stderr}"
);
}
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
/// before anything runs.
#[test]
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
let cwd = temp_cwd("tap-selector-usage");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
for args in [
vec!["--tap", "fast_tapmean"],
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
] {
let mut full = vec!["run", bp_path.to_str().expect("utf-8 path")];
full.extend(args);
let out = Command::new(BIN)
.args(&full)
.current_dir(&cwd)
.output()
.expect("spawn aura run");
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
assert!(!cwd.join("runs").exists());
}
}
+3 -3
View File
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
let scale = g.add(match knobs {
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::builder(1).named("stop_k"),
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::configured(1).named("stop_k"),
});
g.feed(price, [delay.input("series"), sub.input("lhs")]);
g.connect(delay.output("value"), sub.input("rhs"));
@@ -174,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
let entry = g.input_role("entry_price");
let stop = g.input_role("stop_price");
let agg = g.add(CostSum::builder(n));
let agg = g.add(CostSum::configured(n));
// Per-role geometry fan targets, collected across all nodes, fed once per role.
let mut closed_t = Vec::with_capacity(n);
@@ -35,6 +35,7 @@ impl ConstLongBias {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(ConstLongBias { out: [Cell::from_f64(0.0)] }),
)
+4
View File
@@ -7,6 +7,10 @@ publish.workspace = true
[dependencies]
serde = { workspace = true }
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
# already a workspace dependency of four other crates (see docs/specs/
# construction-args.md § Dependency note).
chrono-tz = { version = "0.10", default-features = false }
[dev-dependencies]
serde_json = { workspace = true }
+2 -1
View File
@@ -44,7 +44,8 @@ pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use node::{
zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder,
};
pub use scalar::{Scalar, ScalarKind, Timestamp};
+469 -5
View File
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
instance_name: Option<String>,
schema: NodeSchema,
bound: Vec<BoundParam>,
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
schema: NodeSchema,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
}
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
/// not yet constructible. `schema` carries only the doc line (empty
/// inputs/output/params — the real signature forms inside `make`, once
/// `try_args` has parsed values to hand it); `build` on a pending builder
/// panics (the `bind` panic-contract twin) — the data path always goes
/// through `try_args` first, so no pending builder ever reaches `build`.
pub fn pending(
name: &'static str,
doc: &'static str,
specs: &'static [ArgSpec],
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
) -> Self {
Self {
name,
instance_name: None,
schema: NodeSchema { doc, ..Default::default() },
bound: Vec::new(),
args: ArgsState::Pending { specs, make },
build: Box::new(move |_| {
panic!(
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
call try_args first"
)
}),
}
}
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
/// configured builder (`args: Configured{..}`) with this builder's
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
/// (uniform op application, #157's try_bind precedent); `Plain` +
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
/// first unknown arg name, then a duplicate, then the first spec-order
/// missing arg, then per-kind parse failures (`BadValue`).
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
let (specs, make) = match &self.args {
ArgsState::Plain | ArgsState::Configured { .. } => {
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
}
ArgsState::Pending { specs, make } => (*specs, *make),
};
for (name, _) in raw {
if !specs.iter().any(|s| s.name == name.as_str()) {
return Err(ArgOpError::UnknownArg(name.clone()));
}
}
for i in 0..raw.len() {
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
}
}
for spec in specs {
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
return Err(ArgOpError::MissingArg(spec.name.to_string()));
}
}
let mut values = Vec::with_capacity(specs.len());
let mut accepted = Vec::with_capacity(specs.len());
for spec in specs {
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
arg: spec.name.to_string(),
kind: spec.kind,
got: raw_val.clone(),
})?;
values.push((spec.name.to_string(), value));
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
}
let mut built = make(&values);
built.instance_name = self.instance_name;
built.args = ArgsState::Configured { values: accepted };
Ok(built)
}
/// The declared construction-arg specs (a view into the pending recipe);
/// empty unless this builder `is_pending()`.
pub fn arg_specs(&self) -> &[ArgSpec] {
match &self.args {
ArgsState::Pending { specs, .. } => specs,
_ => &[],
}
}
/// The accepted, verbatim construction-arg pairs (the serialize surface);
/// empty unless this builder was configured via `try_args`.
pub fn construction_args(&self) -> &[ConstructionArg] {
match &self.args {
ArgsState::Configured { values } => values,
_ => &[],
}
}
/// True for an arg-bearing recipe not yet configured via `try_args`.
pub fn is_pending(&self) -> bool {
matches!(self.args, ArgsState::Pending { .. })
}
/// Set this node instance's explicit name. Must be non-empty: the name forms
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
/// panic contract (and its pinned messages); `try_bind` reports the same three
/// conditions as values.
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
/// follow-up) does, refusing a ganged member's raw path at the use seam
/// before it silently de-fuses the gang.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BindOpError {
/// No still-open param has this name.
@@ -333,6 +438,141 @@ pub enum BindOpError {
AmbiguousParam(String),
/// The value's kind does not equal the param's declared kind.
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
/// `param` (the full qualified path) is a member of the gang named
/// `gang` — binding it directly would freeze that one member while the
/// gang's public knob keeps driving its siblings. Bind the gang's own
/// public knob instead (accepted residue: unbindable at the use seam,
/// #317).
AlreadyGanged { param: String, gang: String },
}
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
/// are bootstrap metadata, never streamed, so the two closedness axes stay
/// separate types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArgKind {
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
Tz,
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
TimeOfDay,
/// A plain positive decimal count (>= 1, no leading zeros).
Count,
}
impl ArgKind {
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
/// the canonical form (spec §Closedness) — there is no normalization
/// layer, so a near-miss string refuses rather than being silently
/// rewritten (content ids stay input-variance-free by refusal).
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
// no information of its own — `try_args` is the only caller and always maps
// it to `ArgOpError::BadValue`, which is where the real detail lives.
#[allow(clippy::result_unit_err)]
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
match self {
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
ArgKind::TimeOfDay => {
let bytes = raw.as_bytes();
if bytes.len() != 5 || bytes[2] != b':' {
return Err(());
}
let (h, m) = (&raw[0..2], &raw[3..5]);
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
let hour: u32 = h.parse().map_err(|_| ())?;
let minute: u32 = m.parse().map_err(|_| ())?;
if hour > 23 || minute > 59 {
return Err(());
}
Ok(ArgValue::TimeOfDay { hour, minute })
}
ArgKind::Count => {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(());
}
if raw.len() > 1 && raw.starts_with('0') {
return Err(()); // leading zero: not the canonical form
}
let n: usize = raw.parse().map_err(|_| ())?;
if n == 0 {
return Err(()); // Count is >= 1
}
Ok(ArgValue::Count(n))
}
}
}
/// The static, per-kind hint line (introspection surface, C29) — a closed
/// table keyed only by `ArgKind`, never per-entry freetext.
pub fn hint(&self) -> &'static str {
match self {
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
ArgKind::TimeOfDay => "local wall-clock HH:MM",
ArgKind::Count => "positive integer count",
}
}
}
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ArgSpec {
pub name: &'static str,
pub kind: ArgKind,
}
/// A parsed, validated construction-arg value (in-memory only; documents carry
/// the canonical string form in [`ConstructionArg`]).
#[derive(Clone, Debug, PartialEq)]
pub enum ArgValue {
Tz(chrono_tz::Tz),
TimeOfDay { hour: u32, minute: u32 },
Count(usize),
}
/// A consumed construction arg — the id-bearing, serialized twin of
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
/// (never re-normalized — see `ArgKind::parse`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConstructionArg {
pub name: String,
pub value: String,
}
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
/// for the data-level construction surface.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArgOpError {
/// Args were given, but this type declares none (a `Plain` builder).
NotArgBearing,
/// A given arg name is not among the declared `ArgSpec`s.
UnknownArg(String),
/// The same arg name was given more than once.
DuplicateArg(String),
/// A declared arg has no value in the given set.
MissingArg(String),
/// A given value failed its declared kind's strict-form parse.
BadValue { arg: String, kind: ArgKind, got: String },
}
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
enum ArgsState {
/// No construction args declared (every existing node, unaffected).
Plain,
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
/// parsed value set into the real, configured builder.
Pending {
specs: &'static [ArgSpec],
#[allow(clippy::type_complexity)]
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
},
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
/// pairs — the render/serialize surface (`construction_args`).
Configured { values: Vec<ConstructionArg> },
}
/// A node's declared interface: its inputs (in order) and its output record — an
@@ -345,6 +585,36 @@ pub struct NodeSchema {
pub inputs: Vec<PortSpec>,
pub output: Vec<FieldSpec>,
pub params: Vec<ParamSpec>,
/// One-line meaning of the node — non-load-bearing metadata, shown by
/// introspection surfaces.
pub doc: &'static str,
}
/// Shape fault of a vocabulary entry's meaning line (C29 gate).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocGateFault {
Empty,
RestatesName,
}
/// Deterministic shape check for a vocabulary entry's meaning line —
/// string shape only, never content judgement (the engine must not
/// evaluate prose).
pub fn doc_gate(name: &str, doc: &str) -> Result<(), DocGateFault> {
if doc.trim().is_empty() {
return Err(DocGateFault::Empty);
}
fn norm(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
let short_name = name.rsplit("::").next().unwrap_or(name);
if norm(doc) == norm(name) || norm(doc) == norm(short_name) {
return Err(DocGateFault::RestatesName);
}
Ok(())
}
/// The universal composable dataflow unit (C8): a **producer, consumer, or both**.
@@ -379,6 +649,15 @@ pub trait Node {
"node".to_string()
}
/// Start-of-stream hook: `Harness::run` calls it once per node, in
/// topological order, before the first source value — the mirror of
/// [`Node::finalize`]. A consumer overrides it to acquire run resources
/// (e.g. the file a recording sink streams into). Infallible by
/// signature: an implementation that can fail stores its error, degrades
/// to inert, and surfaces the failure once, terminally, at `finalize`.
/// The default is a no-op, so existing nodes are unaffected.
fn initialize(&mut self) {}
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
/// order, after the source loop drains. A folding sink overrides it to flush
/// its accumulated summary; the default is a no-op, so existing nodes are
@@ -437,13 +716,14 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
assert_eq!(with.label(), "SMA");
let none = PrimitiveBuilder::new(
"Sub",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(none.label(), "Sub");
@@ -473,7 +753,7 @@ mod tests {
fn node_name_strips_namespace_prefix() {
let b = PrimitiveBuilder::new(
"demo::Identity",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| unreachable!("never built in this test"),
);
assert_eq!(b.node_name(), "identity");
@@ -505,7 +785,7 @@ mod tests {
fn primitive_builder_build_runs_the_closure() {
let f = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(Bare),
);
assert_eq!(f.params(), Vec::<ParamSpec>::new());
@@ -520,6 +800,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -529,6 +810,21 @@ mod tests {
);
}
#[test]
fn schema_carries_declared_doc() {
let b = PrimitiveBuilder::new(
"SMA",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![],
doc: "simple moving average",
},
|_| Box::new(Bare),
);
assert_eq!(b.schema().doc, "simple moving average");
}
/// A node whose label echoes the param vector its constructor received — lets a
/// test read back the positional vector `.build()` reconstructed after binds.
struct Probe(Vec<Cell>);
@@ -556,6 +852,7 @@ mod tests {
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
],
doc: "test-only schema",
},
|p| Box::new(Probe(p.to_vec())),
)
@@ -632,6 +929,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -650,6 +948,7 @@ mod tests {
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -665,6 +964,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
);
@@ -716,6 +1016,7 @@ mod tests {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
@@ -737,6 +1038,169 @@ mod tests {
})
);
}
#[test]
fn doc_gate_refuses_empty_and_whitespace() {
assert_eq!(doc_gate("EMA", ""), Err(DocGateFault::Empty));
assert_eq!(doc_gate("EMA", " \t"), Err(DocGateFault::Empty));
}
#[test]
fn doc_gate_refuses_name_restatement() {
assert_eq!(doc_gate("EMA", "EMA"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("EMA", "e_m_a"), Err(DocGateFault::RestatesName));
assert_eq!(doc_gate("rolling_max", "Rolling Max"), Err(DocGateFault::RestatesName));
}
/// C29 tightening (#316 fieldtest): for a namespaced entry, the bare
/// display segment is a restatement too — a doc that adds nothing beyond
/// the name must not pass just because the name carries a `::` prefix.
/// Genuine meaning lines that merely *contain* the name stay legal.
#[test]
fn doc_gate_refuses_namespaced_short_name_restatement() {
// The most natural alibi at the extension-author seam: the node's
// own display name as its doc.
assert_eq!(doc_gate("lab316_nodes::Scale", "Scale"), Err(DocGateFault::RestatesName));
// Guard: a real meaning line stays Ok even when it contains the
// short name as a substring...
assert_eq!(
doc_gate("lab316_nodes::Scale", "scalar gain: emits the input times the factor param"),
Ok(())
);
// ...and a namespaced entry with a genuine doc is untouched.
assert_eq!(doc_gate("und::Opaque", "one-input f64 pass-through"), Ok(()));
}
#[test]
fn doc_gate_accepts_a_meaning_line() {
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
}
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
const DEMO_ARG_SPECS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// A minimal `make`: the fixture does not need `values` to shape a real
/// schema (that's `Session::make`'s job, Task 2) — it only proves
/// `try_args` reaches `make` with parsed values and carries the result
/// forward as `Configured`.
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let _ = values;
PrimitiveBuilder::new(
"Demo",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|_| Box::new(Bare),
)
}
fn demo_pending() -> PrimitiveBuilder {
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
}
#[test]
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
assert_eq!(
plain.try_args(&[("x".into(), "1".into())]).err(),
Some(ArgOpError::NotArgBearing),
);
}
#[test]
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
let plain = PrimitiveBuilder::new(
"Bare",
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|_| Box::new(Bare),
);
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
assert_eq!(after.schema().doc, "plain doc");
assert!(after.construction_args().is_empty());
}
#[test]
fn try_args_unknown_arg_names_the_first_unknown() {
assert_eq!(
demo_pending()
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
.err(),
Some(ArgOpError::UnknownArg("nope".into())),
);
}
#[test]
fn try_args_duplicate_arg_names_the_repeated_name() {
assert_eq!(
demo_pending()
.try_args(&[
("tz".into(), "Europe/Berlin".into()),
("tz".into(), "Europe/Berlin".into()),
("open".into(), "09:30".into()),
])
.err(),
Some(ArgOpError::DuplicateArg("tz".into())),
);
}
#[test]
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
assert_eq!(
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
Some(ArgOpError::MissingArg("open".into())),
);
}
#[test]
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
assert_eq!(
demo_pending()
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
.err(),
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
);
}
/// Strict form IS the canonical form (spec §Closedness): no normalization
/// layer, so a near-miss string refuses rather than being rewritten.
#[test]
fn arg_kind_parse_strict_form_refusals_and_accepts() {
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
assert!(ArgKind::Count.parse("2").is_ok());
}
#[test]
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
let configured = demo_pending()
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
.expect("a full valid set configures");
assert!(!configured.is_pending());
assert_eq!(
configured.construction_args(),
&[
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
ConstructionArg { name: "open".into(), value: "09:30".into() },
],
);
}
#[test]
fn pending_arg_specs_are_visible_before_configuration() {
let pending = demo_pending();
assert!(pending.is_pending());
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
assert!(pending.construction_args().is_empty());
}
#[test]
#[should_panic(expected = "unconfigured")]
fn pending_build_panics() {
let _ = demo_pending().build(&[]);
}
}
#[cfg(test)]
+192 -11
View File
@@ -15,7 +15,8 @@
//! C23) and no external dependency (C16).
use aura_core::{
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
@@ -129,7 +130,15 @@ fn derive_signature(c: &Composite) -> NodeSchema {
.collect();
let mut params = Vec::new();
collect_params(c.nodes(), c.gangs(), "", &mut params);
NodeSchema { inputs, output, params }
NodeSchema {
inputs,
output,
params,
// A derived composite signature is graph wiring, not a vocabulary entry:
// no seam walks this doc. The described surface is the composite's own
// `doc`, gated at register (C29, register seam).
doc: "",
}
}
/// The scalar kind of the interior input slot a composite target addresses,
@@ -186,6 +195,38 @@ pub struct GangMember {
pub name: String,
}
/// Deterministic shape gate for authored composite render names (#331) —
/// shape only, never content judgement (C29 discipline): non-empty, no path
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
/// exists because the name keys a trace directory on disk unsanitized
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
/// blueprint-envelope root-name intake — never at store read-back (C29:
/// registered artifacts are never retroactively invalidated), and never on
/// the Rust builder API (`GraphBuilder::new`, role-2 native authoring).
pub fn name_gate(name: &str) -> Result<(), NameGateFault> {
if name.is_empty() {
return Err(NameGateFault::Empty);
}
if name.contains('/') || name.contains('\\') {
return Err(NameGateFault::ContainsSeparator);
}
if name == "." || name == ".." {
return Err(NameGateFault::DotSegment);
}
Ok(())
}
/// The [`name_gate`] shape violation — WHY the name was refused; the caller
/// already holds the offending name (WHAT) to build a message from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameGateFault {
Empty,
ContainsSeparator,
DotSegment,
}
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
/// interior edges (local indices), input roles (role `r` fans into the interior
@@ -232,6 +273,37 @@ impl Composite {
pub fn doc(&self) -> Option<&str> {
self.doc.as_deref()
}
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
/// interior — nodes, edges, roles, output — is untouched; only `name()`
/// changes, so a fetched, registered subgraph renders (and path-prefixes
/// its `param_space()`, via `collect_params`'s composite arm) under the
/// caller-chosen instance identifier instead of its own authored name.
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
self.name = name.into();
self
}
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
/// same underlying mechanic every bind goes through
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
/// nested composite frames by node/composite-name prefix — the segmentation
/// `reopen_in` (#246) also walks, but binding an open param instead of
/// unbinding a bound one. A path matching no node at any level is
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
/// param lives there); a path that reaches a primitive but whose leaf
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
/// own fault to carry the full qualified path in place of the bare leaf
/// name, so every fault out of this walk names the same thing: the path
/// the caller wrote. A path landing on a GANGED member's own param is
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
/// it directly would silently de-fuse the gang (the member frozen while
/// the gang's public knob keeps driving its siblings); the gang's own
/// public knob stays unbindable at the use seam (accepted residue). On
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
/// posture `reopen` uses.
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
Ok(self)
}
/// Install declared measurement taps (the output-side twin of `input_roles`).
/// Fluent, mirroring `with_doc`. Empty by default.
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
@@ -1089,6 +1161,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
hits
}
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
/// `full_path` is the whole original path (named in every fault this
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
/// its (node, original-pos) is checked against every gang member; a hit
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
/// the member out from under its gang's public knob (review finding,
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
/// `BlueprintNode`s by value rather than mutating through a reference.
fn bind_path_in(
items: &mut Vec<BlueprintNode>,
gangs: &[Gang],
full_path: &str,
rest: &str,
value: Scalar,
) -> Result<(), BindOpError> {
// The prefix this frame has already consumed off `full_path` (composite
// names and up) — used to qualify a gang's public name the same way
// `collect_params` does, so the refusal below names the SAME address
// `param_space()` would show for that gang.
let prefix = &full_path[..full_path.len() - rest.len()];
for i in 0..items.len() {
let child_rest = match &items[i] {
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
};
let Some(child_rest) = child_rest else { continue };
let child_rest = child_rest.to_string();
// Gang guard (#317 follow-up review finding): a raw path landing on a
// GANGED member's own param must not silently freeze it while the
// gang's public knob keeps driving its siblings — refuse by
// identifier before ever reaching `try_bind`. The gang's own public
// knob staying unbindable at the use seam is accepted residue.
if let BlueprintNode::Primitive(b) = &items[i]
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
{
return Err(BindOpError::AlreadyGanged {
param: full_path.to_string(),
gang: format!("{prefix}{}", g.name),
});
}
let item = items.remove(i);
return match item {
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
Ok(b2) => {
items.insert(i, BlueprintNode::Primitive(b2));
Ok(())
}
Err(e) => Err(qualify_bind_error(e, full_path)),
},
BlueprintNode::Composite(mut c) => {
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
items.insert(i, BlueprintNode::Composite(c));
r
}
};
}
// no node at any level matched `rest`'s leading segment: no open param
// lives at this path.
Err(BindOpError::UnknownParam(full_path.to_string()))
}
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
/// visibility past the one primitive it was called on).
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
match e {
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
BindOpError::KindMismatch { expected, got, .. } => {
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
}
// `try_bind` never raises this one (it has no gang awareness) — the
// gang guard above constructs it directly, already fully qualified.
// Kept here only so this match stays total over `BindOpError`.
BindOpError::AlreadyGanged { gang, .. } => {
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
}
}
}
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
@@ -1393,6 +1551,19 @@ mod tests {
use aura_strategy::Bias;
use std::sync::mpsc;
/// `name_gate` (#331) unit table: an ordinary name passes; an empty
/// name, one containing either path separator, and the two dot segments
/// each fault with their own fault variant.
#[test]
fn name_gate_accepts_ordinary_names_and_refuses_shape_violations() {
assert_eq!(name_gate("ny_momentum"), Ok(()));
assert_eq!(name_gate(""), Err(NameGateFault::Empty));
assert_eq!(name_gate("a/b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("a\\b"), Err(NameGateFault::ContainsSeparator));
assert_eq!(name_gate("."), Err(NameGateFault::DotSegment));
assert_eq!(name_gate(".."), Err(NameGateFault::DotSegment));
}
/// One knob fanning into two sibling open params passes the gate; the
/// value carries the gang table.
#[test]
@@ -1695,7 +1866,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -1734,7 +1905,7 @@ mod tests {
Composite::new(
"sig3",
vec![
LinComb::builder(2).into(),
LinComb::configured(2).into(),
Bias::builder().into(),
Sma::builder().named("c").into(),
],
@@ -1838,6 +2009,7 @@ mod tests {
inputs: vec![],
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
};
let to = NodeSchema {
inputs: vec![
@@ -1846,6 +2018,7 @@ mod tests {
],
output: vec![],
params: vec![],
doc: "test-only schema",
};
assert!(edge_kind_check(&from, 0, &to, 0).is_ok());
assert_eq!(
@@ -2269,7 +2442,7 @@ mod tests {
fn pass1() -> BlueprintNode {
PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
)
.into()
@@ -2277,7 +2450,12 @@ mod tests {
fn join2() -> BlueprintNode {
PrimitiveBuilder::new(
"Join2",
NodeSchema { inputs: vec![f64_any(), f64_any()], output: out_v(), params: vec![] },
NodeSchema {
inputs: vec![f64_any(), f64_any()],
output: out_v(),
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Join2 { out: [Cell::from_f64(0.0)] }),
)
.into()
@@ -2285,7 +2463,7 @@ mod tests {
fn sink_f64() -> BlueprintNode {
PrimitiveBuilder::new(
"SinkF64",
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![], doc: "test-only schema" },
|_| Box::new(SinkF64),
)
.into()
@@ -2297,6 +2475,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(SinkI64),
)
@@ -2849,6 +3028,7 @@ mod tests {
inputs: vec![f64_any()],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
@@ -3098,7 +3278,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
@@ -3306,7 +3486,7 @@ mod tests {
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
// fan fast_slow's output into both LinComb terms so every interior slot
// is wired (the totality check, cycle 0040); param order is unaffected.
vec![
@@ -3366,7 +3546,7 @@ mod tests {
use aura_std::{LinComb, Sma};
let bp = Composite::new(
"root",
vec![Sma::builder().into(), LinComb::builder(2).into()],
vec![Sma::builder().into(), LinComb::configured(2).into()],
vec![],
vec![],
vec![], // output
@@ -3454,6 +3634,7 @@ mod tests {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::I64 }],
params: vec![],
doc: "test-only schema",
},
|_| panic!("build must not run when validation fails pre-build"),
);
@@ -3556,7 +3737,7 @@ mod tests {
// cannot take .named).
let paramless_sma = PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
)
.named("sma");
+219 -15
View File
@@ -7,18 +7,29 @@
//! closure and the declared schema are re-derived on load from the injected
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
//! carries no node logic (C17) and references a closed vocabulary (C24).
//!
//! Construction args (#271) are structural, id-bearing data — like `bound`
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
//! `PrimitiveData.args` untouched.
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder};
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
/// The format version the loader understands. Bumped only by a load-bearing
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
/// discipline activates at the first external ship — which consciously
/// freezes v1 (gangs included).
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
/// The CEILING format version this build's loader understands (and the
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
/// 2026-07-10): while the project is unshipped every document lives in-repo
/// and reader/writer change atomically, so the Tier-2 bump discipline
/// activates at the first external ship.
///
/// #271 (data-driven version): the writer no longer emits a single fixed
/// version. A document emits `1` when args-free (byte-identical to every
/// pre-#271 document — content ids stable, C18) and `2` the moment any
/// primitive, at any nesting depth, carries construction `args` — the
/// must-understand signal a pre-#271 loader needs. The loader accepts the
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
/// Top-level envelope: the version is read before the payload is interpreted.
#[derive(serde::Serialize, serde::Deserialize)]
@@ -61,17 +72,33 @@ pub enum NodeData {
}
/// A primitive node as data: its compiled-in type identity, optional instance
/// name, and bound params. The schema + build closure are re-derived on load.
/// name, its accepted construction args (#271), and bound params. The schema
/// + build closure are re-derived on load.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PrimitiveData {
#[serde(rename = "type")]
pub type_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The consumed `(name, value)` construction-arg pairs (#271) — the
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
/// (empty) for every args-free primitive, so a document with no args
/// anywhere stays byte-identical to its pre-#271 form.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound: Vec<BoundParam>,
}
/// One serialized construction-arg pair (#271) — the wire twin of
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
/// string, stored verbatim (never re-normalized).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ArgData {
pub name: String,
pub value: String,
}
/// Serializer failure (typed, named).
#[derive(Debug)]
pub enum SerializeError {
@@ -111,9 +138,19 @@ fn project_node(n: &BlueprintNode) -> NodeData {
// construction once a multi-param node enters the vocabulary.
let mut bound = b.bound_params().to_vec();
bound.sort_by_key(|bp| bp.pos);
// Construction args (#271) are declared in a fixed `ArgSpec` order
// (not player-chosen like binds), so no re-canonicalization is
// needed — `construction_args()` already returns them in that
// deterministic order.
let args: Vec<ArgData> = b
.construction_args()
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
NodeData::Primitive(PrimitiveData {
type_id: b.label(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}
@@ -121,8 +158,32 @@ fn project_node(n: &BlueprintNode) -> NodeData {
}
}
/// Any primitive, at any nesting depth, carries construction args (#271) —
/// the must-understand signal for the data-driven version (spec §Data-driven
/// format version).
fn has_args(b: &CompositeData) -> bool {
b.nodes.iter().any(|n| match n {
NodeData::Primitive(p) => !p.args.is_empty(),
NodeData::Composite(c) => has_args(c),
})
}
/// The version THIS document must declare (#271): `1` for an args-free
/// document (byte-identical to every pre-#271 document, C18) or `2` the
/// moment any primitive anywhere carries args — never the fixed
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
fn document_version(b: &CompositeData) -> u32 {
if has_args(b) {
2
} else {
1
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
let blueprint = project(c);
let format_version = document_version(&blueprint);
BlueprintDoc { format_version, blueprint }
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
@@ -200,11 +261,16 @@ pub enum LoadError {
/// addition needs finer granularity than a version bump.
UnsupportedVersion { found: u32, supported: u32 },
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
/// vocabulary — unknown node, or a sink node not in #155's set).
UnknownNodeType(String),
/// The document's gangs section fails structural validation against its
/// own nodes (a hand-edited or corrupted document).
Gang(crate::blueprint::CompileError),
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
/// an arg-bearing type serialized/hand-written with an empty `args` —
/// a document naming an arg-bearing type without args must not load.
BadArg(aura_core::ArgOpError),
}
fn reconstruct(
@@ -220,6 +286,15 @@ fn reconstruct(
if let Some(n) = &p.name {
b = b.named(n);
}
// Apply construction args (#271) BEFORE bound params — the
// `try_args` seam. An arg-bearing type with no `args` in the
// document refuses HERE as `MissingArg` (no pending builder
// ever reaches `bind`/enters the built graph); a `Plain` type
// has empty `p.args`, so this is `Ok(self)` unchanged for
// every pre-#271 document.
let args: Vec<(String, String)> =
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
b = b.try_args(&args).map_err(LoadError::BadArg)?;
// Re-apply bound params BY NAME. The effective param vector is
// order-independent (each `bind` computes its slot against the
// shrunk schema); ascending original `pos` is the canonical order.
@@ -251,7 +326,9 @@ pub fn blueprint_from_json(
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
return Err(LoadError::UnsupportedVersion {
found: doc.format_version,
supported: BLUEPRINT_FORMAT_VERSION,
@@ -266,7 +343,8 @@ mod tests {
use crate::blueprint::{Composite, GangMember, OutField, Role};
use crate::harness::{Edge, Target};
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
use aura_market::Session;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
@@ -436,11 +514,14 @@ mod tests {
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
}
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
/// used to be refused (v1 was the only understood version); now that v2 loads
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
#[test]
fn unsupported_version_fails_named() {
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
}
#[test]
@@ -813,4 +894,127 @@ mod tests {
"the gang itself is identity-bearing"
);
}
// ---- construction args (#271) --------------------------------------
// A single-node composite around an arg-configured `Session` (arity-free —
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
Composite::new(
"sess",
vec![Session::configured(9, 30, tz, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
)
}
/// #271 acceptance (engine layer): an args-bearing composite serializes its
/// construction-arg pairs, declares `format_version: 2` (the data-driven
/// must-understand tier, spec §Data-driven format version), and round-trips
/// byte-stably through load -> re-serialize.
#[test]
fn args_round_trip_preserves_pairs_and_version_2() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
let json = blueprint_to_json(&c).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
assert!(
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
"carries the verbatim accepted arg pairs: {json}"
);
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
json,
"args round-trip byte-stably"
);
}
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
/// here under the #271-specific test name the plan names.
#[test]
fn args_free_document_serializes_byte_identical_and_version_1() {
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
let json = blueprint_to_json(&signal).expect("serializes");
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
}
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
/// outer composite still trips the outer document's version to 2 — the
/// version walk recurses through `NodeData::Composite`, not just the
/// top-level node list.
#[test]
fn nested_spliced_args_composite_is_version_2() {
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
let outer = Composite::new(
"outer",
vec![crate::blueprint::BlueprintNode::Composite(inner)],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
let json = blueprint_to_json(&outer).expect("serializes");
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
}
/// #271 (identity is arg-bearing): construction args are structural,
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
/// two builds differing ONLY in one accepted arg value never share an
/// identity JSON, while renaming the composite (a pure debug symbol)
/// still does not affect it.
#[test]
fn identity_json_retains_args() {
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
assert_ne!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&paris).expect("identity-serializes"),
"a differing construction-arg value survives the identity projection"
);
// renaming the composite (a debug symbol) does not affect identity.
let renamed = Composite::new(
"sess_renamed",
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
vec![],
vec![Role {
name: "trigger".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "bars".into() }],
);
assert_eq!(
blueprint_identity_json(&berlin).expect("identity-serializes"),
blueprint_identity_json(&renamed).expect("identity-serializes"),
"the composite's own debug name does not affect identity"
);
}
/// #271 (refuse, don't guess): a hand-written document naming an
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
/// must never silently enter the built graph.
#[test]
fn loading_arg_bearing_type_without_args_refuses() {
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
assert!(
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -348,6 +348,7 @@ mod tests {
],
output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
@@ -462,6 +463,7 @@ mod tests {
FieldSpec { name: "lo".into(), kind: ScalarKind::I64 },
],
params: vec![],
doc: "test-only schema",
},
|_| Box::new(Bare),
)
+63
View File
@@ -519,6 +519,14 @@ impl Harness {
let mut cycle_id: u64 = 0;
let mut scratch: Vec<Cell> = Vec::new();
// start-of-stream: initialize every node once, in topological order,
// before the first source value (the mirror of the end-of-stream
// finalize flush below). Runs inside the deterministic sequence — no
// within-sim concurrency (C1).
for &nidx in topo.iter() {
nodes[nidx].node.initialize();
}
loop {
// pick the live source head with the smallest (timestamp, source index).
// strictly-`<` replace + source-order scan preserves C4 tie-breaking.
@@ -870,6 +878,7 @@ mod tests {
inputs: kinds.iter().enumerate().map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") }).collect(),
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
@@ -887,6 +896,7 @@ mod tests {
inputs: vec![f64_port(Firing::Any), f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -916,6 +926,7 @@ mod tests {
inputs: vec![f64_port(Firing::Barrier(0)), f64_port(Firing::Barrier(0))],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -943,6 +954,7 @@ mod tests {
inputs: vec![f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -976,6 +988,7 @@ mod tests {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -1014,6 +1027,7 @@ mod tests {
FieldSpec { name: "volume".into(), kind: ScalarKind::F64 },
],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -1049,6 +1063,7 @@ mod tests {
FieldSpec { name: "i".into(), kind: ScalarKind::I64 },
],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -1076,6 +1091,7 @@ mod tests {
inputs: vec![f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -2434,6 +2450,7 @@ mod tests {
FieldSpec { name: "i".into(), kind: ScalarKind::I64 },
],
params: vec![],
doc: "test-only schema",
}
}
}
@@ -2809,6 +2826,52 @@ mod tests {
assert_eq!(fired, vec![0, 1]);
}
struct InitProbe {
id: usize,
tx: mpsc::Sender<String>,
evaled: bool,
}
impl Node for InitProbe {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn initialize(&mut self) {
let _ = self.tx.send(format!("init{}", self.id));
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
if !self.evaled {
self.evaled = true;
let _ = self.tx.send(format!("eval{}", self.id));
}
None
}
}
#[test]
fn run_initializes_every_node_once_before_the_stream() {
let (tx, rx) = mpsc::channel();
// two sink probes, both fed by the single source; no inter-node edges.
let mut h = boot(
vec![
Box::new(InitProbe { id: 0, tx: tx.clone(), evaled: false }),
Box::new(InitProbe { id: 1, tx, evaled: false }),
],
vec![
recorder_sig(&[ScalarKind::F64], Firing::Any),
recorder_sig(&[ScalarKind::F64], Firing::Any),
],
vec![SourceSpec::raw(ScalarKind::F64, vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }])],
vec![],
)
.expect("valid");
// nothing initialized before the run.
assert!(rx.try_recv().is_err());
h.run(vec![Box::new(VecSource::new(f64_stream(&[(1, 1.0), (2, 2.0)])))]);
// both inits fire, in topo order, strictly before any eval.
let fired: Vec<String> = rx.try_iter().collect();
assert_eq!(fired, vec!["init0", "init1", "eval0", "eval1"]);
}
#[test]
fn bind_tap_attaches_a_caller_built_sink_and_records_the_tapped_series() {
// A tap on a producer; bind_tap attaches a caller-built Recorder; the run
+7 -5
View File
@@ -52,12 +52,13 @@ mod sweep;
mod walkforward;
pub use blueprint::{
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
Tap, TapWire,
};
pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
pub use construction::{replay, GraphSession, Op, OpError};
@@ -88,7 +89,8 @@ pub use walkforward::{
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
// Firing / Timestamp) so a graph builder has one import surface, not two.
pub use aura_core::{
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
Timestamp,
};
#[cfg(test)]
+1
View File
@@ -43,6 +43,7 @@ fn recorder_sig(kinds: &[ScalarKind]) -> NodeSchema {
.collect(),
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
+14 -14
View File
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
fn signal_ops() -> Vec<Op> {
vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
#[test]
fn replayed_construction_compiles_identically_to_graphbuilder() {
// (a) op-script replay through the public construction surface.
let replay_flat = replay("root", signal_ops(), &std_vocabulary)
let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
.expect("op-script resolves")
.compile_with_params(&params())
.expect("replay compiles");
@@ -96,14 +96,14 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
#[test]
fn replay_attributes_eager_fault_to_its_op_index() {
let ops = vec![
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 1 ok
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // 2 fails
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 0 ok
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 1 ok
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, // 3 never runs
];
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
let err = replay("g", ops, &std_vocabulary).err().unwrap();
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
}
@@ -120,15 +120,15 @@ fn replay_attributes_eager_fault_to_its_op_index() {
fn replay_attributes_holistic_fault_to_finalize_step() {
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 3
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 1
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, // 2
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 3
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
// sub.rhs deliberately left unwired -> only finalize can decide totality.
];
let finalize_index = ops.len(); // 6 — the implicit finalize step
let err = replay("g", ops, &std_vocabulary).err().unwrap();
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
"the holistic fault is still attributed to the finalize step, got {err:?}");
}
+1 -1
View File
@@ -110,7 +110,7 @@ fn build_harness(
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(9, 0, Berlin, 15).schema().clone(), // 3
Session::configured(9, 0, Berlin, 15).schema().clone(), // 3
EqConst::builder().schema().clone(), // 4
EqConst::builder().schema().clone(), // 5
And::builder().schema().clone(), // 6
+1 -1
View File
@@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec<f64> {
let sq = g.add(Mul::builder()); // dev * dev
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
let sigma = g.add(Sqrt::builder());
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
let band = g.add(LinComb::configured(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
let upper = g.add(Add::builder()); // mean + k*sigma
let lower = g.add(Sub::builder()); // mean - k*sigma
let gt_hi = g.add(Gt::builder()); // price > upper
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
.schema()
.clone(), // 3
EqConst::builder().schema().clone(), // 4
@@ -248,7 +248,7 @@ pub fn ger40_breakout_blueprint(
);
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let gt = g.add(Gt::builder());
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes));
let session = g.add(Session::configured(open_hour, open_minute, tz, bar_period_minutes));
// The ONLY two params: the EqConst targets, named so the space reads
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
+1
View File
@@ -29,6 +29,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let mut h = Harness::bootstrap(FlatGraph {
nodes: vec![
@@ -35,6 +35,7 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box<dyn Source>) -> RunRep
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
};
let mut h = Harness::bootstrap(FlatGraph {
nodes: vec![
+1
View File
@@ -88,6 +88,7 @@ impl Resample {
FieldSpec { name: "close".into(), kind: ScalarKind::F64 },
],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
doc: "emits a completed bar once per resample boundary, never mid-bar",
},
|p| Box::new(Resample::new(p[0].i64())),
)
+84 -9
View File
@@ -33,10 +33,17 @@
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind,
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
PrimitiveBuilder, Scalar, ScalarKind,
};
use chrono::{TimeZone, Timelike};
/// The declared construction args of a `Session` recipe (spec §Concrete code
/// shapes): the timezone and local open time — structural, non-scalar
/// configuration, never a swept param.
const SESSION_ARGS: &[ArgSpec] =
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
/// midnight), the IANA timezone, and the bar period — baked at construction,
/// never streamed and never a swept param (a timezone is not a scalar).
@@ -60,22 +67,57 @@ impl Session {
}
}
/// The param-generic recipe for a blueprint primitive. The open time,
/// timezone, and period are **structural** config baked into the closure
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
/// declared param list is empty and the `build_fn` ignores its slice (like
/// `Gt` / `Latch`).
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
/// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
/// timezone and open time come through the `args` channel (`try_args`),
/// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
/// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::pending(
"Session",
"bars elapsed since the session open, from the configured open time and timezone",
SESSION_ARGS,
Self::make,
)
}
/// Turn a validated `(tz, open)` value pair into the real signature: one
/// `trigger` input, one `bars_since_open` output, and `period_minutes` as
/// the sole (now scalar) `ParamSpec` — read from the param slice at build.
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
let tz = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("tz", ArgValue::Tz(tz)) => Some(*tz),
_ => None,
})
.expect("try_args validated `tz` as ArgKind::Tz before calling make");
let (open_hour, open_minute) = values
.iter()
.find_map(|(n, v)| match (n.as_str(), v) {
("open", ArgValue::TimeOfDay { hour, minute }) => Some((*hour, *minute)),
_ => None,
})
.expect("try_args validated `open` as ArgKind::TimeOfDay before calling make");
PrimitiveBuilder::new(
"Session",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
doc: "bars elapsed since the session open, from the configured open time and timezone",
},
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)),
move |p| Box::new(Session::new(open_hour, open_minute, tz, p[0].i64())),
)
}
/// Rust-path convenience — the same recipe as the data path (twin
/// identity): `builder().try_args([tz, open]) + .bind("period_minutes", …)`.
pub fn configured(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
Self::builder()
.try_args(&[("tz".to_string(), tz.name().to_string()), ("open".to_string(), format!("{open_hour:02}:{open_minute:02}"))])
.expect("configured: a valid chrono_tz::Tz and in-range hour/minute always parse")
.bind("period_minutes", Scalar::i64(period_minutes))
}
}
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
@@ -99,6 +141,7 @@ impl SessionFrankfurt {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
doc: "bars elapsed since the Frankfurt session open (09:00 Europe/Berlin)",
},
|p| Box::new(Session::new(9, 0, chrono_tz::Europe::Berlin, p[0].i64())),
)
@@ -242,4 +285,36 @@ mod tests {
let inputs = trigger_input();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
/// Twin identity (spec §aura-market): `configured` is exactly
/// `builder().try_args([tz, open]).bind("period_minutes", …)` — same
/// accepted args, same declared signature, whichever path authored it.
#[test]
fn session_configured_twin_equals_builder_try_args() {
let via_configured = Session::configured(9, 30, Berlin, 15);
let via_try_args = Session::builder()
.try_args(&[("tz".into(), "Europe/Berlin".into()), ("open".into(), "09:30".into())])
.expect("valid tz/open configure")
.bind("period_minutes", Scalar::i64(15));
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
assert_eq!(via_configured.schema(), via_try_args.schema());
}
/// Behaviour re-anchor (#271 Task 2): the DST-aware headline property
/// (local 09:45 reads bar 3 in both CEST summer and CET winter) holds
/// identically when the node is built through `configured` instead of
/// `Session::new` directly.
#[test]
fn session_configured_behaviour_unchanged() {
let mut node = Session::configured(9, 0, Berlin, 15).build(&[]);
let mut inputs = trigger_input();
let summer = Berlin.with_ymd_and_hms(2024, 7, 1, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(summer))).map(|r| r[0].i64()), Some(3));
let winter = Berlin.with_ymd_and_hms(2024, 1, 2, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(0.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(winter))).map(|r| r[0].i64()), Some(3));
}
}
+503 -22
View File
@@ -21,8 +21,8 @@ use aura_core::PrimitiveBuilder;
use aura_analysis::{one_sided_p_laplace, MetricStats, MetricVocabulary, SplitMix64};
use aura_backtest::{RunMetrics, RunReport};
use aura_engine::{
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, FamilySelection,
SelectionMode,
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, BlueprintDoc,
CompositeData, FamilySelection, NodeData, SelectionMode,
};
use aura_research::{CampaignDoc, DocRef};
@@ -37,7 +37,10 @@ pub use lineage::{
};
mod trace_store;
pub use trace_store::{FamilyMember, NameKind, RunTraces, TraceStore, TraceStoreError, WriteKind};
pub use trace_store::{
FamilyMember, NameKind, RunTraces, TapTraceWriter, TapWriterOpener, TraceStore,
TraceStoreError, TraceStreamer, WriteKind,
};
/// An append-only run registry over a JSONL file: one serde_json line per
/// `RunReport`.
@@ -140,12 +143,26 @@ impl Registry {
/// so a repeated write re-writes identical content. The registry does NOT
/// verify `sha256(bytes) == hash` (no `sha2` dep here): the caller owns the
/// hash, and reproduction's bit-identical metric compare is the integrity check.
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
/// Raw write — C29-gated callers go through [`Registry::put_blueprint`]; this
/// stays for store-mechanics tests and pre-C29 store content.
fn put_blueprint_unchecked(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
fs::create_dir_all(self.blueprints_dir())?;
fs::write(self.blueprint_path(hash), canonical_json)?;
Ok(())
}
/// Write-once content-addressed put, C29-gated (#316): only a canonical
/// blueprint whose root composite and every named nested composite carry
/// a gate-passing doc enters the store. The gate guards the store
/// boundary itself — verb, sugar, and any future caller alike — and the
/// write path only: already-registered doc-less entries stay readable.
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
let doc: BlueprintDoc = serde_json::from_str(canonical_json)
.map_err(RegistryError::MalformedBlueprint)?;
gate_composite_docs(&doc.blueprint)?;
self.put_blueprint_unchecked(hash, canonical_json)
}
/// Read a stored blueprint by content id; `Ok(None)` if absent — the same
/// treat-as-empty discipline `load` applies to a missing runs store.
pub fn get_blueprint(&self, hash: &str) -> Result<Option<String>, RegistryError> {
@@ -156,6 +173,95 @@ impl Registry {
}
}
/// The label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`,
/// the same "fixed-name sidecar, per-directory isolation" discipline (#191).
fn blueprint_names_path(&self) -> PathBuf {
self.path.with_file_name("blueprint_names.jsonl")
}
/// Raw, ungated append: one label line, unconditionally, under the
/// registry's write mutex (#276). No shape or content-id check —
/// [`Registry::put_blueprint_label`] is the gated public entry; this
/// exists so tests can hand-append a stale line, mirroring
/// `append_identity_index`.
fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let path = self.blueprint_names_path();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&BlueprintNameLine {
name: name.to_string(),
content_id: content_id.to_string(),
})
.expect("two plain strings serialize");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}`
/// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated
/// on a deterministic label shape (nonempty after trim, no whitespace, no
/// control characters — `RegistryError::BadLabel`, C29 discipline: shape
/// only, never content judgement) and on `content_id` resolving via
/// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint`
/// otherwise) — both checked BEFORE the write lock is taken. Re-labelling
/// an existing name appends a new line (a repoint); resolution is
/// latest-wins (see [`Registry::resolve_blueprint_label`]).
pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
gate_label_shape(name)?;
if self.get_blueprint(content_id)?.is_none() {
return Err(RegistryError::UnknownBlueprint(content_id.to_string()));
}
self.append_blueprint_label_line(name, content_id)
}
/// Resolve a label to the content id of the LATEST line naming it, skipping
/// any line whose content id no longer resolves via `get_blueprint`
/// (self-repair spirit, #191) — so "latest" always means the latest VALID
/// entry, even when a later append points at a since-vanished id. Missing
/// sidecar file -> `None` (treat-as-empty, like `load_identity_index`).
pub fn resolve_blueprint_label(&self, name: &str) -> Option<String> {
self.latest_blueprint_labels().remove(name)
}
/// Every registered label, latest-wins-deduped and name-sorted — the
/// discovery surface's data source (`graph introspect --registered`).
/// Dangling entries (content id no longer resolves) are skipped.
pub fn blueprint_labels(&self) -> Vec<(String, String)> {
self.latest_blueprint_labels().into_iter().collect()
}
/// Every content id currently stored in the blueprint store — the
/// candidate list a `use` ref's content-id-PREFIX resolution (#302
/// semantics, #317) filters against, mirroring `list_process_ids`/
/// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")`
/// name the same directory, so `list_doc_ids`'s filename-stem walk
/// applies unchanged.
pub fn list_blueprint_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("blueprints")
}
/// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl`
/// into a name -> content_id map (`BTreeMap` gives the name-sorted order
/// `blueprint_labels` promises for free). A garbage line (torn write,
/// manual edit) is skipped, like every other JSONL sidecar read in this
/// module.
fn latest_blueprint_labels(&self) -> std::collections::BTreeMap<String, String> {
let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else {
return std::collections::BTreeMap::new();
};
let mut map = std::collections::BTreeMap::new();
for raw in text.lines() {
let Ok(line) = serde_json::from_str::<BlueprintNameLine>(raw) else { continue };
if self.get_blueprint(&line.content_id).ok().flatten().is_some() {
map.insert(line.name, line.content_id);
}
}
map
}
/// The content-addressed document-store dir for one document kind —
/// a sibling of the runs store, like `blueprints/`.
fn doc_dir(&self, kind: &str) -> PathBuf {
@@ -256,6 +362,57 @@ impl Registry {
}
}
/// One label->content mapping, one JSON object per line in
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
/// shape, mirrored for the label store.
#[derive(serde::Serialize, serde::Deserialize)]
struct BlueprintNameLine {
name: String,
content_id: String,
}
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
/// after trim, no whitespace, no control characters. String shape only —
/// never content judgement (the C29 discipline `doc_gate` also follows).
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
if name.trim().is_empty() {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
}
if name.chars().any(char::is_whitespace) {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
}
if name.chars().any(|c| c.is_control()) {
return Err(RegistryError::BadLabel {
name: name.to_string(),
rule: "must not contain control characters",
});
}
Ok(())
}
/// C29 walk (#316): the root and every named nested composite must carry a
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
/// before it enters a new composition (fail fast on a doc-less fetched
/// entry) — the one walk, two call sites (register's write-path gate, use's
/// read-path gate), never a second implementation.
pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
let fault = match c.doc.as_deref() {
None => Some(aura_core::DocGateFault::Empty),
Some(d) => aura_core::doc_gate(&c.name, d).err(),
};
if let Some(fault) = fault {
return Err(RegistryError::UndescribedComposite { name: c.name.clone(), fault });
}
for n in &c.nodes {
if let NodeData::Composite(nested) = n {
gate_composite_docs(nested)?;
}
}
Ok(())
}
/// Referential-validation findings for a campaign document. By-identifier
/// and Display-free (the CLI phrases them).
#[derive(Clone, Debug, PartialEq)]
@@ -264,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
@@ -366,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
@@ -894,6 +1074,22 @@ pub enum RegistryError {
NonRMetric(String),
/// A generalization was asked for with fewer than two instruments.
TooFewInstruments(usize),
/// C29 register seam (#316): the canonical bytes offered to the blueprint
/// store do not parse as a canonical blueprint — the gate cannot certify
/// what it cannot read.
MalformedBlueprint(serde_json::Error),
/// C29 register seam (#316): a root or named nested composite entering
/// the store without a gate-passing doc. Registered artifacts are never
/// retroactively invalidated — the gate guards the write path only.
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
/// Label sidecar (#317): a `put_blueprint_label` name failing the
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
/// control characters). By-identifier, naming the violated rule.
BadLabel { name: String, rule: &'static str },
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
/// labels a blueprint the store does not have.
UnknownBlueprint(String),
}
impl fmt::Display for RegistryError {
@@ -917,6 +1113,29 @@ impl fmt::Display for RegistryError {
f,
"a generalization needs >= 2 instruments, got {n}"
),
RegistryError::MalformedBlueprint(e) => {
write!(f, "blueprint: not a canonical blueprint: {e}")
}
RegistryError::UndescribedComposite { name, fault } => match fault {
aura_core::DocGateFault::Empty => write!(
f,
"blueprint: composite `{name}` carries no doc — a registered \
composite describes itself (C29); add a doc line (builder \
.doc(\"...\"), blueprint \"doc\" member, or op-script \
{{\"op\":\"doc\"}}) before register"
),
aura_core::DocGateFault::RestatesName => write!(
f,
"blueprint: composite `{name}` has a doc that merely restates \
its name a registered composite describes itself (C29)"
),
},
RegistryError::BadLabel { name, rule } => {
write!(f, "blueprint label {name:?}: {rule}")
}
RegistryError::UnknownBlueprint(id) => {
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
}
}
}
}
@@ -1067,13 +1286,178 @@ mod tests {
fn blueprint_store_round_trips_by_content_id() {
let reg = Registry::open(temp_family_dir("blueprint_store_round_trip"));
let canonical = r#"{"format_version":1,"input_roles":[],"nodes":[],"edges":[]}"#;
reg.put_blueprint("deadbeef", canonical).expect("put");
reg.put_blueprint_unchecked("deadbeef", canonical).expect("put");
// exact bytes come back, keyed by content id
assert_eq!(reg.get_blueprint("deadbeef").expect("get"), Some(canonical.to_string()));
// an absent id is Ok(None), not an error (treat-as-empty discipline)
assert_eq!(reg.get_blueprint("never-written").expect("get"), None);
}
/// C29 (#316): the store refuses a doc-less root (None and "" alike),
/// a name-restating doc, and admits a described composite. The raw
/// unchecked path still round-trips pre-C29 doc-less bytes — registered
/// artifacts are never retroactively invalidated.
#[test]
fn blueprint_store_gates_docs_at_the_write_boundary() {
let reg = Registry::open(temp_family_dir("blueprint_store_doc_gate"));
let docless = r#"{"format_version":1,"blueprint":{"name":"s","nodes":[]}}"#;
let e = reg.put_blueprint("h1", docless).expect_err("doc-less root must refuse");
assert!(
e.to_string().contains("composite `s` carries no doc"),
"prose names the composite and the rule: {e}"
);
let restated = r#"{"format_version":1,"blueprint":{"name":"echo","doc":"Echo","nodes":[]}}"#;
let e = reg.put_blueprint("h2", restated).expect_err("restated doc must refuse");
assert!(e.to_string().contains("merely restates"), "prose names the rule: {e}");
let described = r#"{"format_version":1,"blueprint":{"name":"s","doc":"a described fixture blueprint","nodes":[]}}"#;
reg.put_blueprint("h3", described).expect("described root registers");
assert_eq!(reg.get_blueprint("h3").expect("get"), Some(described.to_string()));
// never-retroactive witness: a pre-C29 doc-less entry stays readable
reg.put_blueprint_unchecked("h4", docless).expect("raw write");
assert_eq!(reg.get_blueprint("h4").expect("get"), Some(docless.to_string()));
}
/// A doc-less named NESTED composite refuses even when the root is
/// described — the walk covers every named nested composite (C29).
#[test]
fn blueprint_store_gates_nested_composite_docs() {
let reg = Registry::open(temp_family_dir("blueprint_store_nested_doc_gate"));
let nested_docless = r#"{"format_version":1,"blueprint":{"name":"root","doc":"a described root","nodes":[{"composite":{"name":"inner","nodes":[]}}]}}"#;
let e = reg.put_blueprint("h1", nested_docless).expect_err("doc-less nested must refuse");
assert!(
e.to_string().contains("composite `inner` carries no doc"),
"prose names the nested composite: {e}"
);
}
/// A described fixture blueprint, ready for `put_blueprint`'s C29 gate —
/// `put_blueprint_label`'s content-id verification routes through
/// `get_blueprint`, so its target must first be a gate-passing entry.
fn described_fixture_blueprint(name: &str) -> String {
format!(
r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"#
)
}
/// Property (#317): a label round-trips to the blueprint it names, and
/// re-labelling the SAME name repoints it — resolution and the discovery
/// listing both answer with the latest target, never the first.
#[test]
fn blueprint_label_round_trips_latest_wins() {
let reg = Registry::open(temp_family_dir("label_round_trip"));
let a = described_fixture_blueprint("a");
let b = described_fixture_blueprint("b");
reg.put_blueprint("ha", &a).expect("put a");
reg.put_blueprint("hb", &b).expect("put b");
reg.put_blueprint_label("x", "ha").expect("label x -> a");
assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string()));
reg.put_blueprint_label("x", "hb").expect("re-label x -> b");
assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins");
assert_eq!(
reg.blueprint_labels(),
vec![("x".to_string(), "hb".to_string())],
"the discovery listing dedups to the latest target",
);
}
/// Property (#317): the label-shape gate is deterministic string shape
/// only (nonempty after trim, no whitespace, no control characters) —
/// each bad shape refuses with `BadLabel` naming a (non-empty) rule.
#[test]
fn blueprint_label_refuses_bad_shapes() {
let reg = Registry::open(temp_family_dir("label_bad_shapes"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
for bad in ["", " ", "a b", "a\tb"] {
match reg.put_blueprint_label(bad, "h1") {
Err(RegistryError::BadLabel { name, rule }) => {
assert_eq!(name, bad);
assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}");
}
other => panic!("expected BadLabel for {bad:?}, got {other:?}"),
}
}
}
/// Property (#317): a label may only ever point at a blueprint the store
/// actually has — labelling an unregistered content id refuses
/// by-identifier, never silently writing a dangling line.
#[test]
fn blueprint_label_refuses_unknown_content_id() {
let reg = Registry::open(temp_family_dir("label_unknown_id"));
match reg.put_blueprint_label("x", "never-written") {
Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"),
other => panic!("expected UnknownBlueprint, got {other:?}"),
}
assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written");
}
/// Property (#317, #191 spirit): a dangling LATEST line (content id no
/// longer resolves) is skipped by resolution, which falls back to the
/// earlier still-valid line for the same name — latest-wins means
/// latest-VALID-wins.
#[test]
fn blueprint_label_resolution_skips_a_dangling_entry() {
let reg = Registry::open(temp_family_dir("label_dangling"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
reg.put_blueprint_label("x", "h1").expect("label x -> h1");
// hand-append a dangling line after the valid one, mirroring
// `append_identity_index`'s stale-line test shape: the content id it
// names was never registered.
reg.append_blueprint_label_line("x", "never-written").expect("append stale line");
assert_eq!(
reg.resolve_blueprint_label("x"),
Some("h1".to_string()),
"the dangling latest line is skipped; the earlier valid line for \"x\" wins",
);
assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]);
}
/// Property (#317, #276 discipline): N threads appending labels through
/// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed —
/// every successful append surviving as its own intact, resolvable line,
/// none torn or merged (mirrors `concurrent_appends_keep_the_family_
/// store_well_formed`).
#[test]
fn concurrent_label_appends_keep_the_sidecar_well_formed() {
let reg = Registry::open(temp_family_dir("concurrent_label"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|tid| {
let reg = &reg;
scope.spawn(move || {
let mut ok = 0usize;
for iter in 0..iters {
let name = format!("t{tid}-l{iter}");
if reg.put_blueprint_label(&name, "h1").is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
assert_eq!(
reg.blueprint_labels().len(),
total_ok,
"every successful label append must survive as its own intact, resolvable entry",
);
}
#[test]
fn document_stores_round_trip_by_content_id() {
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
@@ -1793,21 +2177,20 @@ mod tests {
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
// A minimal fixture composite with one OPEN param, built from a
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
// plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
// risk_executor*) all route through `LinComb`, whose builder needs a
// structural arity argument and is therefore deliberately absent from
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
// — they cannot round-trip through `blueprint_from_json` with this
// resolver, so this fixture stands in as the "real, open-param,
// vocabulary-loadable composite" the test needs.
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
// independent of that construction channel, so it stands in as the
// "real, open-param, vocabulary-loadable composite" the test needs.
let composite = Composite::new(
"fixture",
vec![aura_strategy::Bias::builder().named("b").into()],
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let space = composite.param_space();
let real = space.first().expect("fixture composite has an open param");
let real_name = real.name.clone();
@@ -1891,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
@@ -1938,6 +2413,7 @@ mod tests {
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test")
}
/// A second, identity-distinct fixture: same shape over the zero-arg
@@ -1952,6 +2428,7 @@ mod tests {
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test")
}
#[test]
@@ -2092,7 +2569,8 @@ mod tests {
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let (_json_b, content_b, identity_twin) = seed_blueprint(&reg, &twin);
assert_eq!(identity, identity_twin, "twins must share one identity id");
assert_ne!(content_a, content_b, "twins must have distinct content ids");
@@ -2147,7 +2625,8 @@ mod tests {
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
assert!(composite.param_space().is_empty(), "fixture is fully bound");
let bound = composite.bound_param_space();
let bound_param = bound.first().expect("fixture has one bound param");
@@ -2240,7 +2719,8 @@ mod tests {
vec![],
vec![],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let space = composite.param_space();
assert_eq!(space.len(), 2, "fixture exposes two open params");
let covered = space[0].name.clone(); // e.g. "b1.scale"
@@ -2313,7 +2793,8 @@ mod tests {
source: Some(aura_core::ScalarKind::F64),
}],
vec![],
);
)
.with_doc("fixture composite for the registry seeder test");
let space = composite.param_space();
let axis = space.first().expect("fixture has an open param").name.clone();
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
+389 -17
View File
@@ -7,8 +7,11 @@
use std::fmt;
use std::fs;
use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use aura_core::{Cell, ScalarKind, Timestamp};
use aura_engine::{ColumnarTrace, RunManifest};
use serde::{Deserialize, Serialize};
@@ -97,24 +100,19 @@ impl TraceStore {
let run_dir = self.dir.join(name);
fs::create_dir_all(&run_dir)?;
for tap in taps {
let path = run_dir.join(format!("{}.json", tap.tap));
let body = serde_json::to_string(tap).map_err(|source| TraceStoreError::Parse {
file: path.display().to_string(),
source,
})?;
fs::write(&path, body)?;
write_tap_file(&run_dir, tap)?;
}
let index = Index {
manifest: manifest.clone(),
taps: taps.iter().map(|t| t.tap.clone()).collect(),
};
let index_path = run_dir.join("index.json");
let body = serde_json::to_string(&index).map_err(|source| TraceStoreError::Parse {
file: index_path.display().to_string(),
source,
})?;
fs::write(&index_path, body)?;
Ok(())
let order: Vec<String> = taps.iter().map(|t| t.tap.clone()).collect();
write_index(&run_dir, manifest, &order)
}
/// Begin a streamed run write under `traces/<name>/` (directories created
/// now; `index.json` only at [`TraceStreamer::finish`] — the crash
/// discipline `read` already honors: no index → `NotFound`).
pub fn begin_run(&self, name: &str) -> Result<TraceStreamer, TraceStoreError> {
let run_dir = self.dir.join(name);
fs::create_dir_all(&run_dir)?;
Ok(TraceStreamer { run_dir })
}
/// Read a run's index + taps back, in index order. A missing run directory (no
@@ -239,6 +237,216 @@ impl TraceStore {
}
}
/// One tap's canonical `<tap>.json` under `run_dir` — the single write shape
/// both `TraceStore::write` and `TraceStreamer::write_full` share.
fn write_tap_file(run_dir: &Path, tap: &ColumnarTrace) -> Result<(), TraceStoreError> {
let path = run_dir.join(format!("{}.json", tap.tap));
let body = serde_json::to_string(tap).map_err(|source| TraceStoreError::Parse {
file: path.display().to_string(),
source,
})?;
fs::write(&path, body)?;
Ok(())
}
/// The run's `index.json` (manifest + tap order) — shared by `write` and
/// `TraceStreamer::finish`. Written last by both paths (crash discipline:
/// no `index.json` → `NotFound`, treat-as-absent).
fn write_index(
run_dir: &Path,
manifest: &RunManifest,
taps: &[String],
) -> Result<(), TraceStoreError> {
let index = Index { manifest: manifest.clone(), taps: taps.to_vec() };
let index_path = run_dir.join("index.json");
let body = serde_json::to_string(&index).map_err(|source| TraceStoreError::Parse {
file: index_path.display().to_string(),
source,
})?;
fs::write(&index_path, body)?;
Ok(())
}
/// A begun streamed run write: hands out per-tap incremental writers and
/// writes the closing `index.json`. Tap order in the index is the caller's
/// (declared-tap order — the caller tracks it; this type keeps no order
/// state so record-writer threads and post-run fold writes can interleave).
pub struct TraceStreamer {
run_dir: PathBuf,
}
impl TraceStreamer {
/// An incremental writer for one tap, opened now. `Send`. Expressed
/// through [`TraceStreamer::register_tap`] so there is exactly one open
/// path and the two cannot drift.
pub fn tap_writer(&self, tap: &str, kind: ScalarKind) -> Result<TapTraceWriter, TraceStoreError> {
self.register_tap(tap, kind).open()
}
/// The deferred twin of [`TraceStreamer::tap_writer`]: everything needed
/// to open one tap's incremental writer later — from inside a consumer
/// node's `initialize`, at run start — without holding the streamer.
/// `Send`; opens no handles until [`TapWriterOpener::open`].
pub fn register_tap(&self, tap: &str, kind: ScalarKind) -> TapWriterOpener {
TapWriterOpener { run_dir: self.run_dir.clone(), tap: tap.to_string(), kind }
}
/// Write one complete tap file at once (the fold path's one-row trace) —
/// the same bytes `TraceStore::write` produces for it.
pub fn write_full(&self, tap: &ColumnarTrace) -> Result<(), TraceStoreError> {
write_tap_file(&self.run_dir, tap)
}
/// Close the run: write `index.json` (manifest + `taps` in caller order).
pub fn finish(self, manifest: &RunManifest, taps: &[String]) -> Result<(), TraceStoreError> {
write_index(&self.run_dir, manifest, taps)
}
}
/// See [`TraceStreamer::register_tap`].
pub struct TapWriterOpener {
run_dir: PathBuf,
tap: String,
kind: ScalarKind,
}
impl TapWriterOpener {
/// Open the two temp column streams and return the incremental writer.
pub fn open(self) -> Result<TapTraceWriter, TraceStoreError> {
let final_path = self.run_dir.join(format!("{}.json", self.tap));
let ts_path = self.run_dir.join(format!("{}.ts.tmp", self.tap));
let col_path = self.run_dir.join(format!("{}.col.tmp", self.tap));
// Read+write handles, NOT `File::create`: `finish` seeks these same
// handles back and `io::copy`-reads them, and a `File::create` handle
// is O_WRONLY — reading it back fails with EBADF (os error 9).
let ts_w = BufWriter::new(
OpenOptions::new().read(true).write(true).create(true).truncate(true).open(&ts_path)?,
);
let col_w = BufWriter::new(
OpenOptions::new().read(true).write(true).create(true).truncate(true).open(&col_path)?,
);
Ok(TapTraceWriter {
tap: self.tap,
kind: self.kind,
final_path,
ts_path,
col_path,
ts_w,
col_w,
any: false,
})
}
}
/// The four-kind tag string (the on-disk `ColumnarTrace.kinds` form). A local
/// copy of `report.rs`'s private `kind_tag`; the streamed-vs-write
/// byte-equality test below is the drift guard.
fn tag(kind: ScalarKind) -> &'static str {
match kind {
ScalarKind::F64 => "F64",
ScalarKind::I64 => "I64",
ScalarKind::Bool => "Bool",
ScalarKind::Timestamp => "Timestamp",
}
}
/// Coerce one cell to the f64 a trace column stores — the same table as
/// `ColumnarTrace::from_rows` (`scalar_to_f64`): f64 as-is, i64 as f64,
/// bool 1.0/0.0, timestamp epoch as f64.
fn cell_to_f64(kind: ScalarKind, cell: Cell) -> f64 {
match kind {
ScalarKind::F64 => cell.f64(),
ScalarKind::I64 => cell.i64() as f64,
ScalarKind::Bool => {
if cell.bool() {
1.0
} else {
0.0
}
}
ScalarKind::Timestamp => cell.ts().0 as f64,
}
}
/// Incremental single-column trace writer: appends each `(ts, cell)` to two
/// temp streams, then `finish` assembles the canonical `ColumnarTrace` JSON
/// by streaming concatenation (constant memory) and removes the temps. The
/// output is byte-equal to `serde_json::to_string(&ColumnarTrace)` for the
/// same rows — pinned by test against `TraceStore::write`, which is the
/// anchor (not a frozen byte-shape assumption).
pub struct TapTraceWriter {
tap: String,
kind: ScalarKind,
final_path: PathBuf,
ts_path: PathBuf,
col_path: PathBuf,
ts_w: BufWriter<File>,
col_w: BufWriter<File>,
any: bool,
}
impl TapTraceWriter {
/// Append one recorded point. Number formatting is `serde_json`'s own
/// (`to_writer` per value) — identical to the whole-struct serialize.
pub fn append(&mut self, ts: Timestamp, cell: Cell) -> Result<(), TraceStoreError> {
if self.any {
self.ts_w.write_all(b",")?;
self.col_w.write_all(b",")?;
}
serde_json::to_writer(&mut self.ts_w, &ts.0).map_err(|source| TraceStoreError::Parse {
file: self.ts_path.display().to_string(),
source,
})?;
serde_json::to_writer(&mut self.col_w, &cell_to_f64(self.kind, cell)).map_err(
|source| TraceStoreError::Parse {
file: self.col_path.display().to_string(),
source,
},
)?;
self.any = true;
Ok(())
}
/// Assemble `{"tap":…,"kinds":[…],"ts":[⟨ts-tmp⟩],"columns":[[⟨col-tmp⟩]]}`
/// and remove the temps.
pub fn finish(self) -> Result<(), TraceStoreError> {
let TapTraceWriter { tap, kind, final_path, ts_path, col_path, ts_w, col_w, any: _ } = self;
let mut ts_f = ts_w.into_inner().map_err(|e| TraceStoreError::Io(e.into_error()))?;
let mut col_f = col_w.into_inner().map_err(|e| TraceStoreError::Io(e.into_error()))?;
ts_f.flush()?;
col_f.flush()?;
ts_f.seek(SeekFrom::Start(0))?;
col_f.seek(SeekFrom::Start(0))?;
let mut out = BufWriter::new(File::create(&final_path)?);
// Field order (tap, kinds, ts, columns) = ColumnarTrace declaration
// order; string fields serialized via serde for correct escaping.
write!(
out,
"{{\"tap\":{},\"kinds\":[{}],\"ts\":[",
serde_json::to_string(&tap).map_err(|source| TraceStoreError::Parse {
file: final_path.display().to_string(),
source,
})?,
serde_json::to_string(tag(kind)).map_err(|source| TraceStoreError::Parse {
file: final_path.display().to_string(),
source,
})?,
)?;
io::copy(&mut ts_f, &mut out)?;
out.write_all(b"],\"columns\":[[")?;
io::copy(&mut col_f, &mut out)?;
out.write_all(b"]]}")?;
out.flush()?;
drop(out);
drop(ts_f);
drop(col_f);
fs::remove_file(&ts_path)?;
fs::remove_file(&col_path)?;
Ok(())
}
}
/// What can go wrong reading or writing the trace store.
#[derive(Debug)]
pub enum TraceStoreError {
@@ -447,4 +655,168 @@ mod tests {
assert!(store.ensure_name_free("fresh", WriteKind::Run).is_ok());
let _ = fs::remove_dir_all(&root);
}
/// The load-bearing compatibility pin: for the same rows, the streamed
/// writer's file bytes equal `TraceStore::write`'s — the legacy path's
/// bytes, whatever they are, define the shape. Covers all four
/// `ScalarKind` arms (f64 fractional/negative, bool, i64, timestamp) —
/// every arm `tag`/`cell_to_f64` dispatch on, since those two helpers are
/// a local copy of `report.rs`'s private `kind_tag`/`scalar_to_f64` and
/// this is the only drift guard against them — plus the empty
/// (never-warm) tap.
#[test]
fn streamed_writer_is_byte_equal_to_write_for_the_same_rows() {
use aura_core::Cell;
type Case<'a> = (&'a str, ScalarKind, Vec<(Timestamp, Cell)>);
let cases: Vec<Case> = vec![
(
"f64_tap",
ScalarKind::F64,
vec![
(Timestamp(2), Cell::from_f64(0.30000000000000004)),
(Timestamp(3), Cell::from_f64(-1.5)),
(Timestamp(5), Cell::from_f64(2.0)),
],
),
(
"bool_tap",
ScalarKind::Bool,
vec![(Timestamp(1), Cell::from_bool(true)), (Timestamp(2), Cell::from_bool(false))],
),
(
"i64_tap",
ScalarKind::I64,
vec![(Timestamp(1), Cell::from_i64(-7)), (Timestamp(2), Cell::from_i64(42))],
),
(
"timestamp_tap",
ScalarKind::Timestamp,
vec![
(Timestamp(1), Cell::from_ts(Timestamp(100))),
(Timestamp(2), Cell::from_ts(Timestamp(-3))),
],
),
("empty_tap", ScalarKind::F64, vec![]),
];
for (tap, kind, rows) in cases {
// legacy path
let legacy_root = temp_traces_root(&format!("byteeq-legacy-{tap}"));
let legacy = TraceStore::open(&legacy_root);
let scalar_rows: Vec<(Timestamp, Vec<Scalar>)> = rows
.iter()
.map(|&(t, c)| (t, vec![Scalar::from_cell(kind, c)]))
.collect();
let trace = ColumnarTrace::from_rows(tap, &[kind], &scalar_rows);
legacy.write("run", &sample_manifest(), &[trace]).expect("legacy write");
let legacy_bytes =
fs::read(legacy_root.join(format!("traces/run/{tap}.json"))).expect("legacy bytes");
// streamed path
let stream_root = temp_traces_root(&format!("byteeq-stream-{tap}"));
let store = TraceStore::open(&stream_root);
let streamer = store.begin_run("run").expect("begin_run");
let mut w = streamer.tap_writer(tap, kind).expect("tap_writer");
for (t, c) in rows {
w.append(t, c).expect("append");
}
w.finish().expect("finish");
let streamed_bytes =
fs::read(stream_root.join(format!("traces/run/{tap}.json"))).expect("streamed bytes");
assert_eq!(
streamed_bytes, legacy_bytes,
"streamed and legacy write bytes must be identical for {tap}"
);
// temps removed
assert!(!stream_root.join(format!("traces/run/{tap}.ts.tmp")).exists());
assert!(!stream_root.join(format!("traces/run/{tap}.col.tmp")).exists());
let _ = fs::remove_dir_all(&legacy_root);
let _ = fs::remove_dir_all(&stream_root);
}
}
/// A begun-but-unfinished streamed run (crash shape: tap file written or
/// half-written, no `index.json`) reads as `NotFound` — treat-as-absent.
#[test]
fn begun_but_unfinished_streamed_run_reads_not_found() {
use aura_core::Cell;
let root = temp_traces_root("stream-crash");
let store = TraceStore::open(&root);
let streamer = store.begin_run("crashed").expect("begin_run");
let mut w = streamer.tap_writer("t", ScalarKind::F64).expect("tap_writer");
w.append(Timestamp(1), Cell::from_f64(1.0)).expect("append");
// no w.finish(), no streamer.finish(): the crash shape.
drop(w);
match store.read("crashed") {
Err(TraceStoreError::NotFound(name)) => assert_eq!(name, "crashed"),
other => panic!("expected NotFound, got {other:?}"),
}
let _ = fs::remove_dir_all(&root);
}
/// `register_tap`/`TapWriterOpener::open` produce the exact same bytes as
/// `tap_writer` for the same rows — the deferred opener and the
/// open-now writer are one write path (`tap_writer` delegates to
/// `register_tap(..).open()`), so they cannot drift.
#[test]
fn register_tap_open_is_the_same_write_path_as_tap_writer() {
use aura_core::Cell;
let root = temp_traces_root("deferred-open");
let store = TraceStore::open(&root);
let rows =
[(Timestamp(2), Cell::from_f64(0.25)), (Timestamp(3), Cell::from_f64(-1.5))];
// run "a": open-now.
let sa = store.begin_run("a").expect("begin a");
let mut wa = sa.tap_writer("t", ScalarKind::F64).expect("open now");
for &(ts, c) in &rows {
wa.append(ts, c).expect("append a");
}
wa.finish().expect("finish a");
// run "b": deferred — the opener is Send and opens at use time.
let sb = store.begin_run("b").expect("begin b");
let opener = sb.register_tap("t", ScalarKind::F64);
fn assert_send<T: Send>(_: &T) {}
assert_send(&opener);
let mut wb = opener.open().expect("deferred open");
for &(ts, c) in &rows {
wb.append(ts, c).expect("append b");
}
wb.finish().expect("finish b");
let a = fs::read(root.join("traces").join("a").join("t.json")).expect("read a");
let b = fs::read(root.join("traces").join("b").join("t.json")).expect("read b");
assert_eq!(a, b, "one open path, identical bytes");
}
/// `TraceStreamer::finish` + `write_full` produce a run `read` returns in
/// caller order — the fold path's one-row trace beside a streamed tap.
#[test]
fn streamer_finish_indexes_streamed_and_full_taps_in_caller_order() {
use aura_core::Cell;
let root = temp_traces_root("stream-index");
let store = TraceStore::open(&root);
let streamer = store.begin_run("mixed").expect("begin_run");
let mut w = streamer.tap_writer("series", ScalarKind::F64).expect("tap_writer");
w.append(Timestamp(1), Cell::from_f64(1.0)).expect("append");
w.finish().expect("finish series");
let fold_row = ColumnarTrace::from_rows(
"agg",
&[ScalarKind::I64],
&[(Timestamp(1), vec![Scalar::i64(1)])],
);
streamer.write_full(&fold_row).expect("write_full");
streamer
.finish(&sample_manifest(), &["series".to_string(), "agg".to_string()])
.expect("streamer finish");
let back = store.read("mixed").expect("read");
let names: Vec<&str> = back.taps.iter().map(|t| t.tap.as_str()).collect();
assert_eq!(names, vec!["series", "agg"], "index order = caller order");
let _ = fs::remove_dir_all(&root);
}
}
+138 -33
View File
@@ -13,7 +13,7 @@
use std::collections::BTreeMap;
use aura_core::{Scalar, ScalarKind};
use aura_core::{doc_gate, DocGateFault, Scalar, ScalarKind};
use serde::{Deserialize, Serialize};
/// The one accepted document format version.
@@ -727,6 +727,14 @@ pub fn content_id_of(canonical: &str) -> String {
// Intrinsic validation (store-free).
// ---------------------------------------------------------------------------
/// A shipped metric with its one-line meaning (C29). One carrier — the
/// #190 guard keeps this list, the RMetrics serde fields, and the CLI
/// count in lockstep; the doc column adds no fourth roster site.
pub struct MetricSchema {
pub id: &'static str,
pub doc: &'static str,
}
/// The closed declared-metric vocabulary: the scalar field names of the
/// shipped metrics/selection types (RMetrics + RunMetrics scalars, plus the
/// FamilySelection annotation scores). Single source for gate/stage checks
@@ -746,25 +754,25 @@ pub fn content_id_of(canonical: &str) -> String {
/// hand when the upstream field sets change (this list stays a hand-copy by
/// the crate's ratified core-only isolation; #147 single-sourced the rankable
/// roster elsewhere and the extended #190 guard pins this list's nesting).
pub fn metric_vocabulary() -> &'static [&'static str] {
pub fn metric_vocabulary() -> &'static [MetricSchema] {
&[
"expectancy_r",
"win_rate",
"avg_win_r",
"avg_loss_r",
"profit_factor",
"max_r_drawdown",
"sqn",
"sqn_normalized",
"net_expectancy_r",
"n_trades",
"n_open_at_end",
"total_pips",
"max_drawdown",
"bias_sign_flips",
"deflated_score",
"overfit_probability",
"neighbourhood_score",
MetricSchema { id: "expectancy_r", doc: "mean realized R per closed trade — the headline E[R]" },
MetricSchema { id: "win_rate", doc: "fraction of closed trades with positive realized R" },
MetricSchema { id: "avg_win_r", doc: "mean realized R over winning trades" },
MetricSchema { id: "avg_loss_r", doc: "mean realized R over losing trades" },
MetricSchema { id: "profit_factor", doc: "gross winning R divided by absolute gross losing R" },
MetricSchema { id: "max_r_drawdown", doc: "deepest peak-to-trough decline of the cumulative R curve" },
MetricSchema { id: "sqn", doc: "System Quality Number: mean R over its std deviation, scaled by trade count" },
MetricSchema { id: "sqn_normalized", doc: "SQN normalized to a 100-trade basis for n-independent comparison" },
MetricSchema { id: "net_expectancy_r", doc: "expectancy net of the cost model's per-trade cost in R" },
MetricSchema { id: "n_trades", doc: "count of closed trades in the run" },
MetricSchema { id: "n_open_at_end", doc: "positions still open when the run ends" },
MetricSchema { id: "total_pips", doc: "summed pip result over all closed trades" },
MetricSchema { id: "max_drawdown", doc: "deepest peak-to-trough decline of the pip equity curve" },
MetricSchema { id: "bias_sign_flips", doc: "count of sign changes in the strategy's bias stream" },
MetricSchema { id: "deflated_score", doc: "selection score deflated for multiple testing against the bootstrap null" },
MetricSchema { id: "overfit_probability", doc: "probability the selected edge is overfit, from the bootstrap null" },
MetricSchema { id: "neighbourhood_score", doc: "plateau robustness: score aggregated over the param-space neighbourhood" },
]
}
@@ -773,12 +781,23 @@ pub fn emit_vocabulary() -> &'static [&'static str] {
&["family_table", "selection_report"]
}
/// A closed-vocabulary persisted-tap slot with its one-line meaning (C29).
pub struct TapSchema {
pub id: &'static str,
pub doc: &'static str,
}
/// The wrap convention's persisted sink names — the closed tap vocabulary
/// (#201 decision 1). A genuinely new observable becomes a new entry or an
/// authored sink in the strategy blueprint (C22: the choice of sinks is part
/// of the experiment) — never an open node-path namespace in the document.
pub fn tap_vocabulary() -> &'static [&'static str] {
&["equity", "exposure", "r_equity", "net_r_equity"]
pub fn tap_vocabulary() -> &'static [TapSchema] {
&[
TapSchema { id: "equity", doc: "cumulative pip equity per cycle" },
TapSchema { id: "exposure", doc: "signed position exposure per cycle" },
TapSchema { id: "r_equity", doc: "cumulative gross R per cycle" },
TapSchema { id: "net_r_equity", doc: "cumulative net R per cycle, after the cost model" },
]
}
/// The closed archive-column vocabulary a campaign `data.bindings` VALUE may
@@ -821,16 +840,24 @@ pub enum DocFault {
UnknownTap { index: usize, tap: String },
UnknownBindingColumn { role: String, column: String },
ProcessRefMustBeContentId,
/// C29 (#316): a document's optional top-level `description` is present
/// but fails the shape gate. Absent is never a fault.
BadDescription { subject: String, fault: DocGateFault },
}
fn is_known_metric(name: &str) -> bool {
metric_vocabulary().contains(&name)
metric_vocabulary().iter().any(|m| m.id == name)
}
/// Intrinsic validation of a process document (P1 constraints included).
/// Returns ALL findings, not the first.
pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
let mut faults = Vec::new();
if let Some(desc) = &doc.description
&& let Err(fault) = doc_gate(&doc.name, desc)
{
faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault });
}
if doc.pipeline.is_empty() {
faults.push(DocFault::EmptyPipeline);
}
@@ -894,6 +921,11 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
/// Returns ALL findings, not the first.
pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
let mut faults = Vec::new();
if let Some(desc) = &doc.description
&& let Err(fault) = doc_gate(&doc.name, desc)
{
faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault });
}
if doc.data.instruments.is_empty() {
faults.push(DocFault::EmptyInstruments);
}
@@ -1000,7 +1032,7 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
}
}
for (i, t) in doc.presentation.persist_taps.iter().enumerate() {
if !tap_vocabulary().contains(&t.as_str()) {
if !tap_vocabulary().iter().any(|k| k.id == t.as_str()) {
faults.push(DocFault::UnknownTap { index: i, tap: t.clone() });
}
}
@@ -1070,7 +1102,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
SlotKind::Strings => "list of strings",
SlotKind::Windows => "list of { from_ms, to_ms }",
SlotKind::Ref => "one of { content_id } | { identity_id }",
SlotKind::ContentRef => "content id of a process document",
SlotKind::ContentRef => "{ content_id }: content id of a process document",
SlotKind::Axes => "map: param name -> { kind, values }",
SlotKind::EmitKinds => "list of: family_table | selection_report",
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
@@ -1269,8 +1301,10 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
}
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
// deliberately narrower than the strategy-ref hint: validate_campaign
// refuses an identity_id process ref, so the guide must not offer it
slots.push(open("process.ref", "required, content id of a process document"));
// refuses an identity_id process ref, so the guide must not offer it.
// The tagged { content_id } shape is stated explicitly (#329) so the
// hint cannot be misread as accepting a bare id string.
slots.push(open("process.ref", "required, { content_id }: content id of a process document"));
}
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
slots.push(open("seed", "required, non-negative integer"));
@@ -1280,7 +1314,7 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
"presentation",
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
),
));
}
@@ -1294,6 +1328,14 @@ fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSl
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
slots.push(open("kind", format!("required, must be \"{kind}\"")));
}
// C29 (#323): the description slot is enumerable like every other
// envelope slot — optional, so only listed while absent.
if v.get("description").is_none() {
slots.push(open(
"description",
"optional, string — a one-line meaning (C29-gated when present)",
));
}
}
#[cfg(test)]
@@ -1438,6 +1480,7 @@ mod tests {
"format_version": 1,
"kind": "campaign",
"name": "ger40-momentum-screen",
"description": "GER40 momentum screen over the fast/slow SMA grid.",
"data": {
"instruments": ["GER40"],
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
@@ -1881,6 +1924,56 @@ mod tests {
assert_eq!(validate_process(&doc), Vec::new());
}
/// C29 (#316): a present document description passes the shape gate;
/// absent is never a fault. Both fault arms fire and carry the doc name.
#[test]
fn document_description_gate_flags_empty_and_restatement() {
let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let mut doc: ProcessDoc = serde_json::from_str(base).expect("fixture parses");
assert!(validate_process(&doc).is_empty(), "no description → no fault");
doc.description = Some(" ".into());
assert!(matches!(
validate_process(&doc).as_slice(),
[DocFault::BadDescription { subject, fault: DocGateFault::Empty }] if subject == "p"
));
doc.description = Some("P".into());
assert!(matches!(
validate_process(&doc).as_slice(),
[DocFault::BadDescription { fault: DocGateFault::RestatesName, .. }]
));
doc.description = Some("walk-forward validation methodology".into());
assert!(validate_process(&doc).is_empty(), "described → clean");
}
/// The campaign side takes the identical gate, exercised over the
/// crate's existing valid campaign fixture (`CAMPAIGN_FIXTURE`, which
/// validates clean — pinned by `validate_campaign_accepts_a_valid_risk_section`).
#[test]
fn campaign_description_gate_mirrors_process() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
assert!(validate_campaign(&doc).is_empty(), "fixture validates clean");
doc.description = Some("".into());
assert!(matches!(
validate_campaign(&doc).as_slice(),
[DocFault::BadDescription { fault: DocGateFault::Empty, .. }]
));
doc.description = Some("GER40 walk-forward over the momentum stack".into());
assert!(validate_campaign(&doc).is_empty(), "described → clean");
}
/// C29 (#316): a present description participates in the content id
/// (absent ⇒ byte-identical is pinned by the existing round-trips).
#[test]
fn document_description_participates_in_the_content_id() {
let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let bare: ProcessDoc = serde_json::from_str(base).unwrap();
let mut described: ProcessDoc = serde_json::from_str(base).unwrap();
described.description = Some("walk-forward validation methodology".into());
let bare_id = content_id_of(&process_to_json(&bare));
let described_id = content_id_of(&process_to_json(&described));
assert_ne!(bare_id, described_id, "description must move the content id");
}
#[test]
fn validate_process_accepts_the_fixture_and_reports_each_fault() {
let ok = parse_process(PROCESS_FIXTURE).unwrap();
@@ -2072,7 +2165,8 @@ mod tests {
/// never an open node-path namespace in the document.
#[test]
fn tap_vocabulary_is_the_wrap_conventions_four_sink_names() {
assert_eq!(tap_vocabulary(), ["equity", "exposure", "r_equity", "net_r_equity"]);
let ids: Vec<&str> = tap_vocabulary().iter().map(|t| t.id).collect();
assert_eq!(ids, ["equity", "exposure", "r_equity", "net_r_equity"]);
}
/// Each `presentation.persist_taps` entry outside `tap_vocabulary()` is an
@@ -2092,7 +2186,7 @@ mod tests {
);
// every in-vocabulary name passes
let mut all = ok.clone();
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.to_string()).collect();
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.id.to_string()).collect();
assert_eq!(validate_campaign(&all), Vec::new());
}
@@ -2157,7 +2251,10 @@ mod tests {
assert_eq!(slot.kind, SlotKind::TapKinds);
assert_eq!(
slot_kind_label(slot.kind),
format!("list of: {}", tap_vocabulary().join(" | "))
format!(
"list of: {}",
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
)
);
assert_eq!(
slot_kind_label(slot.kind),
@@ -2188,7 +2285,7 @@ mod tests {
pres.hint,
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().join(" | ")
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
)
);
assert_eq!(
@@ -2279,6 +2376,14 @@ mod tests {
label.contains("content"),
"process_ref describe must still name the content-id form: {label}"
);
// #329: the label must state the TAGGED shape ({ content_id: ... }) the
// parser actually requires, not read like a bare id string — mirroring
// the `{ content_id } | { identity_id }` bracket notation std::strategy
// already uses, narrowed to the one key process.ref accepts.
assert!(
label.contains("{ content_id }"),
"process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {label}"
);
let strategy = describe_block("std::strategy").expect("strategy describable");
let strat_ref =
@@ -2335,7 +2440,7 @@ mod tests {
"presentation": { "persist_taps": [], "emit": [] } }"#;
let slots = open_slots_campaign(draft).unwrap();
assert!(slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"));
&& s.hint == "required, { content_id }: content id of a process document"));
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
@@ -2371,7 +2476,7 @@ mod tests {
// ...and the `{}` process ref reports the narrower content-id-only hint.
assert!(
slots.iter().any(|s| s.path == "process.ref"
&& s.hint == "required, content id of a process document"),
&& s.hint == "required, { content_id }: content id of a process document"),
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
);
}
+116
View File
@@ -45,6 +45,75 @@ pub fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool {
wrapped == raw || wrapped_to_raw_axis(wrapped) == raw
}
/// (a) #328: the explicit `--axis` acceptance predicate, shared by BOTH
/// intake routes (`validate_and_register_axes`'s real route and the synthetic
/// sweep-verb intake ahead of `run_blueprint_sweep`). RAW is checked FIRST and
/// independently — a legal RAW name (an open param's [`wrapped_to_raw_axis`]
/// suffix, or a bound param's own already-raw name, #203) is accepted
/// outright, so a pathological name that is both raw-legal and wrapped-exact
/// (e.g. an unwrapped param) resolves as raw, never as a translation refusal.
/// Only once that fails is a WRAPPED hit considered (an exact `param_space()`
/// name, or one leading segment stripped off `name` landing on a bound
/// param): that is a translation refusal naming the raw candidate, never a
/// silent alias. Anything matching neither namespace is [`AxisIntake::Unknown`]
/// — the caller's own unmatched-axis prose applies, unchanged.
///
/// Deliberately NOT built on [`raw_matches_wrapped`]: that predicate's own
/// equality branch also matches an EXACT wrapped name (see its doc comment),
/// so using it as the acceptance gate would silently accept the retired form
/// instead of refusing it.
#[derive(Debug, PartialEq, Eq)]
pub enum AxisIntake {
/// A legal RAW name (`--list-axes`'s own namespace, #328) — accept as-is.
Raw,
/// A retired WRAPPED name; the carried string is its translated RAW
/// candidate — never applied silently, only ever surfaced in a refusal.
WrappedRetired(String),
/// Neither namespace: the caller's own unknown-axis prose applies.
Unknown,
}
/// Classify one `--axis` name against `wrapped_open` (the WRAPPED open-param
/// probe, `blueprint_axis_probe(..).param_space()`) and `raw_bound` (the
/// strategy's own `bound_param_space()` names — already RAW, #203). See
/// [`AxisIntake`] for the precedence.
pub fn classify_axis_intake(
name: &str,
wrapped_open: &[ParamSpec],
raw_bound: &HashSet<String>,
) -> AxisIntake {
let is_raw = wrapped_open.iter().any(|p| wrapped_to_raw_axis(&p.name) == name)
|| raw_bound.contains(name);
if is_raw {
return AxisIntake::Raw;
}
let is_wrapped = wrapped_open.iter().any(|p| p.name == name)
|| raw_bound.iter().any(|b| name.split_once('.').map(|(_, rest)| rest) == Some(b.as_str()));
if is_wrapped {
AxisIntake::WrappedRetired(wrapped_to_raw_axis(name).to_string())
} else {
AxisIntake::Unknown
}
}
/// Translate one RAW axis name onto the ONE wrapped `space` slot it
/// suffix-matches ([`raw_matches_wrapped`]), falling back to `raw` unchanged
/// when no wrapped slot matches — the caller's own downstream name
/// resolution (e.g. the sweep terminal's `UnknownKnob`) surfaces that case,
/// never panicked here. #328: the shared translation `blueprint_sweep_family`
/// established for its `SweepBinder::axis` call, extracted so
/// `blueprint_sweep_over`/`validate_axis_grid` (the walkforward synthetic
/// route's own binder calls, which key by the same exact wrapped
/// `param_space()` name) can translate a RAW `--axis` name too instead of
/// requiring the retired wrapped form.
pub fn wrapped_name_of(raw: &str, space: &[ParamSpec]) -> String {
space
.iter()
.find(|p| raw_matches_wrapped(raw, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| raw.to_string())
}
/// Suffix-join each raw campaign axis onto exactly one wrapped param
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
/// node segment yields raw), then require every wrapped slot to be covered.
@@ -139,6 +208,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"]
);
}
}
+250 -34
View File
@@ -15,8 +15,9 @@
//! CLI shell still needs its own for call sites outside any family builder).
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use aura_campaign::{catch_member_panic, member_fault_prose, MemberFault};
use aura_composites::StopRule;
use aura_core::{Cell, ParamSpec, Scalar, Timestamp};
use aura_engine::{
@@ -253,16 +254,30 @@ const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
/// fully-prosed message string (wrapped from `override_paths`' own
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
/// the walkforward path's rejection reaches stderr as bare prose too.
///
/// #328 render-seam fix: `BindError::MissingKnob`/`KindMismatch` carry the
/// WRAPPED knob name (the terminal's own bind-time frame — untouched by this
/// cycle, per the minuted deviation on acceptance-criterion 2). No
/// user-facing surface may print a wrapped axis name, so this renderer strips
/// the one leading node segment (`wrapped_to_raw_axis`, the same convention
/// `axes.rs` defines) before the name ever reaches prose — the fix lives at
/// the RENDER seam only, not in the error type or the resolution machinery.
pub fn render_bind_error(e: &BindError) -> String {
match e {
BindError::MissingKnob(name) => 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::MissingKnob(name) => {
let name = crate::axes::wrapped_to_raw_axis(name);
format!(
"axis {name}: an open param with no axis and no bound default — \
bind it with `--axis {name}=<value>` see `aura sweep <bp> --list-axes`"
)
}
BindError::KindMismatch { knob, expected, got } => {
let knob = crate::axes::wrapped_to_raw_axis(knob);
format!(
"axis {knob}: expected {expected:?}, supplied {got:?} — \
see `aura sweep <bp> --list-axes`"
)
}
BindError::UnknownKnob(msg) => msg.clone(),
BindError::DuplicateBinding(name) => format!(
"axis {name}: bound twice — each param takes exactly one axis — \
@@ -315,7 +330,10 @@ pub fn select_winner(
/// 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.
/// 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 {
@@ -334,6 +352,48 @@ fn axis_grid_probe_report() -> RunReport {
}
}
/// 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
@@ -348,11 +408,17 @@ fn validate_axis_grid(
) -> Result<(), BindError> {
let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
// preflight ahead of this call, mirroring the plain sweep verb) — translate
// each onto the ONE wrapped `space` slot it suffix-matches
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds its
// own `SweepBinder`) before this terminal's exact-name resolution.
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
let mut binder = probe.axis(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), vals.clone());
}
binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ())
}
@@ -417,24 +483,67 @@ pub fn blueprint_sweep_family(
&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.
// seed the named axes: `axes` names are RAW (#328 — refused at intake before
// this fn ever runs), so each is translated to the ONE wrapped `space` slot
// it suffix-matches (`raw_matches_wrapped`, the same convention
// `override_paths` just resolved the override set through) before feeding
// `Composite::axis`/`SweepBinder::axis`, which still key by the exact
// wrapped `param_space()` name. `resolve_axes` name- and kind-checks the
// translated axes at the sweep terminal, so an UnknownKnob / KindMismatch
// is returned, not panicked (translation cannot itself introduce one: every
// incoming name was already validated raw-or-bound at the intake / override
// preflight above).
let wrapped_name_of = |raw: &str| -> String {
space
.iter()
.find(|p| crate::axes::raw_matches_wrapped(raw, &p.name))
.map(|p| p.name.clone())
.unwrap_or_else(|| raw.to_string())
};
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
let mut binder = probe.axis(&wrapped_name_of(first_name), first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
binder = binder.axis(&wrapped_name_of(n), vals.clone());
}
binder
// manifest.params records RAW names (#328): a name-only reshaping of `space`
// (order/kind untouched) fed to `run_blueprint_member`, which only zips it
// against `point` BY POSITION (`zip_params`) — never re-resolves by name —
// so renaming here is purely the manifest's own namespace, with no effect
// on member resolution.
let manifest_space: Vec<ParamSpec> = space
.iter()
.map(|p| ParamSpec { name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(), kind: p.kind })
.collect();
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
// otherwise unwind straight through this sweep to an uncaught exit 101 —
// contained the same way the real/campaign path contains it, via
// `catch_member_panic` + a per-point capture, resolved to the lowest
// enumeration index's message after the sweep joins (thread-order-
// independent, C1).
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
let family = binder
.sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs.
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
match catch_member_panic(|| {
run_blueprint_member(reload(doc), point, &manifest_space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
}) {
Ok(report) => report,
Err(msg) => {
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
axis_grid_probe_report()
}
}
})
// render the sweep terminal's BindError to prose (#247), the fn's String error
// contract — never the raw Debug struct.
.map_err(|e| render_bind_error(&e))
.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
@@ -447,10 +556,19 @@ pub fn blueprint_sweep_family(
/// 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>), BindError> {
) -> 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")
@@ -470,17 +588,33 @@ pub fn blueprint_sweep_over(
let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?;
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
let space = probe.param_space();
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
// preflight ahead of the caller, mirroring the plain sweep verb) —
// translate each onto the ONE wrapped `space` slot it suffix-matches
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds
// its own `SweepBinder`) before this terminal's exact-name resolution.
let mut iter = axes.iter();
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
let mut binder = probe.axis(first_name, first_vals.clone());
let mut binder = probe.axis(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
for (n, vals) in iter {
binder = binder.axis(n, vals.clone());
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), vals.clone());
}
binder.sweep_with_lattice(|point| {
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");
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
})
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
@@ -509,6 +643,37 @@ pub fn run_oos_blueprint(
(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`;
@@ -568,18 +733,45 @@ pub fn blueprint_walkforward_family(
eprintln!("aura: {}", render_bind_error(&e));
std::process::exit(2);
}
walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
// #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); }
};
let (oos_equity, mut oos_report) =
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides);
oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
})
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
@@ -642,11 +834,35 @@ pub fn blueprint_mc_family(
// 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");
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
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
+7
View File
@@ -18,10 +18,17 @@ pub mod member;
pub mod project;
pub mod reproduce;
pub mod runner;
pub mod tap_plan;
pub mod tap_recorder;
pub mod translate;
pub use project::Env;
pub use runner::DefaultMemberRunner;
pub use tap_plan::{
bind_tap_plan, BoundTaps, FoldBuildCtx, FoldEntry, FoldOutput, FoldRegistry, FoldSink, TapPlan,
TapPlanError, TapSubscription,
};
pub use tap_recorder::TapRecorder;
/// A refusal a library function reports instead of exiting the process
/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place
+105 -40
View File
@@ -7,15 +7,14 @@
//! into an `IcReport`) is unrelated to this run path and stays in the CLI
//! shell (`dispatch_measure_ic`), which assembles and prints that DTO.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::mpsc;
use std::collections::BTreeMap;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{ColumnarTrace, Composite, Harness, MeasurementReport, RunManifest, TapBindError};
use aura_std::Recorder;
use aura_core::{Scalar, Timestamp};
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData};
use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, RunData};
use crate::project::Env;
use crate::tap_plan::{bind_tap_plan, TapPlan};
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit`
@@ -56,10 +55,11 @@ pub fn measurement_manifest(
/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27).
/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this
/// is where the measured O(cycles) retention is removed. The tap machinery is
/// duplicated (not extracted) so `run_signal_r` stays byte-identical.
#[allow(clippy::type_complexity)]
/// the shared `bind_tap_plan`/`BoundTaps` pair — one wiring for both entry
/// points, so they cannot drift (#283).
pub fn run_measurement(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan,
) -> MeasurementReport {
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
@@ -79,7 +79,7 @@ pub fn run_measurement(
std::process::exit(1);
}
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
let defaults = wrapped_bound_defaults(&signal);
let 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.
@@ -88,22 +88,12 @@ pub fn run_measurement(
std::process::exit(1);
});
// Bind each declared tap to a Recorder (mirrors run_signal_r).
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec<Scalar>)>)> = Vec::new();
let declared: Vec<aura_engine::FlatTap> = flat.taps.clone();
for tap in &declared {
if !seen.insert(tap.name.clone()) {
eprintln!("aura: {}", TapBindError::DuplicateBind { name: tap.name.clone() });
std::process::exit(1);
}
let kind = flat.signatures[tap.node].output[tap.field].kind;
let (tx, rx) = mpsc::channel();
let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone();
flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig)
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
tap_drains.push((tap.name.clone(), kind, rx));
}
// Bind each declared tap per the plan's subscription (mirrors
// run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror).
let bound = bind_tap_plan(&mut flat, plan, env, &run_name).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
let mut h = Harness::bootstrap(flat).expect("valid measurement harness");
h.run_bound(key_supply(&binding, sources))
@@ -116,20 +106,95 @@ pub fn run_measurement(
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
// Drain + persist each declared tap (mirrors run_signal_r).
let tap_names: Vec<String> = tap_drains.iter().map(|(n, _, _)| n.clone()).collect();
if !tap_drains.is_empty() {
let tap_traces: Vec<ColumnarTrace> = tap_drains
.into_iter()
.map(|(name, kind, rx)| {
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
ColumnarTrace::from_rows(&name, &[kind], &rows)
})
.collect();
env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| {
eprintln!("aura: writing tap traces failed: {e}");
std::process::exit(1);
});
}
// Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
let tap_names: Vec<String> = bound.declared_names().to_vec();
bound.finish(&manifest).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
MeasurementReport { manifest, taps: tap_names }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::ProjectEnv;
use crate::tap_plan::TapSubscription;
use aura_engine::blueprint_from_json;
use aura_vocabulary::std_vocabulary;
use std::path::PathBuf;
/// A fresh project root so the run's `runs/` lands in a temp dir.
fn temp_project_env(name: &str) -> (Env, PathBuf) {
let root = std::env::temp_dir()
.join(format!("aura-measure-plan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp project root");
let env = Env::with_project(ProjectEnv {
root: root.clone(),
toml: Default::default(),
commit: None,
native: None,
});
(env, root)
}
/// The shipped `examples/r_sma.json` with one declared tap on node 0
/// ("fast", SMA(length=2)) field 0 — the same patch shape as
/// `aura-cli/tests/tap_recording.rs`.
fn tapped_r_sma() -> Composite {
let doc = include_str!("../../aura-cli/examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(doc).expect("parse r_sma.json");
v["blueprint"]["taps"] =
serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
let patched = serde_json::to_string(&v).expect("re-serialize");
blueprint_from_json(&patched, &|t| std_vocabulary(t)).expect("tapped r_sma loads")
}
/// The exact synthetic price array the `RunData::Synthetic` path feeds
/// (`r_sma_prices()`, main.rs) — duplicated so the fold value is pinned
/// against an independently-derived expectation, not a blind capture
/// (the `tap_recording.rs` discipline).
const R_SMA_PRICES: [f64; 18] = [
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
];
#[test]
fn measurement_fold_plan_writes_one_row_trace_through_the_shared_pair() {
let (env, root) = temp_project_env("fold");
let mut plan = TapPlan::empty();
plan.subscribe("fast_tap", TapSubscription::named("mean"));
let report = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan);
assert_eq!(report.taps, vec!["fast_tap".to_string()]);
let text = std::fs::read_to_string(
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("one-row fold trace persisted");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
assert_eq!(v["kinds"], serde_json::json!(["F64"]));
let ts = v["ts"].as_array().expect("ts");
let col = v["columns"][0].as_array().expect("col");
assert_eq!((ts.len(), col.len()), (1, 1), "exactly one summary row");
// SMA(2) series (warm from sample 2), folded mean — sequential sum,
// same operation order as FoldState (bit-identical, IEEE-754).
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
.collect();
let mean = sma.iter().sum::<f64>() / sma.len() as f64;
assert_eq!(col[0].as_f64().expect("f64 row"), mean, "streamed mean == slice mean");
assert_eq!(ts[0].as_i64().expect("ts"), R_SMA_PRICES.len() as i64, "last folded ts");
}
#[test]
fn measurement_tap_free_blueprint_writes_nothing() {
let (env, root) = temp_project_env("tapfree");
let doc = include_str!("../../aura-cli/examples/r_sma.json");
let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads");
let report =
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
assert!(report.taps.is_empty(), "no declared taps");
assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry");
}
}
+224 -87
View File
@@ -9,14 +9,14 @@
//! `CliMemberRunner`, and any downstream World program) drive real data
//! through.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::collections::{BTreeMap, HashSet};
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, ColumnarTrace, Composite, GraphBuilder, Harness,
RunManifest, VecSource,
blueprint_from_json, window_of, BlueprintNode, Composite, GraphBuilder, Harness, RunManifest,
VecSource,
};
use aura_backtest::{
summarize, summarize_r, RunMetrics, RunReport, SimBroker, PM_FIELD_NAMES, PM_RECORD_KINDS,
@@ -26,6 +26,7 @@ use aura_strategy::{cost_port, GEOMETRY_WIDTH};
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};
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
@@ -81,22 +82,20 @@ 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 honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor
@@ -326,7 +325,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)),
);
@@ -410,7 +409,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))
@@ -480,6 +479,7 @@ pub fn resolve_run_data(
#[allow(clippy::type_complexity)]
pub fn run_signal_r(
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
plan: TapPlan,
) -> RunReport {
// topology_hash's own two-line body, inlined: `content_id_of` over the
// canonical (#164) blueprint JSON — the CLI shell's `topology_hash`
@@ -504,7 +504,7 @@ pub fn run_signal_r(
.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();
@@ -517,25 +517,14 @@ pub fn run_signal_r(
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
std::process::exit(1);
});
// Bind each declared tap to a fresh `Recorder` + channel, before bootstrap
// — `flat.taps` already carries the signal's declared taps hoisted to the
// root. Dedup is the caller's per `TapBindError::DuplicateBind`'s doc (the
// engine keeps no cross-call state).
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec<Scalar>)>)> = Vec::new();
let declared: Vec<aura_engine::FlatTap> = flat.taps.clone();
for tap in &declared {
if !seen.insert(tap.name.clone()) {
eprintln!("aura: {}", aura_engine::TapBindError::DuplicateBind { name: tap.name.clone() });
std::process::exit(1);
}
let kind = flat.signatures[tap.node].output[tap.field].kind;
let (tx, rx) = mpsc::channel();
let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone();
flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig)
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
tap_drains.push((tap.name.clone(), kind, rx));
}
// 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);
});
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
@@ -557,23 +546,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, &[]));
// Drain + persist each declared tap's series, guarded so a tap-free
// `aura run` stays byte-identical to today (no `runs/` write at all).
// `manifest` is built above so the persisted `index.json` carries this
// run's own provenance.
if !tap_drains.is_empty() {
let tap_traces: Vec<ColumnarTrace> = tap_drains
.into_iter()
.map(|(name, kind, rx)| {
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
ColumnarTrace::from_rows(&name, &[kind], &rows)
})
.collect();
env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| {
eprintln!("aura: writing tap traces failed: {e}");
std::process::exit(1);
});
}
// 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 }
}
@@ -599,7 +578,7 @@ pub fn run_blueprint_member(
cost: &[aura_research::CostSpec],
instrument: &str,
) -> RunReport {
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();
@@ -725,55 +704,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 sweep <bp> --list-axes`"
));
}
}
}
Ok(overrides)
@@ -801,6 +797,7 @@ pub fn reopen_all(signal: Composite, overrides: &[String]) -> Composite {
fn r_breakout_signal(channel: Option<i64>) -> Composite {
use aura_std::{Delay, Gt, Latch};
let mut g = GraphBuilder::new("r_breakout_signal");
g.doc("rolling-extreme breakout latched into a long/short signal");
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let mut mx_b = RollingMax::builder().named("channel_hi");
let mut mn_b = RollingMin::builder().named("channel_lo");
@@ -846,6 +843,7 @@ fn r_breakout_signal(channel: Option<i64>) -> Composite {
fn r_meanrev_signal(window: Option<i64>, band_k: Option<f64>) -> Composite {
use aura_std::{Add, Ema, Gt, Latch, Mul, Scale, Sqrt};
let mut g = GraphBuilder::new("r_meanrev_signal");
g.doc("EMA deviation against a volatility band, latched into a mean-reversion signal");
let (mut mean_b, mut var_b) =
(Ema::builder().named("mean_window"), Ema::builder().named("var_window"));
if let Some(n) = window {
@@ -910,6 +908,7 @@ fn r_meanrev_signal(window: Option<i64>, band_k: Option<f64>) -> Composite {
fn r_channel_signal(channel: Option<i64>) -> Composite {
use aura_std::{Delay, Gt, Latch};
let mut g = GraphBuilder::new("hl_channel");
g.doc("prior high/low channel breaks latched into a directional signal");
let delay_hi = g.add(Delay::builder().named("prev_high").bind("lag", Scalar::i64(1)));
let delay_lo = g.add(Delay::builder().named("prev_low").bind("lag", Scalar::i64(1)));
let mut mx_b = RollingMax::builder().named("channel_hi");
@@ -954,6 +953,9 @@ mod tests {
use aura_engine::blueprint_to_json;
use aura_strategy::{ConstantCost, VolSlippageCost};
use aura_vocabulary::std_vocabulary;
use crate::tap_plan::{bind_tap_plan, TapPlanError, TapSubscription};
use crate::project::ProjectEnv;
use std::path::{Path, PathBuf};
#[test]
/// Independently pins the shipped `r_meanrev_signal` carve's FADE direction —
@@ -1158,8 +1160,8 @@ 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);
let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env);
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());
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
}
@@ -1311,13 +1313,14 @@ 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);
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
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(),
);
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
}
@@ -1646,4 +1649,138 @@ mod tests {
);
}
/// 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()
.join(format!("aura-member-plan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp project root");
let env = Env::with_project(ProjectEnv {
root: root.clone(),
toml: Default::default(),
commit: None,
native: None,
});
(env, root)
}
/// The shipped `examples/r_sma.json` with one declared tap on node 0
/// ("fast", SMA(length=2)) field 0 — the `tap_recording.rs` patch shape.
/// `Composite` is `!Clone`, so each run loads it afresh.
fn tapped_r_sma() -> Composite {
let doc = include_str!("../../aura-cli/examples/r_sma.json");
let mut v: serde_json::Value = serde_json::from_str(doc).expect("parse r_sma.json");
v["blueprint"]["taps"] =
serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
let patched = serde_json::to_string(&v).expect("re-serialize");
blueprint_from_json(&patched, &|t| std_vocabulary(t)).expect("tapped r_sma loads")
}
fn read_tap_json(root: &Path) -> serde_json::Value {
let text = std::fs::read_to_string(
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("persisted tap trace");
serde_json::from_str(&text).expect("parse tap trace")
}
/// #283 acceptance: a fold plan's one-row summary and a live plan's
/// collected stream both agree with the recorded series of the same
/// blueprint (C1 — three identical runs, three drain policies), and a
/// live-only plan persists nothing at all.
#[test]
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());
let series = read_tap_json(&root_a);
let ts: Vec<i64> =
series["ts"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
let vals: Vec<f64> =
series["columns"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(!vals.is_empty(), "the recorded series is the reference");
// Run B: fold to mean (the aggregate question, same blueprint).
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);
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()]));
assert_eq!(row["columns"], serde_json::json!([[mean]]), "fold row == slice mean, bit-exact");
// Run C: live only (nothing persisted; the closure sees the series).
let (env_c, root_c) = temp_project_env("live");
let (live_tx, live_rx) = std::sync::mpsc::channel();
let mut plan_c = TapPlan::empty();
plan_c.subscribe(
"fast_tap",
TapSubscription::live(move |ts, cell| {
let _ = live_tx.send((ts.0, cell.f64()));
}),
);
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_c, plan_c);
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)");
assert!(!root_c.join("runs").exists(), "a live-only plan persists nothing");
}
/// Plan refusals are typed and fire before any store I/O.
#[test]
fn bind_tap_plan_refuses_unknown_tap_and_unknown_label_before_the_store() {
let (env, root) = temp_project_env("refusals");
// Unknown tap name.
let mut flat = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
let mut plan = TapPlan::record_all();
plan.subscribe("no_such_tap", TapSubscription::named("mean"));
// `BoundTaps` (the `Ok` side) carries no `Debug`, so `expect_err` is
// unavailable here — match explicitly instead (same refusal intent).
let err = match bind_tap_plan(&mut flat, plan, &env, "sma_signal") {
Err(e) => e,
Ok(_) => panic!("unknown tap must be refused"),
};
assert!(
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");
let mut plan2 = TapPlan::record_all();
plan2.subscribe("fast_tap", TapSubscription::named("p95"));
let err2 = match bind_tap_plan(&mut flat2, plan2, &env, "sma_signal") {
Err(e) => e,
Ok(_) => panic!("unknown label must be refused"),
};
let msg = err2.to_string();
assert!(msg.starts_with("unknown fold 'p95'") && msg.contains("mean"), "{msg}");
assert!(!root.join("runs").exists(), "refusals fire before any store write");
}
/// C1: two identical record-all runs produce byte-identical tap files.
#[test]
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());
let a = std::fs::read(
root_a.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("run a trace");
let b = std::fs::read(
root_b.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
)
.expect("run b trace");
assert_eq!(a, b, "same input, same bytes (the index carries env provenance; the tap file must not)");
}
}
+61 -2
View File
@@ -7,7 +7,7 @@
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
//! merged resolver, runs root, data path, provenance.
use aura_core::PrimitiveBuilder;
use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate};
use aura_core::project::{
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
@@ -63,6 +63,11 @@ pub enum ProjectError {
/// handing off to `load_crate`, so this never reaches the cargo-metadata
/// probe (whose raw os-error text would otherwise leak through).
PointerDirMissing(PathBuf),
/// C29 load seam (#316): a resolved extension-vocabulary entry whose doc
/// fails the shape gate — the load refuses rather than admit an
/// undescribed node. Field presence is compile-enforced; shape is not,
/// so the gate carries the failed rule for the refusal prose.
UndescribedVocabularyEntry { type_id: String, fault: DocGateFault },
}
impl fmt::Display for ProjectError {
@@ -100,6 +105,19 @@ impl fmt::Display for ProjectError {
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
),
Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
Self::UndescribedVocabularyEntry { type_id, fault } => {
let rule = match fault {
DocGateFault::Empty => "has an empty doc",
DocGateFault::RestatesName => {
"has a doc that merely restates its name"
}
};
write!(
f,
"vocabulary entry `{type_id}` {rule} — every vocabulary \
entry ships a one-line meaning (C29)"
)
}
}
}
}
@@ -444,7 +462,7 @@ fn stale_warning(
) -> Option<String> {
if source_mtime > dylib_mtime {
Some(format!(
"warning: {} may be stale — dylib built {} but a project source \
"aura: warning: {} may be stale — dylib built {} but a project source \
file was modified {} (run `cargo build` to refresh); proceeding \
with the existing dylib",
dylib_path.display(),
@@ -579,6 +597,18 @@ fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectErro
let type_id_list = (desc.type_ids)();
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
// C29 load seam (#316): every resolved entry must describe itself. The
// charter cross-check above guarantees each listed id resolves, so a
// `None` here is unreachable — skipped rather than panicked on (a
// hostile dylib must never panic the host).
for &t in type_id_list {
if let Some(builder) = resolver(t) {
doc_gate(t, builder.schema().doc).map_err(|fault| {
ProjectError::UndescribedVocabularyEntry { type_id: t.to_string(), fault }
})?;
}
}
Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
}
@@ -886,6 +916,7 @@ mod tests {
let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
let path = PathBuf::from("/tmp/x.so");
let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
assert!(msg.starts_with("aura: warning: "), "carries the warning class marker (#278): {msg}");
assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
}
@@ -903,4 +934,32 @@ mod tests {
"older source: not stale"
);
}
/// The C29 load-seam refusal prose (`UndescribedVocabularyEntry`) names
/// the entry and the failed rule for BOTH `DocGateFault` arms. Only
/// `Empty` is reachable end-to-end today (the fixture in
/// `aura-cli/tests/project_load.rs` uses `doc: ""`); `RestatesName` has
/// no such fixture, so this pins its rendered message directly against
/// `Display` — a regression there would otherwise pass every test.
#[test]
fn undescribed_vocabulary_entry_display_names_the_entry_and_rule() {
let empty = ProjectError::UndescribedVocabularyEntry {
type_id: "ns::Foo".to_string(),
fault: DocGateFault::Empty,
};
assert_eq!(
empty.to_string(),
"vocabulary entry `ns::Foo` has an empty doc — every vocabulary \
entry ships a one-line meaning (C29)"
);
let restates = ProjectError::UndescribedVocabularyEntry {
type_id: "ns::Foo".to_string(),
fault: DocGateFault::RestatesName,
};
assert_eq!(
restates.to_string(),
"vocabulary entry `ns::Foo` has a doc that merely restates its \
name every vocabulary entry ships a one-line meaning (C29)"
);
}
}
+26 -1
View File
@@ -9,6 +9,7 @@
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;
@@ -142,7 +143,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
+88 -7
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,
@@ -247,7 +262,7 @@ pub fn tap_channel(tap: &str) -> Option<TapChannel> {
debug_assert!(
aura_research::tap_vocabulary()
.iter()
.all(|t| matches!(*t, "equity" | "exposure" | "r_equity" | "net_r_equity")),
.all(|t| matches!(t.id, "equity" | "exposure" | "r_equity" | "net_r_equity")),
"tap_vocabulary drifted from the channels persist_campaign_traces routes"
);
match tap {
@@ -259,6 +274,28 @@ pub fn tap_channel(tap: &str) -> Option<TapChannel> {
}
}
/// The note text for a requested tap outside the closed vocabulary (#278):
/// pure over just the tap name, so the exact marker-bearing text
/// `persist_campaign_traces` writes to stderr is unit-testable without
/// booting a real archive run (the `tap_channel` `None` arm that reaches
/// this is defensive — `validate_campaign` already refuses an unknown tap
/// name before this function ever runs).
fn tap_not_produced_note(tap: &str) -> String {
format!("aura: note: tap \"{tap}\" is not produced by this run; skipped")
}
/// The note text for a cell with neither a nominee nor a non-empty terminal
/// family (#278): pure over the record's own cell fields, so the exact
/// marker-bearing text `persist_campaign_traces` writes to stderr is
/// unit-testable without booting a real archive run — the `cell_member_fanout`
/// twin above already covers the emptiness logic itself.
fn no_nominee_note(strategy: &str, instrument: &str, window_ms: (i64, i64)) -> String {
format!(
"aura: note: cell {strategy}/{instrument}/[{}, {}]: no nominee; no traces persisted",
window_ms.0, window_ms.1
)
}
/// The content-derived on-disk key of one campaign cell under
/// `traces/<trace_name>/` — the `member_key` discipline (content, never a
/// runtime ordinal): strategy content-id prefix + instrument +
@@ -368,10 +405,10 @@ pub fn persist_campaign_traces(
for tap in taps {
match tap_channel(tap) {
Some(TapChannel::Net) if campaign.cost.is_empty() => eprintln!(
"aura: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped"
"aura: note: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped"
),
Some(ch) => routed.push((tap.as_str(), ch)),
None => eprintln!("aura: tap \"{tap}\" is not produced by this run; skipped"),
None => eprintln!("{}", tap_not_produced_note(tap)),
}
}
@@ -388,8 +425,8 @@ pub fn persist_campaign_traces(
let members = cell_member_fanout(cell_out);
if members.is_empty() {
eprintln!(
"aura: cell {}/{}/[{}, {}]: no nominee; no traces persisted",
cell_rec.strategy, cell_rec.instrument, cell_rec.window_ms.0, cell_rec.window_ms.1
"{}",
no_nominee_note(&cell_rec.strategy, &cell_rec.instrument, cell_rec.window_ms)
);
continue;
}
@@ -468,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!(
@@ -753,4 +811,27 @@ mod tests {
assert_eq!(tap_channel("net_r_equity"), Some(TapChannel::Net));
assert_eq!(tap_channel("bogus"), None);
}
#[test]
/// #278: the unrecognized-tap note carries the `note` class marker AND
/// names the offending tap — the exact text `persist_campaign_traces`
/// writes to stderr on `tap_channel`'s defensive `None` arm.
fn tap_not_produced_note_carries_the_note_marker_and_names_the_tap() {
let msg = tap_not_produced_note("bogus");
assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}");
assert!(msg.contains("\"bogus\""), "names the offending tap: {msg}");
assert!(msg.contains("is not produced by this run"), "states the reason: {msg}");
}
#[test]
/// #278: the no-candidate-cell note carries the `note` class marker AND
/// names the cell (strategy/instrument/window) — the exact text
/// `persist_campaign_traces` writes to stderr when `cell_member_fanout`
/// (unit-tested above) reports no nominee and no non-empty family.
fn no_nominee_note_carries_the_note_marker_and_names_the_cell() {
let msg = no_nominee_note("bb34aa55", "GER40", (1_000, 2_000));
assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}");
assert!(msg.contains("bb34aa55/GER40/[1000, 2000]"), "names the cell: {msg}");
assert!(msg.contains("no nominee; no traces persisted"), "states the reason: {msg}");
}
}
+720
View File
@@ -0,0 +1,720 @@
//! The per-run tap subscription plan (#283, 2026-07-21 design revision) and
//! the layered fold registry it resolves against. A tap stays a pure
//! declaration (C27); this module is where the run/measurement side declares
//! what consumes it. Record, fold, and live are ONE mechanism — a consumer
//! of the tap's `(Timestamp, Cell)` stream; the only real axis is data-layer
//! serializability: a `Named` subscription (label + scalar params) is fully
//! expressible as data, a `Live` closure is the single deliberately non-data
//! variant. The registry mirrors the node-vocabulary pattern
//! (`std_vocabulary` + `Env::resolve`): a closed, typed vocabulary whose
//! growth is a new Rust entry (C25) — core seeds here, higher layers
//! (e.g. trading) register their own entries without bleeding into the core.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::mpsc::{self, Receiver, Sender};
use aura_core::{Cell, Firing, Node, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp};
use aura_engine::{ColumnarTrace, FlatGraph, RunManifest, TapBindError};
use aura_registry::{TapWriterOpener, TraceStoreError, TraceStreamer};
use aura_std::{fold_binds_at, fold_output_kind, FoldKind, TapFold, TapLive};
use crate::project::Env;
use crate::tap_recorder::TapRecorder;
/// What consumes one declared tap during a run — the drain policy, declared
/// on the run/measurement side (#283), never on the tap.
pub enum TapSubscription {
/// A consumer from the plan's fold registry, by label, with scalar-typed
/// construction parameters (v1 core entries take none).
Named { label: String, params: Vec<(String, Scalar)> },
/// An in-process consumer; loss policy is the closure's own.
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
}
impl TapSubscription {
/// A named subscription with no construction parameters.
pub fn named(label: &str) -> Self {
TapSubscription::Named { label: label.to_string(), params: Vec::new() }
}
/// A named subscription with construction parameters.
pub fn named_with(label: &str, params: Vec<(String, Scalar)>) -> Self {
TapSubscription::Named { label: label.to_string(), params }
}
/// Sugar for a live subscription around a closure.
pub fn live(consumer: impl FnMut(Timestamp, Cell) + Send + 'static) -> Self {
TapSubscription::Live(Box::new(consumer))
}
}
/// Per-run tap plan: name → subscription; unnamed taps get the default. The
/// plan carries the registry it resolves against (the `Env`-at-resolve
/// pattern), so a layered host injects its extended vocabulary as data. The
/// only defaults are `named("record")` (`record_all` — the one entry that
/// binds at every kind and takes no params) and `None` (empty plan; unnamed
/// taps stay unbound, inert per C27) — fold/live subscriptions are per-tap,
/// never a default, structurally (the field is private and no constructor
/// sets another).
pub struct TapPlan {
pub(crate) registry: FoldRegistry,
pub(crate) default_named: Option<(String, Vec<(String, Scalar)>)>,
pub(crate) by_name: BTreeMap<String, TapSubscription>,
}
impl TapPlan {
/// Record every declared tap — the CLI's plan on both verbs.
pub fn record_all() -> Self {
Self::record_all_with(FoldRegistry::core())
}
/// `record_all` over an injected (layered) registry.
pub fn record_all_with(registry: FoldRegistry) -> Self {
TapPlan {
registry,
default_named: Some(("record".to_string(), Vec::new())),
by_name: BTreeMap::new(),
}
}
/// No default: only explicitly subscribed taps are consumed.
pub fn empty() -> Self {
TapPlan { registry: FoldRegistry::core(), default_named: None, by_name: BTreeMap::new() }
}
/// Subscribe one tap by name (replaces an earlier subscription for it).
pub fn subscribe(&mut self, tap: &str, sub: TapSubscription) {
self.by_name.insert(tap.to_string(), sub);
}
}
/// Persistence shape of an entry's consumer.
pub enum FoldOutput {
/// The full series, streamed (the `record` entry).
Series,
/// One summary row at finalize, of this kind.
Row(ScalarKind),
}
/// The sink a build call receives — matching the entry's declared
/// [`FoldOutput`]: `Row` entries get the one-row channel, `Series` entries
/// the deferred writer opener + terminal-outcome channel.
pub enum FoldSink {
Row(Sender<(Timestamp, Vec<Scalar>)>),
Series(TapWriterOpener, Sender<Result<(), TraceStoreError>>),
}
/// What the wiring hands a registry entry's `build`.
pub struct FoldBuildCtx {
/// The tap's column kind (already `binds_at`-validated).
pub kind: ScalarKind,
/// Construction parameters, already validated against the entry's schema.
pub params: Vec<(String, Scalar)>,
pub sink: FoldSink,
}
/// One registry entry: a labelled consumer constructor with docs, a param
/// schema, bind rules, and its persistence shape.
pub struct FoldEntry {
pub label: &'static str,
/// One line for help generation and roster-enumerating refusals.
pub doc: &'static str,
/// Scalar-typed construction parameters (the node vocabulary's param
/// plane). All v1 core entries: empty — the seam ships now because it
/// sits in every entry's build signature (C25: a param that would carry
/// logic is a new entry, never a freetext hole).
pub params: Vec<ParamSpec>,
/// May this entry bind a tap column of this kind?
pub binds_at: Box<dyn Fn(ScalarKind) -> bool>,
/// What the consumer persists, given the tap's column kind.
pub output: Box<dyn Fn(ScalarKind) -> FoldOutput>,
/// Build the consumer node. `ctx.params` arrive validated; `ctx.sink`
/// matches `output` (the wiring consults `output` first — an entry may
/// `panic!` on the wrong variant as a wiring-contract violation).
pub build: Box<dyn Fn(FoldBuildCtx) -> Box<dyn Node>>,
}
/// The label → entry map. Layered: `core()` seeds the standard vocabulary;
/// `register` adds (or deliberately shadows) an entry — dependency
/// injection, no core edit.
pub struct FoldRegistry {
entries: BTreeMap<&'static str, FoldEntry>,
}
impl FoldRegistry {
/// The core vocabulary: `record`, `count`, `sum`, `mean`, `min`, `max`,
/// `first`, `last`.
pub fn core() -> Self {
let mut r = FoldRegistry { entries: BTreeMap::new() };
r.register(FoldEntry {
label: "record",
doc: "persist the full series, lossless, at constant memory (any kind)",
params: Vec::new(),
binds_at: Box::new(|_| true),
output: Box::new(|_| FoldOutput::Series),
build: Box::new(|ctx| {
let FoldSink::Series(opener, outcome_tx) = ctx.sink else {
panic!("wiring contract: record is a Series entry");
};
Box::new(TapRecorder::new(ctx.kind, opener, outcome_tx))
}),
});
for (label, doc, fold) in [
("count", "number of warm rows (any kind; i64 row); one row at the last warm ts", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
(
"mean",
"arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
FoldKind::Mean,
),
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
(
"first",
"first warm value, at its own timestamp (any kind; kind-preserving row)",
FoldKind::First,
),
("last", "last warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::Last),
] {
r.register(FoldEntry {
label,
doc,
params: Vec::new(),
binds_at: Box::new(move |k| fold_binds_at(fold, k)),
output: Box::new(move |k| FoldOutput::Row(fold_output_kind(fold, k))),
build: Box::new(move |ctx| {
let FoldSink::Row(tx) = ctx.sink else {
panic!("wiring contract: {fold:?} is a Row entry");
};
Box::new(TapFold::new(ctx.kind, fold, tx))
}),
});
}
r
}
/// Add an entry. A later registration under an existing label replaces
/// it (layers may deliberately shadow).
pub fn register(&mut self, entry: FoldEntry) {
self.entries.insert(entry.label, entry);
}
pub fn get(&self, label: &str) -> Option<&FoldEntry> {
self.entries.get(label)
}
/// `(label, doc)` in label order — the help surface and the refusal
/// roster.
pub fn roster(&self) -> Vec<(&'static str, &'static str)> {
self.entries.values().map(|e| (e.label, e.doc)).collect()
}
/// The labels alone, label order (refusal messages).
pub fn labels(&self) -> Vec<&'static str> {
self.entries.keys().copied().collect()
}
}
/// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal
/// store fault. Entry points map every variant to the established
/// `aura: ` + exit-1 refusal register via `Display`.
pub enum TapPlanError {
/// The plan names a tap the blueprint does not declare.
UnknownTap { name: String, declared: Vec<String> },
/// The plan names a label the registry does not carry.
UnknownLabel { label: String, roster: Vec<&'static str> },
/// The entry's bind rule rejects the tap's column kind.
KindMismatch { tap: String, label: String, kind: ScalarKind },
/// A param binding names no schema param (`takes` = the schema's names).
UnknownParam { label: String, name: String, takes: Vec<String> },
/// A schema param is unbound.
MissingParam { label: String, name: String, kind: ScalarKind },
/// A param binding's kind mismatches its schema declaration.
ParamKind { label: String, name: String, expected: ScalarKind, got: ScalarKind },
/// Two declared taps share a name (the caller-owned dedup guard).
Bind(aura_engine::TapBindError),
/// The trace store failed (begin/write/finish) — the terminal register.
Store(TraceStoreError),
}
impl fmt::Display for TapPlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TapPlanError::UnknownTap { name, declared } => {
write!(
f,
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
declared.join(", ")
)
}
TapPlanError::UnknownLabel { label, roster } => {
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
}
TapPlanError::KindMismatch { tap, label, kind } => {
write!(f, "fold '{label}' cannot bind tap '{tap}' of kind {kind:?}")
}
TapPlanError::UnknownParam { label, name, takes } => {
if takes.is_empty() {
write!(f, "fold '{label}' takes no parameters (got '{name}')")
} else {
write!(f, "fold '{label}' has no parameter '{name}' — takes: {}", takes.join(", "))
}
}
TapPlanError::MissingParam { label, name, kind } => {
write!(f, "fold '{label}' requires parameter '{name}' ({kind:?})")
}
TapPlanError::ParamKind { label, name, expected, got } => {
write!(f, "fold '{label}' parameter '{name}' is {expected:?}, got {got:?}")
}
TapPlanError::Bind(e) => write!(f, "{e}"),
TapPlanError::Store(e) => write!(f, "writing tap traces failed: {e}"),
}
}
}
impl fmt::Debug for TapPlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TapPlanError({self})")
}
}
impl From<TraceStoreError> for TapPlanError {
fn from(e: TraceStoreError) -> Self {
TapPlanError::Store(e)
}
}
/// Validate one `Named` subscription's param bindings against the entry's
/// schema: every binding names a schema param of the right kind; every
/// schema param is bound.
pub(crate) fn validate_params(
entry: &FoldEntry,
bound: &[(String, Scalar)],
) -> Result<(), TapPlanError> {
let takes: Vec<String> = entry.params.iter().map(|p| p.name.clone()).collect();
for (name, value) in bound {
match entry.params.iter().find(|p| &p.name == name) {
None => {
return Err(TapPlanError::UnknownParam {
label: entry.label.to_string(),
name: name.clone(),
takes,
})
}
Some(p) if p.kind != value.kind() => {
return Err(TapPlanError::ParamKind {
label: entry.label.to_string(),
name: name.clone(),
expected: p.kind,
got: value.kind(),
})
}
Some(_) => {}
}
}
for p in &entry.params {
if !bound.iter().any(|(n, _)| n == &p.name) {
return Err(TapPlanError::MissingParam {
label: entry.label.to_string(),
name: p.name.clone(),
kind: p.kind,
});
}
}
Ok(())
}
/// The one-input sink schema every tap consumer binds with (empty output =
/// pure consumer, C8; the port name is a non-load-bearing debug symbol).
fn tap_sink_schema(kind: ScalarKind) -> NodeSchema {
NodeSchema {
inputs: vec![PortSpec { kind, firing: Firing::Any, name: "in".to_string() }],
output: vec![],
params: vec![],
doc: "internal recording sink bound to a declared tap",
}
}
/// The bound half of a tap plan: what `bind_tap_plan` leaves for the caller
/// to drain after the run. Both declared-tap entry points call the pair, so
/// the mechanism cannot drift between them.
#[allow(clippy::type_complexity)]
pub struct BoundTaps {
streamer: Option<TraceStreamer>,
declared: Vec<String>,
/// Persisting taps (Series and Row alike), declared-tap order — the
/// `index.json` tap order.
persisted: Vec<String>,
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
}
impl BoundTaps {
/// Every declared tap, declared order (the measurement report's roster).
pub fn declared_names(&self) -> &[String] {
&self.declared
}
/// Drain the ≤1-message-per-tap channels and close the run: record
/// outcomes first (declared order), then fold rows written through the
/// same streamer, then `index.json` last. Any fault returns before the
/// index is written — the store's crash discipline (no `index.json` →
/// treat-as-absent) covers the partial directory, exactly like today's
/// failed `write`. A plan that persisted nothing began no streamer and
/// writes nothing.
pub fn finish(mut self, manifest: &RunManifest) -> Result<(), TapPlanError> {
for (_, rx) in &self.outcomes {
// finalize ran inside the harness's end-of-stream flush, so the
// single outcome is already in the channel.
if let Ok(Err(e)) = rx.try_recv() {
return Err(TapPlanError::Store(e));
}
}
let streamer = self.streamer.take();
for (name, kind, rx) in self.rows {
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect(); // ≤1
let trace = ColumnarTrace::from_rows(&name, &[kind], &rows);
streamer
.as_ref()
.expect("a Row tap implies a begun streamer")
.write_full(&trace)?;
}
if let Some(s) = streamer {
s.finish(manifest, &self.persisted)?;
}
Ok(())
}
}
/// Validate the plan against `flat.taps`, begin the streamed run write when
/// anything persists, and bind one consumer per subscribed tap — the shared
/// pre-bootstrap half of both declared-tap entry points. Refusals are typed
/// and complete BEFORE any store I/O (a refused run never half-writes).
pub fn bind_tap_plan(
flat: &mut FlatGraph,
mut plan: TapPlan,
env: &Env,
run_name: &str,
) -> Result<BoundTaps, TapPlanError> {
let declared_taps: Vec<aura_engine::FlatTap> = flat.taps.clone();
// Dedup guard (caller-owned per TapBindError::DuplicateBind's doc).
let mut seen: BTreeSet<String> = BTreeSet::new();
for tap in &declared_taps {
if !seen.insert(tap.name.clone()) {
return Err(TapPlanError::Bind(TapBindError::DuplicateBind { name: tap.name.clone() }));
}
}
// Unknown-tap guard: every plan name must be declared.
for name in plan.by_name.keys() {
if !declared_taps.iter().any(|t| &t.name == name) {
return Err(TapPlanError::UnknownTap {
name: name.clone(),
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
});
}
}
// Resolve each declared tap to its subscription; validate all Named
// subscriptions fully before any store I/O.
enum Resolved {
Named { label: String, params: Vec<(String, Scalar)> },
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
}
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
for tap in &declared_taps {
let kind = flat.signatures[tap.node].output[tap.field].kind;
let sub = match plan.by_name.remove(&tap.name) {
Some(TapSubscription::Named { label, params }) => Resolved::Named { label, params },
Some(TapSubscription::Live(consumer)) => Resolved::Live(consumer),
None => match &plan.default_named {
Some((label, params)) => {
Resolved::Named { label: label.clone(), params: params.clone() }
}
// Unbound, inert (C27) — but only reachable when `plan` carries
// no default (an EXPLICIT plan, e.g. from `--tap`): record-all
// (`default_named` = `Some(("record", …))`) always resolves the
// Some arm above, so this arm never fires under record-all and
// the note is exactly the C14 benign skipped-tap class (#334).
// Emitted here (aura-runner), beside the pre-existing
// runner-side `eprintln!` registers in this module/`member.rs`/
// `measure.rs` — the runner→CLI print migration is #297.
None => {
eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name);
continue;
}
},
};
if let Resolved::Named { label, params } = &sub {
let entry = plan.registry.get(label).ok_or_else(|| TapPlanError::UnknownLabel {
label: label.clone(),
roster: plan.registry.labels(),
})?;
if !(entry.binds_at)(kind) {
return Err(TapPlanError::KindMismatch {
tap: tap.name.clone(),
label: label.clone(),
kind,
});
}
validate_params(entry, params)?;
}
resolved.push((tap.name.clone(), kind, sub));
}
// Anything persisting? Then begin the run (directory creation is the
// pre-run refusal point).
let persists = resolved.iter().any(|(_, _, s)| matches!(s, Resolved::Named { .. }));
let streamer = if persists { Some(env.trace_store().begin_run(run_name)?) } else { None };
// Bind one consumer per resolved tap.
let mut bound = BoundTaps {
streamer,
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
persisted: Vec::new(),
rows: Vec::new(),
outcomes: Vec::new(),
};
for (name, kind, sub) in resolved {
let node: Box<dyn Node> = match sub {
Resolved::Named { label, params } => {
let entry = plan.registry.get(&label).expect("validated above");
bound.persisted.push(name.clone());
match (entry.output)(kind) {
FoldOutput::Series => {
let opener = bound
.streamer
.as_ref()
.expect("persisting tap implies a begun streamer")
.register_tap(&name, kind);
let (tx, rx) = mpsc::channel();
bound.outcomes.push((name.clone(), rx));
(entry.build)(FoldBuildCtx { kind, params, sink: FoldSink::Series(opener, tx) })
}
FoldOutput::Row(out_kind) => {
let (tx, rx) = mpsc::channel();
bound.rows.push((name.clone(), out_kind, rx));
(entry.build)(FoldBuildCtx { kind, params, sink: FoldSink::Row(tx) })
}
}
}
Resolved::Live(consumer) => Box::new(TapLive::new(kind, consumer)),
};
flat.bind_tap(&name, node, tap_sink_schema(kind))
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
}
Ok(bound)
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Ctx};
use std::sync::mpsc;
#[test]
fn roster_lists_the_eight_core_entries_with_docs() {
let r = FoldRegistry::core();
let labels: Vec<&str> = r.roster().iter().map(|(l, _)| *l).collect();
// BTreeMap order — alphabetical.
assert_eq!(
labels,
vec!["count", "first", "last", "max", "mean", "min", "record", "sum"]
);
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
}
/// C29 entry seam for the registry roster: every entry ships a gate-clean
/// one-line meaning, and the bind/output prose inside it matches the
/// entry's executable rules — the drift-pin the retired aura-std
/// `fold_vocabulary` table carried, moved here with the surface (#332).
#[test]
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
let r = FoldRegistry::core();
for entry in r.entries.values() {
aura_core::doc_gate(entry.label, entry.doc)
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
if entry.doc.contains("f64 taps") {
assert!(
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
"{} claims f64-only but binds wider",
entry.label
);
} else {
assert!(
entry.doc.contains("any kind"),
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
entry.label,
entry.doc
);
assert!(
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
"{} claims any-kind but refuses a kind",
entry.label
);
}
if entry.doc.contains("i64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
"{} claims an i64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("f64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
"{} claims an f64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("kind-preserving row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
"{} claims kind-preserving but outputs otherwise",
entry.label
);
} else {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
"{}: doc names no row kind, so it must be the series entry",
entry.label
);
}
}
}
#[test]
fn unknown_label_refusal_enumerates_the_roster() {
let r = FoldRegistry::core();
let e = TapPlanError::UnknownLabel { label: "p95".to_string(), roster: r.labels() };
let msg = e.to_string();
assert!(msg.starts_with("unknown fold 'p95'"), "{msg}");
assert!(msg.contains("record") && msg.contains("mean"), "roster enumerated: {msg}");
}
#[test]
fn arithmetic_folds_bind_only_at_f64_record_and_count_anywhere() {
let r = FoldRegistry::core();
for label in ["sum", "mean", "min", "max"] {
let e = r.get(label).expect(label);
assert!((e.binds_at)(ScalarKind::F64), "{label} binds f64");
assert!(!(e.binds_at)(ScalarKind::I64), "{label} refuses i64");
assert!(!(e.binds_at)(ScalarKind::Bool), "{label} refuses bool");
}
for label in ["record", "count", "first", "last"] {
let e = r.get(label).expect(label);
for k in [ScalarKind::F64, ScalarKind::I64, ScalarKind::Bool, ScalarKind::Timestamp] {
assert!((e.binds_at)(k), "{label} binds {k:?}");
}
}
}
#[test]
fn a_param_on_a_param_less_entry_is_refused() {
let r = FoldRegistry::core();
let e = validate_params(
r.get("mean").expect("mean"),
&[("q".to_string(), Scalar::f64(0.95))],
)
.expect_err("core entries take no params");
assert_eq!(e.to_string(), "fold 'mean' takes no parameters (got 'q')");
}
#[test]
fn a_layer_registered_entry_resolves_and_builds() {
// The dependency-injection proof without a trading layer: a test-only
// entry registered on top of core resolves by label and its consumer
// runs (here: a re-labelled count over the committed TapFold core).
let mut r = FoldRegistry::core();
r.register(FoldEntry {
label: "tally",
doc: "test-only: warm-row count under a layer label",
params: Vec::new(),
binds_at: Box::new(|_| true),
output: Box::new(|_| FoldOutput::Row(ScalarKind::I64)),
build: Box::new(|ctx| {
let FoldSink::Row(tx) = ctx.sink else {
panic!("wiring contract: tally is a Row entry");
};
Box::new(TapFold::new(ctx.kind, FoldKind::Count, tx))
}),
});
assert_eq!(r.labels().len(), 9, "core plus the layer entry");
let entry = r.get("tally").expect("layer entry resolves");
let (tx, rx) = mpsc::channel();
let mut node = (entry.build)(FoldBuildCtx {
kind: ScalarKind::F64,
params: Vec::new(),
sink: FoldSink::Row(tx),
});
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
node.initialize();
for (t, v) in [(1_i64, 5.0_f64), (2, 7.0)] {
inputs[0].push(Scalar::f64(v)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
node.finalize();
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
assert_eq!(rows, vec![(Timestamp(2), vec![Scalar::i64(2)])]);
}
/// Exercises the `record` entry's Series-sink build path (destructure
/// `FoldSink::Series` -> construct `TapRecorder`), left uncovered by
/// `a_layer_registered_entry_resolves_and_builds` above, which only
/// drives a Row-sink entry.
#[test]
fn record_entry_builds_a_series_sink_that_persists_the_stream() {
let root = std::env::temp_dir()
.join(format!("aura-runner-tap_plan-record-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp store root");
let store = aura_registry::TraceStore::open(&root);
let streamer = store.begin_run("plan").expect("begin run");
let opener = streamer.register_tap("t", ScalarKind::F64);
let r = FoldRegistry::core();
let entry = r.get("record").expect("record");
let (outcome_tx, outcome_rx) = mpsc::channel();
let mut node = (entry.build)(FoldBuildCtx {
kind: ScalarKind::F64,
params: Vec::new(),
sink: FoldSink::Series(opener, outcome_tx),
});
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
node.initialize();
for (t, v) in [(1_i64, 5.0_f64), (2, 7.0)] {
inputs[0].push(Scalar::f64(v)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
node.finalize();
let outcomes: Vec<Result<(), TraceStoreError>> = outcome_rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_ok(), "record's Series build path writes cleanly: {outcomes:?}");
}
#[test]
fn schema_params_are_validated_missing_mismatched_and_unknown() {
// A schema-carrying test entry reaches the validation branches v1's
// param-less core entries cannot: missing, kind-mismatched, and
// unknown-against-a-non-empty-schema.
let entry = FoldEntry {
label: "windowed",
doc: "test-only: carries a param schema",
params: vec![ParamSpec { name: "length".to_string(), kind: ScalarKind::I64 }],
binds_at: Box::new(|_| true),
output: Box::new(|_| FoldOutput::Row(ScalarKind::F64)),
build: Box::new(|_| panic!("validation-only entry is never built")),
};
let missing = validate_params(&entry, &[]).expect_err("length is required");
assert_eq!(missing.to_string(), "fold 'windowed' requires parameter 'length' (I64)");
let mismatched = validate_params(&entry, &[("length".to_string(), Scalar::f64(2.0))])
.expect_err("kind mismatch");
assert_eq!(mismatched.to_string(), "fold 'windowed' parameter 'length' is I64, got F64");
let unknown = validate_params(&entry, &[("q".to_string(), Scalar::i64(1))])
.expect_err("unknown param");
assert_eq!(unknown.to_string(), "fold 'windowed' has no parameter 'q' — takes: length");
}
}
+196
View File
@@ -0,0 +1,196 @@
//! `TapRecorder` — the `record` fold-registry entry's consumer (C8 sink) for
//! one declared tap: holds the streaming trace writer **in-graph** and
//! appends `(ctx.now(), cell)` per fired warm cycle — 16 B, `Copy`, zero heap
//! per cycle (#77 part 2); no writer thread, no channel (#283 design
//! revision). Lives in the assembly crate, not `aura-std`, because the
//! writer type is `aura-registry`'s (C28 layering). Lifecycle: `initialize`
//! opens the writer (deferred acquisition; an open error is stored and the
//! node degrades to inert — the run completes), `eval` appends (an I/O error
//! mid-run likewise stores + degrades), `finalize` finishes the writer and
//! reports exactly one terminal outcome over the wiring's channel.
use aura_core::{Cell, Ctx, Node, ScalarKind};
use aura_registry::{TapTraceWriter, TapWriterOpener, TraceStoreError};
use aura_std::newest_cell;
use std::sync::mpsc::Sender;
/// A single-column recording sink around an in-graph streaming writer.
pub struct TapRecorder {
kind: ScalarKind,
opener: Option<TapWriterOpener>,
writer: Option<TapTraceWriter>,
error: Option<TraceStoreError>,
emitted: bool,
outcome_tx: Sender<Result<(), TraceStoreError>>,
}
impl TapRecorder {
pub fn new(
kind: ScalarKind,
opener: TapWriterOpener,
outcome_tx: Sender<Result<(), TraceStoreError>>,
) -> Self {
Self { kind, opener: Some(opener), writer: None, error: None, emitted: false, outcome_tx }
}
}
impl Node for TapRecorder {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn initialize(&mut self) {
match self.opener.take().expect("initialize runs once per run").open() {
Ok(w) => self.writer = Some(w),
Err(e) => self.error = Some(e),
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let cell = newest_cell(self.kind, &ctx)?; // None until warm
if let Some(w) = self.writer.as_mut()
&& let Err(e) = w.append(ctx.now(), cell)
{
// I/O failure mid-run: store it, go inert, keep consuming —
// the run completes; the failure surfaces once at finalize.
self.error = Some(e);
self.writer = None;
}
None
}
fn finalize(&mut self) {
if self.emitted {
return;
}
self.emitted = true;
let outcome = match (self.writer.take(), self.error.take()) {
(Some(w), None) => w.finish(),
(_, Some(e)) => Err(e),
(None, None) => Ok(()), // finalize without initialize: benign
};
let _ = self.outcome_tx.send(outcome);
}
fn label(&self) -> String {
"TapRecorder".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
use aura_registry::TraceStore;
use std::path::PathBuf;
use std::sync::mpsc;
/// A fresh unique store root under the OS temp dir (aura-runner has no
/// tempfile dev-dep; mirror of trace_store's own temp_traces_root).
fn temp_store_root(name: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("aura-runner-taprec-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp store root");
dir
}
#[test]
fn initialize_opens_records_after_warmup_and_reports_one_ok_outcome() {
let root = temp_store_root("happy");
let store = TraceStore::open(&root);
let streamer = store.begin_run("rec").expect("begin");
let (tx, rx) = mpsc::channel();
let mut sink =
TapRecorder::new(ScalarKind::F64, streamer.register_tap("t", ScalarKind::F64), tx);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
sink.initialize();
// cold: nothing appended.
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(1))), None);
for (t, v) in [(2_i64, 10.0_f64), (3, 20.0), (4, 30.0)] {
inputs[0].push(Scalar::f64(v)).unwrap();
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
sink.finalize();
sink.finalize(); // second flush must not emit a second outcome
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_ok(), "happy path outcome: {outcomes:?}");
let text = std::fs::read_to_string(root.join("traces").join("rec").join("t.json"))
.expect("finished tap file");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
assert_eq!(v["tap"], "t");
assert_eq!(v["ts"], serde_json::json!([2, 3, 4]));
assert_eq!(v["columns"], serde_json::json!([[10.0, 20.0, 30.0]]));
}
#[test]
fn a_failed_open_degrades_to_inert_and_reports_one_err_outcome() {
let root = temp_store_root("failopen");
let store = TraceStore::open(&root);
let streamer = store.begin_run("rec").expect("begin");
let opener = streamer.register_tap("t", ScalarKind::F64);
// Make the deferred open fail: remove the run dir before initialize.
std::fs::remove_dir_all(root.join("traces").join("rec")).expect("remove run dir");
let (tx, rx) = mpsc::channel();
let mut sink = TapRecorder::new(ScalarKind::F64, opener, tx);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
sink.initialize();
// inert but alive: eval consumes without panicking.
inputs[0].push(Scalar::f64(1.0)).unwrap();
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(2))), None);
sink.finalize();
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_err(), "the stored open error surfaces terminally");
}
/// Exercises the mid-run branch at `eval` (lines 51-57): a successfully
/// opened writer whose *write* later faults must store the error, drop
/// `self.writer` (go inert — no further append attempts), and still
/// report exactly one terminal `Err` at `finalize`, same as a failed
/// open. `/dev/full` always faults on write, so pointing the ts temp
/// stream at it (after a normal open) is a deterministic mid-run I/O
/// fault once the `BufWriter`'s internal buffer forces a real flush.
#[test]
#[cfg(unix)]
fn a_mid_run_append_failure_degrades_to_inert_and_reports_one_err_outcome() {
let root = temp_store_root("appendfail");
let store = TraceStore::open(&root);
let streamer = store.begin_run("rec").expect("begin");
let opener = streamer.register_tap("t", ScalarKind::F64);
// Retarget the ts temp stream at a permanently-full device: `open`
// still succeeds (create/truncate are no-ops on a char device), only
// a later flushed write faults.
std::os::unix::fs::symlink(
"/dev/full",
root.join("traces").join("rec").join("t.ts.tmp"),
)
.expect("symlink t.ts.tmp to /dev/full");
let (tx, rx) = mpsc::channel();
let mut sink = TapRecorder::new(ScalarKind::F64, opener, tx);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
sink.initialize();
// Enough warm evals to force the BufWriter past its internal
// capacity and trigger a real (faulting) flush; `eval` never emits a
// row regardless of the underlying write outcome.
for t in 0..20_000_i64 {
inputs[0].push(Scalar::f64(1.0)).unwrap();
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
sink.finalize();
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
assert!(outcomes[0].is_err(), "the mid-run write fault surfaces terminally");
}
}
-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
View File
@@ -23,6 +23,7 @@ impl Abs {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "absolute value of the input series",
},
|_| Box::new(Abs::new()),
)
+1
View File
@@ -38,6 +38,7 @@ impl Add {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "elementwise sum of two input series",
},
|_| Box::new(Add::new()),
)
+1
View File
@@ -37,6 +37,7 @@ impl And {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![],
doc: "logical AND of two boolean series",
},
|_| Box::new(And::new()),
)
+1
View File
@@ -33,6 +33,7 @@ impl Const {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "clock".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "value".into(), kind: ScalarKind::F64 }],
doc: "constant-valued stream from a single param",
},
|p| Box::new(Const::new(p[0].f64())),
)
+1
View File
@@ -53,6 +53,7 @@ impl CumSum {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "running cumulative sum of the input series",
},
|_| Box::new(CumSum::new()),
)
+1
View File
@@ -65,6 +65,7 @@ impl Delay {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "lag".into(), kind: ScalarKind::I64 }],
doc: "emits the input from `lag` cycles ago — the explicit delay register (C9)",
},
|p| Box::new(Delay::new(p[0].i64() as usize)),
)
+1
View File
@@ -30,6 +30,7 @@ impl Div {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "elementwise quotient of two input series",
},
|_| Box::new(Div::new()),
)
+1
View File
@@ -63,6 +63,7 @@ impl Ema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
doc: "exponential moving average over the input series",
},
|p| Box::new(Ema::new(p[0].i64() as usize)),
)
+1
View File
@@ -40,6 +40,7 @@ impl EqConst {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "value".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![ParamSpec { name: "target".into(), kind: ScalarKind::I64 }],
doc: "true where the input equals a constant param",
},
|p| Box::new(EqConst::new(p[0].i64())),
)
+6 -1
View File
@@ -41,7 +41,12 @@ impl GatedRecorder {
let build_kinds = kinds.clone();
PrimitiveBuilder::new(
"GatedRecorder",
NodeSchema { inputs, output: vec![], params: vec![] }, // sink: empty output (C8)
NodeSchema {
inputs,
output: vec![],
params: vec![],
doc: "recording sink that persists its input while the gate is true, plus a final row at finalize",
}, // sink: empty output (C8)
move |_| Box::new(GatedRecorder::new(&build_kinds, gate_col, tx.clone())),
)
}
+1
View File
@@ -39,6 +39,7 @@ impl Gt {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
params: vec![],
doc: "true where the first input exceeds the second",
},
|_| Box::new(Gt::new()),
)
+1
View File
@@ -46,6 +46,7 @@ impl Latch {
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
doc: "level-sensitive set/reset register holding 1.0 when latched, 0.0 when reset",
},
|_| Box::new(Latch::new()),
)
+6
View File
@@ -43,6 +43,9 @@ mod sign;
mod sma;
mod sqrt;
mod sub;
mod tap_cell;
mod tap_fold;
mod tap_live;
mod when;
pub use abs::Abs;
pub use add::Add;
@@ -70,4 +73,7 @@ pub use sign::Sign;
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, FoldKind, TapFold};
pub use tap_live::TapLive;
pub use when::When;

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