Compare commits

...

7 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
35 changed files with 1972 additions and 101 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`, The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior `gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
wire; `doc` declares the composite's one-line meaning, required at register — interior wire; `doc` declares the composite's one-line meaning, required at
C29; see the authoring guide). See register — C29; `use` splices a registered blueprint in as a nested composite,
by content id or label; see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script `aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics. grammar in the design ledger for the full semantics.
+308 -31
View File
@@ -9,16 +9,18 @@ use std::path::Path;
use aura_engine::{ use aura_engine::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError, 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 serde::Deserialize;
use crate::research_docs::resolve_id_prefix;
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in // `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
// production code; tests still exercise it directly. // production code; tests still exercise it directly.
#[cfg(test)] #[cfg(test)]
use aura_vocabulary::std_vocabulary; use aura_vocabulary::std_vocabulary;
/// The op-list reference `aura graph build --help` appends (#323): the nine /// The op-list reference `aura graph build --help` appends (#323): the ten
/// op kinds with their fields and one worked element each. Lives beside /// 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. /// [`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): pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order):
@@ -40,6 +42,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o
fuse two or more sibling params into one public knob fuse two or more sibling params into one public knob
{"op":"doc","text":"..."} {"op":"doc","text":"..."}
declare the composite's one-line meaning (C29) 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>"#; Node types and their ports: aura graph introspect --vocabulary | --node <T>"#;
@@ -82,26 +87,66 @@ enum OpDoc {
/// Declare the composite's one-line meaning (C29, #316) — the op-script /// Declare the composite's one-line meaning (C29, #316) — the op-script
/// twin of the builder's `.doc(...)`. /// twin of the builder's `.doc(...)`.
Doc { text: String }, 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 { impl OpDoc {
/// The op-kind label for the `op N (kind): cause` message. /// The op-kind label for the `op N (kind): cause` message. A `use` op
fn kind_label(&self) -> &'static str { /// 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 { match self {
OpDoc::Source { .. } => "source", OpDoc::Source { .. } => "source".to_string(),
OpDoc::Input { .. } => "input", OpDoc::Input { .. } => "input".to_string(),
OpDoc::Add { .. } => "add", OpDoc::Add { .. } => "add".to_string(),
OpDoc::Feed { .. } => "feed", OpDoc::Feed { .. } => "feed".to_string(),
OpDoc::Connect { .. } => "connect", OpDoc::Connect { .. } => "connect".to_string(),
OpDoc::Expose { .. } => "expose", OpDoc::Expose { .. } => "expose".to_string(),
OpDoc::Tap { .. } => "tap", OpDoc::Tap { .. } => "tap".to_string(),
OpDoc::Gang { .. } => "gang", OpDoc::Gang { .. } => "gang".to_string(),
OpDoc::Doc { .. } => "doc", 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 { 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 { fn from(d: OpDoc) -> Op {
match d { match d {
OpDoc::Source { role, kind } => Op::Source { role, kind }, OpDoc::Source { role, kind } => Op::Source { role, kind },
@@ -115,6 +160,14 @@ impl From<OpDoc> for Op {
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name }, OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into }, OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
OpDoc::Doc { text } => Op::Doc { text }, 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(),
},
} }
} }
} }
@@ -147,10 +200,12 @@ fn format_op_error(e: &OpError) -> String {
BindOpError::KindMismatch { param, expected, got } => { BindOpError::KindMismatch { param, expected, got } => {
format!("param {node}.{param} expects {expected:?} but got {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::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"), 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 } => { OpError::GangKindMismatch { member, expected, got } => {
format!("gang: member `{member}` is {got:?}, expected {expected:?}") format!("gang: member `{member}` is {got:?}, expected {expected:?}")
} }
@@ -160,18 +215,174 @@ fn format_op_error(e: &OpError) -> String {
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"), OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::Incomplete(ce) => format!("{ce:?}"), OpError::Incomplete(ce) => format!("{ce:?}"),
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(), 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 /// 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, /// 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 /// 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> { 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 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 labels: Vec<String> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect(); let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| { 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); let mut cause = format_op_error(&err);
// #244: the op-script path reports an unresolvable namespaced type as // #244: the op-script path reports an unresolvable namespaced type as
// `OpError::UnknownNodeType`, a different error family than the // `OpError::UnknownNodeType`, a different error family than the
@@ -245,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> { 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 docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let resolver = |t: &str| env.resolve(t); 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() { for (i, d) in docs.into_iter().enumerate() {
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?; session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
} }
@@ -256,6 +471,33 @@ pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<
Ok(out) 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 /// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id /// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
/// group must be set; zero or more than one is the usage error (exit 2). The id /// group must be set; zero or more than one is the usage error (exit 2). The id
@@ -266,11 +508,12 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
+ cmd.node.is_some() as usize + cmd.node.is_some() as usize
+ cmd.unwired as usize + cmd.unwired as usize
+ cmd.folds as usize + cmd.folds as usize
+ cmd.registered as usize
+ cmd.params.is_some() as usize + cmd.params.is_some() as usize
+ (cmd.content_id.is_some() || cmd.identity_id) as usize; + (cmd.content_id.is_some() || cmd.identity_id) as usize;
if count != 1 { if count != 1 {
eprintln!( eprintln!(
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --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); std::process::exit(2);
} }
@@ -294,6 +537,17 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
for (label, doc) in aura_runner::FoldRegistry::core().roster() { for (label, doc) in aura_runner::FoldRegistry::core().roster() {
println!("{label:<7}{doc}"); 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() { } else if let Some(type_id) = cmd.node.as_deref() {
match introspect_node(type_id, env) { match introspect_node(type_id, env) {
Ok(s) => print!("{s}"), Ok(s) => print!("{s}"),
@@ -580,13 +834,20 @@ fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String,
Ok(out) Ok(out)
} }
/// `aura graph register <blueprint.json>` (#196): parse the blueprint through /// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
/// the project vocabulary, canonicalize, content-address, and store — the /// #317): parse the blueprint through the project vocabulary, canonicalize,
/// `process register` pattern, printing the store path so the trail is /// content-address, and store — the `process register` pattern, printing
/// followable. Prose to stderr + exit 1 on any refusal. /// the store path so the trail is followable. With `--name`, additionally
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) { /// labels the stored content id (`aura_registry::Registry::put_blueprint_label`),
match register_blueprint(file, env) { /// echoing the repoint when the label already pointed elsewhere. Prose to
Ok(line) => println!("{line}"), /// 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) => { Err(m) => {
eprintln!("aura: {m}"); eprintln!("aura: {m}");
std::process::exit(1); std::process::exit(1);
@@ -594,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) let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?; .map_err(|e| format!("cannot read {}: {e}", file.display()))?;
// Shape-discriminated like file-mode --content-id (#202): either shape // Shape-discriminated like file-mode --content-id (#202): either shape
@@ -604,10 +869,21 @@ fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<St
let id = crate::content_id(&canonical); let id = crate::content_id(&canonical);
let registry = env.registry(); let registry = env.registry();
registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?; registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
Ok(format!( let mut lines = vec![format!(
"registered blueprint {id} ({})", "registered blueprint {id} ({})",
registry.blueprint_path(&id).display() 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)] #[cfg(test)]
@@ -636,7 +912,8 @@ mod tests {
assert_eq!(docs[1].kind_label(), "add"); assert_eq!(docs[1].kind_label(), "add");
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect(); 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). // 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"); composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
} }
+8 -1
View File
@@ -1232,6 +1232,10 @@ enum GraphSub {
Register { Register {
/// The blueprint .json file to register. /// The blueprint .json file to register.
file: std::path::PathBuf, 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>,
}, },
} }
@@ -1249,6 +1253,9 @@ struct GraphIntrospectCmd {
/// List the closed tap-fold vocabulary (fold, bind rule, output kind, meaning). /// List the closed tap-fold vocabulary (fold, bind rule, output kind, meaning).
#[arg(long)] #[arg(long)]
folds: bool, 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 /// Print the graph's content id (topology hash). With FILE, read the
/// document from it (a blueprint envelope or an op-list, shape- /// document from it (a blueprint envelope or an op-list, shape-
/// discriminated, #196); without FILE, read a stdin op-list as before. /// discriminated, #196); without FILE, read a stdin op-list as before.
@@ -1943,7 +1950,7 @@ fn dispatch_graph(a: GraphCmd, env: &aura_runner::project::Env) {
}, },
Some(GraphSub::Build) => graph_construct::build_cmd(env), Some(GraphSub::Build) => graph_construct::build_cmd(env),
Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, 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),
} }
} }
+1 -1
View File
@@ -294,7 +294,7 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
/// caller's existing not-found prose applies unchanged. `Err`: two or more /// caller's existing not-found prose applies unchanged. `Err`: two or more
/// candidates share the prefix — refused by name, listing every candidate, /// candidates share the prefix — refused by name, listing every candidate,
/// rather than guessed. /// rather than guessed.
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> { pub(crate) fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect(); let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
match matches.as_slice() { match matches.as_slice() {
[] => Ok(None), [] => Ok(None),
+315
View File
@@ -466,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. /// The absolute path of a blueprint fixture shipped with this test crate.
fn fixture(name: &str) -> String { fn fixture(name: &str) -> String {
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")) format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
@@ -1551,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}"); 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}"
);
}
@@ -58,6 +58,7 @@ fn sugar_verbs_name_their_document_shape() {
/// #323: `graph build --help` carries the op-list reference — the op kinds /// #323: `graph build --help` carries the op-list reference — the op kinds
/// and fields are learnable from the binary, not only from serde refusals. /// and fields are learnable from the binary, not only from serde refusals.
/// #317: the `use` op joins the roster (nine -> ten).
#[test] #[test]
fn graph_build_help_carries_the_op_reference() { fn graph_build_help_carries_the_op_reference() {
let (out, err, ok) = run(&["graph", "build", "--help"]); let (out, err, ok) = run(&["graph", "build", "--help"]);
@@ -72,12 +73,83 @@ fn graph_build_help_carries_the_op_reference() {
r#"{"op":"tap""#, r#"{"op":"tap""#,
r#"{"op":"gang""#, r#"{"op":"gang""#,
r#"{"op":"doc""#, r#"{"op":"doc""#,
r#"{"op":"use""#,
] { ] {
assert!(out.contains(op), "op reference carries {op}: {out}"); assert!(out.contains(op), "op reference carries {op}: {out}");
} }
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}"); assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
} }
/// #317: `graph introspect --registered` lists every registry label — row
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
/// as prose, not a blank/garbled listing.
#[test]
fn graph_introspect_registered_lists_labels() {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
// Empty store: prose, not a blank listing, exit 0.
let out_empty = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out_empty.status.success(), "empty store still exits 0");
assert_eq!(
String::from_utf8_lossy(&out_empty.stdout),
"no labels registered\n",
"the empty-store form reads as prose"
);
// Register + label a described open pattern, then list it.
let pattern = r#"[
{"op":"doc","text":"a reusable smoothing pattern"},
{"op":"input","role":"x"},
{"op":"add","type":"SMA","name":"sma"},
{"op":"feed","role":"x","into":["sma.series"]},
{"op":"expose","from":"sma.value","as":"out"}
]"#;
let bp_path = dir.join("pattern.ops.json");
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
let built = Command::new(BIN)
.args(["graph", "build"])
.current_dir(&dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
child.wait_with_output()
})
.expect("build the pattern");
assert!(built.status.success(), "the pattern builds");
let envelope = dir.join("pattern.bp.json");
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
let reg = Command::new(BIN)
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
.current_dir(&dir)
.output()
.expect("spawn aura register");
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(&reg.stderr));
let out = Command::new(BIN)
.args(["graph", "introspect", "--registered"])
.current_dir(&dir)
.output()
.expect("spawn aura");
assert!(out.status.success(), "listing succeeds");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
let fields: Vec<&str> = row.split_whitespace().collect();
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
}
/// #315: the node vocabulary listing carries each type's one-line meaning — /// #315: the node vocabulary listing carries each type's one-line meaning —
/// no more bare names that teach nothing. /// no more bare names that teach nothing.
#[test] #[test]
+10 -1
View File
@@ -324,7 +324,10 @@ impl PrimitiveBuilder {
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the /// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its /// data-level construction surface (`GraphSession`, #157). `bind` keeps its
/// panic contract (and its pinned messages); `try_bind` reports the same three /// panic contract (and its pinned messages); `try_bind` reports the same three
/// conditions as values. /// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
/// follow-up) does, refusing a ganged member's raw path at the use seam
/// before it silently de-fuses the gang.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum BindOpError { pub enum BindOpError {
/// No still-open param has this name. /// No still-open param has this name.
@@ -333,6 +336,12 @@ pub enum BindOpError {
AmbiguousParam(String), AmbiguousParam(String),
/// The value's kind does not equal the param's declared kind. /// The value's kind does not equal the param's declared kind.
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind }, KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
/// `param` (the full qualified path) is a member of the gang named
/// `gang` — binding it directly would freeze that one member while the
/// gang's public knob keeps driving its siblings. Bind the gang's own
/// public knob instead (accepted residue: unbindable at the use seam,
/// #317).
AlreadyGanged { param: String, gang: String },
} }
/// A node's declared interface: its inputs (in order) and its output record — an /// A node's declared interface: its inputs (in order) and its output record — an
+119 -1
View File
@@ -15,7 +15,8 @@
//! C23) and no external dependency (C16). //! C23) and no external dependency (C16).
use aura_core::{ use aura_core::{
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind, BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
}; };
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target}; use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
@@ -240,6 +241,37 @@ impl Composite {
pub fn doc(&self) -> Option<&str> { pub fn doc(&self) -> Option<&str> {
self.doc.as_deref() self.doc.as_deref()
} }
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
/// interior — nodes, edges, roles, output — is untouched; only `name()`
/// changes, so a fetched, registered subgraph renders (and path-prefixes
/// its `param_space()`, via `collect_params`'s composite arm) under the
/// caller-chosen instance identifier instead of its own authored name.
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
self.name = name.into();
self
}
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
/// same underlying mechanic every bind goes through
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
/// nested composite frames by node/composite-name prefix — the segmentation
/// `reopen_in` (#246) also walks, but binding an open param instead of
/// unbinding a bound one. A path matching no node at any level is
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
/// param lives there); a path that reaches a primitive but whose leaf
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
/// own fault to carry the full qualified path in place of the bare leaf
/// name, so every fault out of this walk names the same thing: the path
/// the caller wrote. A path landing on a GANGED member's own param is
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
/// it directly would silently de-fuse the gang (the member frozen while
/// the gang's public knob keeps driving its siblings); the gang's own
/// public knob stays unbindable at the use seam (accepted residue). On
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
/// posture `reopen` uses.
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
Ok(self)
}
/// Install declared measurement taps (the output-side twin of `input_roles`). /// Install declared measurement taps (the output-side twin of `input_roles`).
/// Fluent, mirroring `with_doc`. Empty by default. /// Fluent, mirroring `with_doc`. Empty by default.
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self { pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
@@ -1097,6 +1129,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
hits hits
} }
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
/// `full_path` is the whole original path (named in every fault this
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
/// its (node, original-pos) is checked against every gang member; a hit
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
/// the member out from under its gang's public knob (review finding,
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
/// `BlueprintNode`s by value rather than mutating through a reference.
fn bind_path_in(
items: &mut Vec<BlueprintNode>,
gangs: &[Gang],
full_path: &str,
rest: &str,
value: Scalar,
) -> Result<(), BindOpError> {
// The prefix this frame has already consumed off `full_path` (composite
// names and up) — used to qualify a gang's public name the same way
// `collect_params` does, so the refusal below names the SAME address
// `param_space()` would show for that gang.
let prefix = &full_path[..full_path.len() - rest.len()];
for i in 0..items.len() {
let child_rest = match &items[i] {
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
};
let Some(child_rest) = child_rest else { continue };
let child_rest = child_rest.to_string();
// Gang guard (#317 follow-up review finding): a raw path landing on a
// GANGED member's own param must not silently freeze it while the
// gang's public knob keeps driving its siblings — refuse by
// identifier before ever reaching `try_bind`. The gang's own public
// knob staying unbindable at the use seam is accepted residue.
if let BlueprintNode::Primitive(b) = &items[i]
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
{
return Err(BindOpError::AlreadyGanged {
param: full_path.to_string(),
gang: format!("{prefix}{}", g.name),
});
}
let item = items.remove(i);
return match item {
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
Ok(b2) => {
items.insert(i, BlueprintNode::Primitive(b2));
Ok(())
}
Err(e) => Err(qualify_bind_error(e, full_path)),
},
BlueprintNode::Composite(mut c) => {
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
items.insert(i, BlueprintNode::Composite(c));
r
}
};
}
// no node at any level matched `rest`'s leading segment: no open param
// lives at this path.
Err(BindOpError::UnknownParam(full_path.to_string()))
}
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
/// visibility past the one primitive it was called on).
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
match e {
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
BindOpError::KindMismatch { expected, got, .. } => {
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
}
// `try_bind` never raises this one (it has no gang awareness) — the
// gang guard above constructs it directly, already fully qualified.
// Kept here only so this match stays total over `BindOpError`.
BindOpError::AlreadyGanged { gang, .. } => {
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
}
}
}
/// Read-only twin of `collect_params` over the BOUND surface (#246): same /// Read-only twin of `collect_params` over the BOUND surface (#246): same
/// recursion shape and prefix rules, but enumerating `bound_params()` (with /// recursion shape and prefix rules, but enumerating `bound_params()` (with
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang /// values) instead of the open `params()`. Gangs are irrelevant here — a gang
+501 -41
View File
@@ -2,9 +2,12 @@
//! (#157). A `GraphSession` applies `Op`s one at a time, running the //! (#157). A `GraphSession` applies `Op`s one at a time, running the
//! eagerly-decidable checks (name resolution, edge kind-match, the >1-cover //! 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 //! double-wire arm, param bind) at the offending op; the only-at-end-decidable
//! checks (0-cover totality, param-namespace injectivity, root-role boundness) //! checks (0-cover totality, param-namespace injectivity) run holistically in
//! run holistically in `finish`. Both cadences call the SAME §A predicates — no //! `finish`. Both cadences call the SAME §A predicates — no second validator
//! second validator (C24). The node vocabulary is an injected closed resolver //! (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 //! (`Fn(&str)->Option<PrimitiveBuilder>`), so the engine stays domain-free
//! (invariant 9). //! (invariant 9).
@@ -13,8 +16,8 @@ use std::collections::{HashMap, HashSet};
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind}; use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use crate::blueprint::{ use crate::blueprint::{
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring, check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
BlueprintNode, Composite, Gang, GangMember, OutField, Role, Tap, TapWire, Gang, GangMember, OutField, Role, Tap, TapWire,
}; };
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError}; use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
use crate::harness::{Edge, Target}; 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 /// twin of the builder's `.doc(...)`. At most one per script; a second
/// is a fault (a declarative script carries no last-wins ambiguity). /// is a fault (a declarative script carries no last-wins ambiguity).
Doc { text: String }, 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. /// 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 /// Holistic role-kind fault, by-identifier: a root input role fans into slots
/// of differing scalar kinds. /// of differing scalar kinds.
RoleKindMismatch { role: String }, 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. /// Gang members must share one scalar kind.
GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind }, GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind },
/// The param is already claimed by an earlier gang. /// The param is already claimed by an earlier gang.
@@ -98,6 +108,11 @@ pub enum OpError {
Incomplete(CompileError), Incomplete(CompileError),
/// A second `doc` op — the meaning line is declared at most once. /// A second `doc` op — the meaning line is declared at most once.
DuplicateDoc, 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 /// A per-op-fallible blueprint accumulator. Holds the same interior data a
@@ -106,6 +121,13 @@ pub enum OpError {
pub struct GraphSession<'v> { pub struct GraphSession<'v> {
name: String, name: String,
vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>, 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>, nodes: Vec<BlueprintNode>,
schemas: Vec<NodeSchema>, schemas: Vec<NodeSchema>,
ids: Vec<String>, ids: Vec<String>,
@@ -124,11 +146,17 @@ pub struct GraphSession<'v> {
impl<'v> GraphSession<'v> { impl<'v> GraphSession<'v> {
/// Start a session for a composite named `name`, resolving node types through /// Start a session for a composite named `name`, resolving node types through
/// the injected closed vocabulary `vocab`. /// the injected closed vocabulary `vocab` and registered-blueprint splices
pub fn new(name: impl Into<String>, vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>) -> Self { /// (`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 { Self {
name: name.into(), name: name.into(),
vocab, vocab,
subgraph,
nodes: Vec::new(), nodes: Vec::new(),
schemas: Vec::new(), schemas: Vec::new(),
ids: Vec::new(), ids: Vec::new(),
@@ -165,9 +193,47 @@ impl<'v> GraphSession<'v> {
self.doc = Some(text); self.doc = Some(text);
Ok(()) 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> { fn add_role(&mut self, role: String, kind: Option<ScalarKind>) -> Result<(), OpError> {
if self.role_names.contains_key(&role) { if self.role_names.contains_key(&role) {
return Err(OpError::DuplicateRole(role)); return Err(OpError::DuplicateRole(role));
@@ -368,7 +434,16 @@ impl<'v> GraphSession<'v> {
let (node_name, param_name) = Self::split_port(port)?; let (node_name, param_name) = Self::split_port(port)?;
let ti = self.node_index(&node_name)?; let ti = self.node_index(&node_name)?;
let BlueprintNode::Primitive(b) = &self.nodes[ti] else { 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 let hits: Vec<usize> = b
.params() .params()
@@ -426,13 +501,17 @@ impl<'v> GraphSession<'v> {
open open
} }
/// Assemble the `Composite` and run the holistic gates (totality, /// Assemble the `Composite` and run the holistic construction gates (totality,
/// param-namespace injectivity, root-role boundness) — the SAME engine /// param-namespace injectivity) — the SAME engine predicates the eager path
/// predicates the eager path shares, now at end-of-document cadence. The /// shares, now at end-of-document cadence. Root-role boundness is
/// index-carrying `CompileError`s the gates return are translated back to the /// deliberately NOT checked here (#317, open patterns): a `finish()`ed
/// surface's by-identifier `OpError` variants (slot/role names via the session's /// composite may still carry an open root role, declaring a reusable
/// retained `ids`/`schemas` and the composite's roles); a fault with no /// pattern's formal parameters; only `compile`/bootstrap (the runnability
/// by-identifier mapping falls through to `OpError::Incomplete`. /// 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> { pub fn finish(self) -> Result<Composite, OpError> {
let roles: Vec<Role> = self let roles: Vec<Role> = self
.roles .roles
@@ -458,25 +537,22 @@ impl<'v> GraphSession<'v> {
}, },
other => OpError::Incomplete(other), 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) Ok(c)
} }
} }
/// Replay a document of ops from scratch, stopping at the first failing op and /// 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 /// 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( pub fn replay(
name: impl Into<String>, name: impl Into<String>,
ops: Vec<Op>, ops: Vec<Op>,
vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>, vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
subgraph: &dyn Fn(&str) -> Option<Composite>,
) -> Result<Composite, (usize, OpError)> { ) -> Result<Composite, (usize, OpError)> {
let mut s = GraphSession::new(name, vocab); let mut s = GraphSession::new(name, vocab, subgraph);
let n = ops.len(); let n = ops.len();
for (i, op) in ops.into_iter().enumerate() { for (i, op) in ops.into_iter().enumerate() {
s.apply(op).map_err(|e| (i, e))?; s.apply(op).map_err(|e| (i, e))?;
@@ -487,11 +563,12 @@ pub fn replay(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{GraphSession, Op, OpError}; use super::{GraphSession, Op, OpError};
use crate::blueprint::{BlueprintNode, Composite, Role};
use aura_core::{BindOpError, Scalar, ScalarKind}; use aura_core::{BindOpError, Scalar, ScalarKind};
use aura_vocabulary::std_vocabulary; use aura_vocabulary::std_vocabulary;
fn session(name: &str) -> GraphSession<'static> { fn session(name: &str) -> GraphSession<'static> {
GraphSession::new(name, &std_vocabulary) GraphSession::new(name, &std_vocabulary, &|_: &str| None)
} }
#[test] #[test]
@@ -752,7 +829,7 @@ mod tests {
Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() }, Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".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") .expect("replays")
.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)]) .compile_with_params(&[Scalar::i64(2), Scalar::i64(4)])
.expect("compiles"); .expect("compiles");
@@ -774,7 +851,7 @@ mod tests {
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() }, Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".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(); let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, ["length"]); assert_eq!(names, ["length"]);
} }
@@ -820,7 +897,7 @@ mod tests {
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] }, Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".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"); .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:?}"); "finalize names the open slot by-identifier, got {err:?}");
} }
/// A reserved `Input` root role that is fed but never source-bound finishes with /// #317 (open patterns): `finish()` no longer runs the root-role gate — a
/// the by-identifier `UnboundRootRole` (not a raw role index). /// 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] #[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"); let mut s = session("g");
s.apply(Op::Input { role: "ext".into() }).unwrap(); 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::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::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(); s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
let err = s.finish().err().unwrap(); let c = s.finish().expect("finish() no longer gates root-role boundness");
assert!(matches!(&err, OpError::UnboundRootRole { role } if role == "ext"), assert_eq!(
"an unbound Input root role finishes by-identifier, got {err:?}"); 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` /// 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(); let finalize = ops.len();
// `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures // `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures
// and is not `Debug`. // 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() .err()
.expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)"); .expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)");
// An eager realisation attributes the fault to the closing connect op; a // 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() }, Op::Expose { from: "s.value".into(), as_name: "out".into() },
]; ];
assert!( 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)" "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 // `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
// not `Debug` (it holds build closures), which `unwrap_err` would require. // 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()))); 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::Connect { from: "sub.value".into(), to: "bias.signal".into() },
Op::Expose { from: "bias.bias".into(), as_name: "bias".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") .expect("replay resolves")
.compile_with_params(&params) .compile_with_params(&params)
.expect("replay compiles"); .expect("replay compiles");
@@ -1113,7 +1198,7 @@ mod tests {
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() }, Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".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(...). // (b) the GraphBuilder twin — same adds/feeds/connects/expose + g.gang(...).
let mut g = GraphBuilder::new("g"); let mut g = GraphBuilder::new("g");
@@ -1165,7 +1250,7 @@ mod tests {
Op::Tap { from: "sub.value".into(), as_name: "spread".into() }, Op::Tap { from: "sub.value".into(), as_name: "spread".into() },
Op::Expose { from: "sub.value".into(), as_name: "bias".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(...). // (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...).
let mut g = GraphBuilder::new("g"); let mut g = GraphBuilder::new("g");
@@ -1191,4 +1276,379 @@ mod tests {
assert_eq!(replay_flat.taps, built_flat.taps); assert_eq!(replay_flat.taps, built_flat.taps);
assert_eq!(replay_flat.edges, built_flat.edges); 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] #[test]
fn replayed_construction_compiles_identically_to_graphbuilder() { fn replayed_construction_compiles_identically_to_graphbuilder() {
// (a) op-script replay through the public construction surface. // (a) op-script replay through the public construction surface.
let replay_flat = replay("root", signal_ops(), &std_vocabulary) let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
.expect("op-script resolves") .expect("op-script resolves")
.compile_with_params(&params()) .compile_with_params(&params())
.expect("replay compiles"); .expect("replay compiles");
@@ -103,7 +103,7 @@ fn replay_attributes_eager_fault_to_its_op_index() {
]; ];
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build // `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
// closures and is not `Debug`, which `unwrap_err`'s bound would require. // closures and is not `Debug`, which `unwrap_err`'s bound would require.
let err = replay("g", ops, &std_vocabulary).err().unwrap(); let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into()))); assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
} }
@@ -128,7 +128,7 @@ fn replay_attributes_holistic_fault_to_finalize_step() {
// sub.rhs deliberately left unwired -> only finalize can decide totality. // sub.rhs deliberately left unwired -> only finalize can decide totality.
]; ];
let finalize_index = ops.len(); // 6 — the implicit finalize step let finalize_index = ops.len(); // 6 — the implicit finalize step
let err = replay("g", ops, &std_vocabulary).err().unwrap(); let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index), assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
"the holistic fault is still attributed to the finalize step, got {err:?}"); "the holistic fault is still attributed to the finalize step, got {err:?}");
} }
+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 — /// The content-addressed document-store dir for one document kind —
/// a sibling of the runs store, like `blueprints/`. /// a sibling of the runs store, like `blueprints/`.
fn doc_dir(&self, kind: &str) -> PathBuf { fn doc_dir(&self, kind: &str) -> PathBuf {
@@ -273,10 +362,42 @@ impl Registry {
} }
} }
/// One label->content mapping, one JSON object per line in
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
/// shape, mirrored for the label store.
#[derive(serde::Serialize, serde::Deserialize)]
struct BlueprintNameLine {
name: String,
content_id: String,
}
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
/// after trim, no whitespace, no control characters. String shape only —
/// never content judgement (the C29 discipline `doc_gate` also follows).
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
if name.trim().is_empty() {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
}
if name.chars().any(char::is_whitespace) {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
}
if name.chars().any(|c| c.is_control()) {
return Err(RegistryError::BadLabel {
name: name.to_string(),
rule: "must not contain control characters",
});
}
Ok(())
}
/// C29 walk (#316): the root and every named nested composite must carry a /// C29 walk (#316): the root and every named nested composite must carry a
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are /// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
/// the Empty fault ("carries no doc"). /// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> { /// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
/// before it enters a new composition (fail fast on a doc-less fetched
/// entry) — the one walk, two call sites (register's write-path gate, use's
/// read-path gate), never a second implementation.
pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
let fault = match c.doc.as_deref() { let fault = match c.doc.as_deref() {
None => Some(aura_core::DocGateFault::Empty), None => Some(aura_core::DocGateFault::Empty),
Some(d) => aura_core::doc_gate(&c.name, d).err(), Some(d) => aura_core::doc_gate(&c.name, d).err(),
@@ -938,6 +1059,14 @@ pub enum RegistryError {
/// the store without a gate-passing doc. Registered artifacts are never /// the store without a gate-passing doc. Registered artifacts are never
/// retroactively invalidated — the gate guards the write path only. /// retroactively invalidated — the gate guards the write path only.
UndescribedComposite { name: String, fault: aura_core::DocGateFault }, UndescribedComposite { name: String, fault: aura_core::DocGateFault },
/// Label sidecar (#317): a `put_blueprint_label` name failing the
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
/// control characters). By-identifier, naming the violated rule.
BadLabel { name: String, rule: &'static str },
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
/// labels a blueprint the store does not have.
UnknownBlueprint(String),
} }
impl fmt::Display for RegistryError { impl fmt::Display for RegistryError {
@@ -978,6 +1107,12 @@ impl fmt::Display for RegistryError {
its name a registered composite describes itself (C29)" its name a registered composite describes itself (C29)"
), ),
}, },
RegistryError::BadLabel { name, rule } => {
write!(f, "blueprint label {name:?}: {rule}")
}
RegistryError::UnknownBlueprint(id) => {
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
}
} }
} }
} }
@@ -1172,6 +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] #[test]
fn document_stores_round_trip_by_content_id() { fn document_stores_round_trip_by_content_id() {
let reg = Registry::open(temp_family_dir("document_store_round_trip")); let reg = Registry::open(temp_family_dir("document_store_round_trip"));
+10 -2
View File
@@ -221,14 +221,15 @@ $ aura graph build < smacross.json > blueprint.json
Nodes are referenced by an **identifier** (given by `add`, see below); ports Nodes are referenced by an **identifier** (given by `add`, see below); ports
are dotted `<identifier>.<port>` on both sides of a wire. are dotted `<identifier>.<port>` on both sides of a wire.
### The nine ops ### The ten ops
| op | JSON shape | does | | 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. | | `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). | | `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — for a fragment meant to be wired by an *enclosing* graph. A standalone document built with `aura graph build` finalizes as a closed root, so an `input` role that is never bound refuses at the end: `finalize: root input role <name> is unbound`. | | `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). |
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. | | `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"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). | | `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). | | `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. | | `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
@@ -405,6 +406,13 @@ registered blueprint 597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876da
The printed content id is the address a campaign document's `strategies[].ref` The printed content id is the address a campaign document's `strategies[].ref`
points at (§3). points at (§3).
`graph register <file> --name <label>` additionally records a **blueprint
label** — a legible, re-pointable handle for the `use` op's `{"name":…}`
ref form (re-registering under the same label repoints it; the echo names
the previous target). `aura graph introspect --registered` lists the
labels with their id prefix and root doc line — the discovery surface for
what `use` can reference.
## 2. Process documents — a validation methodology (role 5) ## 2. Process documents — a validation methodology (role 5)
A process document is a named, versionable pipeline of **std stage blocks** A process document is a named, versionable pipeline of **std stage blocks**
+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 arm, param bind, and acyclicity (a `connect` that would close a cycle is rejected
eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic** eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic**
finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity
(`check_param_namespace_injective`), root-role boundness (`check_root_roles_bound`). (`check_param_namespace_injective`). Both cadences call the **same extracted
Both cadences call the **same extracted predicates** — no second validator. The predicates** — no second validator. The holistic faults read **by-identifier**
holistic faults read **by-identifier** (#162): `finish()` translates the (#162): `finish()` translates the index-carrying `CompileError`s (`UnconnectedPort` /
index-carrying `CompileError`s (`UnconnectedPort` / `RoleKindMismatch` / `RoleKindMismatch`) into by-identifier `OpError` variants while still *calling* the
`UnboundRootRole`) into by-identifier `OpError` variants while still *calling* the unchanged holistic gates. **Root-role boundness moved off this list (#317, open
unchanged holistic gates. Build-free introspection answers over the closed vocabulary: patterns):** `check_root_roles_bound` no longer runs in `finish()` — a `finish()`ed
`graph introspect --vocabulary | --node <T> | --unwired`. The engine `Op` stays op-script may still carry an open (unbound) root role, declaring a reusable
serde-free; the wire DTO (`OpDoc`) is CLI-side (`aura-cli::graph_construct`). pattern's formal parameter; only `compile`/bootstrap (the runnability gate, not a
Acceptance: a graph built purely through the ops compiles identical (C1) to its construction gate) still call it, unchanged. Build-free introspection answers over
Rust-built twin, an invalid op is rejected at the op naming the cause, introspection the closed vocabulary: `graph introspect --vocabulary | --node <T> | --unwired`. The
answers without a build. engine `Op` stays serde-free; the wire DTO (`OpDoc`) is CLI-side
(`aura-cli::graph_construct`). Acceptance: a graph built purely through the ops
compiles identical (C1) to its Rust-built twin, an invalid op is rejected at the op
naming the cause, introspection answers without a build.
**Registered-blueprint splice (#317).** The `use` op resolves a reference — a store
content id, a unique content-id prefix (#302 semantics), or a registry **name
label** (`graph register --name`, latest-wins, `aura-registry`'s
`blueprint_names.jsonl` sidecar) — to a previously registered blueprint and
splices it into the building graph as a nested `BlueprintNode::Composite` under an
instance identifier. The **engine never resolves**: `GraphSession`/`replay` take a
second injected closure, `subgraph: &dyn Fn(&str) -> Option<Composite>`, mirroring
`vocab`'s closed-lookup posture exactly — this is the same enforcement-shift
permission §"Enforcement shift" above already grants the node vocabulary, extended
to registered-blueprint references. All store I/O — label/prefix resolution, the
`get_blueprint` fetch, the C29 doc-gate re-walk over the fetched composite (before
it ever reaches the session, so a doc-less fetched entry cannot enter a NEW
composition — a **backstop**: the register verb already gates both input forms, so
this fires only for store content written before C29 or through the raw in-crate
path), and the resolution echo — happens CLI-side, at DTO conversion, before
replay; 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 **Invariant-5 lockstep.** The eager acyclicity gate is a *second* home of invariant 5
alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two
@@ -166,12 +189,14 @@ rejected at construction.
The op-script is a JSON **array of ops**, each object internally tagged by `"op"`, The op-script is a JSON **array of ops**, each object internally tagged by `"op"`,
replayed in order; nodes are referenced **by identifier**, ports as dotted replayed in order; nodes are referenced **by identifier**, ports as dotted
`<identifier>.<port>`. The eight verbs: `<identifier>.<port>`. The ten verbs:
- `source``{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root - `source``{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
source role producing a base column of `kind`. source role producing a base column of `kind`.
- `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred - `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred
from the slots it feeds). from the slots it feeds) — a reusable **pattern**'s formal parameter, left open
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``{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}`
add a node of compiled-in type identity `type`; **`name` is its identifier** add a node of compiled-in type identity `type`; **`name` is its identifier**
(mirrors the builder's `.named(...)`; defaults to the lowercased type label, so (mirrors the builder's `.named(...)`; defaults to the lowercased type label, so
@@ -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 leave the sweepable param space and `as` replaces them; the bound or swept
value fans out to every member at bootstrap. Members must share one scalar value fans out to every member at bootstrap. Members must share one scalar
kind and stay open (un-bound). kind and stay open (un-bound).
- `doc``{"op":"doc","text":<str>}` — declare the composite's one-line meaning
(C29, #316) — the op-script twin of the builder's `.doc(...)`; at most one per
script, a second is a fault (a declarative script carries no last-wins
ambiguity).
- `use``{"op":"use","ref":{"content_id":<str>}|{"name":<str>},"name":<str>?,"bind":{<path>:<Scalar>}?}`
— splice a **registered** blueprint into the building graph as a nested
composite under an instance identifier (`name`, defaulting to the fetched
composite's own name); `bind` path-qualifies the spliced instance's params
(e.g. `"sma.length"`), applied after the splice (#317). An instance's open
input roles become its `<instance>.<role>` in-ports, its output boundary
aliases its `<instance>.<field>` out-fields — the same `Composite` arm every
other add/wiring path walks, not a new mechanism. See §"The construction
service" below for the resolution split.
Value forms are the serialized representations: a `Scalar` bind value is the typed tag Value forms are the serialized representations: a `Scalar` bind value is the typed tag
form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind` form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind`
+12 -2
View File
@@ -27,13 +27,23 @@ cannot pass its short-name alibi
restatement. restatement.
3. **Register seam (data plane).** The registry's public `put_blueprint` 3. **Register seam (data plane).** The registry's public `put_blueprint`
parses the canonical bytes and requires a gate-passing doc on the root parses the canonical bytes and requires a gate-passing doc on the root
composite and on every named nested composite the gate guards the composite and on **every** nested composite (the walk recurses
store boundary itself, so the register verb, every register-then-run unconditionally — it does not stop at an unnamed one) — the gate guards
the store boundary itself, so the register verb, every register-then-run
sugar path, and any future caller are gated by construction. The sugar path, and any future caller are gated by construction. The
op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s
twin ([C25](c25-role-model.md): a closed, typed metadata construct). twin ([C25](c25-role-model.md): a closed, typed metadata construct).
Process/campaign documents take an additive-optional top-level Process/campaign documents take an additive-optional top-level
`description`, gated only when present (`DocFault::BadDescription`). `description`, gated only when present (`DocFault::BadDescription`).
4. **Use seam (data plane, construction-time, #317).** The `use`
construction op re-runs the SAME `gate_composite_docs` walk over a
blueprint FETCHED from the store, before it ever splices into a new
composition — a build-time gate on already-registered content, not a
second write-path check. A doc-less entry (reachable only through the
raw in-crate write survivor class, since `put_blueprint`'s own gate keeps
the store clean going forward) cannot enter a NEW composition this way,
but stays readable and runnable on its own — no retroactive invalidation,
the same discipline the Forbids clause states for the register seam.
**Scope at this record's writing (#321).** The gate walks five **Scope at this record's writing (#321).** The gate walks five
vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and
+8
View File
@@ -31,6 +31,10 @@ A strategy's primary, backtestable DAG output: one signed, bounded `f64 ∈ [-1,
**Avoid:** — **Avoid:** —
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while `run` uses it as-is. The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while `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 ### bootstrap
**Avoid:** — **Avoid:** —
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null, the `aura mc` R path, and since 0108 the `std::monte_carlo` stage's `stage bootstrap` annotation; context (construction vs annotation) disambiguates. The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null, the `aura mc` R path, and since 0108 the `std::monte_carlo` stage's `stage bootstrap` annotation; context (construction vs annotation) disambiguates.
@@ -333,6 +337,10 @@ Since #283 what CONSUMES a declared tap is itself declared per run by a **tap pl
**Avoid:** — **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`. 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 ### veto
**Avoid:** risk-manager **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. 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"}]}}