audit: cycle 0104 tidy (drift-clean after three doc-mirror alignments)

Architect drift review (range d5c4361..45fb06d): drift_found, three items,
all doc-mirror — resolved inline as close-fixes:
- docs/design/INDEX.md — the two #171 deferral passages (C18 #158
  realization + C24 status) now record the shipped identity id (additive
  sibling; content id keeps the store/reproduce roles; introspection-only
  until a dedup consumer exists). Whole-harness / structural-axis
  content-addressing stays deferred.
- README.md — introspect table gains the --identity-id row incl. the
  combinable id-flag behaviour.
- docs/glossary.md — three record-reality entries: content id, identity id
  (Avoid: identity hash, topology-identity hash), topology hash; the
  README row aligned to the canonical term.

What holds (architect): additive-sibling discipline intact (no store code
touched, every content-id/topology_hash pin green); C23 honoured precisely
(each strip arm has a protecting property test; factoring byte-preserving
via the canonical golden); the two extra engine tests are genuinely
distinct coverage (recursion arm, role/output arms), no redundancy.

Regression gates (all green): cargo build clean; cargo test --workspace
884 passed / 0 failed (873 baseline + 11 new); clippy --all-targets
-D warnings clean; cargo doc --no-deps 0 warnings.

Ephemeral cycle artifacts removed per project convention (git rm):
docs/specs/0104-topology-identity-hash.md,
docs/plans/0104-topology-identity-hash.md.

