feat(aura-runner, aura-cli, aura-registry): a run's trace directory is keyed by its own identity

A single run recorded into `runs/traces/<render-name>/`, so a second run of the
same blueprint overwrote the first and could leave a stale tap file beside an
index that no longer listed it. The directory is now
`runs/traces/<render-name>-<id8>/`, where the id is a digest over the run's own
manifest: two runs differing in any identity-bearing input land in two
directories, two runs differing in nothing land in one.

The digest hashes the manifest with its two provenance fields removed and its
parameter vectors merged.

- `commit` (the aura binary's build sha) and `project.commit` (the project
  repo's HEAD plus a `-dirty` marker, re-derived on every invocation) record who
  built or checked out the code, not what the run was. The latter would have
  minted a fresh directory for any uncommitted file anywhere in the project
  worktree — the authoring loop's normal state.
- `params` and `defaults` are merged name-sorted before hashing: how a value was
  supplied is not what the run is, so a no-op `--override` no longer splits one
  run across two directories. The two vectors are disjoint by construction.

`project.dylib_sha256` stays in. It is what the C13 hot-reload comparison
varies — an unchanged blueprint against a rebuilt node crate — which the
campaign leg structurally cannot express, since its axes vary params,
instrument and window, never code.

The manifest is now assembled before the tap bind rather than after the run.
Every input it needs was already resolved at that point, and the one value both
keys the directory and enters the report, so a handle can never name one
identity while the record describes another.

`aura chart <render-name>` no longer names a directory; it now lists the trace
handles beginning with that name instead of refusing blankly.
`TraceStoreError::NameTaken` stops prescribing `--trace`, retired in #319.

C18, C22, C23 and C27 record the new identity, each superseded clause moved
verbatim to its history sidecar; C23 gains its first.

closes #311
This commit is contained in:
2026-07-27 10:52:56 +02:00
parent e759b89b0e
commit b18a695531
20 changed files with 982 additions and 116 deletions
+29 -10
View File
@@ -460,16 +460,35 @@ fn render_chart_by_kind(
}
}
None => {
eprintln!(
"aura: no recorded run or family '{name}' under runs/traces \
(a single run prints its handle as `trace_name` on stdout; a campaign \
run prints it as `campaign_run.trace_name`, one per run — check that \
value for a typo. A trace is produced by running a blueprint that \
declares taps: `aura exec <blueprint>` records its declared taps by \
default, and `--tap <TAP>=<FOLD>` selects among them by name; a \
campaign produces one through its `presentation.persist_taps` \
section. Naming a handle here produces nothing)"
);
// #311: a bare render name is no longer a directory —
// a run's directory is `<render-name>-<id8>`. Ahead of
// the generic message, say what IS recorded under that
// name, through the same helper the family-id branch
// uses. Say what was found, never that nothing exists:
// an unreadable store also yields an empty list.
let recorded = recorded_handles_with_prefix(name, store);
if recorded.is_empty() {
eprintln!(
"aura: no recorded run or family '{name}' under runs/traces \
(a single run prints its handle as `trace_name` on stdout; a campaign \
run prints it as `campaign_run.trace_name`, one per run — check that \
value for a typo. A trace is produced by running a blueprint that \
declares taps: `aura exec <blueprint>` records its declared taps by \
default, and `--tap <TAP>=<FOLD>` selects among them by name; a \
campaign produces one through its `presentation.persist_taps` \
section. Naming a handle here produces nothing)"
);
} else {
let listed: String =
recorded.iter().map(|h| format!(" {h}\n")).collect();
eprintln!(
"aura: no recorded trace is named `{name}`\n trace handles beginning \
with `{name}`:\n\
{listed} pass one of these handles; a single run prints its own as \
`trace_name` on stdout, a campaign run as `campaign_run.trace_name`, \
one per run"
);
}
}
}
std::process::exit(1);
+121
View File
@@ -4688,3 +4688,124 @@ fn chart_family_id_with_no_recorded_campaign_suggests_nothing() {
let _ = std::fs::remove_dir_all(&cwd);
}
/// #311: the bare render name no longer names a directory, so `chart` reports
/// the runs recorded under it instead of failing blankly. The decoy pins the
/// negative side of `recorded_handles_with_prefix`'s boundary check — a stored
/// name that begins with the prefix but WITHOUT the separating hyphen is a
/// different blueprint, not a run of this one (a render name is a far likelier
/// prefix of another render name than an 8-hex campaign head is of another's).
#[test]
fn chart_bare_render_name_lists_the_recorded_run_handles() {
let cwd = temp_cwd("chart-render-name-listing");
for handle in ["ma-blend-3f9a1c02", "ma-blend-b7d4e158"] {
let dir = cwd.join("runs/traces").join(handle);
std::fs::create_dir_all(&dir).expect("fabricate a recorded run");
std::fs::write(dir.join("index.json"), "{}").expect("write run index");
}
let decoy = cwd.join("runs/traces/ma-blendx");
std::fs::create_dir_all(&decoy).expect("fabricate the boundary decoy");
std::fs::write(decoy.join("index.json"), "{}").expect("write decoy index");
let out = Command::new(BIN)
.args(["chart", "ma-blend"])
.current_dir(&cwd)
.output()
.expect("spawn chart on a bare render name");
assert_eq!(out.status.code(), Some(1), "an unresolvable name still refuses: {:?}", out.status);
assert!(out.stdout.is_empty(), "nothing may be rendered: {:?}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no recorded trace is named"),
"the refusal must say the name is not a trace: {stderr}"
);
assert!(
stderr.contains("ma-blend-3f9a1c02") && stderr.contains("ma-blend-b7d4e158"),
"both recorded handles must be listed: {stderr}"
);
assert!(
!stderr.contains("ma-blendx"),
"a name sharing the prefix without the separating hyphen is a different blueprint: {stderr}"
);
assert!(
stderr.contains("pass one of these handles"),
"the refusal must name the one-step fix: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// #311: with nothing recorded under the name, the listing branch stays silent
/// and the generic refusal fires — the store's emptiness is never dressed up as
/// a candidate.
#[test]
fn chart_bare_render_name_with_nothing_recorded_invents_no_handle() {
let cwd = temp_cwd("chart-render-name-empty");
let out = Command::new(BIN)
.args(["chart", "ma-blend"])
.current_dir(&cwd)
.output()
.expect("spawn chart on a bare render name");
assert_eq!(out.status.code(), Some(1), "an unknown name refuses: {:?}", out.status);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no recorded run or family 'ma-blend'"),
"the generic refusal still fires: {stderr}"
);
assert!(
!stderr.contains("trace handles beginning with"),
"nothing recorded means nothing listed: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// #311: the two refusals land in DIFFERENT arms, which is what the new listing
/// branch depends on. `family_id_campaign_prefix`'s positive recognition is
/// already pinned by the four family-id tests above; its *loosening* direction —
/// an ordinary hyphenated render name must NOT reach the family arm — is what
/// this pins, including the 8-hex-head requirement that keeps a render name
/// wearing the family-id suffix out of it.
#[test]
fn chart_family_id_and_render_name_land_in_different_arms() {
let cwd = temp_cwd("chart-arm-split");
let fam = cwd.join("runs/traces/deadbeef-0/cell/member");
std::fs::create_dir_all(&fam).expect("fabricate a family fan-out");
std::fs::write(fam.join("index.json"), "{}").expect("write member index");
let run_dir = cwd.join("runs/traces/ma-blend-3f9a1c02");
std::fs::create_dir_all(&run_dir).expect("fabricate a recorded run");
std::fs::write(run_dir.join("index.json"), "{}").expect("write run index");
let chart = |name: &str| -> String {
let out = Command::new(BIN)
.args(["chart", name])
.current_dir(&cwd)
.output()
.expect("spawn chart");
assert_eq!(out.status.code(), Some(1), "every arm here refuses: {:?}", out.status);
String::from_utf8_lossy(&out.stderr).into_owned()
};
// (a) family-id-shaped -> the family-id arm.
let a = chart("deadbeef-1-GER40-w0-r0-s0-0");
assert!(a.contains("family id"), "the family-id arm must fire: {a}");
assert!(!a.contains("trace handles beginning with"), "not the listing arm: {a}");
// (b) a merely hyphenated render name -> the listing arm.
let b = chart("ma-blend");
assert!(!b.contains("family id"), "an ordinary render name is not a family id: {b}");
assert!(b.contains("ma-blend-3f9a1c02"), "the recorded run must be listed: {b}");
// (c) a render name wearing the family-id SUFFIX but no 8-hex head -> neither
// arm: the guard's hex-head requirement holds, and nothing is recorded
// under that longer prefix, so the generic refusal fires.
let c = chart("ma-blend-w0-r0-s0-0");
assert!(!c.contains("family id"), "an 8-hex head is required to read a name as a family id: {c}");
assert!(!c.contains("ma-blend-3f9a1c02"), "nothing is recorded under that prefix: {c}");
assert!(c.contains("no recorded run or family"), "the generic refusal fires: {c}");
let _ = std::fs::remove_dir_all(&cwd);
}
+70 -7
View File
@@ -458,13 +458,20 @@ fn exec_blueprint_tap_selector_persists_a_fold_summary_row() {
);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
// #311: the trace directory is the run's own identity-keyed handle, read
// off the record line — never the render name alone.
let line = out.lines().find(|l| l.starts_with('{')).expect("record line");
let record: serde_json::Value = serde_json::from_str(line).expect("record parses");
let handle = record["trace_name"].as_str().expect("a recording run prints its trace_name");
let run_dir = cwd.join("runs/traces").join(handle);
let index_text =
std::fs::read_to_string(run_dir.join("index.json")).expect("read index.json");
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace_text =
std::fs::read_to_string(run_dir.join("fast_tap.json")).expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
assert_eq!(trace["tap"], "fast_tap");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
@@ -614,7 +621,8 @@ base {base_out} / overridden {ov_out}"
/// Follow-up to the #343 revised pin above: `run_signal_r`'s in-graph tap
/// persistence (`bound.finish(&manifest)`, `aura-registry::trace_store`)
/// writes `traces/<name>/index.json` INSIDE `run_signal_r`, before
/// writes `traces/<handle>/index.json` (the run's identity-keyed handle,
/// #311 — not just the render name) INSIDE `run_signal_r`, before
/// `exec_blueprint_leg` ever sees the returned `RunReport` — so the
/// persisted manifest must ALSO carry the base document's reference-semantics
/// hash, not just the stdout record line. A tap-bearing blueprint (so
@@ -646,8 +654,16 @@ fn exec_blueprint_override_persists_the_base_documents_hash_in_the_trace_store()
ov_v["manifest"]["topology_hash"].as_str().expect("overridden topology_hash").to_string();
assert_eq!(ov_stdout_hash, base_hash, "stdout already carries the base document's hash");
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
// #311: the override run's own identity-keyed handle names its directory.
// `fast.length=2` is a no-op override here (the base document already
// binds `fast.length` to 2, per the doc comment above) — the digest keys
// on the merged, effective parameterisation, not on which of
// `params`/`defaults` a value rides in, so `ov_handle` below IS the base
// run's own handle, not a distinct one.
let ov_handle = ov_v["trace_name"].as_str().expect("a recording run prints its trace_name");
let index_text =
std::fs::read_to_string(cwd.join("runs/traces").join(ov_handle).join("index.json"))
.expect("read index.json");
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
let persisted_hash =
index["manifest"]["topology_hash"].as_str().expect("persisted topology_hash").to_string();
@@ -658,6 +674,53 @@ as stdout, not the reopened topology's own — index.json: {index_text}"
);
}
/// #311 acceptance criterion 2, end-to-end: a bare run and a run with a
/// no-op `--override` (pinning a param to the value it already defaults to)
/// are the SAME run, and must land in the SAME trace directory — not two
/// directories for one identity. Reproduces the digest defect this cycle's
/// review caught: keying on the `params`/`defaults` PARTITION rather than the
/// merged, effective parameterisation minted two handles here.
#[test]
fn exec_blueprint_noop_override_lands_in_the_same_trace_directory_as_the_bare_run() {
let cwd = temp_cwd("noop-override-same-dir");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let (base_out, base_code) =
run_code_in(&cwd, &["exec", bp_path.to_str().expect("utf-8 path")]);
assert_eq!(base_code, Some(0), "stdout/stderr: {base_out}");
let base_line = base_out.lines().find(|l| l.starts_with('{')).expect("record line");
let base_v: serde_json::Value = serde_json::from_str(base_line).expect("record parses");
let base_handle =
base_v["trace_name"].as_str().expect("a recording run prints its trace_name").to_string();
let (ov_out, ov_code) = run_code_in(
&cwd,
&["exec", bp_path.to_str().expect("utf-8 path"), "--override", "fast.length=2"],
);
assert_eq!(ov_code, Some(0), "stdout/stderr: {ov_out}");
let ov_line = ov_out.lines().find(|l| l.starts_with('{')).expect("record line");
let ov_v: serde_json::Value = serde_json::from_str(ov_line).expect("record parses");
let ov_handle =
ov_v["trace_name"].as_str().expect("a recording run prints its trace_name").to_string();
assert_eq!(
base_handle, ov_handle,
"fast.length=2 is a no-op override (the base document already binds it to 2) — \
same effective params must mint the same handle: base {base_out} / overridden {ov_out}"
);
let entries: Vec<_> = std::fs::read_dir(cwd.join("runs/traces"))
.expect("read traces dir")
.map(|e| e.expect("dir entry").file_name())
.collect();
assert_eq!(
entries.len(),
1,
"one identity, one directory — found {entries:?} under runs/traces"
);
}
/// A malformed override token (no `NODE.PARAM=VALUE` shape) refuses as usage.
#[test]
fn exec_override_malformed_token_refuses_with_usage() {
+9 -4
View File
@@ -2130,12 +2130,17 @@ fn tap_authored_via_op_script_runs_and_persists_the_series() {
let bp_path = dir.join("tapped.json");
std::fs::write(&bp_path, &blueprint_json).expect("write built blueprint");
let (_stdout, stderr2, code2) = run_in(&dir, &["exec", bp_path.to_str().unwrap()]);
let (stdout2, stderr2, code2) = run_in(&dir, &["exec", bp_path.to_str().unwrap()]);
assert_eq!(code2, Some(0), "aura run over the op-authored tap: {stderr2}");
// `aura graph build` always names the root composite "graph" (`composite_from_str`),
// so the trace store's run-name directory is fixed.
let trace_path = dir.join("runs/traces/graph/slow_ma.json");
// `aura graph build` always names the root composite "graph"
// (`composite_from_str`), but #311 keys the directory by the RUN's identity,
// so the render name is only its prefix — read the handle off the record.
let record: serde_json::Value =
serde_json::from_str(stdout2.trim()).expect("stdout is one JSON record line");
let handle = record["trace_name"].as_str().expect("a recording run prints its trace_name");
assert!(handle.starts_with("graph-"), "the render name stays the readable prefix: {handle}");
let trace_path = dir.join("runs/traces").join(handle).join("slow_ma.json");
let trace_text = std::fs::read_to_string(&trace_path)
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
+30 -11
View File
@@ -31,7 +31,11 @@ fn two_tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
}
fn run_measurement(cwd: &Path) {
/// Run the two-tap measurement blueprint and return the trace handle it
/// printed. #311: the trace directory is keyed by the run's own identity, so
/// every `measure ic` argv below addresses the run by the handle it reported —
/// which is also this file's read-back pin for the analysis consumer.
fn run_measurement(cwd: &Path) -> String {
let bp = cwd.join("measurement.json");
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
let out = Command::new(BIN)
@@ -40,6 +44,15 @@ fn run_measurement(cwd: &Path) {
.output()
.expect("spawn aura run");
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
let handle = v["trace_name"]
.as_str()
.expect("a recording run prints its trace_name")
.to_string();
assert!(handle.starts_with("sma_signal-"), "the render name stays the prefix: {handle}");
handle
}
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
@@ -51,11 +64,17 @@ fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
#[test]
fn measure_ic_emits_a_well_formed_report() {
let cwd = temp_cwd("wellformed");
run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &[]);
let handle = run_measurement(&cwd);
let out = measure_ic(&cwd, &handle, &[]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
assert_eq!(r["run"], "sma_signal");
// #311: the handle the run printed reaches that run's data — the analysis
// consumer's own round trip through the identity-keyed store.
assert_eq!(
r["run"].as_str(),
Some(handle.as_str()),
"the report addresses the run by the handle the run itself printed"
);
assert_eq!(r["signal_tap"], "signal");
assert_eq!(r["price_tap"], "price");
assert_eq!(r["horizon"], 1);
@@ -71,9 +90,9 @@ fn measure_ic_emits_a_well_formed_report() {
#[test]
fn measure_ic_is_deterministic() {
let cwd = temp_cwd("determinism");
run_measurement(&cwd);
let a = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
let b = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
let handle = run_measurement(&cwd);
let a = measure_ic(&cwd, &handle, &["--seed", "9"]);
let b = measure_ic(&cwd, &handle, &["--seed", "9"]);
assert!(a.status.success() && b.status.success());
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
}
@@ -97,8 +116,8 @@ fn measure_ic_unknown_run_errors() {
#[test]
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
let cwd = temp_cwd("oversizedhorizon");
run_measurement(&cwd);
let out = measure_ic(&cwd, "sma_signal", &["--horizon", "100000000"]);
let handle = run_measurement(&cwd);
let out = measure_ic(&cwd, &handle, &["--horizon", "100000000"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
assert_eq!(r["horizon"], 100_000_000);
@@ -110,9 +129,9 @@ fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
#[test]
fn measure_ic_missing_tap_errors() {
let cwd = temp_cwd("missingtap");
run_measurement(&cwd);
let handle = run_measurement(&cwd);
let out = Command::new(BIN)
.args(["measure", "ic", "sma_signal", "--signal", "nope", "--price", "price"])
.args(["measure", "ic", &handle, "--signal", "nope", "--price", "price"])
.current_dir(&cwd)
.output()
.expect("spawn");
+19 -3
View File
@@ -15,6 +15,19 @@ fn temp_cwd(name: &str) -> std::path::PathBuf {
dir
}
/// The `trace_name` handle a recording run printed on its stdout record line.
/// #311: the trace directory is `<render-name>-<id8>`, keyed by the run's own
/// identity, so the path is derived from this value rather than hard-coded.
fn trace_handle(stdout: &[u8]) -> String {
let line = String::from_utf8_lossy(stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
v["trace_name"]
.as_str()
.expect("a recording run prints its trace_name")
.to_string()
}
/// The shipped `examples/r_sma.json`, turned MEASUREMENT-shaped: one declared tap
/// on node 0 ("fast", SMA(2)) field 0, and its `bias` output removed. Same closed
/// topology (`sma_signal`), so it runs on the built-in synthetic stream.
@@ -49,7 +62,8 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
assert_eq!(report["manifest"]["broker"], "measurement");
// The tapped series is persisted through the trace store.
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
let trace_path =
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json");
let trace_text = std::fs::read_to_string(&trace_path)
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
@@ -71,8 +85,10 @@ fn measurement_run_honours_the_tap_selector() {
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace_text = std::fs::read_to_string(
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json"),
)
.expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
+255 -23
View File
@@ -41,6 +41,20 @@ fn tap_blueprint_json() -> String {
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
}
/// The `trace_name` handle a recording run printed on its stdout record line —
/// the address of the directory its taps landed in. #311: that directory is
/// `<render-name>-<id8>`, keyed by the run's own identity, so no test may
/// hard-code it; every trace path in this file is derived from this value.
fn trace_handle(stdout: &[u8]) -> String {
let line = String::from_utf8_lossy(stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
v["trace_name"]
.as_str()
.expect("a recording run prints its trace_name")
.to_string()
}
/// Property: on the single-run path (`aura run <blueprint.json>` over the
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
/// per-cycle series is persisted through the trace store — the persisted
@@ -59,7 +73,8 @@ fn single_run_persists_a_declared_tap_series() {
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
let trace_path =
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json");
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
});
@@ -200,6 +215,15 @@ fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
/// but the record consumer's deferred open in `initialize` fails; the run
/// COMPLETES, the failure surfaces terminally through the register, and no
/// `index.json` is written (the store's treat-as-absent crash shape).
///
/// #311: the run directory's name is the run's own identity and cannot be known
/// in advance, so the premise is set up by RUNNING first — the identity property
/// itself guarantees that a second, byte-identical invocation targets the same
/// directory. Emptying it and dropping its write bit between the two runs
/// reproduces the original premise exactly (an existing, unwritable run dir)
/// without hard-coding a name. Making the store ROOT unwritable instead would
/// move the failure to `begin_run`, i.e. into the pre-run arm its sibling at
/// `an_unwritable_store_root_exits_through_the_tap_trace_register` already pins.
#[cfg(unix)]
#[test]
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
@@ -208,16 +232,25 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
let cwd = temp_cwd("run-dir-read-only");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run_dir = cwd.join("runs/traces/sma_signal");
std::fs::create_dir_all(&run_dir).expect("pre-create run dir");
let exec = || {
Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run")
};
let first = exec();
assert!(first.status.success(), "stderr: {}", String::from_utf8_lossy(&first.stderr));
let run_dir = cwd.join("runs/traces").join(trace_handle(&first.stdout));
for entry in std::fs::read_dir(&run_dir).expect("read the run dir") {
std::fs::remove_file(entry.expect("dir entry").path()).expect("empty the run dir");
}
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
.expect("make run dir read-only");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn aura run");
let out = exec();
// restore permissions FIRST so the next run's temp_cwd cleanup works.
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
@@ -232,6 +265,11 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
"the tap-trace register must fire terminally: {stderr}"
);
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
assert_eq!(
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
1,
"the identical re-run targeted the same directory rather than minting a second"
);
}
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
@@ -251,13 +289,14 @@ fn run_tap_selector_persists_a_fold_summary_row() {
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
let run_dir = cwd.join("runs/traces").join(trace_handle(&out.stdout));
let index_text =
std::fs::read_to_string(run_dir.join("index.json")).expect("read index.json");
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
.expect("read fold trace");
let trace_text =
std::fs::read_to_string(run_dir.join("fast_tap.json")).expect("read fold trace");
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
assert_eq!(trace["tap"], "fast_tap");
let rows = trace["columns"][0].as_array().expect("columns[0] array");
@@ -281,7 +320,7 @@ fn run_explicit_record_plan_matches_the_record_all_default() {
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
.expect("write two-tap blueprint");
}
let run = |cwd: &std::path::Path, extra: &[&str]| {
let run = |cwd: &std::path::Path, extra: &[&str]| -> std::path::PathBuf {
let mut args = vec!["exec", "two_tap.json"];
args.extend_from_slice(extra);
let out = Command::new(BIN)
@@ -290,21 +329,28 @@ fn run_explicit_record_plan_matches_the_record_all_default() {
.output()
.expect("spawn aura run");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
cwd.join("runs/traces").join(trace_handle(&out.stdout))
};
run(&cwd_a, &[]);
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
let dir_a = run(&cwd_a, &[]);
let dir_b = run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
// #311: the tap PLAN is not identity-bearing — it selects what is written,
// not what the run IS — so both invocations mint the same handle.
assert_eq!(
dir_a.file_name(),
dir_b.file_name(),
"the same blueprint under the same inputs is one identity either way"
);
for file in ["fast_tap.json", "slow_tap.json"] {
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
let a = std::fs::read(dir_a.join(file)).expect("default trace");
let b = std::fs::read(dir_b.join(file)).expect("explicit trace");
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
}
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
.expect("read index.json");
let taps_of = |dir: &std::path::Path| -> serde_json::Value {
let text = std::fs::read_to_string(dir.join("index.json")).expect("read index.json");
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
};
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
assert_eq!(taps_of(&dir_a), taps_of(&dir_b));
}
/// #310: an unknown fold label is refused through the registry's
@@ -452,7 +498,19 @@ fn recording_run_reports_its_trace_handle_on_stdout() {
let line = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(line.trim()).expect("stdout is one JSON object");
let handle = v["trace_name"].as_str().expect("trace_name is present and a string");
assert_eq!(handle, "sma_signal", "the handle is the run's own name");
// #311: the handle is the run's own IDENTITY, not its render name alone —
// the render name is the readable prefix, the 8-hex tail the run identity.
// (The next assertion, that the handle names the directory the traces
// landed in, is name-agnostic and unchanged.)
assert!(
handle.starts_with("sma_signal-"),
"the handle carries the run's render name as its prefix; got {handle}"
);
assert_eq!(
handle.len(),
"sma_signal-".len() + 8,
"the identity suffix is exactly 8 hex chars; got {handle}"
);
assert!(
cwd.join("runs/traces").join(handle).join("fast_tap.json").exists(),
"the reported handle must name the directory the traces landed in; got {handle}"
@@ -462,6 +520,12 @@ fn recording_run_reports_its_trace_handle_on_stdout() {
/// #309: the handle is appended, never interleaved — the report's own key
/// order and bytes survive the flattening wrapper untouched. This is the first
/// pin of `serde(flatten)` byte behaviour in this workspace; assert bytes.
///
/// #311: the handle's own spelling (`<render-name>-<id8>`) is pinned elsewhere
/// (`recording_run_reports_its_trace_handle_on_stdout`); this test only pins the
/// BYTE POSITION — that whatever the handle is, it is the last key, appended
/// after every other field, never interleaved — so it reads the handle back off
/// the same line rather than hard-coding its value.
#[test]
fn trace_handle_is_appended_as_the_last_key() {
let cwd = temp_cwd("trace-handle-bytes");
@@ -475,9 +539,11 @@ fn trace_handle_is_appended_as_the_last_key() {
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
let handle = trace_handle(out.stdout.as_slice());
let expected_tail = format!(",\"trace_name\":\"{handle}\"}}");
assert!(
line.ends_with(",\"trace_name\":\"sma_signal\"}"),
line.ends_with(&expected_tail),
"the handle must be the last key, appended before the closing brace: {line}"
);
}
@@ -502,3 +568,169 @@ fn tap_free_run_line_carries_no_trace_handle() {
"a run that recorded nothing must emit no trace_name key at all: {line}"
);
}
/// #311 headline: two runs of ONE blueprint that differ in an identity-bearing
/// input (an `--override` value that actually changes the bound parameter,
/// `fast.length=2` vs `fast.length=3`) land in TWO trace directories — the
/// first run's tapped series is still on disk, byte-unchanged, after the
/// second run completes. Before #311, both runs minted `traces/sma_signal/`
/// and the second overwrote the first, losing the earlier series
/// unrecoverably.
#[test]
fn two_runs_of_one_blueprint_with_different_overrides_keep_both_traces() {
let cwd = temp_cwd("two-overrides-two-traces");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run = |value: &str| -> String {
let ov = format!("fast.length={value}");
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--override", &ov])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
trace_handle(&out.stdout)
};
let handle_a = run("2");
let series_a = std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
.expect("the first run's tapped series");
let handle_b = run("3");
assert_ne!(
handle_a, handle_b,
"two runs differing in an identity-bearing input must land in two directories"
);
let series_b = std::fs::read(cwd.join("runs/traces").join(&handle_b).join("fast_tap.json"))
.expect("the second run's tapped series");
assert_ne!(series_a, series_b, "SMA(2) and SMA(3) are different series");
assert_eq!(
std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
.expect("the first run's series survives the second run"),
series_a,
"the earlier run's trace must survive the later run untouched"
);
for handle in [&handle_a, &handle_b] {
assert!(
cwd.join("runs/traces").join(handle).join("index.json").is_file(),
"each run closes its own index: {handle}"
);
}
}
/// #311 acceptance criterion 2: two runs identical in every identity-bearing
/// input write ONE directory and print ONE handle. Identity keying separates
/// runs that differ; it does not mint a directory per invocation.
#[test]
fn two_identical_runs_share_one_trace_directory() {
let cwd = temp_cwd("identical-runs-one-dir");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
let run = || -> String {
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
trace_handle(&out.stdout)
};
let first = run();
let second = run();
assert_eq!(first, second, "same inputs, same identity, same handle");
assert!(
first.starts_with("sma_signal-") && first.len() == "sma_signal-".len() + 8,
"the render name is the readable prefix, the 8-hex tail the identity: {first}"
);
assert_eq!(
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
1,
"an unchanged re-run must not grow the store"
);
}
/// #311 acceptance criterion 3 — and the pin for the two source-read properties
/// the `project.commit` exclusion rests on: the field carries a `-dirty` marker,
/// and it is re-derived on EVERY invocation (`aura-runner::project::project_commit`).
/// A run whose project worktree is dirty must still mint the handle it minted
/// with a clean worktree; otherwise editing any file in the project — including
/// the blueprint being worked on — would turn the store into an unbounded
/// directory generator.
#[test]
fn a_dirty_project_worktree_does_not_change_the_run_handle() {
let cwd = temp_cwd("provenance-does-not-churn");
std::fs::write(cwd.join("Aura.toml"), "# identity fixture: data-only project\n")
.expect("write Aura.toml");
let bp_path = cwd.join("tapped_r_sma.json");
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
// Ignore the trace store the first `exec()` below creates — otherwise
// `runs/` itself would be an untracked path and `git status --porcelain`
// would already report the worktree dirty on the SECOND invocation
// regardless of `scratch.txt`, making that write inert rather than the
// thing that actually flips the marker.
std::fs::write(cwd.join(".gitignore"), "runs/\n").expect("write .gitignore");
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(&cwd)
.output()
.expect("run git");
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "test"]);
git(&["add", "-A"]);
git(&["commit", "-m", "fixture"]);
let exec = || -> (String, String) {
let out = Command::new(BIN)
.args(["exec", bp_path.to_str().expect("utf-8 path")])
.current_dir(&cwd)
.output()
.expect("spawn exec");
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let line = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value =
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
let commit = v["manifest"]["project"]["commit"]
.as_str()
.expect("a run inside a git-backed project stamps its repo HEAD")
.to_string();
(trace_handle(&out.stdout), commit)
};
let (clean_handle, clean_commit) = exec();
// `runs/` is gitignored (above), so this is the only untracked change —
// it, and nothing else, is what flips `git status --porcelain` for the
// second invocation below.
std::fs::write(cwd.join("scratch.txt"), "an uncommitted edit\n").expect("dirty the worktree");
let (dirty_handle, dirty_commit) = exec();
// The two source-read properties, pinned: the marker exists, and the field
// is re-derived per invocation rather than captured once.
assert!(
!clean_commit.ends_with("-dirty"),
"a committed worktree stamps a bare HEAD: {clean_commit}"
);
assert!(
dirty_commit.ends_with("-dirty"),
"an uncommitted file stamps the -dirty marker: {dirty_commit}"
);
assert_ne!(clean_commit, dirty_commit, "project.commit is re-derived on every invocation");
// ...and yet it is the same run.
assert_eq!(
clean_handle, dirty_handle,
"the project repo's HEAD/dirty state is provenance, not identity"
);
assert_eq!(
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
1,
"one identity, one directory"
);
}
+31 -1
View File
@@ -495,7 +495,16 @@ impl fmt::Display for TraceStoreError {
NameKind::Family => "family",
NameKind::NotFound => "name",
};
write!(f, "'{name}' already used as a {kind}; pick another --trace name")
// #311: no verb has carried a trace-naming flag since #319 —
// a single run's handle is its own identity digest and a
// campaign's is `derive_trace_name`'s, so the only remedy left
// is freeing the directory.
write!(
f,
"'{name}' already used as a {kind}; both a single run's handle and a \
campaign's are machine-minted, so free the name by moving or deleting \
runs/traces/{name}"
)
}
}
}
@@ -706,6 +715,27 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
/// #311: the refusal's remedy must not send the reader to a flag that does
/// not exist — no verb has carried `--trace` since #319. Both handles are
/// machine-minted; freeing the directory is the only remedy left.
#[test]
fn name_taken_refusal_prescribes_freeing_the_directory_not_a_retired_flag() {
let root = temp_traces_root("nametaken-prose");
let store = TraceStore::open(&root);
store.write("asrun", &sample_manifest(), &sample_taps()).expect("write run");
let msg = store
.ensure_name_free("asrun", WriteKind::Family)
.expect_err("cross-kind reuse is refused")
.to_string();
assert!(msg.contains("already used as a run"), "the existing kind is named: {msg}");
assert!(!msg.contains("-trace"), "no verb carries a trace-naming flag since #319: {msg}");
assert!(
msg.contains("runs/traces/asrun"),
"the remedy names the directory to free: {msg}"
);
let _ = fs::remove_dir_all(&root);
}
/// The load-bearing compatibility pin: for the same rows, the streamed
/// writer's file bytes equal `TraceStore::write`'s — the legacy path's
/// bytes, whatever they are, define the shape. Covers all four
+232
View File
@@ -59,3 +59,235 @@ pub struct RunnerError {
pub exit_code: i32,
pub message: String,
}
/// The 8-hex identity of a run: a digest over its manifest with the two
/// provenance fields removed and `params`/`defaults` merged.
///
/// Both removed fields record *who built or checked out the code*, not what the
/// run was. `commit` is the aura binary's own build sha (`crates/aura-cli/build.rs`),
/// so it moves whenever the engine's checkout moves. `project.commit` is the
/// project repository's HEAD plus a `-dirty` marker, and — unlike the former —
/// it is re-evaluated on every invocation (`project.rs::project_commit`), so any
/// uncommitted file in the project worktree, including the blueprint being
/// edited, would mint a fresh directory. Including either would make an
/// identity-keyed store an unbounded directory generator.
///
/// `project.dylib_sha256` is deliberately kept: it is the node crate's own
/// content hash, which is what the C13 hot-reload comparison varies.
///
/// **Removing the keys, not blanking them, is load-bearing.**
/// `ProjectProvenance.commit` is `skip_serializing_if = "Option::is_none"`, so
/// the key is *absent* — not null — whenever the project's HEAD is not
/// derivable. Blanking would canonicalise one run to `{"project":{"commit":null}}`
/// and an otherwise-identical run to `{"project":{}}`: two digests for two runs
/// that differ in nothing identity-bearing. Removal collapses both to the same
/// bytes.
///
/// **`params` and `defaults` are merged into one name-sorted sequence, not
/// hashed as the two separate vectors the manifest carries.** The partition
/// between them records *how a value was supplied* — reopened by an axis vs.
/// held at its bound default (`RunManifest.defaults`' own doc comment,
/// `crates/aura-engine/src/report.rs`) — which is exactly the same kind of
/// provenance-not-identity distinction the two removed fields above are
/// removed for. Two runs whose bound parameterisation is identical but whose
/// values happen to be partitioned differently between the vectors (e.g. one
/// run leaves `fast.length` at its default of `2`, another passes
/// `--override fast.length=2` — a no-op override) compute the exact same
/// signal and must land in the same trace directory. The merge is total and
/// lossless *because* the two vectors are disjoint by construction — a bound
/// param is either varied (`params`) or held (`defaults`), never both — so no
/// name collision can silently drop or shadow a value.
///
/// serde_json's map is sorted, so re-serialising a parsed value yields
/// deterministic bytes — the same technique aura-bench uses for its record-line
/// fingerprint (`crates/aura-bench/src/surfaces/fixed_cost.rs`).
pub fn run_identity_digest(manifest: &aura_engine::RunManifest) -> String {
use aura_engine::Scalar;
use sha2::{Digest, Sha256};
let mut merged: Vec<(String, Scalar)> = manifest
.params
.iter()
.chain(manifest.defaults.iter())
.cloned()
.collect();
merged.sort_by(|a, b| a.0.cmp(&b.0));
let mut v = serde_json::to_value(manifest).expect("a run manifest serializes");
if let Some(obj) = v.as_object_mut() {
obj.remove("commit");
if let Some(project) = obj.get_mut("project").and_then(|p| p.as_object_mut()) {
project.remove("commit");
}
obj.remove("defaults");
obj.insert(
"params".to_string(),
serde_json::to_value(&merged).expect("the merged param vec serializes"),
);
}
let canonical = serde_json::to_string(&v).expect("re-serializing a parsed value cannot fail");
let hex = format!("{:x}", Sha256::digest(canonical.as_bytes()));
hex[..8].to_string()
}
#[cfg(test)]
mod tests {
use super::run_identity_digest;
use aura_engine::{ProjectProvenance, RunManifest, Scalar, Timestamp};
/// A manifest with every field populated the way a real single run stamps
/// them — the base each case below perturbs by exactly one field.
fn manifest() -> RunManifest {
RunManifest {
commit: "aabbccdd".to_string(),
params: vec![("fast.length".to_string(), Scalar::I64(2))],
defaults: vec![("bias.scale".to_string(), Scalar::F64(0.5))],
window: (Timestamp(1), Timestamp(18)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: Some("0f1e2d3c".to_string()),
project: Some(ProjectProvenance {
namespace: None,
dylib_sha256: None,
commit: Some("deadbeef".to_string()),
}),
}
}
#[test]
fn run_identity_digest_is_eight_lowercase_hex() {
let d = run_identity_digest(&manifest());
assert_eq!(d.len(), 8, "the handle suffix is 8 hex chars: {d}");
assert!(
d.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"lowercase hex only: {d}"
);
}
#[test]
fn run_identity_digest_ignores_the_binary_build_sha() {
let a = manifest();
let mut b = manifest();
b.commit = "0123456789abcdef".to_string();
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
"manifest.commit is the binary's build provenance, not the run's identity"
);
}
#[test]
fn run_identity_digest_ignores_the_project_head_and_its_dirty_marker() {
let a = manifest();
let mut b = manifest();
b.project.as_mut().expect("base manifest carries provenance").commit =
Some("deadbeef-dirty".to_string());
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
"project.commit is re-derived per invocation; editing the worktree is not a new run"
);
}
/// The key-presence case that removal (rather than blanking) exists for: an
/// absent `project.commit` is a MISSING key, not `null`
/// (`skip_serializing_if`), so blanking would split one identity in two.
#[test]
fn run_identity_digest_collapses_an_absent_project_commit_onto_a_present_one() {
let a = manifest();
let mut b = manifest();
b.project.as_mut().expect("base manifest carries provenance").commit = None;
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
"a derivable HEAD and an underivable one are the same run"
);
}
#[test]
fn run_identity_digest_separates_two_param_settings() {
let a = manifest();
let mut b = manifest();
b.params = vec![("fast.length".to_string(), Scalar::I64(3))];
assert_ne!(
run_identity_digest(&a),
run_identity_digest(&b),
"params are identity-bearing — this is the headline case"
);
}
/// A no-op `--override` that pins a param to the value it already defaults
/// to moves the pair from `defaults` to `params` without changing the
/// effective parameterisation — the digest must not tell the two runs
/// apart. This is the union-not-partition property: same name/value pairs,
/// different vector each rides in.
#[test]
fn run_identity_digest_ignores_which_vector_a_param_rides_in() {
let a = manifest(); // params: [fast.length=2], defaults: [bias.scale=0.5]
let mut b = manifest();
b.params = vec![];
b.defaults = vec![
("fast.length".to_string(), Scalar::I64(2)),
("bias.scale".to_string(), Scalar::F64(0.5)),
];
assert_eq!(
run_identity_digest(&a),
run_identity_digest(&b),
"same effective params, different params/defaults partition — same run"
);
}
/// The C13 discriminator stays IN: a reloaded node crate with different
/// bytes is a different run.
#[test]
fn run_identity_digest_keeps_the_project_dylib_hash() {
let a = manifest();
let mut b = manifest();
b.project.as_mut().expect("base manifest carries provenance").dylib_sha256 =
Some("ff00".to_string());
assert_ne!(
run_identity_digest(&a),
run_identity_digest(&b),
"dylib_sha256 is exactly what C13 hot-reload varies"
);
}
/// The absolute property every other case in this module only tests
/// relationally: ONE fully-populated manifest (every field set, including
/// a `project` with `namespace`, `dylib_sha256` AND `commit` all present)
/// yields this ONE specific 8-hex value — stably, across builds and
/// processes, since the whole trace store's addressing rests on it.
///
/// If this literal ever needs to change, that change is NOT a casual
/// update: it means `run_identity_digest`'s canonicalisation shifted
/// (e.g. a workspace-wide serde_json `preserve_order` feature unification
/// would turn `Map::remove` into a swap-remove and reorder keys), and
/// every previously recorded trace directory has been silently
/// re-addressed. Update it only with that consequence understood.
#[test]
fn run_identity_digest_is_stable_for_a_fixed_manifest() {
let m = RunManifest {
commit: "aabbccdd".to_string(),
params: vec![("fast.length".to_string(), Scalar::I64(2))],
defaults: vec![("bias.scale".to_string(), Scalar::F64(0.5))],
window: (Timestamp(1), Timestamp(18)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: Some("0f1e2d3c".to_string()),
project: Some(ProjectProvenance {
namespace: Some("quadriga".to_string()),
dylib_sha256: Some("ff00".to_string()),
commit: Some("deadbeef".to_string()),
}),
};
assert_eq!(
run_identity_digest(&m),
"3d080714",
"the digest for this fixed manifest must be stable across builds and processes"
);
}
}
+22 -12
View File
@@ -94,7 +94,9 @@ pub fn run_measurement(
let topo = aura_research::content_id_of(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
); // before signal is consumed
let run_name = signal.name().to_string();
// #311 (mirrors run_signal_r): the render name is the readable prefix; the
// directory is minted from the manifest below.
let render_name = signal.name().to_string();
// C14 class 2: fault in argv-named content (#297)
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
.map_err(|m| RunnerError { exit_code: 2, message: m })?;
@@ -118,6 +120,19 @@ pub fn run_measurement(
let mut flat = signal.compile_with_params(params)
.map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &role_names) })?;
// #311 (mirrors run_signal_r): assemble the manifest BEFORE the bind and
// key the trace directory by the run's own identity. Every input this
// needs — `topo` (above), `defaults` (above), `window` (from
// `resolve_run_data`) — is already resolved by this point; `names` is
// untouched by the compile call, which passes `role_names`.
let named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = measurement_manifest(named_params, window, seed);
manifest.defaults = defaults;
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
let run_name = format!("{}-{}", render_name, crate::run_identity_digest(&manifest));
// Bind each declared tap per the plan's subscription (mirrors
// run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror).
// C14 class 2: fault in argv-named content (#297)
@@ -129,13 +144,6 @@ pub fn run_measurement(
h.run_bound(key_supply(&binding, sources))
.expect("sources opened against `binding` key-match that binding's own roles by construction");
let named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = measurement_manifest(named_params, window, seed);
manifest.defaults = defaults;
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
// Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
let tap_names: Vec<String> = bound.declared_names().to_vec();
let skipped = bound.skipped().to_vec();
@@ -198,13 +206,15 @@ mod tests {
let (env, root) = temp_project_env("fold");
let mut plan = TapPlan::empty();
plan.subscribe("fast_tap", TapSubscription::named("mean"));
let report =
run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan)
.expect("measurement run succeeds").report;
let outcome = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan)
.expect("measurement run succeeds");
// #311: the run reports the identity-keyed directory it wrote into.
let handle = outcome.trace_name.clone().expect("a recording run reports its handle");
let report = outcome.report;
assert_eq!(report.taps, vec!["fast_tap".to_string()]);
let text = std::fs::read_to_string(
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
root.join("runs").join("traces").join(&handle).join("fast_tap.json"),
)
.expect("one-row fold trace persisted");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
+48 -21
View File
@@ -583,7 +583,11 @@ pub fn run_signal_r(
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
), // before signal is consumed
};
let run_name = signal.name().to_string(); // before signal is consumed by `wrap_r`
// #311: the render name is only the readable PREFIX of the run's directory
// now; the directory itself is minted below, from the run's own manifest.
// Captured here because `signal.name()` is unreachable after `wrap_r`
// consumes the signal.
let render_name = signal.name().to_string(); // before signal is consumed by `wrap_r`
// The default binding (name defaults; `aura run` carries no campaign
// overrides). Refusals propagate as a `RunnerError` for the CLI to
// print and exit on, never a process exit inside the library (#297).
@@ -611,6 +615,24 @@ pub fn run_signal_r(
// C14 class 2: fault in argv-named content (#297)
let mut flat = wrapped.compile_with_params(params)
.map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &names, params) })?;
// #311: the run's trace directory is keyed by the run's own identity, not
// by the render name alone. The manifest is assembled HERE — every input it
// needs (`topo`, `defaults`, `window`, `pip_size`, `names`, `params`,
// `seed`, `env`) is resolved by this point and no field of it is derived
// from the run's execution — and the SAME value both mints the directory
// name and, after the run, enters the report: assembling it twice would let
// the two drift, and the handle would then name a directory whose identity
// the record does not describe. `compile_error_prose` above borrows `names`
// only inside the `map_err` closure, so the borrow ends with that statement
// and `names.into_iter()` still moves cleanly here.
let named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = sim_optimal_manifest(named_params, window, seed, pip_size);
manifest.defaults = defaults;
manifest.broker = r_sma_broker_label(pip_size);
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
let run_name = format!("{}-{}", render_name, crate::run_identity_digest(&manifest));
// Bind each declared tap per the plan's subscription, before bootstrap
// (#283): typed refusals for bad plans, record consumers hold their
// streaming writer in-graph, folds accumulate O(1), live closures run
@@ -631,13 +653,6 @@ pub fn run_signal_r(
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let named_params: Vec<(String, Scalar)> =
names.into_iter().zip(params.iter().copied()).collect();
let mut manifest = sim_optimal_manifest(named_params, window, seed, pip_size);
manifest.defaults = defaults;
manifest.broker = r_sma_broker_label(pip_size);
manifest.topology_hash = Some(topo);
manifest.project = env.provenance();
let mut metrics = summarize(&aura_engine::f64_field(&eq_rows, 0), &aura_engine::f64_field(&ex_rows, 0));
metrics.r = Some(summarize_r(&r_rows, &[]));
let skipped = bound.skipped().to_vec();
@@ -1870,9 +1885,11 @@ mod tests {
blueprint_from_json(&patched, &|t| std_vocabulary(t)).expect("tapped r_sma loads")
}
fn read_tap_json(root: &Path) -> serde_json::Value {
/// #311: the run directory is the run's own identity-keyed handle, which
/// the outcome reports — never the render name alone.
fn read_tap_json(root: &Path, handle: &str) -> serde_json::Value {
let text = std::fs::read_to_string(
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
root.join("runs").join("traces").join(handle).join("fast_tap.json"),
)
.expect("persisted tap trace");
serde_json::from_str(&text).expect("parse tap trace")
@@ -1886,9 +1903,11 @@ mod tests {
fn fold_and_live_plans_agree_with_the_recorded_series() {
// Run A: record (the charting question).
let (env_a, root_a) = temp_project_env("record");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run A records");
let series = read_tap_json(&root_a);
let out_a =
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run A records");
let handle_a = out_a.trace_name.expect("a recording run reports its handle");
let series = read_tap_json(&root_a, &handle_a);
let ts: Vec<i64> =
series["ts"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
let vals: Vec<f64> =
@@ -1899,9 +1918,10 @@ mod tests {
let (env_b, root_b) = temp_project_env("fold");
let mut plan_b = TapPlan::record_all();
plan_b.subscribe("fast_tap", TapSubscription::named("mean"));
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b, None)
let out_b = run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b, None)
.expect("run B folds");
let row = read_tap_json(&root_b);
let handle_b = out_b.trace_name.expect("a recording run reports its handle");
let row = read_tap_json(&root_b, &handle_b);
let mean = vals.iter().sum::<f64>() / vals.len() as f64; // sequential — FoldState's order
assert_eq!(row["ts"], serde_json::json!([*ts.last().unwrap()]));
assert_eq!(row["columns"], serde_json::json!([[mean]]), "fold row == slice mean, bit-exact");
@@ -1968,16 +1988,23 @@ mod tests {
fn two_identical_record_runs_produce_byte_identical_tap_files() {
let (env_a, root_a) = temp_project_env("det-a");
let (env_b, root_b) = temp_project_env("det-b");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run a records");
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all(), None)
.expect("run b records");
let out_a =
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None)
.expect("run a records");
let out_b =
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all(), None)
.expect("run b records");
let handle_a = out_a.trace_name.expect("run a reports its handle");
let handle_b = out_b.trace_name.expect("run b reports its handle");
// #311: identical inputs are ONE identity — the two runs differ only in
// which temp project root they wrote into, which is not in the manifest.
assert_eq!(handle_a, handle_b, "same input, same identity, same handle");
let a = std::fs::read(
root_a.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
root_a.join("runs").join("traces").join(&handle_a).join("fast_tap.json"),
)
.expect("run a trace");
let b = std::fs::read(
root_b.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
root_b.join("runs").join("traces").join(&handle_b).join("fast_tap.json"),
)
.expect("run b trace");
assert_eq!(a, b, "same input, same bytes (the index carries env provenance; the tap file must not)");
+22 -15
View File
@@ -233,7 +233,7 @@ are dotted `<identifier>.<port>` on both sides of a wire.
| op | JSON shape | does |
|---|---|---|
| `name` | `{"op":"name","name":<str>}` | set the composite's **render name**, script-level and at-most-once (a second `name` op refuses: `a script names its blueprint at most once`). The name must be a single path segment (non-empty, no `/`, `\`, `.` or `..`) since it keys a trace directory (`traces/<name>/`) at run time. Omitting the op keeps the CLI's own default, `"graph"`. |
| `name` | `{"op":"name","name":<str>}` | set the composite's **render name**, script-level and at-most-once (a second `name` op refuses: `a script names its blueprint at most once`). The name must be a single path segment (non-empty, no `/`, `\`, `.` or `..`) since it prefixes a trace directory (`traces/<name>-<id8>/`, #311) at run time. Omitting the op keeps the CLI's own default, `"graph"`. |
| `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — 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). |
@@ -245,20 +245,22 @@ are dotted `<identifier>.<port>` on both sides of a wire.
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura exec` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a campaign member run leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
| `gang` | `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` | Fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space and `as` replaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound). |
Omitting `name` is fine for a single strategy, but it has three live
Omitting `name` is fine for a single strategy, but it has two live
consequences once a project has more than one: every unnamed store document
is indistinguishable by name (`"graph"` again), every unnamed strategy's
default tap recording lands in the same shared `traces/graph/` directory (a
second unnamed strategy's run overwrites the first's), and two `use`-splices
of unnamed blueprints collide on the same default instance identifier
(`graph`) inside the same composing script. A one-line `name` op dissolves
the first and third of these — giving each blueprint its own distinct name
stops *different* blueprints from colliding with each other. It does NOT by
itself dissolve the second: two runs of the *identical*, now uniquely-named
blueprint still share one `traces/<name>/` directory, so a later run still
overwrites an earlier one's trace — naming separates blueprints from each
other, not a blueprint's own repeated runs from each other (that trace-dir
question is tracked separately, not resolved here).
is indistinguishable by name (`"graph"` again), and two `use`-splices of
unnamed blueprints collide on the same default instance identifier (`graph`)
inside the same composing script. A one-line `name` op dissolves both.
The third consequence that used to sit here — every unnamed strategy's tap
recording landing in one shared `traces/graph/` directory, where a later run
overwrote an earlier one's trace — is **resolved** (#311), and no longer
depends on naming at all: a run's trace directory is keyed by the run's own
identity (`traces/<name>-<id8>/`, a digest over its manifest), so two runs
that differ in any identity-bearing input — a different topology, an
`--override` value, the data window, the seed — land in two directories,
while two runs that differ in nothing land in one and the later simply
rewrites what the earlier wrote. The render name survives as the directory's
readable prefix, and `aura chart <name>` lists the runs recorded under it.
Value forms are the typed-tag representations used everywhere in this
family of artifacts:
@@ -849,7 +851,12 @@ name — chartable with `aura chart <trace_name>` (members keyed
against the stored campaign documents when no literal trace directory
matches, refusing rather than guessing when the name is ambiguous across
recorded runs. A single run reports its handle the same way: the `trace_name` value on
its own stdout line, absent when the run recorded nothing.
its own stdout line, absent when the run recorded nothing. That handle is
`<render-name>-<id8>` (#311) — the render name plus an 8-hex digest over the
run's manifest, with the aura binary's build sha and the project repo's HEAD
excluded — so two runs of one blueprint that differ in an identity-bearing
input keep both traces, and `aura chart <render-name>` lists the handles
recorded under that name rather than charting one of them.
Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any
cell runs (invalid document, missing project, unresolvable strategy) exits 1;
+26
View File
@@ -251,6 +251,32 @@ unproducible-tap skip (`net_r_equity` needs a cost leg the campaign runner wires
of). Known debt: `aura chart` over the campaign family ROOT (cells spanning
instruments) is untested / semantically undefined — only per-cell read-back is pinned.
**Single-run trace identity (#311).** A single `aura exec` run's taps land under
`traces/<render-name>-<id8>/`. `id8` is the 8-hex head of a SHA-256 over the run's
own `RunManifest` with two keys *removed*`commit`, the aura binary's build sha,
and `project.commit`, the project repo's HEAD plus a `-dirty` marker — and
`params`/`defaults` *merged* into one name-sorted sequence. The two removed keys
answer *who built or checked out the code*, not *what the run was*, and the second
is re-derived on every invocation, so including either would make the store an
unbounded directory generator. `params` and `defaults` are merged because the
partition between them records *how* a bound value was supplied (varied vs. held
at its default, #246) rather than *what* the run's effective parameterisation is —
the same provenance-not-identity distinction, applied to a different pair of keys;
the merge is lossless since the two vectors are disjoint by construction. What
remains — the merged params, window, seed, broker, instrument, `topology_hash`,
and the project's `namespace`/`dylib_sha256` (the C13 hot-reload discriminator) —
is identity-bearing. The two provenance keys are removed rather than blanked
because `ProjectProvenance.commit` skips serializing when it is `None`: blanking
would canonicalise an absent key to `null` and split one identity in two.
`aura-runner::run_identity_digest` is the single implementation; both single-run
entry points assemble the manifest *before* the tap bind and reuse that one value
for the record, so a handle can never name a directory whose identity the stored
record does not describe. This is consistent with C18's plane split above:
`commit = identity` names plane (1), the code identity; a *run's* identity (plane
2) is its manifest minus provenance, with params/defaults collapsed to their
effective union. The campaign leg is unaffected — its handle is
`derive_trace_name`'s `{campaign8}-{run}`, already collision-free by construction.
**Identity-ref resolution is index-first (#191).** `find_blueprint_by_identity`
consults the persistent `blueprint_identity_index.jsonl` sidecar (identity id →
content id; a fixed-name sibling of the runs store, appended under the #276 lock)
@@ -136,6 +136,16 @@ envelope honestly as range bars / OHLC rather than a polyline (#112); a `--width
budget flag and true intra-bucket min/max ordering for the non-default continuous
x-mode (#110).
**Current-state "Single run" clause (superseded 2026-07-27 by #311's
identity-keyed single-run trace directory):** "`aura exec <blueprint.json>`
(#319) persists every tap the blueprint declares to the trace store under the
run's own name, on both shapes: …"
**Current-state "Encoding / storage / rendering split (C14)" clause
(superseded 2026-07-27 by #311's identity-keyed single-run trace directory):**
"file I/O is `aura-registry::TraceStore`, persisting each run under
`runs/traces/<name>/` beside the run registry's `runs.jsonl`"
**Current-state "Single run" + "Families" + "Newcomer" clauses (superseded by
the #319 sugar retirement, 2026-07-25):** "**Single run.** `aura run
<blueprint.json>` persists every tap the blueprint declares to the trace
+28 -3
View File
@@ -56,13 +56,15 @@ per fired cycle, tagged `ctx.now()`), matching a trace of timestamped events (C1
**Encoding / storage / rendering split (C14).** A drained tap is encoded as a
columnar (SoA, C7) `ColumnarTrace` — struct→JSON only — in `aura-engine`
(`report.rs`); file I/O is `aura-registry::TraceStore`, persisting each run under
`runs/traces/<name>/` beside the run registry's `runs.jsonl`; rendering is
`runs/traces/<handle>/` beside the run registry's `runs.jsonl``<handle>` is a
single run's own identity-keyed name (`<render-name>-<id8>`, #311) or a
campaign family's derived name, unchanged; rendering is
`aura-cli` (`render_chart_html` + the vendored `chart-viewer.js`). The engine stays
headless.
**Single run.** `aura exec <blueprint.json>` (#319) persists every tap the
blueprint declares to the trace store under the run's own name, on both
shapes: a `bias`-output strategy (`aura-runner::member::run_signal_r`,
blueprint declares to the trace store under the run's own identity-keyed
handle — `<render-name>-<id8>`, #311 — on both shapes: a `bias`-output strategy (`aura-runner::member::run_signal_r`,
R-wrapped) and a bare measurement blueprint with ≥1 declared tap but no
`bias` (`aura-runner::measure::run_measurement`). A tap-free run writes
nothing to `runs/` and its stdout stays byte-identical. `exec` carries no
@@ -78,6 +80,29 @@ guessed. The library hands it to an embedding caller as a value beside the
report, like the unbound-tap names (C27/#297); it is not part of the stored
record.
**Single-run trace identity (#311).** The handle is `<render-name>-<id8>`,
where `id8` is the 8-hex head of a SHA-256 over the run's own `RunManifest`
with two keys *removed*`commit` (the aura binary's build sha) and
`project.commit` (the project repo's HEAD plus a `-dirty` marker, re-derived
on every invocation) — and `params`/`defaults` *merged* into one name-sorted
sequence. The two removed keys are provenance — who built or checked out the
code — not what the run was; including either would make the store an
unbounded directory generator. The merge is the same distinction applied to a
different pair: `params`/`defaults` record *how* a bound value was supplied
(varied vs. held at its default, #246), not *what* the run's effective
parameterisation is, and the two vectors are disjoint by construction, so the
merge is lossless. Everything else the manifest carries is identity-bearing,
`project.dylib_sha256` included (it is what C13 hot-reload varies). The
manifest is assembled *before* the tap bind and the same value is reused for
the record, so the handle can never name a directory whose identity the
stored record does not describe. Consequences: two runs of one blueprint
differing in any identity-bearing input write two directories and neither
overwrites the other; two runs with the same effective parameterisation —
including a no-op `--override` that merely moves a value between `params` and
`defaults` — write one; and the bare render name is no longer a directory, so
`aura chart <render-name>` refuses with the handles recorded under that name
(`recorded_handles_with_prefix`, the same helper the family-id branch uses).
**Families.** A campaign document's `presentation.persist_taps` (the closed
tap vocabulary, C18/C27) requests traces for each cell's nominee, run through
`aura exec <campaign.json|id>` (#319 — a document's `axes` replace the
@@ -0,0 +1,11 @@
# C23 — Graph compilation and behaviour-preserving optimisation: history
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp
> and may be superseded; this file is NOT current truth and NOT a grounding
> surface. Current contract: [c23-graph-compilation.md](c23-graph-compilation.md).
**Render-name run-time-role clause (#331; superseded 2026-07-27 by #311's
identity-keyed single-run trace directory):** "…but at *run* time, outside
compilation, it keys the trace directory (`traces/<name>/`); that operational
role, not any load-bearing weight inside the flat graph, is exactly why the
authored-blueprint intakes shape-gate it."
@@ -11,8 +11,9 @@ survive at most as **informative debug symbols** (exactly as `FieldSpec.name` al
is, C8), kept for tracing / rendering (C9 graph-as-data) but carrying no run
semantics. (Reconciling with the root composite's own render name, #331: that
claim stands unweakened — the name never influences compilation, identity, or
execution semantics — but at *run* time, outside compilation, it keys the trace
directory (`traces/<name>/`); that operational role, not any load-bearing
execution semantics — but at *run* time, outside compilation, it prefixes the
trace directory (`traces/<name>-<id8>/`, #311 — the run's own identity digest
completes it); that operational role, not any load-bearing
weight inside the flat graph, is exactly why the authored-blueprint intakes
shape-gate it.) The flat graph is then the target of **behaviour-preserving optimisation**
— any transform that leaves every observable sink trace bit-identical (C1 is the
@@ -80,3 +81,5 @@ points (`compile`, `compile_with_params`, and the `bind` paths) as well as from
- [C9](c09-fractal-composition.md) — inlining vs runtime sub-engine.
- [C11](c11-sources-record-replay.md), [C12](c12-atomic-sim-unit.md) — recorded-stream sharing and the sweep family.
- [C19](c19-bootstrap.md) — the flat-graph representation this optimises.
> History: [c23-graph-compilation.history.md](c23-graph-compilation.history.md)
@@ -14,3 +14,11 @@ CLI verbs pass a record-all plan."
retirement, 2026-07-25):** "…both arms of the single CLI verb `aura run`,
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named`
selection data-reachable…"
**Current-state `RunOutcome` sentence (superseded 2026-07-27 by #311's
identity-keyed single-run trace directory):** "Since #309 both entry points
return that pair as a named `RunOutcome { report, skipped, trace_name }`, the
third field being the trace-store handle the run recorded under (`Some` under
exactly the condition that opens the write path, `None` otherwise) — the same
beside-the-report discipline, widened rather than re-cut: the record keeps its
shape and the shell renders the handle."
+4 -2
View File
@@ -79,8 +79,10 @@ unbound tap names out (`skipped: Vec<String>`), riding beside the returned
report rather than inside it, so the CLI — not the library — prints the
unbound-tap note (#297). Since #309 both entry points return that pair as a
named `RunOutcome { report, skipped, trace_name }`, the third field being the
trace-store handle the run recorded under (`Some` under exactly the condition
that opens the write path, `None` otherwise) — the same beside-the-report
trace-store handle the run recorded under — since #311 the run's own identity,
`<render-name>-<id8>`, minted from the manifest the entry point now assembles
*before* the bind and reuses for the record (`Some` under exactly the
condition that opens the write path, `None` otherwise) — the same beside-the-report
discipline, widened rather than re-cut: the record keeps its shape and the
shell renders the handle. The boundary is thereby
fixed in place: *selecting* a subscription is run-mode authority, exercised
+2 -2
View File
@@ -37,7 +37,7 @@ The param-generic, input-role-generic graph-as-data produced by running a Rust b
### 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. Orthogonal to the composite's own **render name** (the `name` field carried in the blueprint bytes, op-settable via `{"op":"name",...}`, #331; defaults to `"graph"` if omitted): the render name keys the run's trace directory (`traces/<name>/`) and a `use`-splice's default instance identifier, while the label is an explicit, separately-set store pointer.
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. Orthogonal to the composite's own **render name** (the `name` field carried in the blueprint bytes, op-settable via `{"op":"name",...}`, #331; defaults to `"graph"` if omitted): the render name *prefixes* the run's trace directory (`traces/<name>-<id8>/`, #311 — the run's own identity digest completes it) and is a `use`-splice's default instance identifier, while the label is an explicit, separately-set store pointer.
### bootstrap
**Avoid:** —
@@ -337,7 +337,7 @@ An orchestration axis varying tuning params (grid or random) within a fixed stru
### tap
**Avoid:** probe, monitor, scope (for the observation slot — "probe" is taken by the sweep-terminal `blueprint_axis_probe` sense; the #77 `Recorder``Probe` rename was retired, 2026-07-21)
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's deterministic `trace_name` (`"{campaign8}-{run}"`, never a user-chosen name since #319) charts its whole family (`aura chart <handle>`; members keyed `<cell>/<member>` in the chart) — or, equivalently, a bare campaign id/name that uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing). A single run reports its own handle the same way, as `trace_name` on its stdout line (#309) — the run's render name, absent when the run recorded nothing. NB a `family id` is not a handle: it names one cell/stage record within a run, and cutting it down does not yield the run's handle.
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's deterministic `trace_name` (`"{campaign8}-{run}"`, never a user-chosen name since #319) charts its whole family (`aura chart <handle>`; members keyed `<cell>/<member>` in the chart) — or, equivalently, a bare campaign id/name that uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing). A single run reports its own handle the same way, as `trace_name` on its stdout line (#309) — the run's render name plus its 8-hex **run identity** (`<render-name>-<id8>`, #311: a SHA-256 over the run's manifest with the two provenance keys removed — the aura binary's build sha and the project repo's HEAD/dirty marker — and `params`/`defaults` merged into one name-sorted sequence, since that partition records how a value was supplied, not what the run's effective parameterisation is — so two runs differing in any identity-bearing input land in two directories, and two runs with the same effective parameterisation, including one reached via a no-op `--override`, land in one), absent when the run recorded nothing. NB a `family id` is not a handle: it names one cell/stage record within a run, and cutting it down does not yield the run's handle.
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura exec` constructs a recorder at each and persists the series through the trace store; a campaign member run leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.