feat(cli): #196 blueprint on-ramp — graph register, introspect --params, blueprint-file --content-id (0107 task 10)

Closes the F5 authoring gap: a campaign's strategy ref + axes are now
authorable from the public surface without running a sweep first.
'aura graph register <blueprint.json>' puts the canonical form into the
content-addressed store (id == topology hash) and prints the stored
path via the pub blueprint_path accessor; 'aura graph introspect
--params <FILE|64-hex-ID>' lists the RAW composite param_space — the
exact namespace campaign axes are validated against (the wrapped
--list-axes namespace stays the sweep-verb view); --content-id gains an
optional FILE that shape-discriminates a blueprint envelope from an
op-list document (stdin behaviour byte-identical without FILE).
is_content_id promoted pub(crate) and shared with graph_construct so
the two FILE-or-id surfaces cannot drift on the id shape.

RED evidence was produced retroactively by the loop (stash-probe: the
5 new tests fail for the expected reasons without the src changes) —
ordering deviation noted, outcome unaffected.

Gates: graph_ 46/0, workspace 987/0, clippy -D warnings clean.

closes #196
refs #198
This commit is contained in:
2026-07-03 22:00:14 +02:00
parent aeb0366aed
commit 367f8678f1
5 changed files with 452 additions and 30 deletions
+5 -2
View File
@@ -32,8 +32,11 @@ use crate::research_docs::{
doc_error_prose, doc_fault_prose, fault_block, parse_valid_campaign, ref_fault_prose,
};
/// A bare store address: exactly 64 lowercase hex chars (the content-id key shape).
fn is_content_id(s: &str) -> bool {
/// A bare store address: exactly 64 lowercase hex chars (the content-id key
/// shape). `pub(crate)`: `graph_construct`'s `--params <FILE|ID>` resolution
/// (#196) shares this exact predicate rather than re-inlining it, so the two
/// FILE-or-id surfaces cannot drift apart on the id shape a second time.
pub(crate) fn is_content_id(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
+159 -25
View File
@@ -5,10 +5,11 @@
//! injected `aura_std::std_vocabulary`.
use std::collections::BTreeMap;
use std::path::Path;
use aura_engine::{
blueprint_identity_json, blueprint_to_json, replay, BindOpError, Composite, GraphSession, Op,
OpError, Scalar, ScalarKind,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
};
use serde::Deserialize;
@@ -191,18 +192,19 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
}
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / the id group must be set; zero
/// or more than one is the usage error (exit 2). The id group is `--content-id`
/// and/or `--identity-id` — the two id flags may combine (one build, both ids,
/// content id first).
/// `--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 is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
/// combine (one build, both ids, content id first).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
+ cmd.unwired as usize
+ (cmd.content_id || cmd.identity_id) as usize;
+ cmd.params.is_some() as usize
+ (cmd.content_id.is_some() || cmd.identity_id) as usize;
if count != 1 {
eprintln!(
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id | --identity-id (the two id flags may be combined)"
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
);
std::process::exit(2);
}
@@ -232,28 +234,64 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
std::process::exit(1);
}
}
} else {
// --content-id / --identity-id (combinable): one build of the op-list, then
// each requested id on its own line, content id first. The content id (#158)
// is the SHA256 of the same `blueprint_to_json` bytes `graph build` emits;
// the identity id (#171) the SHA256 of the debug-name-blind
// `blueprint_identity_json` form — both via the one shared
// `crate::content_id` primitive `topology_hash` also uses, so all surfaces
// agree by construction.
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(1);
}
let composite = match composite_from_str(&doc, env) {
Ok(c) => c,
} else if let Some(target) = cmd.params.as_deref() {
// --params <FILE|ID> (#196): the RAW composite param space — exactly the
// namespace campaign axes are validated against (`validate_campaign_refs`
// checks the raw space; the wrapped `--list-axes` namespace on `aura sweep`
// is the sweep-verb view, not the campaign view).
match params_lines(target, env) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
} else {
// --content-id [FILE] / --identity-id (combinable): one build, then each
// requested id on its own line, content id first. With a FILE value the
// document is read from it, shape-discriminated (#196): a JSON array is a
// construction op-list, a JSON object the #155 blueprint envelope — so a
// registered blueprint's printed id is exactly its store key. Without a
// FILE the op-list is read from stdin (the pre-#196 behaviour, unchanged).
// The content id (#158) is the SHA256 of the same `blueprint_to_json`
// bytes `graph build` emits; the identity id (#171) the SHA256 of the
// debug-name-blind `blueprint_identity_json` form — both via the one
// shared `crate::content_id` primitive `topology_hash` also uses, so all
// surfaces agree by construction.
let composite = match cmd.content_id.as_ref() {
Some(Some(file)) => {
let text = match std::fs::read_to_string(file) {
Ok(t) => t,
Err(e) => {
eprintln!("aura: cannot read {}: {e}", file.display());
std::process::exit(1);
}
};
match composite_from_any(&text, env) {
Ok(c) => c,
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
}
_ => {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(1);
}
match composite_from_str(&doc, env) {
Ok(c) => c,
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
}
};
if cmd.content_id {
if cmd.content_id.is_some() {
match blueprint_to_json(&composite) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(e) => {
@@ -274,6 +312,102 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
}
}
/// Phrase a blueprint-document `LoadError` as prose (Debug-leak-free; the
/// engine error types are `Display`-free by convention — this is the CLI's
/// presentation layer, like `format_op_error`).
fn blueprint_load_prose(e: &LoadError) -> String {
match e {
LoadError::Json(err) => format!("blueprint document is not valid JSON: {err}"),
LoadError::UnsupportedVersion { found, supported } => {
format!("blueprint format_version {found} is unsupported (this build reads {supported})")
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
}
}
/// #196: build a `Composite` from a document that is EITHER a #155 blueprint
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
/// each canonicalized by its own rules.
fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
match value {
serde_json::Value::Array(_) => composite_from_str(text, env),
serde_json::Value::Object(_) => {
blueprint_from_json(text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))
}
_ => Err("document is neither a blueprint envelope (object) nor an op-list (array)".into()),
}
}
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
/// id in the project store (the campaign-run target-addressing convention).
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
/// cannot drift apart on what counts as a store address.
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
let path = Path::new(target);
if path.is_file() {
return std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()));
}
if crate::campaign_run::is_content_id(target) {
return match env.registry().get_blueprint(target) {
Ok(Some(json)) => Ok(json),
Ok(None) => Err(format!("no blueprint {target} in the project store")),
Err(e) => Err(e.to_string()),
};
}
Err(format!("'{target}' is neither a readable .json file nor a 64-hex content id"))
}
/// `aura graph introspect --params <FILE|ID>` (#196): one `{name}:{kind:?}`
/// line per open param of the RAW composite (no harness wrap) — the
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
/// the same form `introspect --node` already uses for param kinds.
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
use std::fmt::Write as _;
let text = resolve_blueprint_text(target, env)?;
let composite =
blueprint_from_json(&text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))?;
let mut out = String::new();
for p in composite.param_space() {
let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
}
Ok(out)
}
/// `aura graph register <blueprint.json>` (#196): parse the blueprint through
/// the project vocabulary, canonicalize, content-address, and store — the
/// `process register` pattern, printing the store path so the trail is
/// followable. Prose to stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
match register_blueprint(file, env) {
Ok(line) => println!("{line}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
}
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
let composite =
blueprint_from_json(&text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))?;
let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?;
let id = crate::content_id(&canonical);
let registry = env.registry();
registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
Ok(format!(
"registered blueprint content:{id} ({})",
registry.blueprint_path(&id).display()
))
}
#[cfg(test)]
mod tests {
use super::*;
+15 -3
View File
@@ -3805,6 +3805,11 @@ enum GraphSub {
Build,
/// Introspect a graph.
Introspect(GraphIntrospectCmd),
/// Register a blueprint document into the content-addressed store (#196).
Register {
/// The blueprint .json file to register.
file: std::path::PathBuf,
},
}
#[derive(Args)]
@@ -3818,12 +3823,18 @@ struct GraphIntrospectCmd {
/// List the graph's unwired (unbound) ports.
#[arg(long)]
unwired: bool,
/// Print the graph's content id (topology hash).
#[arg(long)]
content_id: bool,
/// Print the graph's content id (topology hash). With FILE, read the
/// document from it (a blueprint envelope or an op-list, shape-
/// discriminated, #196); without FILE, read a stdin op-list as before.
#[arg(long, value_name = "FILE")]
content_id: Option<Option<std::path::PathBuf>>,
/// Print the graph's topology-identity id (debug names stripped).
#[arg(long)]
identity_id: bool,
/// Print a blueprint's raw param space (the campaign-axis namespace), one
/// name:kind line per open param. FILE path or 64-hex store content id (#196).
#[arg(long, value_name = "FILE|ID")]
params: Option<String>,
}
#[derive(Args)]
@@ -4341,6 +4352,7 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) {
None => print!("{}", render::render_html(&sample_blueprint())),
Some(GraphSub::Build) => graph_construct::build_cmd(env),
Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env),
Some(GraphSub::Register { file }) => graph_construct::register_cmd(&file, env),
}
}
+227
View File
@@ -378,3 +378,230 @@ fn graph_introspect_no_flag_is_usage_exit_2() {
assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
}
/// A fresh, unique working directory for a test that persists content-addressed
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
/// Run `aura <args>` in `dir` (no stdin); return (stdout, stderr, exit code).
fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>) {
let out = Command::new(BIN)
.args(args)
.current_dir(dir)
.output()
.expect("binary runs");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
}
/// The absolute path of a blueprint fixture shipped with this test crate.
fn fixture(name: &str) -> String {
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// The id extracted from a `registered blueprint content:{id} ({path})` line.
fn registered_id(stdout: &str) -> String {
stdout
.lines()
.find(|l| l.starts_with("registered blueprint content:"))
.expect("register line")
.trim_start_matches("registered blueprint content:")
.split(' ')
.next()
.expect("id")
.to_string()
}
/// Property (#196, the on-ramp): `aura graph register <blueprint.json>` parses
/// the document through the vocabulary, canonicalizes, and stores it content-
/// addressed under `runs/blueprints/<id>.json`, printing the id + store path.
#[test]
fn graph_register_stores_and_prints_content_id() {
let dir = temp_cwd("register");
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("sma_signal.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
let id = registered_id(&stdout);
assert_eq!(id.len(), 64, "a 64-hex content id: {id:?}");
assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
assert!(
dir.join("runs").join("blueprints").join(format!("{id}.json")).is_file(),
"stored under runs/blueprints/<id>.json: {stdout}"
);
}
/// Register is write-once content-addressed: the same document registers to the
/// same id, exit 0 both times (the idempotent `put_blueprint` contract).
#[test]
fn graph_register_is_idempotent() {
let dir = temp_cwd("register-idempotent");
let bp = fixture("sma_signal.json");
let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]);
let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(code1, Some(0), "first register: {out1} {err1}");
assert_eq!(code2, Some(0), "second register: {out2} {err2}");
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
}
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
/// shows. The open fixture leaves exactly the two SMA lengths unbound
/// (`bias.scale` is bound in the document).
#[test]
fn graph_params_lists_raw_axis_namespace() {
let dir = temp_cwd("params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &fixture("sma_signal_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
/// id is exactly the store key `graph register` prints — the file is shape-
/// discriminated from the op-list form and canonicalized by the blueprint rules.
#[test]
fn graph_content_id_accepts_a_blueprint_file() {
let dir = temp_cwd("content-id-file");
let bp = fixture("sma_signal.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let (id_out, id_err, id_code) = run_in(&dir, &["graph", "introspect", "--content-id", &bp]);
assert_eq!(id_code, Some(0), "content-id: {id_out} {id_err}");
assert_eq!(id_out.trim(), registered_id(&reg_out), "file content id == store key");
}
/// The one-mode guard extends over the new `--params` mode: zero modes and two
/// modes both refuse usage-exit-2, and the usage line names the new mode.
#[test]
fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() {
let dir = temp_cwd("mode-guard");
let (_out0, err0, code0) = run_in(&dir, &["graph", "introspect"]);
assert_eq!(code0, Some(2), "zero modes is usage exit 2: {err0}");
assert!(err0.contains("--params <FILE|ID>"), "usage line names --params: {err0}");
let (_out2, err2, code2) =
run_in(&dir, &["graph", "introspect", "--vocabulary", "--params", "x.json"]);
assert_eq!(code2, Some(2), "two modes is usage exit 2: {err2}");
assert!(err2.contains("Usage: aura graph introspect"), "prints the usage line: {err2}");
}
/// Property (#196, the register->params-by-id round trip): `--params <ID>`
/// with a registered blueprint's content id resolves through the project
/// store and prints exactly the same raw axis namespace `--params <FILE>`
/// prints for the same document — the advertised `<FILE|ID>` addressing is
/// two paths to the identical answer, not merely two accepted shapes.
#[test]
fn graph_params_by_content_id_matches_params_by_file() {
let dir = temp_cwd("params-by-id");
let bp = fixture("sma_signal_open.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let (by_id_out, by_id_err, by_id_code) =
run_in(&dir, &["graph", "introspect", "--params", &id]);
assert_eq!(by_id_code, Some(0), "stdout: {by_id_out} stderr: {by_id_err}");
let (by_file_out, by_file_err, by_file_code) =
run_in(&dir, &["graph", "introspect", "--params", &bp]);
assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
/// that was never registered refuses with a store-miss message, not a panic
/// or a silent empty namespace — the registry lookup branch of
/// `resolve_blueprint_text` is exercised.
#[test]
fn graph_params_by_unregistered_id_reports_a_store_miss() {
let dir = temp_cwd("params-missing-id");
let bogus_id = "a".repeat(64);
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &bogus_id]);
assert_ne!(code, Some(0), "non-zero exit on a store miss: {stdout} {stderr}");
assert!(stdout.is_empty(), "no partial namespace on a miss: {stdout}");
assert!(
stderr.contains(&format!("no blueprint {bogus_id} in the project store")),
"names the miss: {stderr}"
);
}
/// Property (#196, negative): `--params <TARGET>` where TARGET is neither an
/// existing file nor a 64-hex content id refuses naming both readings — the
/// `resolve_blueprint_text` fall-through branch, shared with `campaign run`'s
/// target resolution via `is_content_id`.
#[test]
fn graph_params_target_neither_file_nor_id_is_refused() {
let dir = temp_cwd("params-neither");
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", "not-a-file-or-id"]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no partial namespace on refusal: {stdout}");
assert!(
stderr.contains("'not-a-file-or-id' is neither a readable .json file nor a 64-hex content id"),
"names both readings: {stderr}"
);
}
/// Property (#196, the op-list branch of file-mode --content-id): a FILE that
/// is a JSON *array* (a construction op-list, not a blueprint envelope) is
/// shape-discriminated correctly and produces the SAME content id as piping
/// the identical document to stdin — `composite_from_any`'s `Array` arm is
/// exercised end-to-end, not just its `Object` sibling.
#[test]
fn graph_content_id_accepts_an_op_list_file() {
let dir = temp_cwd("content-id-op-list-file");
let op_list_path = dir.join("op-list.json");
std::fs::write(&op_list_path, SIGNAL_DOC).expect("write op-list fixture");
let (file_out, file_err, file_code) = run_in(
&dir,
&["graph", "introspect", "--content-id", op_list_path.to_str().unwrap()],
);
assert_eq!(file_code, Some(0), "stdout: {file_out} stderr: {file_err}");
let (stdin_out, _e, stdin_ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
assert!(stdin_ok, "stdin variant succeeds");
assert_eq!(file_out.trim(), stdin_out.trim(), "op-list file and stdin agree on content id");
}
/// Property (#196, negative): a FILE whose top-level JSON value is neither an
/// object (blueprint envelope) nor an array (op-list) — e.g. a bare string —
/// is refused, exercising `composite_from_any`'s fall-through arm.
#[test]
fn graph_content_id_rejects_a_file_that_is_neither_object_nor_array() {
let dir = temp_cwd("content-id-neither-shape");
let bad_path = dir.join("bad.json");
std::fs::write(&bad_path, "\"just a string\"").expect("write bad fixture");
let (stdout, stderr, code) = run_in(
&dir,
&["graph", "introspect", "--content-id", bad_path.to_str().unwrap()],
);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no content id emitted: {stdout}");
assert!(
stderr.contains("document is neither a blueprint envelope (object) nor an op-list (array)"),
"names the shape refusal: {stderr}"
);
}
/// Property (#196, negative): `register` on a syntactically-valid blueprint
/// envelope that names an unknown node type refuses with the `blueprint_load_prose`
/// `UnknownNodeType` prose (not a panic, not a bare Debug dump) — this refusal
/// path is shared by `register`, `--params`, and file-mode `--content-id` (all
/// route through `blueprint_from_json` -> `blueprint_load_prose`), so exercising
/// it once through `register` closes the whole family.
#[test]
fn graph_register_rejects_an_unknown_node_type_with_prose() {
let dir = temp_cwd("register-unknown-node");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "register", &fixture("unknown_node.json")]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
assert!(
stderr.contains("unknown node type \"Nope\""),
"names the unknown node type: {stderr}"
);
}
+46
View File
@@ -835,6 +835,52 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
assert_eq!(file_line, id_line, "file- and id-addressed runs must refuse identically");
}
/// Property (#196 on-ramp x #198 executor): a blueprint registered through
/// `aura graph register` — NOT the pre-#196 `aura sweep` side-effect
/// `seed_blueprint` relies on elsewhere in this file — is a fully valid
/// campaign strategy ref: its content id resolves through the referential
/// gate, and its raw param space (`fast.length`/`slow.length`, the same
/// names `graph introspect --params` reports for this fixture) binds against
/// the campaign document's axes in `bind_axes`. This closes fieldtest F5
/// ("strategy refs unresolvable from the public surface — no blueprint
/// register verb") end to end: the run reaches the member-data seam (the
/// same [1, 2] 1970 window used elsewhere in this file for a deterministic,
/// data-free refusal) rather than failing earlier at referencing or binding.
#[test]
fn campaign_run_accepts_a_graph_register_seeded_strategy() {
let _fixture = project_lock();
let dir = built_project();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir.clone()),
ScratchPath::File(dir.join("onramp.process.json")),
ScratchPath::File(dir.join("onramp.campaign.json")),
]);
let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR"));
let (reg_out, reg_code) = run_code_in(dir, &["graph", "register", &open_bp]);
assert_eq!(reg_code, Some(0), "graph register failed: {reg_out}");
let bp_id = reg_out
.lines()
.find(|l| l.starts_with("registered blueprint content:"))
.expect("register line")
.trim_start_matches("registered blueprint content:")
.split(' ')
.next()
.expect("id")
.to_string();
let proc_id = register_process_doc(dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
write_doc(dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
let (out, code) = run_code_in(dir, &["campaign", "run", "onramp.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("no recorded geometry") || out.contains("no data for instrument"),
"referencing + axis-binding must succeed, reaching the member-data \
seam rather than a referential or bind refusal: {out}"
);
}
/// `campaign run`'s file-sugar branch calls `parse_valid_campaign` directly
/// (unwrapped), the SAME intrinsic validator `campaign register` calls but
/// wraps in its own "refusing to register:" prefix. This pins that `run`