refs #180
This commit is contained in:
2026-07-02 21:29:38 +02:00
parent 45fb06dba3
commit 7b429f931a
5 changed files with 24 additions and 857 deletions
+1
View File
@@ -80,6 +80,7 @@ of by-identifier construction ops replayed against the standard node vocabulary.
| `aura graph introspect --node <T>` | Show one type's input ports, output fields, and param paths (with the bind-value form). |
| `aura graph introspect --unwired` | Read a *partial* op-list on stdin and list its still-open interior slots. |
| `aura graph introspect --content-id` | Read an op-list on stdin and print the SHA-256 content id of the blueprint it would build. |
| `aura graph introspect --identity-id` | Read an op-list on stdin and print the SHA-256 **identity id** — the topology's debug-name-blind id, so two documents (or an op-script and a Rust-built blueprint) expressing the same topology print the same id. Combinable with `--content-id` (both ids, one per line, content id first). |
### Op-list shape
+11 -5
View File
@@ -1351,9 +1351,13 @@ blueprint does not use leaves the id byte-stable (acc 3, composing #156/#164).
`families.jsonl` — the precondition for a bit-identical compare (C1). **Signal-only this
cycle**: the id covers the signal blueprint; the fixed r-sma scaffolding stays
commit-identified (it is not yet blueprint-data, C24). Whole-harness / structural-axis
content-addressing, and whether the id should exclude the composite **debug-name** (a
non-load-bearing symbol, invariant 11 — an op-script and the Rust builder produce
different canonical forms today), remain deferred (#171, #170). Reproduction is proven on
content-addressing remains deferred. The debug-name-in-id question was settled additively
(cycle 0104, #171): the **identity id** — the canonical form with every non-load-bearing
debug symbol blanked (invariant 11), hashed through the same `content_id` primitive and
surfaced as `aura graph introspect --identity-id` beside `--content-id` (combinable) —
makes same-topology blueprints comparable across authoring paths, while the byte-exact
`topology_hash` keeps every role untouched (introspection-only: no manifest field, no
store key until a dedup consumer exists). Reproduction is proven on
synthetic (deterministic) data; recorded-dataset reproduction rides the DataServer seam
(#124). **Cycle 0095 (#170):** `aura reproduce` now also spans `FamilyKind::MonteCarlo`
branching on the family kind, it reconstructs each member's seed-driven synthetic walk from
@@ -1976,8 +1980,10 @@ r-sma wrapping (`sma_signal.fast.length`, the nested-composite prefix), not the
landed cycle 0092; re-deriving a member's FlatGraph from a stored manifest + the
content-addressed blueprint store (`aura reproduce`) shipped cycle 0094 (see C18
Realization). What **remains** is a content-id that covers **structural-axis / whole-harness
variants** (the scaffolding is not yet blueprint-data) and the debug-name-in-id question
(#171/#170). Still deferred: retiring the pre-C24
variants** (the scaffolding is not yet blueprint-data); the debug-name-in-id question is
settled by cycle 0104's **identity id** (#171: `blueprint_identity_json` + `graph
introspect --identity-id`, an additive sibling — the byte-exact content id keeps the
store/reproduce roles; introspection-only until a dedup consumer exists). Still deferred: retiring the pre-C24
hard-wired `aura-cli` harnesses (`HarnessKind`, `run_r_sma`, `*_sweep_family`) once
the project-as-crate layer lands (#159, paired with #157's data-authoring surface). **Out of the first cut's round-trippable set**
(deliberate; fails clean as `UnknownNodeType`, never a silent wrong graph): recording
+12
View File
@@ -55,6 +55,10 @@ The type-erased 64-bit word holding one scalar-base-type value with its kind str
**Avoid:** —
A node that wires a sub-graph and exposes one output (a combined signal, or a strategy) — composition is fractal and acyclic. Also names the multi-column stream a node emits: the **record** a producer's `eval` returns, bundling 1..K base scalar columns (e.g. OHLCV), each bound field-wise by a consumer (C7/C8).
### content id
**Avoid:** —
The SHA-256 (hex) of a blueprint's byte-canonical `blueprint_to_json` form — the byte-exact identity that keys the reproduction store and anchors `aura reproduce` (stamped into a run manifest as the `topology hash`). Surfaced as `aura graph introspect --content-id`; two documents share a content id only when their canonical bytes agree, debug names included — contrast `identity id`.
### conviction-based risk allocation
**Avoid:** risk budgeting, risk parity, risk contribution
A feed-forward, order-independent research axis for scaling risk by bias strength — **flat** (sum of `realized_r`, sign only) vs **conviction-weighted** (sum of `|bias| · realized_r`) R-aggregation. Distinct from the deploy-only money-`Sizer`: because per-trade R is size-invariant, conviction is an **R-aggregation weight**, never a position-size scale (tested via `conviction_at_entry` / `conviction_terciles_r`).
@@ -119,6 +123,10 @@ The closed root sim graph that actually runs — sources bound to a strategy's r
**Avoid:** —
The authoring-loop mechanism in which the project-side cdylib is rebuilt and reloaded live during research; authoring-only, never applied to a live bot. A sweep pays no hot-reload tax — params are runtime data, so the cdylib loads once.
### identity id
**Avoid:** identity hash, topology-identity hash
The SHA-256 of a blueprint's identity-canonical form (`blueprint_identity_json`: the canonical document with every non-load-bearing debug symbol blanked — composite, instance, bound-param, role, and output names), surfaced as `aura graph introspect --identity-id`. Same topology → same id across authoring paths (op-script vs Rust builder, renamed twins) while openness, bound values, wiring, and order stay identity-bearing; an introspection-only comparison id — the byte-exact `content id` keeps the store/reproduce roles.
### ingestion boundary
**Avoid:** —
The single point where heterogeneous timestamped sources are k-way-merged into one chronological cycle stream and source-native time is normalized to canonical epoch-ns. The only place a merge happens — there is no merge or as-of join inside the graph.
@@ -267,6 +275,10 @@ An orchestration axis varying tuning params (grid or random) within a fixed stru
**Avoid:** —
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.
### topology hash
**Avoid:** —
The `content id` of a run's signal blueprint in its run-record role: stamped into the manifest as `topology_hash`, keying the reproduction store and anchoring `aura reproduce` — the same SHA-256 over the same canonical bytes. Distinct from the debug-name-blind `identity id`.
### veto
**Avoid:** risk-manager
The **optional** documented pre-trade-gate seam in the execution chain (`stop-rule → [Veto] → position-management`): position / exposure / notional caps that may reject or scale a trade. A pass-through identity DCE'd away when absent (C19/C23); kept as a named seam — it is what LEAN calls "Risk Management" and nautilus the RiskEngine.
-668
View File
@@ -1,668 +0,0 @@
# Topology-identity hash — Implementation Plan
> **Parent spec:** `docs/specs/0104-topology-identity-hash.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to
> run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Two blueprints expressing the same topology get the same identity
hash regardless of authoring path (op-script vs Rust builder, or twins
differing only in debug names) — additive beside `topology_hash`, whose
three roles (introspect content id, reproduction store key, reproduce fetch
anchor) stay byte-for-byte untouched.
**Architecture:** One engine-side projection (`blueprint_identity_json` in
`blueprint_serde.rs`: build the existing `BlueprintDoc`, blank every
non-load-bearing debug symbol, serialize with the existing serializer) and
one additive CLI flag (`aura graph introspect --identity-id`, a sibling of
`--content-id` sharing its input path and the `crate::content_id` SHA-256
helper). The exactly-one-flag introspect dispatch is deliberately relaxed so
the two id flags may combine (both ids, one per line, content id first).
**Tech Stack:** aura-engine (`blueprint_serde.rs`, `lib.rs` re-exports),
aura-cli (`main.rs` clap struct + in-crate tests, `graph_construct.rs`
dispatch), e2e in `crates/aura-cli/tests/graph_construct.rs`. No new
dependencies.
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint_serde.rs:100-103` — factor
`build_doc`/`serialize_doc` out of `blueprint_to_json` (byte-preserving),
add `blueprint_identity_json` + `strip_debug_symbols`; property tests in
the existing `mod tests` (anchor `:179`).
- Modify: `crates/aura-engine/src/lib.rs:60-63` — re-export
`blueprint_identity_json`.
- Modify: `crates/aura-cli/src/graph_construct.rs:9` (import),
`:109-127` (factor `composite_from_str` out of `build_from_str`),
`:186-245` (`introspect_cmd`: relaxed dispatch + id branch).
- Modify: `crates/aura-cli/src/main.rs:3817-3819``identity_id` clap flag
on `GraphIntrospectCmd`; in-crate cross-path twin test after
`topology_hash_is_the_content_id_of_the_canonical_form` (`:6013-6016`).
- Test: `crates/aura-cli/tests/graph_construct.rs` — four new e2e tests
(renamed-twin bridging, combined flags, bad-doc rejection, no-flag usage
exit 2).
Baselines this plan must leave byte-unchanged: the canonical golden
(`signal_serializes_to_canonical_golden`), every `--content-id` output,
`topology_hash`, the blueprint store, `aura reproduce`.
---
### Task 1: Engine — the identity projection
**Files:**
- Modify: `crates/aura-engine/src/blueprint_serde.rs` (fns at `:97-103`,
tests mod at `:179`)
- Modify: `crates/aura-engine/src/lib.rs:60-63`
- [ ] **Step 1: Write the failing property tests**
In `crates/aura-engine/src/blueprint_serde.rs`, inside the existing
`#[cfg(test)] mod tests` (starts `:179`):
First widen two existing import lines at the top of the mod. Change
```rust
use crate::blueprint::{Composite, Role};
```
to
```rust
use crate::blueprint::{Composite, OutField, Role};
```
and change
```rust
use aura_std::Recorder;
```
to
```rust
use aura_std::{Bias, Recorder, Sma, Sub};
```
Then append, at the end of the mod (after
`unsupported_version_fails_named`), the helper and the four tests:
```rust
// A minimal SMA-cross variant with controllable debug names, boundness, and
// wiring for the identity-projection property tests — the same 4-node shape
// as `test_fixtures::sink_free_sma_cross_signal`.
fn identity_probe(
comp_name: &str,
fast_name: &str,
fast_len: Option<i64>,
swap_sub_slots: bool,
) -> Composite {
let mut fast = Sma::builder().named(fast_name);
if let Some(l) = fast_len {
fast = fast.bind("length", Scalar::i64(l));
}
let (fast_slot, slow_slot) = if swap_sub_slots { (1, 0) } else { (0, 1) };
Composite::new(
comp_name,
vec![
fast.into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
Bias::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: fast_slot, from_field: 0 },
Edge { from: 1, to: 2, slot: slow_slot, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 3, field: 0, name: "bias".into() }],
)
}
/// #171 acc 1 (engine layer): twins identical up to debug names (composite
/// name, instance name) share the identity JSON while their canonical JSON
/// differs.
#[test]
fn renamed_twins_share_identity_json_not_canonical_json() {
let a = identity_probe("sma_cross", "fast", Some(2), false);
let b = identity_probe("renamed", "speedy", Some(2), false);
assert_ne!(
blueprint_to_json(&a).expect("serializes"),
blueprint_to_json(&b).expect("serializes"),
"canonical bytes keep the debug names apart"
);
let ia = blueprint_identity_json(&a).expect("identity-serializes");
let ib = blueprint_identity_json(&b).expect("identity-serializes");
assert_eq!(ia, ib, "identity form is debug-name-blind");
assert!(!ia.contains("speedy") && !ia.contains("sma_cross"), "no debug symbol leaks: {ia}");
}
/// #171 acc 2 (openness is identity-bearing): a blueprint with `fast.length`
/// bound and its open twin never share an identity JSON.
#[test]
fn open_vs_bound_param_changes_identity() {
let bound = identity_probe("x", "fast", Some(2), false);
let open = identity_probe("x", "fast", None, false);
assert_ne!(
blueprint_identity_json(&bound).expect("identity-serializes"),
blueprint_identity_json(&open).expect("identity-serializes"),
"boundness survives the identity projection"
);
}
/// #171 acc 2 (bound values are identity-bearing): value twins differing in
/// one bound value (I64:2 vs I64:3) never share an identity JSON.
#[test]
fn bound_value_changes_identity() {
assert_ne!(
blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
.expect("identity-serializes"),
blueprint_identity_json(&identity_probe("x", "fast", Some(3), false))
.expect("identity-serializes"),
"a bound value survives the identity projection"
);
}
/// Wiring is identity-bearing: swapping the two Sub input slots (fast->rhs,
/// slow->lhs) is a different topology, hence a different identity JSON.
#[test]
fn edge_swap_changes_identity() {
assert_ne!(
blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
.expect("identity-serializes"),
blueprint_identity_json(&identity_probe("x", "fast", Some(2), true))
.expect("identity-serializes"),
"edge slots survive the identity projection"
);
}
```
(`Edge`, `Target`, `Scalar` are already imported by the mod; `Composite::new`
takes the same argument shapes as `test_fixtures::sink_free_sma_cross_signal`.)
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-engine identity`
Expected: COMPILE FAILURE — `error[E0425]: cannot find function
`blueprint_identity_json`` (the RED for this task; the fn does not exist yet).
- [ ] **Step 3: Implement the projection (factoring + new fns + re-export)**
In `crates/aura-engine/src/blueprint_serde.rs`, replace the current
`blueprint_to_json` (`:97-103`):
```rust
/// Serialize a blueprint to canonical, versioned JSON: compact, struct-declaration
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
let doc = BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) };
serde_json::to_string(&doc).map_err(SerializeError::Json)
}
```
with the factored trio plus the identity pair:
```rust
fn build_doc(c: &Composite) -> BlueprintDoc {
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
serde_json::to_string(doc).map_err(SerializeError::Json)
}
/// Serialize a blueprint to canonical, versioned JSON: compact, struct-declaration
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
serialize_doc(&build_doc(c))
}
/// The identity-canonical form (#171): the canonical document with every
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
/// bound-param names, role names, output re-export names. Everything load-bearing
/// survives: type ids, node order, edges, role targets/order, output pairs/order,
/// and bound positions/kinds/values (param openness stays identity-bearing: a
/// bound slot is textually present, an open one absent). Research-side comparison
/// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
let mut doc = build_doc(c);
strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc)
}
fn strip_debug_symbols(b: &mut CompositeData) {
b.name = String::new();
for node in &mut b.nodes {
match node {
NodeData::Primitive(p) => {
p.name = None;
for bp in &mut p.bound {
bp.name = String::new(); // pos/kind/value survive
}
}
NodeData::Composite(c) => strip_debug_symbols(c),
}
}
for role in &mut b.input_roles {
role.name = String::new(); // targets + order survive
}
for out in &mut b.output {
out.name = String::new(); // (node, field) + order survive
}
}
```
In `crates/aura-engine/src/lib.rs`, extend the `blueprint_serde` re-export
block (`:60-63`) — change
```rust
pub use blueprint_serde::{
blueprint_from_json, blueprint_to_json, BlueprintDoc, CompositeData, LoadError, NodeData,
PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
```
to
```rust
pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
};
```
- [ ] **Step 4: Run the new tests to verify they pass**
Run: `cargo test -p aura-engine identity`
Expected: PASS — 4 passed (`renamed_twins_share_identity_json_not_canonical_json`,
`open_vs_bound_param_changes_identity`, `bound_value_changes_identity`,
`edge_swap_changes_identity`), 0 failed.
- [ ] **Step 5: Verify the factoring is byte-preserving (golden + serde suite)**
Run: `cargo test -p aura-engine blueprint_serde`
Expected: PASS — all `blueprint_serde` tests green, including
`signal_serializes_to_canonical_golden` byte-identical (10 passed = 6
existing + 4 new in this module's filter scope), 0 failed.
---
### Task 2: CLI — the `--identity-id` sibling flag and the cross-path twin test
**Files:**
- Modify: `crates/aura-cli/src/main.rs:3817-3819` (clap field), tests mod
after `:6016` (const + test)
- Modify: `crates/aura-cli/src/graph_construct.rs:9`, `:109-127`, `:186-245`
- [ ] **Step 1: Add the clap flag**
In `crates/aura-cli/src/main.rs`, in `struct GraphIntrospectCmd`
(`:3806-3820`), after the `content_id` field
```rust
/// Print the graph's content id (topology hash).
#[arg(long)]
content_id: bool,
```
append:
```rust
/// Print the graph's topology-identity id (debug names stripped).
#[arg(long)]
identity_id: bool,
```
- [ ] **Step 2: Factor `composite_from_str` and rework the id branch**
In `crates/aura-cli/src/graph_construct.rs`:
(a) Change the import at `:9` from
```rust
use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind};
```
to
```rust
use aura_engine::{
blueprint_identity_json, blueprint_to_json, replay, BindOpError, Composite, GraphSession, Op,
OpError, Scalar, ScalarKind,
};
```
(b) Replace `build_from_str` (`:109-127`) with the factored pair
(behaviour-preserving: same fault strings, same success bytes):
```rust
/// Parse a JSON op-list document and replay it through the env's vocabulary into
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
/// past the last op).
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
let cause = format_op_error(&err);
match labels.get(idx) {
Some(kind) => format!("op {idx} ({kind}): {cause}"),
None => format!("finalize: {cause}"),
}
})
}
/// Parse a JSON op-list document, replay it through the env's vocabulary, and
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
let composite = composite_from_str(doc, env)?;
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
}
```
(c) In `introspect_cmd` (`:186-245`): update the doc comment and the
dispatch — replace
```rust
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set;
/// zero or more than one is the usage error (exit 2).
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 as usize;
if count != 1 {
eprintln!(
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id"
);
std::process::exit(2);
}
```
with
```rust
/// `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).
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;
if count != 1 {
eprintln!(
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --content-id | --identity-id (the two id flags may be combined)"
);
std::process::exit(2);
}
```
and replace the final `else` branch (`:226-244`, the `--content-id` arm)
```rust
} else {
// --content-id: the content id (#158) of the op-list's canonical blueprint:
// SHA256 (hex) of the same `blueprint_to_json` bytes `graph build` emits, via
// the one shared `crate::content_id` primitive `topology_hash` also uses — so
// this surface and hashing a `graph build` output 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);
}
match build_from_str(&doc, env) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
}
```
with
```rust
} 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,
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
};
if cmd.content_id {
match blueprint_to_json(&composite) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(e) => {
eprintln!("aura: serialize error: {e:?}");
std::process::exit(1);
}
}
}
if cmd.identity_id {
match blueprint_identity_json(&composite) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(e) => {
eprintln!("aura: serialize error: {e:?}");
std::process::exit(1);
}
}
}
}
```
(For `--content-id` alone this is byte- and exit-code-identical to the old
path: same success line, same `aura: op N (kind): cause` / `aura: serialize
error: ...` faults, same exit 1.)
- [ ] **Step 3: Build**
Run: `cargo build -p aura-cli`
Expected: clean build, 0 errors.
- [ ] **Step 4: Write the in-crate cross-path twin test**
In `crates/aura-cli/src/main.rs`, in the same `#[cfg(test)]` tests module
that holds `content_id_is_stable_across_the_store_round_trip` (`:5976`),
immediately after `topology_hash_is_the_content_id_of_the_canonical_form`
(`:6013-6016`), insert:
```rust
/// The op-script twin of `sma_signal(Some(2), Some(4))`: same topology —
/// SMA(2)/SMA(4) over one `price` role, spread, Bias with `scale` BOUND to
/// 0.5 (= `R_SMA_BIAS_SCALE`; boundness is identity-bearing) — differing
/// only in debug names (composite "graph" vs "sma_signal", unnamed Bias vs
/// `.named("bias")`).
const IDENTITY_TWIN_DOC: &str = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias","bind":{"scale":{"F64":0.5}}},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// #171 acc 1 (cross-path identity): the Rust `sma_signal` builder and its
/// op-script twin differ in canonical bytes (debug names) — distinct content
/// ids — but project to the same identity JSON, hence one identity id across
/// authoring paths.
#[test]
fn identity_id_bridges_the_rust_builder_and_op_script_paths() {
let rust_built = sma_signal(Some(2), Some(4));
let env = project::Env::std();
let json = crate::graph_construct::build_from_str(IDENTITY_TWIN_DOC, &env)
.expect("op-script twin builds");
assert_ne!(
content_id(&json),
topology_hash(&rust_built),
"authoring paths keep distinct content ids"
);
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads");
let identity = |c: &Composite| {
content_id(&aura_engine::blueprint_identity_json(c).expect("identity-serializes"))
};
assert_eq!(
identity(&loaded),
identity(&rust_built),
"one topology -> one identity id across authoring paths"
);
}
```
Note: this test is expected GREEN on first run — it pins the cross-path
property using Task 1's engine projection over this crate's fixtures (its
RED partner was Task 1 Step 2's compile failure). Its role is the #171
acceptance-1 evidence and a regression guard, not this task's RED.
- [ ] **Step 5: Run the new and neighbouring in-crate tests**
Run: `cargo test -p aura-cli --bin aura identity`
Expected: PASS — 1 passed
(`identity_id_bridges_the_rust_builder_and_op_script_paths`), 0 failed.
Run: `cargo test -p aura-cli --bin aura content_id`
Expected: PASS — 3 passed (`content_id_is_stable_across_the_store_round_trip`,
`content_id_is_stable_across_a_tolerated_tier1_field`,
`topology_hash_is_the_content_id_of_the_canonical_form`), 0 failed.
---
### Task 3: E2E — the `--identity-id` binary contract
**Files:**
- Test: `crates/aura-cli/tests/graph_construct.rs` (append after
`graph_introspect_unknown_node_flag_is_usage_exit_2`)
- [ ] **Step 1: Write the four e2e tests**
Append to `crates/aura-cli/tests/graph_construct.rs`:
```rust
/// Property (#171 acc 1 at the binary seam): `--identity-id` bridges op-scripts
/// that differ only in debug names — the renamed twin keeps the same identity id
/// while its content id moves. Rename via blanket replace: "fast" occurs as the
/// instance name and in every `fast.*` reference, so the replaced document stays
/// internally consistent.
#[test]
fn graph_introspect_identity_id_bridges_renamed_op_scripts() {
let renamed = SIGNAL_DOC.replace("fast", "speedy");
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let ia = ia.trim().to_string();
assert_eq!(ia.len(), 64, "a 64-hex identity id: {ia:?}");
assert!(ia.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {ia:?}");
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], &renamed);
assert!(ok2, "exit success");
assert_eq!(ia, ib.trim(), "renamed twin -> same identity id");
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], &renamed);
assert!(ok3 && ok4, "exit success");
assert_ne!(ca.trim(), cb.trim(), "renamed twin -> different content ids");
}
/// Property (combinable id flags): `--content-id --identity-id` prints both ids,
/// one per line, content id first — each byte-equal to its single-flag output,
/// and the two differ (SIGNAL_DOC carries debug names the identity form blanks).
#[test]
fn graph_introspect_content_and_identity_id_combine() {
let (both, _e, ok) = run(&["graph", "introspect", "--content-id", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let lines: Vec<&str> = both.lines().collect();
assert_eq!(lines.len(), 2, "two lines, one id each: {both:?}");
let (content, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (identity, _e3, ok3) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok2 && ok3, "exit success");
assert_eq!(lines[0], content.trim(), "content id first");
assert_eq!(lines[1], identity.trim(), "identity id second");
assert_ne!(lines[0], lines[1], "the two ids differ on a debug-named document");
}
/// Property (negative, mirror of the content-id rejection): `--identity-id` on a
/// bad op-list exits non-zero with the cause on stderr and prints no id.
#[test]
fn graph_introspect_identity_id_rejects_a_bad_document() {
let (stdout, stderr, ok) =
run(&["graph", "introspect", "--identity-id"], r#"[{"op":"add","type":"Nope"}]"#);
assert!(!ok, "non-zero exit on a bad op-list");
assert!(stdout.is_empty(), "no identity id emitted on error: {stdout}");
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}
/// Property (usage gate — the previously untested count!=1 dispatch path):
/// `graph introspect` with no selection flag is a usage fault — exit 2 with the
/// usage line on stderr.
#[test]
fn graph_introspect_no_flag_is_usage_exit_2() {
let (stderr, code) = run_code(&["graph", "introspect"], "");
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}");
}
```
- [ ] **Step 2: Run the e2e target**
Run: `cargo test -p aura-cli --test graph_construct`
Expected: PASS — 20 passed (16 existing + 4 new), 0 failed. In particular
`graph_introspect_content_id_is_deterministic_and_distinguishes` and
`graph_introspect_content_id_rejects_a_bad_document` stay green (the
`--content-id` byte-unchanged pin, spec acceptance 3).
---
### Task 4: Full regression
**Files:** none (verification only)
- [ ] **Step 1: Build**
Run: `cargo build --workspace`
Expected: clean, 0 errors.
- [ ] **Step 2: Full suite**
Run: `cargo test --workspace`
Expected: 0 failed across all targets; total grows by exactly the 9 new
tests vs the cycle-0103 baseline of 873 (= 882 passed). Reproduce, store,
goldens all untouched (spec acceptance 3).
- [ ] **Step 3: Lint**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean, 0 warnings.
- [ ] **Step 4: Doc build**
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: finishes with 0 warnings.
-184
View File
@@ -1,184 +0,0 @@
# The topology-identity hash — Design Spec
**Date:** 2026-07-02
**Status:** Draft — awaiting sign-off (boss run: grounding-check gate)
**Authors:** orchestrator + Claude
**Cycle:** 0104 — milestone "Project environment — the project-as-crate
authoring loop" (#180); work item #171 (fork resolutions: the cycle-0104
reconciliation comment on #171)
## Goal
Two blueprints expressing the same topology get the same **identity hash**
regardless of authoring path — an op-script-built graph and a Rust-builder
graph, or two builders differing only in debug names, become comparable by
id. The identity hash is **additive**: the byte-canonical `topology_hash`
keeps all three of its current roles (introspect content id, reproduction
store key, reproduce fetch anchor) byte-for-byte untouched.
## Architecture
One new engine-side projection and one additive CLI flag:
- **`blueprint_identity_json` (aura-engine, `blueprint_serde.rs`)** builds
the same serialization document as `blueprint_to_json`, then normalizes
every non-load-bearing debug symbol before serializing: the composite
debug-name, primitive instance names, bound-param names, role names, and
output re-export names are all replaced by `""`. Everything load-bearing
survives untouched: node **type ids**, node **order**, **edges** (raw
indices), role **targets and order**, output **(node, field) pairs and
order**, and the **`bound` arrays' positions, kinds, and values** — so
param **openness** stays identity-bearing (a bound slot carries
`pos`+`value`, an open slot is textually absent) and an open blueprint
never shares an id with any bound instance.
- **`aura graph introspect --identity-id` (aura-cli)** — a sibling of the
existing `--content-id`: hashes the identity JSON through the same
research-side SHA-256 helper `content_id` already uses (sha2 stays out of
the engine, invariant 8). `--content-id` output is byte-unchanged.
Decisions carried from the #171 reconciliation comment (adversarially
reviewed): the initial "redefine `topology_hash`" shape was **refuted**
`topology_hash` keys the byte-exact reproduction store and `reproduce`
recovers bootstrap points **by param-path name** against the fetched
blueprint, so instance names are load-bearing on that path and a name-blind
store key would let cross-path name-twins clobber it. Hence: sibling id,
zero golden churn, reproduction provably unaffected. No manifest field and
no store keyed by the identity hash this cycle (no dedup consumer exists —
the deferred-until-a-consumer discipline).
## Concrete code shapes
### The user-facing comparison (the acceptance-criterion evidence)
```console
# two authoring paths, one topology: the op-script (composite named "graph")
# and the Rust builder (named "sma_signal") — whatever input form
# `--content-id` consumes today, `--identity-id` consumes identically
$ aura graph introspect --content-id <op-script path's input>
9f3c…e1 # byte id, path A
$ aura graph introspect --identity-id <op-script path's input>
5b77…a2 # topology id, path A
$ aura graph introspect --identity-id <rust-builder path's input>
5b77…a2 # SAME topology id
$ aura graph introspect --content-id <rust-builder path's input>
41d0…07 # byte ids still differ
```
(The hex values are illustrative; the input form mirrors `--content-id`'s
existing contract exactly.)
### Engine: the identity projection (secondary, before → after shape)
`blueprint_to_json` today walks the composite into a `BlueprintDoc` DTO and
serializes it. The identity projection reuses that walk and normalizes the
DTO in place:
```rust
/// Identity-canonical form: the byte-canonical document with every
/// non-load-bearing debug symbol blanked (invariant 11 / C23). Feeds the
/// research-side identity hash; NOT a load path and NOT the reproduction
/// store's byte form.
pub fn blueprint_identity_json(bp: &Composite) -> String {
let mut doc = build_doc(bp); // the existing blueprint_to_json walk
strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc) // the existing serializer
}
fn strip_debug_symbols(b: &mut BlueprintData) {
b.name = String::new();
for node in &mut b.nodes {
match node {
NodeData::Primitive(p) => {
p.name = None;
for bound in &mut p.bound {
bound.name = String::new(); // pos/kind/value survive
}
}
NodeData::Composite(c) => strip_debug_symbols(c),
}
}
for role in &mut b.input_roles {
role.name = String::new(); // targets + order survive
}
for out in &mut b.output {
out.name = String::new(); // (node, field) + order survive
}
}
```
(Field/type names above follow the existing `blueprint_serde.rs` DTOs; the
planner pins exact identifiers. If `build_doc`/`serialize_doc` are not yet
factored out of `blueprint_to_json`, factoring them is part of this cycle —
behaviour-preserving for the byte path, guarded by the existing canonical
golden.)
### CLI: the sibling flag (secondary)
`GraphIntrospectCmd` gains one flag beside `--content-id`:
```rust
/// Print the topology-identity id (debug names stripped) and exit.
#[arg(long)]
identity_id: bool,
```
Its branch mirrors the `--content-id` branch byte-for-byte, substituting
`blueprint_identity_json` for the canonical bytes before the same
`content_id` SHA-256 helper.
## Components
| Component | Crate | New/changed |
|---|---|---|
| `blueprint_identity_json` + `strip_debug_symbols` (+ doc-walk factoring if needed) | aura-engine | new fn(s) |
| `--identity-id` flag + branch | aura-cli (`graph_construct.rs`) | additive |
| Property tests (engine) + e2e (CLI) | both | new tests |
Untouched by design: `topology_hash` stamping, `put_blueprint`/
`get_blueprint`, `aura reproduce`, `blueprint_to_json` bytes, every golden.
## Data flow
```
Composite ──build_doc──► BlueprintDoc ──serialize──► byte-canonical JSON ──► content_id / topology_hash / store
└─clone──► strip_debug_symbols ──serialize──► identity JSON ──► identity id (introspect only)
```
## Error handling
No new failure modes: `--identity-id` shares `--content-id`'s input path
(stdin/file op-list or blueprint doc) and its existing refusal contract;
combining `--content-id --identity-id` prints both, one per line, in that
order (flags are not mutually exclusive).
## Testing strategy
1. **Engine property tests (`blueprint_serde.rs`):**
- renamed twins: two builders identical up to `.named(...)` and composite
name → equal identity JSON, unequal canonical JSON;
- openness: a blueprint with `scale` bound vs unbound → unequal identity
JSON (boundness survives);
- bound values: I64:4 vs I64:5 → unequal identity JSON (the existing
distinguishing property, now on the identity form);
- wiring: swapping two edges' targets → unequal identity JSON (order and
indices load-bearing);
- the existing byte-canonical golden stays byte-identical (the factoring
is behaviour-preserving).
2. **CLI e2e (`graph_construct.rs` tests):** an op-script-built doc and the
Rust-built `sma_signal` fixture expressing the same topology → same
`--identity-id` output, different `--content-id` output; `--content-id`
alone byte-unchanged against its existing pin.
3. **Regression:** full workspace suite green unchanged (reproduce,
goldens, store untouched).
## Acceptance criteria
1. The op-script and Rust-builder twins share an identity id while their
content ids differ (the #171 headline, proven e2e).
2. An open blueprint and any bound instance of it never share an identity
id; bound-value twins differing in one value never share one.
3. `--content-id`, `topology_hash`, the blueprint store, and `aura
reproduce` are byte-for-byte unaffected (existing tests + goldens green
unchanged).
4. The identity projection is documented as introspection-only this cycle
(no manifest field, no store key) — the deferral recorded on #171.