Compare commits
7 Commits
05e9a00afc
...
26b3d689df
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b3d689df | |||
| cb3330ceb5 | |||
| 4a30222fdc | |||
| 2c2c2fdef6 | |||
| 623d304b7f | |||
| 9e26be60f3 | |||
| 7392075aa6 |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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, ®istry)?;
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(®_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(®.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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(¶ms)
|
.compile_with_params(¶ms)
|
||||||
.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¶ms())
|
.compile_with_params(¶ms())
|
||||||
.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:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ®
|
||||||
|
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
@@ -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**
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/runs
|
||||||
@@ -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
|
||||||
@@ -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"}]}}
|
||||||
Reference in New Issue
Block a user