Compare commits

...

22 Commits

Author SHA1 Message Date
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
71 changed files with 3263 additions and 153 deletions
+4 -3
View File
@@ -119,9 +119,10 @@ aura sweep crossover.bp.json --list-axes
```
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
wire; `doc` declares the composite's one-line meaning, required at register —
C29; see the authoring guide). See
`gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
interior wire; `doc` declares the composite's one-line meaning, required at
register — C29; `use` splices a registered blueprint in as a nested composite,
by content id or label; 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.
+353 -33
View File
@@ -9,21 +9,51 @@ 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,
BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
};
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 ten
/// op kinds with their fields and one worked element each. Lives beside
/// [`OpDoc`] so a new op variant is one screen away from its help line.
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":"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)
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 },
@@ -57,26 +87,66 @@ enum OpDoc {
/// 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>,
},
}
/// 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::Doc { .. } => "doc",
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(),
}
}
}
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 },
@@ -90,6 +160,14 @@ impl From<OpDoc> for Op {
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(),
},
}
}
}
@@ -122,10 +200,12 @@ 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::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:?}")
}
@@ -135,18 +215,174 @@ 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:?}")
}
}
}
/// 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
@@ -201,7 +437,8 @@ 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);
for port in &schema.inputs {
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
}
@@ -219,7 +456,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)))?;
}
@@ -230,6 +471,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
@@ -239,17 +507,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) {
@@ -537,13 +834,20 @@ fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String,
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);
@@ -551,7 +855,11 @@ 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
@@ -561,10 +869,21 @@ fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<St
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)]
@@ -593,7 +912,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");
}
+139 -9
View File
@@ -46,7 +46,7 @@ use aura_measurement::information_coefficient;
// (a sibling module reaching it via `crate::blueprint_axis_probe_reopened`,
// the crate-root re-export this `use` gives it), not from this module itself.
use aura_runner::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, wrapped_bound_names, RunData};
use aura_runner::TapPlan;
use aura_runner::{TapPlan, TapSubscription};
#[cfg(test)]
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
// The family builders (blueprint sweep / walk-forward / MC), the shared
@@ -105,6 +105,7 @@ use aura_std::Sma;
#[cfg(test)]
use std::sync::mpsc;
use std::collections::HashSet;
use std::collections::BTreeSet;
#[cfg(test)]
use std::collections::BTreeMap;
use clap::{Args, Parser, Subcommand};
@@ -1026,11 +1027,32 @@ fn version_string() -> &'static str {
/// The `aura` root parser. `version` is built from `CARGO_PKG_VERSION` (the
/// workspace `0.1.0`) plus the parenthesized `ENGINE_COMMIT`, so
/// `aura --version` prints `aura 0.1.0 (<commit>)`.
/// The two-layer concepts paragraph `aura --help` opens with (#315): the
/// zero-setup self-description a reader without repo access gets.
const CONCEPTS_HELP: &str = "\
Author, backtest, and validate trading strategies — research CLI.
Two layers, one vocabulary: the research verbs are the convenience surface —
over --real data, sweep, walkforward, mc, and generalize desugar to
registered process/campaign documents and execute them (run is their
single-backtest sibling; synthetic runs execute in-process). That document
data plane is directly authorable: `aura process` / `aura campaign`
(validate | introspect | register | run), growing a document from a bare {}
via `introspect --unwired`.
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 signal quality is measured in R.
Traces: `sweep --real --trace` / `walkforward --real --trace` record per-cycle
taps under runs/, consumed by `aura chart` and `aura measure`.";
#[derive(Parser)]
#[command(
name = "aura",
version = version_string(),
about = "Author, backtest, and validate trading strategies — research CLI",
long_about = CONCEPTS_HELP,
infer_long_args = true
)]
struct Cli {
@@ -1044,22 +1066,43 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
/// Run a single backtest (built-in harness or a loaded blueprint).
///
/// Part of the research sugar surface; the canonical, document-first form
/// of any research run is a registered process + campaign pair — see
/// `aura process` / `aura campaign`.
Run(RunCmd),
/// Render a recorded run's trace to an HTML chart.
Chart(ChartCmd),
/// Emit / construct / introspect a graph.
Graph(GraphCmd),
/// Sweep a parameter grid over a strategy or a loaded blueprint.
///
/// Sugar over the document layer: over --real data this desugars to a
/// registered process (std::sweep) + campaign pair — author the documents
/// directly via `aura process` / `aura campaign`.
Sweep(SweepCmd),
/// Walk-forward validation over a strategy or a loaded blueprint. Over --real, the
/// fixed 90/30-day roller fits to a --from/--to window shorter than it, preserving
/// the 3:1 IS:OOS ratio, instead of refusing the window outright.
///
/// Sugar over the document layer: over --real data this desugars to a
/// registered process ([std::grid, std::walk_forward]) + campaign pair —
/// author the documents directly via `aura process` / `aura campaign`.
Walkforward(WalkforwardCmd),
/// Grade one candidate across multiple instruments.
///
/// Sugar over the document layer: desugars to a registered process
/// ([std::sweep (selection), std::generalize]) + campaign pair — author
/// the documents directly via `aura process` / `aura campaign`.
Generalize(GeneralizeCmd),
/// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint. Over
/// --real, the fixed 90/30-day walk-forward roller fits to a --from/--to window
/// shorter than it, preserving the 3:1 IS:OOS ratio, instead of refusing outright.
///
/// Sugar over the document layer: over --real data this desugars to a
/// registered process ([std::grid, std::walk_forward, std::monte_carlo]) +
/// campaign pair — author the documents directly via `aura process` /
/// `aura campaign`.
Mc(McCmd),
/// List or inspect recorded run families.
Runs(RunsCmd),
@@ -1181,6 +1224,7 @@ struct GraphCmd {
#[derive(Subcommand)]
enum GraphSub {
/// Construct a graph from a stdin op-list.
#[command(after_help = crate::graph_construct::OP_REFERENCE)]
Build,
/// Introspect a graph.
Introspect(GraphIntrospectCmd),
@@ -1188,12 +1232,16 @@ enum GraphSub {
Register {
/// The blueprint .json file to register.
file: std::path::PathBuf,
/// Label the registered content id for `use` by name (#317); a
/// re-registered label repoints (latest-wins on resolve).
#[arg(long)]
name: Option<String>,
},
}
#[derive(Args)]
struct GraphIntrospectCmd {
/// List the closed node vocabulary (one node type per line).
/// List the closed node vocabulary (one node type + its meaning per line).
#[arg(long)]
vocabulary: bool,
/// Describe one node type's ports by name.
@@ -1202,6 +1250,12 @@ struct GraphIntrospectCmd {
/// List the graph's unwired (unbound) ports.
#[arg(long)]
unwired: bool,
/// List the closed tap-fold vocabulary (fold, bind rule, output kind, meaning).
#[arg(long)]
folds: bool,
/// List every registered blueprint label (#317): label, id prefix, root doc.
#[arg(long)]
registered: bool,
/// Print the graph's content id (topology hash). With FILE, read the
/// document from it (a blueprint envelope or an op-list, shape-
/// discriminated, #196); without FILE, read a stdin op-list as before.
@@ -1297,6 +1351,12 @@ struct RunCmd {
/// Not accepted — CLI-side trace persistence is retired (see #224).
#[arg(long)]
trace: Option<String>,
/// Subscribe a declared tap to a fold for this run (repeatable,
/// TAP=FOLD; e.g. --tap signal=mean). Replaces the record-all
/// default: only listed taps are bound, unlisted taps stay unbound.
/// Fold roster: `aura graph introspect --folds`.
#[arg(long = "tap", value_name = "TAP=FOLD")]
tap: Vec<String>,
}
#[derive(Args)]
@@ -1421,7 +1481,7 @@ fn is_blueprint_file(arg: &Option<String>) -> Option<&str> {
/// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to`
/// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2).
fn run_data_from(real: Option<&str>, from: Option<i64>, to: Option<i64>) -> RunData {
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]";
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]";
match real {
Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from, to },
Some(_) => {
@@ -1715,18 +1775,44 @@ fn campaign_window_ms(choice: DataChoice, env: &aura_runner::project::Env) -> (i
)
}
/// Build the run's tap plan from repeated `--tap TAP=FOLD` selections
/// (#310). No selections → the record-all default (today's behaviour).
/// Any selection → an explicit plan that REPLACES the default entirely:
/// only listed taps are bound; unlisted declared taps stay unbound,
/// which C27 defines as inert, not an error. Tap/label existence is
/// bind_tap_plan's to validate (roster refusal / UndeclaredTap), before
/// any store I/O.
fn tap_plan_from_args(args: &[String]) -> Result<TapPlan, String> {
if args.is_empty() {
return Ok(TapPlan::record_all());
}
let mut plan = TapPlan::empty();
let mut seen = BTreeSet::new();
for raw in args {
let (tap, label) = match raw.split_once('=') {
Some((t, l)) if !t.is_empty() && !l.is_empty() => (t, l),
_ => return Err(format!("--tap expects TAP=FOLD, got \"{raw}\"")),
};
if !seen.insert(tap.to_string()) {
return Err(format!("--tap names tap \"{tap}\" twice"));
}
plan.subscribe(tap, TapSubscription::named(label));
}
Ok(plan)
}
/// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or
/// the built-in harness-kind dispatch.
fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
match is_blueprint_file(&a.blueprint) {
Some(path) => {
// The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to;
// The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to/--tap;
// the built-in-only flags are rejected here (exit 2), never silently dropped —
// mirroring the sweep/mc blueprint branches, which reject their non-branch flags
// exhaustively (refuse-don't-guess). clap's optional `[blueprint]` positional
// makes these structurally parseable, so the guard is re-asserted at dispatch.
if a.trace.is_some() {
eprintln!("aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]");
eprintln!("aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]");
std::process::exit(2);
}
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
@@ -1751,6 +1837,13 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
// probing a no-`bias` signal would panic on UnknownOutPort).
let has_bias = signal.output().iter().any(|o| o.name == "bias");
let has_tap = !signal.taps().is_empty();
let tap_plan = match tap_plan_from_args(&a.tap) {
Ok(p) => p,
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
};
if has_bias {
// Refuse an open (free-knob) blueprint at the dispatch boundary,
// mirroring `blueprint_mc_family`'s closed-guard: `run` bootstraps
@@ -1773,7 +1866,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
None => Vec::new(),
};
let data = run_data_from(a.real.as_deref(), a.from, a.to);
let report = run_signal_r(signal, &params, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
let report = run_signal_r(signal, &params, data, a.seed.unwrap_or(0), env, tap_plan);
println!("{}", report.to_json());
} else if has_tap {
// Measurement path: wrap_r-free closed guard via the signal's own
@@ -1794,7 +1887,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
None => Vec::new(),
};
let data = run_data_from(a.real.as_deref(), a.from, a.to);
let report = run_measurement(signal, &params, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
let report = run_measurement(signal, &params, data, a.seed.unwrap_or(0), env, tap_plan);
println!("{}", report.to_json());
} else {
eprintln!(
@@ -1807,7 +1900,8 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
None => {
eprintln!(
"aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] \
[--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]"
[--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] \
[--tap <TAP=FOLD> …]"
);
std::process::exit(2);
}
@@ -1856,7 +1950,7 @@ fn dispatch_graph(a: GraphCmd, env: &aura_runner::project::Env) {
},
Some(GraphSub::Build) => graph_construct::build_cmd(env),
Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env),
Some(GraphSub::Register { file }) => graph_construct::register_cmd(&file, env),
Some(GraphSub::Register { file, name }) => graph_construct::register_cmd(&file, name.as_deref(), env),
}
}
@@ -3813,5 +3907,41 @@ mod tests {
assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime");
assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime");
}
#[test]
fn tap_plan_from_args_empty_is_record_all_ok() {
assert!(tap_plan_from_args(&[]).is_ok());
}
#[test]
fn tap_plan_from_args_refuses_a_pair_without_equals() {
let err = match tap_plan_from_args(&["fast_tapmean".to_string()]) {
Err(e) => e,
Ok(_) => panic!("expected an error"),
};
assert_eq!(err, "--tap expects TAP=FOLD, got \"fast_tapmean\"");
}
#[test]
fn tap_plan_from_args_refuses_empty_tap_or_label() {
assert!(tap_plan_from_args(&["=mean".to_string()]).is_err());
assert!(tap_plan_from_args(&["fast_tap=".to_string()]).is_err());
}
#[test]
fn tap_plan_from_args_refuses_the_same_tap_twice() {
let args = vec!["a=mean".to_string(), "a=last".to_string()];
let err = match tap_plan_from_args(&args) {
Err(e) => e,
Ok(_) => panic!("expected an error"),
};
assert_eq!(err, "--tap names tap \"a\" twice");
}
#[test]
fn tap_plan_from_args_accepts_distinct_selections() {
let args = vec!["a=mean".to_string(), "b=record".to_string()];
assert!(tap_plan_from_args(&args).is_ok());
}
}
+12 -3
View File
@@ -107,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);
}
}
}
@@ -292,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),
@@ -372,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(())
}
+45
View File
@@ -165,6 +165,8 @@ 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())),
@@ -238,6 +240,14 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
`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__`
@@ -455,6 +465,41 @@ mod tests {
assert!(claude.contains("demo_lab_signal.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
/// the bound-param build closure. Its rendered `src/lib.rs` declares a param in
/// the node schema and reads a bound value in the build closure (not the
+330
View File
@@ -354,6 +354,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
@@ -451,6 +466,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"))
@@ -1536,3 +1569,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, "graph.trend.sma.length:I64\n",
"the spliced instance's open param surfaces path-qualified, bare (unbound) form"
);
}
/// 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,247 @@
//! 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).
#[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""#,
] {
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);
}
+5 -4
View File
@@ -871,13 +871,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 +996,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 +1022,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}"
);
}
+25
View File
@@ -56,3 +56,28 @@ 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}");
}
+208
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
@@ -212,3 +227,196 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
);
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
}
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
/// fold instead of the record-all default — the trace store holds ONE
/// summary row whose value is the mean of the full SMA(2) series, and
/// `index.json` lists exactly the subscribed tap.
#[test]
fn run_tap_selector_persists_a_fold_summary_row() {
let cwd = temp_cwd("tap-selector-mean");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let out = Command::new(BIN)
.args(["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());
}
}
+10 -1
View File
@@ -324,7 +324,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 +336,12 @@ 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 },
}
/// A node's declared interface: its inputs (in order) and its output record — an
+119 -1
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};
@@ -240,6 +241,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 {
@@ -1097,6 +1129,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
+501 -41
View File
@@ -2,9 +2,12 @@
//! (#157). A `GraphSession` applies `Op`s one at a time, running the
//! eagerly-decidable checks (name resolution, edge kind-match, the >1-cover
//! double-wire arm, param bind) at the offending op; the only-at-end-decidable
//! checks (0-cover totality, param-namespace injectivity, root-role boundness)
//! run holistically in `finish`. Both cadences call the SAME §A predicates — no
//! second validator (C24). The node vocabulary is an injected closed resolver
//! checks (0-cover totality, param-namespace injectivity) run holistically in
//! `finish`. Both cadences call the SAME §A predicates — no second validator
//! (C24). Root-role boundness is a *runnability* gate, not a construction gate:
//! `finish()` does not run it (#317, open patterns), so an op-script may
//! finish with an open root role; only `compile`/bootstrap enforce it. The
//! node vocabulary is an injected closed resolver
//! (`Fn(&str)->Option<PrimitiveBuilder>`), so the engine stays domain-free
//! (invariant 9).
@@ -13,8 +16,8 @@ use std::collections::{HashMap, HashSet};
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use crate::blueprint::{
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring,
BlueprintNode, Composite, Gang, GangMember, OutField, Role, Tap, TapWire,
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
Gang, GangMember, OutField, Role, Tap, TapWire,
};
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
use crate::harness::{Edge, Target};
@@ -46,6 +49,15 @@ pub enum Op {
/// twin of the builder's `.doc(...)`. At most one per script; a second
/// is a fault (a declarative script carries no last-wins ambiguity).
Doc { text: String },
/// Splice a registered blueprint into the building graph as a nested
/// composite under an instance identifier (#317). `ref_id` is the full
/// content id the injected `subgraph` resolver is keyed by — the engine
/// never sees a label, a content-id prefix, or the registry (CLI-side
/// resolution happens at DTO conversion, before replay). `name` is the
/// instance identifier (`None` -> the fetched composite's own `name()`);
/// `bind` is applied path-qualified (e.g. `"sma.length"`), after the
/// splice.
Use { ref_id: String, name: Option<String>, bind: Vec<(String, Scalar)> },
}
/// A per-op construction fault, by-identifier so the cause names the op.
@@ -85,8 +97,6 @@ pub enum OpError {
/// Holistic role-kind fault, by-identifier: a root input role fans into slots
/// of differing scalar kinds.
RoleKindMismatch { role: String },
/// Holistic root fault, by-identifier: a root input role has no bound source.
UnboundRootRole { role: String },
/// Gang members must share one scalar kind.
GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind },
/// The param is already claimed by an earlier gang.
@@ -98,6 +108,11 @@ pub enum OpError {
Incomplete(CompileError),
/// A second `doc` op — the meaning line is declared at most once.
DuplicateDoc,
/// `Op::Use`'s injected `subgraph` resolver found no composite for
/// `ref_id` (#317) — by-identifier. Unreachable from the CLI (which
/// resolves and fetches before replay, refusing there on a miss), but
/// total: a bare `replay`/`GraphSession` caller can still hit it.
UnknownSubgraph { ref_id: String },
}
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
@@ -106,6 +121,13 @@ pub enum OpError {
pub struct GraphSession<'v> {
name: String,
vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>,
/// Second injected resolver (#317), mirroring `vocab`'s closed-lookup
/// posture: `Op::Use` fetches a registered blueprint by full content id.
/// The engine stays store-free — CLI-side resolution (label/prefix,
/// C29 doc gate, the store fetch itself) happens at DTO conversion,
/// before replay; this closure is a pure lookup into an already-fetched
/// id->`Composite` cache. Build-free introspection paths pass `&|_| None`.
subgraph: &'v dyn Fn(&str) -> Option<Composite>,
nodes: Vec<BlueprintNode>,
schemas: Vec<NodeSchema>,
ids: Vec<String>,
@@ -124,11 +146,17 @@ pub struct GraphSession<'v> {
impl<'v> GraphSession<'v> {
/// Start a session for a composite named `name`, resolving node types through
/// the injected closed vocabulary `vocab`.
pub fn new(name: impl Into<String>, vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>) -> Self {
/// the injected closed vocabulary `vocab` and registered-blueprint splices
/// (`Op::Use`, #317) through the injected `subgraph` lookup.
pub fn new(
name: impl Into<String>,
vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>,
subgraph: &'v dyn Fn(&str) -> Option<Composite>,
) -> Self {
Self {
name: name.into(),
vocab,
subgraph,
nodes: Vec::new(),
schemas: Vec::new(),
ids: Vec::new(),
@@ -165,9 +193,47 @@ impl<'v> GraphSession<'v> {
self.doc = Some(text);
Ok(())
}
Op::Use { ref_id, name, bind } => self.use_subgraph(ref_id, name, bind),
}
}
/// `Op::Use` (#317): fetch a registered blueprint through the injected
/// `subgraph` resolver, rename it to the instance identifier, apply its
/// path-qualified `bind` entries, and push it as an ordinary
/// `BlueprintNode::Composite` — sharing the node-identifier namespace
/// with every `Add`ed primitive (so an instance and a primitive collide
/// the same way two primitives do). A miss on `ref_id` is a by-identifier
/// `UnknownSubgraph`; a `bind` path with no matching open param anywhere
/// in the instance reuses the existing `BadParam` bind-fault shape,
/// naming the instance (`node`) and the qualified within-instance path
/// (`err`'s carried name) — the pair spells out the full qualified path.
fn use_subgraph(
&mut self,
ref_id: String,
name: Option<String>,
bind: Vec<(String, Scalar)>,
) -> Result<(), OpError> {
let composite = (self.subgraph)(&ref_id).ok_or_else(|| OpError::UnknownSubgraph { ref_id: ref_id.clone() })?;
let id = name.unwrap_or_else(|| composite.name().to_string());
if self.names.contains_key(&id) {
return Err(OpError::DuplicateIdentifier(id));
}
let mut composite = composite.renamed(&id);
for (path, value) in bind {
composite = composite
.bind_path(&path, value)
.map_err(|err| OpError::BadParam { node: id.clone(), err })?;
}
let node = BlueprintNode::Composite(composite);
let schema = node.signature();
let idx = self.nodes.len();
self.names.insert(id.clone(), idx);
self.ids.push(id);
self.nodes.push(node);
self.schemas.push(schema);
Ok(())
}
fn add_role(&mut self, role: String, kind: Option<ScalarKind>) -> Result<(), OpError> {
if self.role_names.contains_key(&role) {
return Err(OpError::DuplicateRole(role));
@@ -368,7 +434,16 @@ impl<'v> GraphSession<'v> {
let (node_name, param_name) = Self::split_port(port)?;
let ti = self.node_index(&node_name)?;
let BlueprintNode::Primitive(b) = &self.nodes[ti] else {
unreachable!("sessions only ever add primitives")
// #317: `Op::Use` makes a session node legitimately a
// Composite, not just a Primitive — reachable now, not a
// defect. A gang fuses a PRIMITIVE's raw (name, pos) param
// slot; an instance has no such flat slot (its params are
// nested/path-qualified), so this is the ordinary
// no-such-open-param refusal, not a panic.
return Err(OpError::BadParam {
node: node_name,
err: BindOpError::UnknownParam(param_name),
});
};
let hits: Vec<usize> = b
.params()
@@ -426,13 +501,17 @@ impl<'v> GraphSession<'v> {
open
}
/// Assemble the `Composite` and run the holistic gates (totality,
/// param-namespace injectivity, root-role boundness) — the SAME engine
/// predicates the eager path shares, now at end-of-document cadence. The
/// index-carrying `CompileError`s the gates return are translated back to the
/// surface's by-identifier `OpError` variants (slot/role names via the session's
/// retained `ids`/`schemas` and the composite's roles); a fault with no
/// by-identifier mapping falls through to `OpError::Incomplete`.
/// Assemble the `Composite` and run the holistic construction gates (totality,
/// param-namespace injectivity) — the SAME engine predicates the eager path
/// shares, now at end-of-document cadence. Root-role boundness is
/// deliberately NOT checked here (#317, open patterns): a `finish()`ed
/// composite may still carry an open root role, declaring a reusable
/// pattern's formal parameters; only `compile`/bootstrap (the runnability
/// gate) refuse an unbound root role. The index-carrying `CompileError`s the
/// gates return are translated back to the surface's by-identifier `OpError`
/// variants (slot/role names via the session's retained `ids`/`schemas` and
/// the composite's roles); a fault with no by-identifier mapping falls
/// through to `OpError::Incomplete`.
pub fn finish(self) -> Result<Composite, OpError> {
let roles: Vec<Role> = self
.roles
@@ -458,25 +537,22 @@ impl<'v> GraphSession<'v> {
},
other => OpError::Incomplete(other),
})?;
check_root_roles_bound(c.input_roles()).map_err(|e| match e {
CompileError::UnboundRootRole { role } => OpError::UnboundRootRole {
role: c.input_roles()[role].name.clone(),
},
other => OpError::Incomplete(other),
})?;
Ok(c)
}
}
/// Replay a document of ops from scratch, stopping at the first failing op and
/// naming it by `(op_index, OpError)`; a holistic finalize fault is attributed to
/// index `ops.len()` (the implicit finalize step).
/// index `ops.len()` (the implicit finalize step). `subgraph` is the injected
/// registered-blueprint lookup `Op::Use` (#317) resolves splices through — pass
/// `&|_| None` when no store is in play.
pub fn replay(
name: impl Into<String>,
ops: Vec<Op>,
vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
subgraph: &dyn Fn(&str) -> Option<Composite>,
) -> Result<Composite, (usize, OpError)> {
let mut s = GraphSession::new(name, vocab);
let mut s = GraphSession::new(name, vocab, subgraph);
let n = ops.len();
for (i, op) in ops.into_iter().enumerate() {
s.apply(op).map_err(|e| (i, e))?;
@@ -487,11 +563,12 @@ pub fn replay(
#[cfg(test)]
mod tests {
use super::{GraphSession, Op, OpError};
use crate::blueprint::{BlueprintNode, Composite, Role};
use aura_core::{BindOpError, Scalar, ScalarKind};
use aura_vocabulary::std_vocabulary;
fn session(name: &str) -> GraphSession<'static> {
GraphSession::new(name, &std_vocabulary)
GraphSession::new(name, &std_vocabulary, &|_: &str| None)
}
#[test]
@@ -752,7 +829,7 @@ mod tests {
Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
];
let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None)
.expect("replays")
.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)])
.expect("compiles");
@@ -774,7 +851,7 @@ mod tests {
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
];
let c = super::replay("sig", ops, &std_vocabulary).expect("replays");
let c = super::replay("sig", ops, &std_vocabulary, &|_: &str| None).expect("replays");
let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, ["length"]);
}
@@ -820,7 +897,7 @@ mod tests {
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
];
super::replay("session_blueprint", ops, &std_vocabulary)
super::replay("session_blueprint", ops, &std_vocabulary, &|_: &str| None)
.expect("the preset resolves, wires and builds through an op-script");
}
@@ -922,18 +999,26 @@ mod tests {
"finalize names the open slot by-identifier, got {err:?}");
}
/// A reserved `Input` root role that is fed but never source-bound finishes with
/// the by-identifier `UnboundRootRole` (not a raw role index).
/// #317 (open patterns): `finish()` no longer runs the root-role gate — a
/// reserved `Input` root role that is fed but never source-bound now
/// finishes cleanly (the composite declares an open formal parameter, the
/// pattern's reusable interior). The runnability gate moves entirely to
/// `compile`, which refuses the SAME composite with the unchanged
/// `CompileError::UnboundRootRole` — the split this cycle ratified: finish
/// = construction-complete, compile = runnable.
#[test]
fn finish_reports_unbound_input_root_role_by_identifier() {
fn finish_accepts_an_open_input_role_and_compile_refuses_it() {
let mut s = session("g");
s.apply(Op::Input { role: "ext".into() }).unwrap();
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap();
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
let err = s.finish().err().unwrap();
assert!(matches!(&err, OpError::UnboundRootRole { role } if role == "ext"),
"an unbound Input root role finishes by-identifier, got {err:?}");
let c = s.finish().expect("finish() no longer gates root-role boundness");
assert_eq!(
c.compile().err(),
Some(crate::CompileError::UnboundRootRole { role: 0 }),
"compile() is the runnability gate that refuses the open root role"
);
}
/// A root role fed into two slots of differing scalar kinds (an `f64` `Sub.lhs`
@@ -991,7 +1076,7 @@ mod tests {
let finalize = ops.len();
// `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures
// and is not `Debug`.
let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None)
.err()
.expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)");
// An eager realisation attributes the fault to the closing connect op; a
@@ -1018,7 +1103,7 @@ mod tests {
Op::Expose { from: "s.value".into(), as_name: "out".into() },
];
assert!(
super::replay("g", ops, &aura_vocabulary::std_vocabulary).is_err(),
super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None).is_err(),
"a self-loop op-list must be rejected (DAG invariant 5 / C9)"
);
}
@@ -1032,7 +1117,7 @@ mod tests {
];
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
// not `Debug` (it holds build closures), which `unwrap_err` would require.
let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary).err().unwrap();
let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None).err().unwrap();
assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into())));
}
@@ -1066,7 +1151,7 @@ mod tests {
Op::Connect { from: "sub.value".into(), to: "bias.signal".into() },
Op::Expose { from: "bias.bias".into(), as_name: "bias".into() },
];
let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary)
let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None)
.expect("replay resolves")
.compile_with_params(&params)
.expect("replay compiles");
@@ -1113,7 +1198,7 @@ mod tests {
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
];
let replayed = super::replay("g", ops, &std_vocabulary).expect("replays");
let replayed = super::replay("g", ops, &std_vocabulary, &|_: &str| None).expect("replays");
// (b) the GraphBuilder twin — same adds/feeds/connects/expose + g.gang(...).
let mut g = GraphBuilder::new("g");
@@ -1165,7 +1250,7 @@ mod tests {
Op::Tap { from: "sub.value".into(), as_name: "spread".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
];
let replayed = super::replay("g", ops, &std_vocabulary).expect("replays");
let replayed = super::replay("g", ops, &std_vocabulary, &|_: &str| None).expect("replays");
// (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...).
let mut g = GraphBuilder::new("g");
@@ -1191,4 +1276,379 @@ mod tests {
assert_eq!(replay_flat.taps, built_flat.taps);
assert_eq!(replay_flat.edges, built_flat.edges);
}
// ---- Op::Use (#317) -----------------------------------------------
/// A small two-node fixture composite for the `Op::Use` tests (#317): an
/// open `price` input role feeds an `Sma`, whose `value` feeds a `Bias`;
/// `Bias.bias` re-exports as `out`. Built fresh via `GraphBuilder` on every
/// call — mirroring how a CLI-side registry lookup hands back a freshly
/// loaded `Composite` each fetch — never shared or cloned (`Composite`
/// holds build closures and is not `Clone`).
fn use_fixture() -> Composite {
use crate::GraphBuilder;
use aura_std::Sma;
use aura_strategy::Bias;
let mut g = GraphBuilder::new("fixture");
let sma = g.add(Sma::builder().named("sma"));
let bias = g.add(Bias::builder().named("bias"));
let price = g.input_role("price");
g.feed(price, [sma.input("series")]);
g.connect(sma.output("value"), bias.input("signal"));
g.expose(bias.output("bias"), "out");
g.build().expect("fixture resolves")
}
/// Wrap a sink-free signal (one open input role, one exposed field) in a
/// runnable root that records the exposed field via a channel-backed
/// `Recorder`, feeds a fixed synthetic price stream through it, and
/// returns the recorded trace. Mirrors `blueprint_serde.rs`'s
/// `run_recording` test helper (same shape); duplicated here rather than
/// shared, since that one is private to its own file.
fn run_recording(signal: Composite) -> Vec<(aura_core::Timestamp, Vec<Scalar>)> {
use crate::harness::{Edge, Target};
use aura_core::Firing;
use aura_std::Recorder;
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let root = Composite::new(
"h",
vec![
BlueprintNode::Composite(signal),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
// only `bias.scale` is open (every fixture in this module's tests binds
// `sma.length` at the use seam), so one F64 param point.
let mut h = root.bootstrap_with_params(vec![Scalar::f64(0.5)]).expect("bootstraps");
h.run(vec![Box::new(crate::VecSource::new(crate::test_fixtures::synthetic_prices()))]);
rx.try_iter().collect()
}
/// C1 twin (#317 headline): an op-script using `Op::Use` splices a
/// registered-style subgraph into the building graph byte-identically to
/// the same splice authored directly on `GraphBuilder`
/// (`g.add(BlueprintNode::Composite(x))` plus the same rename/bind) — the
/// correctness oracle for the whole splice path. Mirrors
/// `replay_built_signal_compiles_identically_to_graphbuilder` (topology
/// identity) and `blueprint_serde::serialized_blueprint_runs_bit_identical_
/// to_rust_built` (bit-identical run).
#[test]
fn use_op_splices_byte_identical_to_the_rust_built_twin() {
use crate::blueprint_serde::blueprint_to_json;
use crate::GraphBuilder;
let subgraph = |ref_id: &str| (ref_id == "fixture-id").then(use_fixture);
// (a) op-script: a bound `price` source feeds the spliced instance's
// own "price" port (`Op::Source`, not `Op::Input` — this test
// bootstraps and runs the result, and only compile/bootstrap gate
// root-role boundness now, #317; finish() itself no longer cares);
// the instance's "out" field re-exports as "bias".
let ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Use {
ref_id: "fixture-id".into(),
name: Some("gate".into()),
bind: vec![("sma.length".into(), Scalar::i64(5))],
},
Op::Feed { role: "price".into(), into: vec!["gate.price".into()] },
Op::Expose { from: "gate.out".into(), as_name: "bias".into() },
];
let replayed = super::replay("root", ops, &std_vocabulary, &subgraph).expect("replays");
// (b) the GraphBuilder twin — the identical splice: the SAME fixture,
// renamed to "gate", the SAME bind, added the way a Rust author would.
let mut g = GraphBuilder::new("root");
let price = g.source_role("price", ScalarKind::F64);
let spliced = use_fixture()
.renamed("gate")
.bind_path("sma.length", Scalar::i64(5))
.expect("binds");
let gate = g.add(spliced);
g.feed(price, [gate.input("price")]);
g.expose(gate.output("out"), "bias");
let built = g.build().expect("root resolves");
assert_eq!(
blueprint_to_json(&replayed).expect("replay serializes"),
blueprint_to_json(&built).expect("builder serializes"),
);
// bootstrap + run bit-identical (only bias.scale is open: sma.length
// was bound at the use seam).
let trace_replayed = run_recording(replayed);
let trace_built = run_recording(built);
assert_eq!(
trace_replayed, trace_built,
"the op-script splice diverged from the Rust-built twin at run time"
);
assert!(!trace_replayed.is_empty(), "trace must be populated (non-degenerate)");
}
/// A miss on the injected `subgraph` resolver is a by-identifier fault
/// naming the `ref_id` (#317) — reachable directly against `GraphSession`/
/// `replay` even though the CLI (Task 3) always pre-resolves.
#[test]
fn use_op_unknown_subgraph_is_a_by_identifier_fault() {
let mut s = GraphSession::new("g", &std_vocabulary, &|_: &str| None);
assert_eq!(
s.apply(Op::Use { ref_id: "ghost".into(), name: None, bind: vec![] }),
Err(OpError::UnknownSubgraph { ref_id: "ghost".into() })
);
}
/// An instance identifier colliding with an already-added node is the
/// existing `DuplicateIdentifier` refusal — instance names share the
/// interior node-identifier namespace with primitives (#317).
#[test]
fn use_op_duplicate_instance_identifier_rejected() {
let subgraph = |_: &str| Some(use_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), bind: vec![] }).unwrap();
assert_eq!(
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }),
Err(OpError::DuplicateIdentifier("gate".into()))
);
}
/// A post-splice `bind` path with no matching open param anywhere in the
/// instance faults through the existing bind-fault shape (`BadParam`),
/// naming the instance and the qualified within-instance path (#317).
#[test]
fn use_op_bind_of_a_closed_path_faults_with_the_qualified_path() {
let subgraph = |_: &str| Some(use_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
assert_eq!(
s.apply(Op::Use {
ref_id: "fixture".into(),
name: Some("gate".into()),
bind: vec![("ghost.length".into(), Scalar::i64(2))],
}),
Err(OpError::BadParam {
node: "gate".into(),
err: BindOpError::UnknownParam("ghost.length".into()),
})
);
}
/// A gang-bearing fixture for the ganged-member-bind refusal test below
/// (review finding, #317 follow-up): two `SMA`s ganged on `length` under
/// one public `channel_length` knob, feeding a `Sub` whose difference
/// re-exports as `out`. Mirrors `gang_op_projects_the_param_space`'s
/// op-script shape; built fresh on every call like `use_fixture` (never
/// shared/cloned — `Composite` holds build closures and is not `Clone`).
fn use_gang_fixture() -> Composite {
let ops = vec![
Op::Input { role: "price".into() },
Op::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), bind: vec![] },
Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), bind: vec![] },
Op::Gang {
as_name: "channel_length".into(),
into: vec!["channel_hi.length".into(), "channel_lo.length".into()],
},
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
Op::Feed {
role: "price".into(),
into: vec!["channel_hi.series".into(), "channel_lo.series".into()],
},
Op::Connect { from: "channel_hi.value".into(), to: "sub.lhs".into() },
Op::Connect { from: "channel_lo.value".into(), to: "sub.rhs".into() },
Op::Expose { from: "sub.value".into(), as_name: "out".into() },
];
super::replay("gang_fixture", ops, &std_vocabulary, &|_: &str| None).expect("gang fixture replays")
}
/// Silent gang de-fuse guard (review finding, #317 follow-up): binding a
/// GANGED member's raw path through `Op::Use`'s post-splice `bind` must
/// not quietly freeze that one member while the gang's public knob keeps
/// driving its sibling — refused by identifier, naming the full
/// qualified path and the gang's own public name. The gang's own public
/// knob (`gate.channel_length`) staying unbindable at the use seam is
/// accepted residue, not covered here.
#[test]
fn use_op_bind_of_a_ganged_member_path_refuses_by_identifier() {
let subgraph = |_: &str| Some(use_gang_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
assert_eq!(
s.apply(Op::Use {
ref_id: "fixture".into(),
name: Some("gate".into()),
bind: vec![("channel_hi.length".into(), Scalar::i64(20))],
}),
Err(OpError::BadParam {
node: "gate".into(),
err: BindOpError::AlreadyGanged {
param: "channel_hi.length".into(),
gang: "channel_length".into(),
},
})
);
}
/// Documented residue pin (#317 audit): a `gang` op cannot fuse a spliced
/// instance's member param — a gang fuses a PRIMITIVE's raw `(name, pos)`
/// slot, and an instance's params are nested/path-qualified. The refusal
/// is the ordinary no-such-open-param shape on the instance node, so the
/// authoring-guide/C24 claim "the gang op refuses a composite instance's
/// member path" stays an observed fact, not prose.
#[test]
fn gang_of_a_spliced_instance_member_path_refuses() {
let subgraph = |_: &str| Some(use_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] })
.unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("solo".into()), bind: vec![] })
.unwrap();
assert_eq!(
s.apply(Op::Gang {
as_name: "fused".into(),
into: vec!["gate.sma.length".into(), "solo.length".into()],
}),
Err(OpError::BadParam {
node: "gate".into(),
err: BindOpError::UnknownParam("sma.length".into()),
})
);
}
/// DAG invariant (domain invariant 5 / C9), through an instance: a
/// `connect` closing a cycle through a spliced instance is rejected
/// eagerly, the instance treated as one opaque node (#317) — the same
/// `closes_cycle` gate `replay_rejects_dataflow_cycle` pins for primitives.
#[test]
fn use_op_connect_closing_a_cycle_through_an_instance_is_rejected_eagerly() {
let subgraph = |_: &str| Some(use_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] }).unwrap();
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
s.apply(Op::Connect { from: "s.value".into(), to: "gate.price".into() }).unwrap();
assert_eq!(
s.apply(Op::Connect { from: "gate.out".into(), to: "s.lhs".into() }),
Err(OpError::WouldCycle { from: "gate.out".into(), to: "s.lhs".into() })
);
}
/// An unfed instance in-port finishes with the by-identifier
/// `UnconnectedPort`, naming the instance and the instance's own role name
/// (rendered `<instance>.<role>` by the CLI's `format_op_error`) — the
/// instance's open input roles ARE its in-ports (#317).
#[test]
fn use_op_unfed_instance_role_reports_the_qualified_identifier() {
let subgraph = |_: &str| Some(use_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
s.apply(Op::Expose { from: "gate.out".into(), as_name: "bias".into() }).unwrap();
// gate.price deliberately left unfed.
let err = s.finish().err().unwrap();
assert!(
matches!(&err, OpError::UnconnectedPort { node, slot } if node == "gate" && slot == "price"),
"an unfed instance role finishes by-identifier as <instance>.<role>, got {err:?}"
);
}
/// An instance's still-open interior params surface path-qualified under
/// the instance identifier (`<instance>.<node>.<param>`, #317) — the
/// existing `collect_params` composite-arm prefix rule, now exercised
/// through `Op::Use`'s rename-on-splice (extends the
/// `param_space_is_flat_path_qualified_and_slot_disambiguated` family).
#[test]
fn use_op_instance_params_surface_path_qualified() {
let subgraph = |_: &str| Some(use_fixture());
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
// a bound root role (mirroring the headline twin test's shape;
// `finish()` itself no longer cares either way, #317).
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
s.apply(Op::Feed { role: "price".into(), into: vec!["gate.price".into()] }).unwrap();
s.apply(Op::Expose { from: "gate.out".into(), as_name: "bias".into() }).unwrap();
let c = s.finish().expect("finishes");
let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, ["gate.sma.length", "gate.bias.scale"]);
}
/// Open patterns end-to-end (#317, spec §"Open patterns"): a pattern
/// declaring its formal parameter as an `input` role `finish()`es even
/// though nothing ever binds it (the root-role gate no longer runs in
/// `finish`), survives a real serialize/deserialize round trip through
/// the stored JSON form (the same bytes a registry fetch would hand
/// back), and a source-rooted consumer script splices the RELOADED
/// pattern by ref id through the injected `subgraph` lookup — the outer
/// script compiles and runs, proving the whole author's round trip
/// (build open pattern -> store -> reload -> `use` -> run), not just an
/// in-memory splice.
#[test]
fn open_input_pattern_finishes_registers_shaped_and_splices() {
use crate::blueprint_serde::{blueprint_from_json, blueprint_to_json};
// (a) the reusable pattern: an open `x` input role feeds an Sma, whose
// value re-exports as `out`.
let pattern_ops = vec![
Op::Input { role: "x".into() },
Op::Add {
type_id: "SMA".into(),
as_name: Some("sma".into()),
bind: vec![("length".into(), Scalar::i64(3))],
},
Op::Feed { role: "x".into(), into: vec!["sma.series".into()] },
Op::Expose { from: "sma.value".into(), as_name: "out".into() },
];
let pattern = super::replay("pattern", pattern_ops, &std_vocabulary, &|_: &str| None)
.expect("an input-role pattern finishes without root-role boundness");
// (b) round-trip through the serialized store form.
let json = blueprint_to_json(&pattern).expect("an open pattern serializes");
// (c) a source-rooted outer script splices the RELOADED pattern by ref
// id (the injected `subgraph` lookup re-parses the stored JSON on
// every call, mirroring a fresh registry fetch — `Composite` is not
// `Clone`).
let subgraph = |ref_id: &str| {
(ref_id == "pattern-id")
.then(|| blueprint_from_json(&json, &std_vocabulary).expect("an open pattern deserializes"))
};
let outer_ops = vec![
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
Op::Use { ref_id: "pattern-id".into(), name: Some("p".into()), bind: vec![] },
Op::Feed { role: "price".into(), into: vec!["p.x".into()] },
Op::Expose { from: "p.out".into(), as_name: "bias".into() },
];
let outer = super::replay("root", outer_ops, &std_vocabulary, &subgraph)
.expect("the outer script splices the reloaded open pattern");
// (d) compiles and runs — the same recording-harness shape
// `run_recording` uses, but with zero open params (this pattern binds
// `sma.length` at the pattern seam, unlike the shared fixture, so
// `run_recording`'s hardcoded one-F64-param point does not apply).
use crate::harness::{Edge, Target};
use aura_core::Firing;
use aura_std::Recorder;
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let root = Composite::new(
"h",
vec![
BlueprintNode::Composite(outer),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
let mut h = root.bootstrap().expect("bootstraps with no open params");
h.run(vec![Box::new(crate::VecSource::new(crate::test_fixtures::synthetic_prices()))]);
let trace: Vec<_> = rx.try_iter().collect();
assert!(!trace.is_empty(), "the spliced open pattern must actually produce output");
}
}
+3 -3
View File
@@ -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");
@@ -103,7 +103,7 @@ fn replay_attributes_eager_fault_to_its_op_index() {
];
// `.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())));
}
@@ -128,7 +128,7 @@ fn replay_attributes_holistic_fault_to_finalize_step() {
// 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:?}");
}
+265 -2
View File
@@ -173,6 +173,95 @@ impl Registry {
}
}
/// The label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`,
/// the same "fixed-name sidecar, per-directory isolation" discipline (#191).
fn blueprint_names_path(&self) -> PathBuf {
self.path.with_file_name("blueprint_names.jsonl")
}
/// Raw, ungated append: one label line, unconditionally, under the
/// registry's write mutex (#276). No shape or content-id check —
/// [`Registry::put_blueprint_label`] is the gated public entry; this
/// exists so tests can hand-append a stale line, mirroring
/// `append_identity_index`.
fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let path = self.blueprint_names_path();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&BlueprintNameLine {
name: name.to_string(),
content_id: content_id.to_string(),
})
.expect("two plain strings serialize");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}`
/// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated
/// on a deterministic label shape (nonempty after trim, no whitespace, no
/// control characters — `RegistryError::BadLabel`, C29 discipline: shape
/// only, never content judgement) and on `content_id` resolving via
/// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint`
/// otherwise) — both checked BEFORE the write lock is taken. Re-labelling
/// an existing name appends a new line (a repoint); resolution is
/// latest-wins (see [`Registry::resolve_blueprint_label`]).
pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
gate_label_shape(name)?;
if self.get_blueprint(content_id)?.is_none() {
return Err(RegistryError::UnknownBlueprint(content_id.to_string()));
}
self.append_blueprint_label_line(name, content_id)
}
/// Resolve a label to the content id of the LATEST line naming it, skipping
/// any line whose content id no longer resolves via `get_blueprint`
/// (self-repair spirit, #191) — so "latest" always means the latest VALID
/// entry, even when a later append points at a since-vanished id. Missing
/// sidecar file -> `None` (treat-as-empty, like `load_identity_index`).
pub fn resolve_blueprint_label(&self, name: &str) -> Option<String> {
self.latest_blueprint_labels().remove(name)
}
/// Every registered label, latest-wins-deduped and name-sorted — the
/// discovery surface's data source (`graph introspect --registered`).
/// Dangling entries (content id no longer resolves) are skipped.
pub fn blueprint_labels(&self) -> Vec<(String, String)> {
self.latest_blueprint_labels().into_iter().collect()
}
/// Every content id currently stored in the blueprint store — the
/// candidate list a `use` ref's content-id-PREFIX resolution (#302
/// semantics, #317) filters against, mirroring `list_process_ids`/
/// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")`
/// name the same directory, so `list_doc_ids`'s filename-stem walk
/// applies unchanged.
pub fn list_blueprint_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("blueprints")
}
/// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl`
/// into a name -> content_id map (`BTreeMap` gives the name-sorted order
/// `blueprint_labels` promises for free). A garbage line (torn write,
/// manual edit) is skipped, like every other JSONL sidecar read in this
/// module.
fn latest_blueprint_labels(&self) -> std::collections::BTreeMap<String, String> {
let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else {
return std::collections::BTreeMap::new();
};
let mut map = std::collections::BTreeMap::new();
for raw in text.lines() {
let Ok(line) = serde_json::from_str::<BlueprintNameLine>(raw) else { continue };
if self.get_blueprint(&line.content_id).ok().flatten().is_some() {
map.insert(line.name, line.content_id);
}
}
map
}
/// The content-addressed document-store dir for one document kind —
/// a sibling of the runs store, like `blueprints/`.
fn doc_dir(&self, kind: &str) -> PathBuf {
@@ -273,10 +362,42 @@ impl Registry {
}
}
/// One label->content mapping, one JSON object per line in
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
/// shape, mirrored for the label store.
#[derive(serde::Serialize, serde::Deserialize)]
struct BlueprintNameLine {
name: String,
content_id: String,
}
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
/// after trim, no whitespace, no control characters. String shape only —
/// never content judgement (the C29 discipline `doc_gate` also follows).
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
if name.trim().is_empty() {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
}
if name.chars().any(char::is_whitespace) {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
}
if name.chars().any(|c| c.is_control()) {
return Err(RegistryError::BadLabel {
name: name.to_string(),
rule: "must not contain control characters",
});
}
Ok(())
}
/// C29 walk (#316): the root and every named nested composite must carry a
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
/// the Empty fault ("carries no doc").
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
/// before it enters a new composition (fail fast on a doc-less fetched
/// entry) — the one walk, two call sites (register's write-path gate, use's
/// read-path gate), never a second implementation.
pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
let fault = match c.doc.as_deref() {
None => Some(aura_core::DocGateFault::Empty),
Some(d) => aura_core::doc_gate(&c.name, d).err(),
@@ -938,6 +1059,14 @@ pub enum RegistryError {
/// the store without a gate-passing doc. Registered artifacts are never
/// retroactively invalidated — the gate guards the write path only.
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
/// Label sidecar (#317): a `put_blueprint_label` name failing the
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
/// control characters). By-identifier, naming the violated rule.
BadLabel { name: String, rule: &'static str },
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
/// labels a blueprint the store does not have.
UnknownBlueprint(String),
}
impl fmt::Display for RegistryError {
@@ -978,6 +1107,12 @@ impl fmt::Display for RegistryError {
its name a registered composite describes itself (C29)"
),
},
RegistryError::BadLabel { name, rule } => {
write!(f, "blueprint label {name:?}: {rule}")
}
RegistryError::UnknownBlueprint(id) => {
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
}
}
}
}
@@ -1172,6 +1307,134 @@ mod tests {
);
}
/// A described fixture blueprint, ready for `put_blueprint`'s C29 gate —
/// `put_blueprint_label`'s content-id verification routes through
/// `get_blueprint`, so its target must first be a gate-passing entry.
fn described_fixture_blueprint(name: &str) -> String {
format!(
r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"#
)
}
/// Property (#317): a label round-trips to the blueprint it names, and
/// re-labelling the SAME name repoints it — resolution and the discovery
/// listing both answer with the latest target, never the first.
#[test]
fn blueprint_label_round_trips_latest_wins() {
let reg = Registry::open(temp_family_dir("label_round_trip"));
let a = described_fixture_blueprint("a");
let b = described_fixture_blueprint("b");
reg.put_blueprint("ha", &a).expect("put a");
reg.put_blueprint("hb", &b).expect("put b");
reg.put_blueprint_label("x", "ha").expect("label x -> a");
assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string()));
reg.put_blueprint_label("x", "hb").expect("re-label x -> b");
assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins");
assert_eq!(
reg.blueprint_labels(),
vec![("x".to_string(), "hb".to_string())],
"the discovery listing dedups to the latest target",
);
}
/// Property (#317): the label-shape gate is deterministic string shape
/// only (nonempty after trim, no whitespace, no control characters) —
/// each bad shape refuses with `BadLabel` naming a (non-empty) rule.
#[test]
fn blueprint_label_refuses_bad_shapes() {
let reg = Registry::open(temp_family_dir("label_bad_shapes"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
for bad in ["", " ", "a b", "a\tb"] {
match reg.put_blueprint_label(bad, "h1") {
Err(RegistryError::BadLabel { name, rule }) => {
assert_eq!(name, bad);
assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}");
}
other => panic!("expected BadLabel for {bad:?}, got {other:?}"),
}
}
}
/// Property (#317): a label may only ever point at a blueprint the store
/// actually has — labelling an unregistered content id refuses
/// by-identifier, never silently writing a dangling line.
#[test]
fn blueprint_label_refuses_unknown_content_id() {
let reg = Registry::open(temp_family_dir("label_unknown_id"));
match reg.put_blueprint_label("x", "never-written") {
Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"),
other => panic!("expected UnknownBlueprint, got {other:?}"),
}
assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written");
}
/// Property (#317, #191 spirit): a dangling LATEST line (content id no
/// longer resolves) is skipped by resolution, which falls back to the
/// earlier still-valid line for the same name — latest-wins means
/// latest-VALID-wins.
#[test]
fn blueprint_label_resolution_skips_a_dangling_entry() {
let reg = Registry::open(temp_family_dir("label_dangling"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
reg.put_blueprint_label("x", "h1").expect("label x -> h1");
// hand-append a dangling line after the valid one, mirroring
// `append_identity_index`'s stale-line test shape: the content id it
// names was never registered.
reg.append_blueprint_label_line("x", "never-written").expect("append stale line");
assert_eq!(
reg.resolve_blueprint_label("x"),
Some("h1".to_string()),
"the dangling latest line is skipped; the earlier valid line for \"x\" wins",
);
assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]);
}
/// Property (#317, #276 discipline): N threads appending labels through
/// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed —
/// every successful append surviving as its own intact, resolvable line,
/// none torn or merged (mirrors `concurrent_appends_keep_the_family_
/// store_well_formed`).
#[test]
fn concurrent_label_appends_keep_the_sidecar_well_formed() {
let reg = Registry::open(temp_family_dir("concurrent_label"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|tid| {
let reg = &reg;
scope.spawn(move || {
let mut ok = 0usize;
for iter in 0..iters {
let name = format!("t{tid}-l{iter}");
if reg.put_blueprint_label(&name, "h1").is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
assert_eq!(
reg.blueprint_labels().len(),
total_ok,
"every successful label append must survive as its own intact, resolvable entry",
);
}
#[test]
fn document_stores_round_trip_by_content_id() {
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
+24 -5
View File
@@ -1102,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",
@@ -1301,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"));
@@ -1326,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)]
@@ -1470,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 } ]
@@ -2365,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 =
@@ -2421,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"));
@@ -2457,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:?}"
);
}
+6 -1
View File
@@ -1728,9 +1728,14 @@ mod tests {
Ok(_) => panic!("unknown tap must be refused"),
};
assert!(
matches!(err, TapPlanError::UnknownTap { ref name } if name == "no_such_tap"),
matches!(err, TapPlanError::UnknownTap { ref name, .. } if name == "no_such_tap"),
"{err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("fast_tap"),
"unknown-tap refusal enumerates the declared taps (fixture declares only fast_tap): {msg}"
);
// Unknown label — the refusal enumerates the roster.
let mut flat2 = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
+91 -11
View File
@@ -161,17 +161,21 @@ impl FoldRegistry {
}),
});
for (label, doc, fold) in [
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum),
("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean),
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max),
("count", "number of warm rows (any kind; i64 row); one row at the last warm ts", FoldKind::Count),
("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
(
"mean",
"arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
FoldKind::Mean,
),
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
(
"first",
"first warm value, at its own timestamp (any kind; kind-preserving row)",
FoldKind::First,
),
("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last),
("last", "last warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::Last),
] {
r.register(FoldEntry {
label,
@@ -217,7 +221,7 @@ impl FoldRegistry {
/// `aura: ` + exit-1 refusal register via `Display`.
pub enum TapPlanError {
/// The plan names a tap the blueprint does not declare.
UnknownTap { name: String },
UnknownTap { name: String, declared: Vec<String> },
/// The plan names a label the registry does not carry.
UnknownLabel { label: String, roster: Vec<&'static str> },
/// The entry's bind rule rejects the tap's column kind.
@@ -237,8 +241,12 @@ pub enum TapPlanError {
impl fmt::Display for TapPlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TapPlanError::UnknownTap { name } => {
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap")
TapPlanError::UnknownTap { name, declared } => {
write!(
f,
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
declared.join(", ")
)
}
TapPlanError::UnknownLabel { label, roster } => {
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
@@ -402,7 +410,10 @@ pub fn bind_tap_plan(
// Unknown-tap guard: every plan name must be declared.
for name in plan.by_name.keys() {
if !declared_taps.iter().any(|t| &t.name == name) {
return Err(TapPlanError::UnknownTap { name: name.clone() });
return Err(TapPlanError::UnknownTap {
name: name.clone(),
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
});
}
}
@@ -422,7 +433,18 @@ pub fn bind_tap_plan(
Some((label, params)) => {
Resolved::Named { label: label.clone(), params: params.clone() }
}
None => continue, // unbound, inert (C27)
// Unbound, inert (C27) — but only reachable when `plan` carries
// no default (an EXPLICIT plan, e.g. from `--tap`): record-all
// (`default_named` = `Some(("record", …))`) always resolves the
// Some arm above, so this arm never fires under record-all and
// the note is exactly the C14 benign skipped-tap class (#334).
// 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 {
@@ -504,6 +526,64 @@ mod tests {
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
}
/// C29 entry seam for the registry roster: every entry ships a gate-clean
/// one-line meaning, and the bind/output prose inside it matches the
/// entry's executable rules — the drift-pin the retired aura-std
/// `fold_vocabulary` table carried, moved here with the surface (#332).
#[test]
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
let r = FoldRegistry::core();
for entry in r.entries.values() {
aura_core::doc_gate(entry.label, entry.doc)
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
if entry.doc.contains("f64 taps") {
assert!(
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
"{} claims f64-only but binds wider",
entry.label
);
} else {
assert!(
entry.doc.contains("any kind"),
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
entry.label,
entry.doc
);
assert!(
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
"{} claims any-kind but refuses a kind",
entry.label
);
}
if entry.doc.contains("i64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
"{} claims an i64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("f64 row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
"{} claims an f64 row but outputs otherwise",
entry.label
);
} else if entry.doc.contains("kind-preserving row") {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
"{} claims kind-preserving but outputs otherwise",
entry.label
);
} else {
assert!(
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
"{}: doc names no row kind, so it must be the series entry",
entry.label
);
}
}
}
#[test]
fn unknown_label_refusal_enumerates_the_roster() {
let r = FoldRegistry::core();
+1
View File
@@ -29,6 +29,7 @@ impl CarryCost {
"CarryCost",
Vec::new(),
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
"cost-model node: cost accrued per held cycle (param carry_per_cycle)",
|p| Box::new(CarryCost::new(p[0].f64())),
)
}
@@ -28,6 +28,7 @@ impl ConstantCost {
"ConstantCost",
Vec::new(),
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
"cost-model node: fixed cost per trade (param cost_per_trade), charged at close",
|p| Box::new(ConstantCost::new(p[0].f64())),
)
}
+22 -12
View File
@@ -214,26 +214,20 @@ impl<F: CostNode> Node for CostRunner<F> {
}
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
/// extra inputs, the standard 3-field cost output, the given params, and a build
/// closure. The single home for the cost-node schema shape.
/// extra inputs, the standard 3-field cost output, the given params, a
/// factor-specific one-line `doc` (C29 — each cost node names its own charge
/// basis rather than sharing one generic sentence, #330), and a build closure.
/// The single home for the cost-node schema shape.
pub fn cost_node_builder(
name: &'static str,
extra_inputs: Vec<PortSpec>,
params: Vec<ParamSpec>,
doc: &'static str,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> PrimitiveBuilder {
let mut inputs = geometry_input_ports();
inputs.extend(extra_inputs);
PrimitiveBuilder::new(
name,
NodeSchema {
inputs,
output: cost_output_fields(),
params,
doc: "cost-model node: charges its cost in R from position geometry, at close or accrued per held cycle",
},
build,
)
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params, doc }, build)
}
#[cfg(test)]
@@ -501,6 +495,7 @@ mod tests {
"Probe",
extra.clone(),
params.clone(),
"cost-model node: probe factor for the builder-assembly test",
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
);
let schema = builder.schema();
@@ -537,6 +532,21 @@ mod tests {
assert_eq!(a, "volatility");
}
#[test]
fn cost_node_descriptions_are_pairwise_distinct() {
// C29: `aura graph introspect --vocabulary` must be able to tell the
// three cost nodes apart by their charge basis (flat-per-trade vs
// per-held-cycle-accrual vs volatility-scaled), not read one
// identical generic sentence three times (#330).
use crate::{CarryCost, ConstantCost, VolSlippageCost};
let constant_doc = ConstantCost::builder().schema().doc;
let carry_doc = CarryCost::builder().schema().doc;
let vol_slippage_doc = VolSlippageCost::builder().schema().doc;
assert_ne!(constant_doc, carry_doc, "ConstantCost vs CarryCost doc must differ");
assert_ne!(constant_doc, vol_slippage_doc, "ConstantCost vs VolSlippageCost doc must differ");
assert_ne!(carry_doc, vol_slippage_doc, "CarryCost vs VolSlippageCost doc must differ");
}
#[test]
fn producer_and_aggregator_share_the_triple() {
// The structural lockstep: producer output and aggregator output/inputs all
@@ -43,6 +43,7 @@ impl VolSlippageCost {
"VolSlippageCost",
volatility_input_ports(),
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
"cost-model node: slippage proportional to volatility (slip_vol_mult × volatility input), charged at close",
|p| Box::new(VolSlippageCost::new(p[0].f64())),
)
}
+12 -3
View File
@@ -122,7 +122,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(
"my_lab::Scale",
@@ -220,14 +221,15 @@ $ aura graph build < smacross.json > blueprint.json
Nodes are referenced by an **identifier** (given by `add`, see below); ports
are dotted `<identifier>.<port>` on both sides of a wire.
### The nine ops
### The ten ops
| op | JSON shape | does |
|---|---|---|
| `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — for a fragment meant to be wired by an *enclosing* graph. A standalone document built with `aura graph build` finalizes as a closed root, so an `input` role that is never bound refuses at the end: `finalize: root input role <name> is unbound`. |
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). |
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. |
| `use` | `{"op":"use","ref":{"content_id":<id-or-prefix>}\|{"name":<label>},"name":<str>?,"bind":{<path>:<Scalar>}?}` | splice a **registered blueprint** into the graph as a nested composite under an instance identifier (`name`; default: the stored composite's own name). The ref resolves by content id (full or unique prefix) or by a register-time label; `graph build` echoes every resolution (`aura: note: use "…": … -> <id>`) so the exact id can be pinned. The instance's open input roles wire as `<instance>.<role>`, its outputs as `<instance>.<field>`; its open params surface path-qualified (`<instance>.<node>.<param>`), sweepable (ganging an instance's params is not yet supported — the `gang` op refuses a composite instance's member path). A doc-less stored source refuses at the op (C29). The emitted blueprint contains the subgraph *inline* — no reference survives into the artifact. |
| `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
@@ -404,6 +406,13 @@ registered blueprint 597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876da
The printed content id is the address a campaign document's `strategies[].ref`
points at (§3).
`graph register <file> --name <label>` additionally records a **blueprint
label** — a legible, re-pointable handle for the `use` op's `{"name":…}`
ref form (re-registering under the same label repoints it; the echo names
the previous target). `aura graph introspect --registered` lists the
labels with their id prefix and root doc line — the discovery surface for
what `use` can reference.
## 2. Process documents — a validation methodology (role 5)
A process document is a named, versionable pipeline of **std stage blocks**
+51 -13
View File
@@ -144,17 +144,40 @@ per-op checks — name resolution, edge kind-match (`edge_kind_check`), the doub
arm, param bind, and acyclicity (a `connect` that would close a cycle is rejected
eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic**
finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity
(`check_param_namespace_injective`), root-role boundness (`check_root_roles_bound`).
Both cadences call the **same extracted predicates** — no second validator. The
holistic faults read **by-identifier** (#162): `finish()` translates the
index-carrying `CompileError`s (`UnconnectedPort` / `RoleKindMismatch` /
`UnboundRootRole`) into by-identifier `OpError` variants while still *calling* the
unchanged holistic gates. Build-free introspection answers over the closed vocabulary:
`graph introspect --vocabulary | --node <T> | --unwired`. The engine `Op` stays
serde-free; the wire DTO (`OpDoc`) is CLI-side (`aura-cli::graph_construct`).
Acceptance: a graph built purely through the ops compiles identical (C1) to its
Rust-built twin, an invalid op is rejected at the op naming the cause, introspection
answers without a build.
(`check_param_namespace_injective`). Both cadences call the **same extracted
predicates** — no second validator. The holistic faults read **by-identifier**
(#162): `finish()` translates the index-carrying `CompileError`s (`UnconnectedPort` /
`RoleKindMismatch`) into by-identifier `OpError` variants while still *calling* the
unchanged holistic gates. **Root-role boundness moved off this list (#317, open
patterns):** `check_root_roles_bound` no longer runs in `finish()` — a `finish()`ed
op-script may still carry an open (unbound) root role, declaring a reusable
pattern's formal parameter; only `compile`/bootstrap (the runnability gate, not a
construction gate) still call it, unchanged. Build-free introspection answers over
the closed vocabulary: `graph introspect --vocabulary | --node <T> | --unwired`. The
engine `Op` stays serde-free; the wire DTO (`OpDoc`) is CLI-side
(`aura-cli::graph_construct`). Acceptance: a graph built purely through the ops
compiles identical (C1) to its Rust-built twin, an invalid op is rejected at the op
naming the cause, introspection answers without a build.
**Registered-blueprint splice (#317).** The `use` op resolves a reference — a store
content id, a unique content-id prefix (#302 semantics), or a registry **name
label** (`graph register --name`, latest-wins, `aura-registry`'s
`blueprint_names.jsonl` sidecar) — to a previously registered blueprint and
splices it into the building graph as a nested `BlueprintNode::Composite` under an
instance identifier. The **engine never resolves**: `GraphSession`/`replay` take a
second injected closure, `subgraph: &dyn Fn(&str) -> Option<Composite>`, mirroring
`vocab`'s closed-lookup posture exactly — this is the same enforcement-shift
permission §"Enforcement shift" above already grants the node vocabulary, extended
to registered-blueprint references. All store I/O — label/prefix resolution, the
`get_blueprint` fetch, the C29 doc-gate re-walk over the fetched composite (before
it ever reaches the session, so a doc-less fetched entry cannot enter a NEW
composition — a **backstop**: the register verb already gates both input forms, so
this fires only for store content written before C29 or through the raw in-crate
path), and the resolution echo — happens CLI-side, at DTO conversion, before
replay; build-free introspection paths pass `&|_| None`. The echo
(`aura: note: use "<instance>": <label-or-prefix> -> <full id>`) is the existing
`aura: note:` benign-diagnostic marker (C14) — a new instance of the existing
class, not a new one, so the exit-code/marker taxonomy is unchanged.
**Invariant-5 lockstep.** The eager acyclicity gate is a *second* home of invariant 5
alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two
@@ -166,12 +189,14 @@ rejected at construction.
The op-script is a JSON **array of ops**, each object internally tagged by `"op"`,
replayed in order; nodes are referenced **by identifier**, ports as dotted
`<identifier>.<port>`. The eight verbs:
`<identifier>.<port>`. The ten verbs:
- `source``{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
source role producing a base column of `kind`.
- `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred
from the slots it feeds).
from the slots it feeds) — a reusable **pattern**'s formal parameter, left open
until an enclosing graph wires it (#317's open patterns): `finish()` accepts an
op-script that never binds it, only `compile`/bootstrap require it bound.
- `add``{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}`
add a node of compiled-in type identity `type`; **`name` is its identifier**
(mirrors the builder's `.named(...)`; defaults to the lowercased type label, so
@@ -196,6 +221,19 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
leave the sweepable param space and `as` replaces them; the bound or swept
value fans out to every member at bootstrap. Members must share one scalar
kind and stay open (un-bound).
- `doc``{"op":"doc","text":<str>}` — declare the composite's one-line meaning
(C29, #316) — the op-script twin of the builder's `.doc(...)`; at most one per
script, a second is a fault (a declarative script carries no last-wins
ambiguity).
- `use``{"op":"use","ref":{"content_id":<str>}|{"name":<str>},"name":<str>?,"bind":{<path>:<Scalar>}?}`
— splice a **registered** blueprint into the building graph as a nested
composite under an instance identifier (`name`, defaulting to the fetched
composite's own name); `bind` path-qualifies the spliced instance's params
(e.g. `"sma.length"`), applied after the splice (#317). An instance's open
input roles become its `<instance>.<role>` in-ports, its output boundary
aliases its `<instance>.<field>` out-fields — the same `Composite` arm every
other add/wiring path walks, not a new mechanism. See §"The construction
service" below for the resolution split.
Value forms are the serialized representations: a `Scalar` bind value is the typed tag
form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind`
@@ -0,0 +1,11 @@
# C27 — Declared taps: named measurement points bind sinks run-mode-aware: history
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp
> and may be superseded; this file is NOT current truth and NOT a grounding
> surface. Current contract: [c27-declared-taps.md](c27-declared-taps.md).
**Current-state tap-plan sentence (2026-07-21, #283; superseded 2026-07-24 by
the #310 `--tap` selector):** "…and the shared `bind_tap_plan`/`BoundTaps`
pair called by both declared-tap entry points, `run_signal_r`
(`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both
CLI verbs pass a record-all plan."
+18 -2
View File
@@ -68,12 +68,28 @@ and the roster-enumerating refusal — plus a scalar-typed param schema; all cor
entries are param-less today, the seam ships in every entry's build signature),
and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
(`aura-runner::measure`); both CLI verbs pass a record-all plan. The `record`
(`aura-runner::measure`) both arms of the single CLI verb `aura run`, whose
repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
data-reachable: no flag keeps the record-all default, any flag replaces the
plan entirely (unlisted taps stay unbound/inert). The boundary is thereby
fixed in place: *selecting* a subscription is run-mode authority, exercised
by the run-mode owner — on the one-shot path the CLI invocation itself, a
projection exercising this contract's authority, not a second home for
intent under [C25](c25-role-model.md) — while *adding* a fold stays a Rust
entry (role 2). The campaign/document carrier, and the reconciliation of the
presentation-tap namespace (`persist_taps`) with declared taps it requires,
is deferred to the Measurement-reachable milestone (#312/#327, minuted on
#312). The `record`
consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's
streaming writer in-graph — `initialize` opens (deferred acquisition), `eval`
appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports
exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one
summary row; live closures run inline (`aura-std::TapLive`). The sweep/reduce
summary row, emitted at finalize and stamped with the instant of the last
contributing (warm) value — `first` alone pins the first contributing
instant; `min`/`max` deliberately do not carry the extremum's timestamp (a
whole-window row privileges no interior instant) — ratified as-is, #335;
live closures run inline
(`aura-std::TapLive`). The sweep/reduce
path never calls `bind_tap`.
The chain-pruning benefit — a sweep paying zero for the study wires behind an
+17 -5
View File
@@ -27,13 +27,23 @@ cannot pass its short-name alibi
restatement.
3. **Register seam (data plane).** The registry's public `put_blueprint`
parses the canonical bytes and requires a gate-passing doc on the root
composite and on every named nested composite the gate guards the
store boundary itself, so the register verb, every register-then-run
composite and on **every** nested composite (the walk recurses
unconditionally — it does not stop at an unnamed one) — the gate guards
the store boundary itself, so the register verb, every register-then-run
sugar path, and any future caller are gated by construction. The
op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s
twin ([C25](c25-role-model.md): a closed, typed metadata construct).
Process/campaign documents take an additive-optional top-level
`description`, gated only when present (`DocFault::BadDescription`).
4. **Use seam (data plane, construction-time, #317).** The `use`
construction op re-runs the SAME `gate_composite_docs` walk over a
blueprint FETCHED from the store, before it ever splices into a new
composition — a build-time gate on already-registered content, not a
second write-path check. A doc-less entry (reachable only through the
raw in-crate write survivor class, since `put_blueprint`'s own gate keeps
the store clean going forward) cannot enter a NEW composition this way,
but stays readable and runnable on its own — no retroactive invalidation,
the same discipline the Forbids clause states for the register seam.
**Scope at this record's writing (#321).** The gate walks five
vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and
@@ -67,6 +77,8 @@ for whom the binary is the only always-present teacher. Field evidence
execution semantics and document schemas by CAS forensics — the removed
failure class is exactly that forensic recovery.
Rendering of the carried texts on the help/introspection surfaces is #315;
the generated agent bootstrap card is #267; a fold introspection surface
is blocked on #310.
Rendering of the carried texts on the help/introspection surfaces shipped
with #315; the agent bootstrap card was retired as superseded (#267 — the
C29 surfaces themselves carry it); the fold introspection surface shipped
with #310 and renders the fold-registry roster — labels exactly as the
`--tap` selector accepts them, doc lines from the registry entries (#332).
+9 -1
View File
@@ -31,6 +31,10 @@ A strategy's primary, backtestable DAG output: one signed, bounded `f64 ∈ [-1,
**Avoid:** —
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while `run` uses it as-is.
### blueprint label
**Avoid:** —
A registry-level name pointing at a registered blueprint's content id (`graph register --name <label>`, #317) — a mutable, latest-wins pointer over the immutable content-addressed store, not a second identity: re-registering under an existing label *repoints* it (the earlier content id stays reachable by its own id, never invalidated). `graph introspect --registered` lists every label with its content-id prefix and root `doc` line — the discovery surface a `use (op)` reference by name resolves against.
### bootstrap
**Avoid:** —
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null, the `aura mc` R path, and since 0108 the `std::monte_carlo` stage's `stage bootstrap` annotation; context (construction vs annotation) disambiguates.
@@ -327,12 +331,16 @@ A named recorded stream produced by a recording `sink` — the addressable label
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura run` constructs a recorder at each and persists the series through the trace store; a sweep leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both CLI verbs (`aura run`, `aura measure`) pass a record-all plan, so their semantics are unchanged.
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura run` (`aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura run` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is emitted at finalize and stamped with the instant of the last contributing (warm) value — `first` alone pins the first contributing instant, and `min`/`max` deliberately do not carry the extremum's instant (ratified #335).
### topology hash
**Avoid:** —
The `content id` of a run's signal blueprint in its run-record role: stamped into the manifest as `topology_hash`, keying the reproduction store and anchoring `aura reproduce` — the same SHA-256 over the same canonical bytes. Distinct from the debug-name-blind `identity id`.
### use (op)
**Avoid:** —
The construction op that splices a **registered** blueprint into the building graph as a nested `composite` under a chosen instance name (`{"op":"use","ref":{"content_id"|"name":…},"name":…,"bind":{…}}`, #317): the reference — a content id, a unique content-id prefix, or a `blueprint label` — resolves CLI-side, before the engine ever sees it; the fetched composite is C29-gated, spliced, and its resolution echoed on stderr (`aura: note:`, the existing benign marker). The emitted blueprint carries the splice inline as an ordinary nested composite — no reference or registry dependency survives into the artifact. An instance's open input roles become its `<instance>.<role>` in-ports, its output boundary fields its `<instance>.<field>` out-fields — the existing nested-composite param-prefix discipline extends unchanged (`graph.<instance>.<node>.<param>` on `sweep --list-axes`). Pairs with the `input` op: a pattern meant to be `use`d declares its formal parameters as open `input` roles, left unbound at `finish()` (#317's open-pattern amendment) — only running it standalone requires them bound.
### veto
**Avoid:** risk-manager
The **optional** documented pre-trade-gate seam in the execution chain (`stop-rule → [Veto] → position-management`): position / exposure / notional caps that may reject or scale a trade. A pass-through identity DCE'd away when absent (C19/C23); kept as a named seam — it is what LEAN calls "Risk Management" and nautilus the RiskEngine.
+122
View File
@@ -0,0 +1,122 @@
# Fieldtest transcript — cycle #317 (composites, the `use` op)
Verbatim command/output capture for the `fieldtests/composites-use/` fixtures.
Binary: `target/release/aura` built from HEAD `4a30222` (release profile).
Project: `./lab` (scaffolded with `aura new lab`; `/runs` gitignored).
Commands using the store were run from inside `lab/`.
## Example 1 — register → --name → discover → use by name → splice
```
$ aura graph build < cu_1_pattern_spread.ops.json
{... "input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}] ...}
# open pattern: input role "price" carries no "source":<kind> (contrast a source role)
$ aura graph introspect --params cu_1_pattern_spread.ops.json
fast.length:I64
slow.length:I64
$ aura graph introspect --unwired < cu_1_pattern_spread.ops.json
# (no output — the price input role feeds fast.series/slow.series, so no open interior slot)
# --- inside lab/ ---
$ aura graph register .../cu_1_pattern_spread.ops.json --name spread_xover
registered blueprint f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1 (.../runs/blueprints/f61659....json)
label "spread_xover" -> f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1
$ aura graph introspect --registered
spread_xover f61659aaa8dd open SMA-crossover spread pattern: fast minus slow over an injected price input role
$ aura graph build < cu_1_consumer.ops.json > payload 2> echo
# stderr (echo): aura: note: use "xover": spread_xover -> f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1
# stdout (payload): {... "nodes":[{"composite":{"name":"xover", ... spliced inline ...}}, {"primitive":{"type":"Bias"...}}] ...}
$ aura graph introspect --params cu_1_consumer.ops.json
xover.fast.length:I64
xover.slow.length:I64
bias.scale:F64
```
## Example 2 — ref-form variety + refusal UX
Store contains labels `ema_smoother` (1c8edf24a1eb) and `spread_xover` (f61659aaa8dd),
plus two unlabelled spread variants `e6982f7d...` and `e8f04011...` (both start with `e`).
```
$ aura graph register .../cu_2_docless.ops.json --name docless
aura: blueprint: composite `graph` carries no doc — a registered composite describes itself (C29); add a doc line (...) before register
# exit 1 (register gates doc-less at register; a doc-less entry never enters the store)
# by full content id -> splices, echoes id
$ aura graph build < cu_2_ref_full_id.ops.json
aura: note: use "sp": e6982f7d...d55d82 -> e6982f7d...d55d82 (exit 0)
# by unique prefix "e69" -> splices, echoes resolved id
$ aura graph build < cu_2_ref_prefix.ops.json
aura: note: use "sp": e69 -> e6982f7d...d55d82 (exit 0)
# by ambiguous prefix "e" -> refuses, enumerates candidates
$ aura graph build < cu_2_ref_ambiguous.ops.json
aura: op 2 (use "sp"): ambiguous id "e": candidates e6982f7d...d55d82, e8f04011...07fcf8 (exit 1)
# by unknown prefix "deadbeef" -> refuses
$ aura graph build < cu_2_ref_unknown_prefix.ops.json
aura: op 2 (use "sp"): no registered blueprint matches content id "deadbeef" (exit 1)
# by unknown label "nope" -> refuses, enumerates labels
$ aura graph build < cu_2_ref_unknown_label.ops.json
aura: op 2 (use "sp"): no registered blueprint labeled "nope" — registered labels: ema_smoother, spread_xover (exit 1)
```
## Example 3 — running an open pattern standalone
Docs (authoring guide `input` row; glossary `use (op)`) say: "only running it
standalone refuses (bootstrap's unbound-root-role gate)."
```
# (a) spread-exposing open pattern -> refuses on the run-SHAPE gate (not the role gate)
$ aura run spread_open.bp.json
aura: `aura run` needs either a `bias` output (a strategy) or ≥1 declared tap (a measurement); this blueprint exposes neither (exit 1)
# (b) bias-exposing open pattern, params OPEN -> refuses on the OPEN-PARAMS gate
$ aura run open_bias.bp.json
aura: run requires a closed blueprint (no free parameters); 3 free knob(s) — bind them or use `aura sweep --axis` (exit 2)
$ aura run open_bias.bp.json --params '[{"I64":2},{"I64":4},{"F64":0.5}]'
aura: run requires a closed blueprint (no free parameters); 3 free knob(s) — ... (exit 2) # --params did not close the knobs
# (c) bias-exposing open pattern, params BOUND, input role "price" OPEN -> RUNS CLEAN (docs say refuse)
$ aura run open_bias_bound.bp.json # from cu_3_open_bias_bound.ops.json
{"manifest":{...,"params":[],"defaults":[["graph.fast.length",{"I64":2}],...]},"metrics":{"total_pips":0.3418...}} (exit 0)
# (d) same, but input role named "quote" (not a column name) -> refuses on the ROLE-COLUMN gate
$ aura run altrole.bp.json
aura: input role "quote" of strategy "graph" binds to no archive column — bindable columns: open, high, low, close (alias: price), spread, volume; or bind it explicitly in the campaign data.bindings block (exit 1)
```
## Example 4 — sweep integration + gang refusal + happy-path run
```
$ aura sweep cu_1_consumer.bp.json --list-axes
graph.xover.fast.length:I64
graph.xover.slow.length:I64
graph.bias.scale:F64
$ aura sweep cu_1_consumer.bp.json --axis graph.xover.fast.length=2,3 \
--axis graph.xover.slow.length=6 --axis graph.bias.scale=0.5 --name cu4_sweep
{"family_id":"cu4_sweep-0","report":{"manifest":{...,"params":[["graph.xover.fast.length",{"I64":2}],["graph.xover.slow.length",{"I64":6}],...]}}}
{"family_id":"cu4_sweep-0","report":{"manifest":{...,"params":[["graph.xover.fast.length",{"I64":3}],...]}}}
# exit 0, 2 members — spliced-instance params sweep through the nesting
# gang a spliced instance's member path -> refuses (documented), but message frames it as a missing param
$ aura graph build < cu_4_gang_instance_member.ops.json
aura: note: use "xover": spread_xover -> f61659...
aura: op 6 (gang): node xover has no param "fast.length" (exit 1)
# happy path: use + splice-time bind -> closed blueprint -> run
$ aura graph build < cu_1_consumer_bound.ops.json > closed.bp.json
$ aura graph introspect --params cu_1_consumer_bound.ops.json # (empty = closed)
$ aura run closed.bp.json
{"manifest":{...,"params":[],"defaults":[["graph.xover.fast.length",{"I64":2}],...]},"metrics":{...}} (exit 0)
$ aura run closed.bp.json --real GER40
{"manifest":{...}} (exit 0)
```
@@ -0,0 +1,9 @@
[
{"op": "doc", "text": "consumer: splice the spread pattern by name, feed price, clamp the spread into a bias"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"name": "spread_xover"}, "name": "xover"},
{"op": "feed", "role": "price", "into": ["xover.price"]},
{"op": "add", "type": "Bias", "name": "bias"},
{"op": "connect", "from": "xover.spread", "to": "bias.signal"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,9 @@
[
{"op": "doc", "text": "consumer that splices the spread pattern, binds its params at the use seam, and clamps to a bias — a closed, runnable blueprint"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"name": "spread_xover"}, "name": "xover", "bind": {"fast.length": {"I64": 2}, "slow.length": {"I64": 6}}},
{"op": "feed", "role": "price", "into": ["xover.price"]},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
{"op": "connect", "from": "xover.spread", "to": "bias.signal"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,11 @@
[
{"op": "doc", "text": "open SMA-crossover spread pattern: fast minus slow over an injected price input role"},
{"op": "input", "role": "price"},
{"op": "add", "type": "SMA", "name": "fast"},
{"op": "add", "type": "SMA", "name": "slow"},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "expose", "from": "sub.value", "as": "spread"}
]
@@ -0,0 +1,6 @@
[
{"op": "input", "role": "price"},
{"op": "add", "type": "EMA", "name": "e"},
{"op": "feed", "role": "price", "into": ["e.series"]},
{"op": "expose", "from": "e.value", "as": "smoothed"}
]
@@ -0,0 +1,7 @@
[
{"op": "doc", "text": "open EMA smoother pattern: exponential moving average over an injected price input role"},
{"op": "input", "role": "price"},
{"op": "add", "type": "EMA", "name": "e"},
{"op": "feed", "role": "price", "into": ["e.series"]},
{"op": "expose", "from": "e.value", "as": "smoothed"}
]
@@ -0,0 +1,7 @@
[
{"op": "doc", "text": "ref by an ambiguous one-char prefix (two ids start with e)"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"content_id": "e"}, "name": "sp"},
{"op": "feed", "role": "price", "into": ["sp.price"]},
{"op": "expose", "from": "sp.spread", "as": "spread"}
]
@@ -0,0 +1,7 @@
[
{"op": "doc", "text": "ref a registered spread by full content id"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"content_id": "e6982f7d4ad35dd59f1d9835e10436c193735d94501b7d10518eae0d9cd55d82"}, "name": "sp"},
{"op": "feed", "role": "price", "into": ["sp.price"]},
{"op": "expose", "from": "sp.spread", "as": "spread"}
]
@@ -0,0 +1,7 @@
[
{"op": "doc", "text": "ref a registered spread by unique content-id prefix"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"content_id": "e69"}, "name": "sp"},
{"op": "feed", "role": "price", "into": ["sp.price"]},
{"op": "expose", "from": "sp.spread", "as": "spread"}
]
@@ -0,0 +1,7 @@
[
{"op": "doc", "text": "ref by a label that was never registered"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"name": "nope"}, "name": "sp"},
{"op": "feed", "role": "price", "into": ["sp.price"]},
{"op": "expose", "from": "sp.spread", "as": "spread"}
]
@@ -0,0 +1,7 @@
[
{"op": "doc", "text": "ref by a prefix that matches nothing in the store"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"content_id": "deadbeef"}, "name": "sp"},
{"op": "feed", "role": "price", "into": ["sp.price"]},
{"op": "expose", "from": "sp.spread", "as": "spread"}
]
@@ -0,0 +1,11 @@
[
{"op": "doc", "text": "spread pattern variant 2 for prefix ambiguity probe"},
{"op": "input", "role": "price"},
{"op": "add", "type": "SMA", "name": "fast"},
{"op": "add", "type": "SMA", "name": "slow"},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "expose", "from": "sub.value", "as": "spread"}
]
@@ -0,0 +1,11 @@
[
{"op": "doc", "text": "spread pattern variant 5 for prefix ambiguity probe"},
{"op": "input", "role": "price"},
{"op": "add", "type": "SMA", "name": "fast"},
{"op": "add", "type": "SMA", "name": "slow"},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "expose", "from": "sub.value", "as": "spread"}
]
@@ -0,0 +1,13 @@
[
{"op": "doc", "text": "open bias pattern: SMA-crossover spread clamped to a bias, over an injected price input role"},
{"op": "input", "role": "price"},
{"op": "add", "type": "SMA", "name": "fast"},
{"op": "add", "type": "SMA", "name": "slow"},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias"},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,13 @@
[
{"op": "doc", "text": "open bias pattern with params bound but the price input role still open — isolates the standalone-run input-role refusal"},
{"op": "input", "role": "price"},
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 2}}},
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 4}}},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,10 @@
[
{"op": "doc", "text": "consumer that tries to gang two params of a spliced instance (documented as unsupported)"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "use", "ref": {"name": "spread_xover"}, "name": "xover"},
{"op": "feed", "role": "price", "into": ["xover.price"]},
{"op": "add", "type": "Bias", "name": "bias"},
{"op": "connect", "from": "xover.spread", "to": "bias.signal"},
{"op": "gang", "as": "xover_length", "into": ["xover.fast.length", "xover.slow.length"]},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
+1
View File
@@ -0,0 +1 @@
/runs
+4
View File
@@ -0,0 +1,4 @@
# Static project context only (C17); paths only.
[paths]
runs = "runs"
# data = "/path/to/archive" # the recorded-data root; defaults to the built-in path
+22
View File
@@ -0,0 +1,22 @@
# lab — an aura research project (data-only)
This directory is an aura project: blueprints + research documents over the
std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
- Run: `aura run blueprints/signal.json` (the starter is closed — all
params bound; bound values are defaults — any `--axis` may override them
(#246))
- Sweep: `aura sweep blueprints/signal.json --axis lab_signal.fast.length=2,4,8`
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
- 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`.
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"lab_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"}]}}
@@ -0,0 +1,15 @@
[
{"op": "source", "role": "price", "kind": "F64"},
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
{"op": "add", "type": "SMA", "name": "px", "bind": {"length": {"I64": 1}}},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series", "px.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "tap", "from": "sub.value", "as": "signal"},
{"op": "tap", "from": "px.value", "as": "price"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,18 @@
[
{"op": "source", "role": "price", "kind": "F64"},
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Gt", "name": "cross"},
{"op": "connect", "from": "fast.value", "to": "cross.a"},
{"op": "connect", "from": "slow.value", "to": "cross.b"},
{"op": "add", "type": "Sign", "name": "sgn"},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "connect", "from": "sub.value", "to": "sgn.value"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
{"op": "connect", "from": "sgn.value", "to": "bias.signal"},
{"op": "tap", "from": "cross.value", "as": "above"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,16 @@
[
{"op": "doc", "text": "SMA crossover bias with two measurement taps for fold discovery"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
{"op": "add", "type": "SMA", "name": "px", "bind": {"length": {"I64": 1}}},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series", "px.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "tap", "from": "sub.value", "as": "spread"},
{"op": "tap", "from": "px.value", "as": "price"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,12 @@
[
{"op": "source", "role": "price", "kind": "F64"},
{"op": "add", "type": "SMA", "name": "fast", "params": {"length": {"I64": 3}}},
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias"},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
@@ -0,0 +1,16 @@
[
{"op": "doc", "text": "SMA crossover exposing three measurement taps to probe the skipped-tap note"},
{"op": "source", "role": "price", "kind": "F64"},
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "tap", "from": "fast.value", "as": "fast_ma"},
{"op": "tap", "from": "slow.value", "as": "slow_ma"},
{"op": "tap", "from": "sub.value", "as": "spread"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
+18
View File
@@ -0,0 +1,18 @@
# Milestone 36 fieldtest — "Self-description: every surface explains itself"
Empirical, binary-only check of the milestone promise: that a deployment-posture
agent with **only** the `aura` binary (no engine sources, no repo docs, no
hand-written bootstrap card) can bootstrap from the surfaces alone.
Every artefact here was produced touching nothing but the release binary
(`d26f0c8`) and what it emits (help text, `aura new` scaffold, introspection
output, error messages). One child dir per scenario; each README states the
property it protects. Full findings + verdict: `docs/specs/fieldtest-m1-self-description.md`.
| dir | axis | outcome |
|-----|------|---------|
| s1_bootstrap_author_validate | cold bootstrap → author → validate | green |
| s2_execution_pulse_idiom | execution semantics + pulse idiom | green (1 friction) |
| s3_vocabulary_breadth | vocabulary self-description breadth| pass (1 friction) |
| s4_document_ramp | document-first ramp from `{}` | green (2 gaps) |
| s5_trace_measurement | trace / measurement path | dead-end (1 bug, 1 gap) |
@@ -0,0 +1,33 @@
# s1 — cold bootstrap → author → validate
**Protected property:** starting from `aura --help` alone (no repo, no docs), a
downstream agent can scaffold a project, author a new strategy over the
discovered op-grammar vocabulary, and drive it to a green backtest — using only
what the binary emits.
## Trail (binary-only)
1. `aura --help` — states what aura is, the two-layer model, and the execution
model (bias in [-1,+1], held as target position, stop = R).
2. `aura new demo-strat` — scaffolds `Aura.toml`, `blueprints/signal.json`, and a
machine-written `CLAUDE.md` bootstrap card.
3. `aura graph build --help` — the op-list authoring grammar (one line of meaning
per op).
4. `aura graph introspect --vocabulary` / `--node <T>` — node types and port
schemas.
5. Author `ema_cross.oplist.json` (this dir); build it:
`aura graph build < ema_cross.oplist.json > blueprints/ema_cross.json`
6. Validate green:
- synthetic: `aura run blueprints/ema_cross.json` → exit 0 (18-cycle synthetic
stream → 0 trades; all-zero metrics, no note that the stream is tiny).
- real: `aura run blueprints/ema_cross.json --real EURUSD --from 1577836800000
--to 1583020800000` → 3944 trades, valid metrics JSON, exit 0.
`ema_cross.blueprint.json` is the built artefact `graph build` emitted.
## Notes
- The harness auto-wraps the bias with `sim-optimal+risk-executor` and reports R
metrics even though the blueprint emits only a bias — confirming the documented
execution model without any extra authoring.
- Minor friction: `graph build` has no op to set the blueprint name; it defaults
to `graph`, and that name leaks into the sweep axis namespace
(`graph.fast.length`).
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"graph","doc":"EMA(5)/EMA(20) spread clamped to a directional bias","nodes":[{"primitive":{"type":"EMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"EMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Sub","name":"spread"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.25}}]}}],"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,13 @@
[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"EMA","name":"fast","bind":{"length":{"I64":5}}},
{"op":"add","type":"EMA","name":"slow","bind":{"length":{"I64":20}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"add","type":"Sub","name":"spread"},
{"op":"connect","from":"fast.value","to":"spread.lhs"},
{"op":"connect","from":"slow.value","to":"spread.rhs"},
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.25}}},
{"op":"connect","from":"spread.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"doc","text":"EMA(5)/EMA(20) spread clamped to a directional bias"}
]
@@ -0,0 +1,32 @@
# s2 — execution semantics + one-shot/pulse idiom
**Protected property:** from the surfaces alone a consumer can learn (a) what the
strategy's output stream means, (b) how it is executed against a protective stop,
and (c) the signal-side idiom the surfaces recommend when a held one-shot / pulse
behaviour is wanted — and can author that idiom to a green run.
## What the surfaces say
- `aura --help`: "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 signal quality is measured in R."
- Node schemas make the execution layer concrete: `Bias` (clamp to [-1,+1]),
`FixedStop` (`price → stop_distance`, defines R), `PositionManagement`
(`bias + price + stop_distance + size → managed position in R`). The built-in
`run`/`sweep` harness supplies this risk executor automatically.
- The scaffolded `CLAUDE.md`: "Entry signals become held state via the signal-side
latch/edge-pulse idiom (see `aura graph introspect --vocabulary`)."
## Authored idioms (both build + validate green)
- `latch_hold.oplist.json` — momentary crossover events → held state via `Latch`
(`set = fast>slow`, `reset = slow>fast`), held level → `Bias`. Runs green
(2543 trades over EURUSD 2020-01..03).
- `edge_pulse.oplist.json` — the complementary one-shot pulse, built as the
first-difference of the latched level (`Delay(lag=1)` + `Sub`) fed to `Bias`
(+1 on entry edge, 1 on exit edge, 0 held). Builds + validates green.
## Friction recorded
The `CLAUDE.md` names the "latch/edge-pulse idiom" and points to
`graph introspect --vocabulary`, but that surface only lists node one-liners —
it lists `Latch` but no `edge`/`pulse` primitive and no composition recipe. The
pulse *is* constructible (Latch→Delay→Sub→Bias), but the consumer must infer the
first-difference recipe; the pointer resolves to a list, not a recipe.
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"graph","doc":"edge-pulse: first-difference of the latched level, +1 on entry edge -1 on exit edge","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Gt","name":"cross_up"}},{"primitive":{"type":"Gt","name":"cross_dn"}},{"primitive":{"type":"Latch","name":"hold"}},{"primitive":{"type":"Delay","name":"prev","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Sub","name":"edge"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":0,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":4,"slot":1,"from_field":0},{"from":4,"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":6,"to":7,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
@@ -0,0 +1,24 @@
[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":5}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":20}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"add","type":"Gt","name":"cross_up"},
{"op":"connect","from":"fast.value","to":"cross_up.a"},
{"op":"connect","from":"slow.value","to":"cross_up.b"},
{"op":"add","type":"Gt","name":"cross_dn"},
{"op":"connect","from":"slow.value","to":"cross_dn.a"},
{"op":"connect","from":"fast.value","to":"cross_dn.b"},
{"op":"add","type":"Latch","name":"hold"},
{"op":"connect","from":"cross_up.value","to":"hold.set"},
{"op":"connect","from":"cross_dn.value","to":"hold.reset"},
{"op":"add","type":"Delay","name":"prev","bind":{"lag":{"I64":1}}},
{"op":"connect","from":"hold.value","to":"prev.series"},
{"op":"add","type":"Sub","name":"edge"},
{"op":"connect","from":"hold.value","to":"edge.lhs"},
{"op":"connect","from":"prev.value","to":"edge.rhs"},
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":1.0}}},
{"op":"connect","from":"edge.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"doc","text":"edge-pulse: first-difference of the latched level, +1 on entry edge -1 on exit edge"}
]
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"graph","doc":"latched long: set on fast>slow, reset on slow>fast, held as bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Gt","name":"cross_up"}},{"primitive":{"type":"Gt","name":"cross_dn"}},{"primitive":{"type":"Latch","name":"hold"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":0,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":4,"slot":1,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":5,"field":0,"name":"bias"}]}}
@@ -0,0 +1,19 @@
[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":5}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":20}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"add","type":"Gt","name":"cross_up"},
{"op":"connect","from":"fast.value","to":"cross_up.a"},
{"op":"connect","from":"slow.value","to":"cross_up.b"},
{"op":"add","type":"Gt","name":"cross_dn"},
{"op":"connect","from":"slow.value","to":"cross_dn.a"},
{"op":"connect","from":"fast.value","to":"cross_dn.b"},
{"op":"add","type":"Latch","name":"hold"},
{"op":"connect","from":"cross_up.value","to":"hold.set"},
{"op":"connect","from":"cross_dn.value","to":"hold.reset"},
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":1.0}}},
{"op":"connect","from":"hold.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"doc","text":"latched long: set on fast>slow, reset on slow>fast, held as bias"}
]
@@ -0,0 +1,22 @@
# s3 — vocabulary self-description breadth
**Protected property:** every closed-vocabulary entry class the binary exposes is
reachable and carries a non-empty one-line meaning, and no entry's meaning is a
byte-identical (non-discriminating) duplicate of a sibling in the same class.
`breadth_probe.sh` drives only `aura` introspection output; `breadth_probe.out`
is its captured result. Run with `AURA=<path-to-release-aura> bash breadth_probe.sh`.
## Result (see breadth_probe.out)
- 33/33 node types resolve via `graph introspect --node <T>` with a
`"<Name> — <meaning>"` schema. PASS.
- 7/7 folds, 6/6 process blocks, 6/6 campaign blocks, 17/17 metrics reachable and
carry a meaning (metrics also carry applicability tags). PASS.
- **One blemish:** three distinct node types — `CarryCost`, `ConstantCost`,
`VolSlippageCost` — share the byte-identical one-liner
*"cost-model node: charges its cost in R from position geometry, at close or
accrued per held cycle"*. From `--vocabulary` (the primary discovery surface)
a consumer cannot tell them apart; the distinguishing detail (the param
`carry_per_cycle` vs `cost_per_trade` vs `slip_vol_mult`, and the extra
`volatility` input) lives only in `--node` and in the campaign `cost` slot
prose. The one-liner exists (C29 satisfied) but is not entry-specific.
@@ -0,0 +1,45 @@
== node vocabulary: every node resolves via --node with a meaning ==
nodes probed: 33
== duplicate node one-liners (non-discriminating meanings) ==
x3 identical: cost-model node: charges its cost in R from position geometry, at close or accrued per held cycle
== folds carry a meaning ==
ok: Count
ok: Sum
ok: Mean
ok: Min
ok: Max
ok: First
ok: Last
== process blocks resolve via --block ==
ok: std::sweep
ok: std::gate
ok: std::walk_forward
ok: std::monte_carlo
ok: std::generalize
ok: std::grid
== campaign blocks resolve via --block ==
ok: std::data
ok: std::risk
ok: std::cost
ok: std::strategy
ok: std::process_ref
ok: std::presentation
== metrics carry an applicability tag + meaning ==
ok: expectancy_r
ok: win_rate
ok: avg_win_r
ok: avg_loss_r
ok: profit_factor
ok: max_r_drawdown
ok: sqn
ok: sqn_normalized
ok: net_expectancy_r
ok: n_trades
ok: n_open_at_end
ok: total_pips
ok: max_drawdown
ok: bias_sign_flips
ok: deflated_score
ok: overfit_probability
ok: neighbourhood_score
RESULT: fail=0
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Scenario 3 — vocabulary self-description breadth probe.
# Protected property: every closed-vocabulary entry class the binary exposes is
# REACHABLE and carries a non-empty one-line meaning; no entry's meaning is a
# byte-for-byte duplicate of another entry in the SAME class (a duplicate is a
# non-discriminating meaning).
# Binary-only: uses nothing but `aura` introspection output.
set -u
AURA="${AURA:?set AURA to the release binary path}"
fail=0
echo "== node vocabulary: every node resolves via --node with a meaning =="
nodes=$("$AURA" graph introspect --vocabulary | awk '{print $1}')
for n in $nodes; do
line=$("$AURA" graph introspect --node "$n" 2>&1 | head -1)
case "$line" in
"$n"*) : ;; # "<Name> — <meaning>"
*) echo " MISSING/BAD meaning for node $n: [$line]"; fail=1 ;;
esac
done
echo " nodes probed: $(echo "$nodes" | wc -w)"
echo "== duplicate node one-liners (non-discriminating meanings) =="
"$AURA" graph introspect --vocabulary \
| sed -E 's/^[A-Za-z0-9_]+ +//' \
| sort | uniq -c | sort -rn \
| awk '$1 > 1 { print " x"$1" identical: "substr($0, index($0,$2)) }'
echo "== folds carry a meaning =="
"$AURA" graph introspect --folds | awk 'NF==0{next} {print " ok: "$1}'
echo "== process blocks resolve via --block =="
for b in $("$AURA" process introspect --vocabulary | awk '{print $1}'); do
"$AURA" process introspect --block "$b" >/dev/null 2>&1 && echo " ok: $b" || { echo " BAD: $b"; fail=1; }
done
echo "== campaign blocks resolve via --block =="
for b in $("$AURA" campaign introspect --vocabulary | awk '{print $1}'); do
"$AURA" campaign introspect --block "$b" >/dev/null 2>&1 && echo " ok: $b" || { echo " BAD: $b"; fail=1; }
done
echo "== metrics carry an applicability tag + meaning =="
"$AURA" process introspect --metrics | awk '{print " ok: "$1}'
echo "RESULT: fail=$fail"
@@ -0,0 +1,38 @@
# s4 — document-first ramp (grow from `{}` to a runnable campaign)
**Protected property:** using only `--unwired` introspection and validation
diagnostics, a consumer can grow a process document and a campaign document from a
bare `{}` to a registered, runnable state, and run the campaign green.
## Trail (binary-only)
1. `echo '{}' > x.json; aura process introspect --unwired x.json` — lists the
whole envelope (`format_version`, `kind`, `name`, `pipeline`).
2. Grow `process.json`: `--unwired` on a doc with `pipeline:[]` says "a process
needs at least one stage"; a stage `{}` is told `pipeline[0].block (required,
block id)` → correct shape is `{"block":"std::sweep"}`.
`aura process validate` → valid; `aura process register` → content id.
3. Grow `campaign.json` similarly (`aura campaign introspect --unwired`,
`--block std::*`). `aura campaign validate` passes all three tiers
(intrinsic / referential / executable); `register` → content id;
`campaign run <id>` → 3 members, green.
`process.json` and `campaign.json` in this dir are the final registered forms;
`ema_open.oplist.json` is the strategy authored with an **open** `fast.length`
param (see friction 2).
## Frictions recorded
1. **`process.ref` shape under-documented.** `campaign introspect --block
std::process_ref` and `--unwired` both describe it as *"content id of a process
document"* (reads as a bare string), but the validator rejects the bare string:
`unknown variant '<id>', expected 'content_id' or 'identity_id'` — the tagged
`{"content_id": "<id>"}` form is required and appears nowhere in the
introspection. Recoverable only via the validate error.
2. **Two-layer axis-namespace seam.** `aura sweep <bp> --list-axes` presents
`graph.fast.length` as sweepable (overriding the *bound* default, #246), and
the sugar `aura sweep --axis graph.fast.length=…` accepts it. But the canonical
**document** form rejects that same name: `axis "graph.fast.length" is not in
the param space` — a campaign axis must be an **open** param, named **without**
the `graph.` prefix (`fast.length`, per `graph introspect --params`, which
prints *nothing* for a fully-bound blueprint). The name a consumer learns from
the sugar (`graph.fast.length`) is not the name the document layer wants
(`fast.length`), and no surface reconciles the two.
@@ -0,0 +1,10 @@
{
"format_version":1,
"kind":"campaign",
"name":"ema_axis_sweep",
"data":{"instruments":["EURUSD"],"windows":[{"from_ms":1577836800000,"to_ms":1583020800000}]},
"strategies":[{"ref":{"content_id":"2c94edd4e3a3253fa446f2700f5ada1cb4cb9ceafcd99494d8d8e5244e3148a6"},"axes":{"fast.length":{"kind":"I64","values":[3,5,8]}}}],
"process":{"ref":{"content_id":"b3c0f7548731f4078ac6f0ba4444781ff51b067b4dd25567a12c49423b5d63ef"}},
"seed":0,
"presentation":{"persist_taps":["r_equity"],"emit":["family_table"]}
}
@@ -0,0 +1,13 @@
[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"EMA","name":"fast"},
{"op":"add","type":"EMA","name":"slow","bind":{"length":{"I64":20}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"add","type":"Sub","name":"spread"},
{"op":"connect","from":"fast.value","to":"spread.lhs"},
{"op":"connect","from":"slow.value","to":"spread.rhs"},
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.25}}},
{"op":"connect","from":"spread.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"doc","text":"EMA(open fast)/EMA(20) spread bias, fast.length swept by campaign"}
]
@@ -0,0 +1 @@
{"format_version":1,"kind":"process","name":"minimal_sweep","pipeline":[{"block":"std::sweep"}]}
@@ -0,0 +1,39 @@
# s5 — trace / measurement path
**Protected property:** from the surfaces a consumer can discover which verbs
persist per-cycle traces and reach a signal-quality measurement — the milestone's
own headline being "signal quality is measured in R".
## What the surfaces say
`aura --help`: "Traces: `sweep --real --trace` / `walkforward --real --trace`
record per-cycle taps under runs/, consumed by `aura chart` and `aura measure`."
`aura measure ic --help`: "Information Coefficient of a **signal** tap against
forward returns of a **price** tap" — requires `--signal <SIGNAL>` and
`--price <PRICE>`.
## What actually happens
- Discovery works: `runs families` lists the traced families; `measure ic`'s
`<RUN>` is the member path **relative to** `runs/traces/`
(`<family>/<instr-window>/<param-combo>`); the missing-tap error is clear and
lists available taps. `measure ic` runs green on the persisted taps.
- **Dead-end for the intended inputs.** The only taps any surface-reachable verb
persists are the closed presentation set `equity` / `exposure` / `r_equity`
(`net_r_equity`) — execution-side R/pip curves, **not** the strategy's bias
signal nor the instrument price. So `measure ic` can only be fed, e.g.,
`--signal exposure --price equity`, which is not a signal-vs-price IC at all
(result ≈ noise, 0.0019).
- **Declared taps are silently dropped.** `graph build --help` documents the op
`tap` as *"declare a **recordable** tap on a wire"*. `tapped.oplist.json` (this
dir) declares `tap bias.bias as signal` and `tap fast.value as price`, yet after
`sweep --real --trace` the persisted set is still `["equity","exposure",
"r_equity"]`; both `measure ic --signal signal` and `chart --tap signal` report
the tap does not exist. There is no surface-documented path to persist a genuine
signal or price tap, so `measure ic`'s stated contract is unreachable end-to-end.
## Evidence (verbatim)
```
$ aura measure ic --signal signal --price price <tapped-run>
aura: run '…' has no tap 'signal' (taps: ["equity", "exposure", "r_equity"])
$ aura chart 010f8afa-0 --tap signal
aura: no family member has a tap named 'signal' (exit 0)
```
@@ -0,0 +1,15 @@
[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":5}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":20}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"add","type":"Sub","name":"spread"},
{"op":"connect","from":"fast.value","to":"spread.lhs"},
{"op":"connect","from":"slow.value","to":"spread.rhs"},
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.25}}},
{"op":"connect","from":"spread.value","to":"bias.signal"},
{"op":"tap","from":"bias.bias","as":"signal"},
{"op":"tap","from":"fast.value","as":"price"},
{"op":"expose","from":"bias.bias","as":"bias"},
{"op":"doc","text":"tapped: bias exposed and tapped as signal, fast tapped as price"}
]