diff --git a/README.md b/README.md index 617621e..77dd533 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ of by-identifier construction ops replayed against the standard node vocabulary. | `aura graph introspect --node ` | 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 diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 6ec4053..b31e95c 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -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 diff --git a/docs/glossary.md b/docs/glossary.md index c5f5007..ce28784 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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. diff --git a/docs/plans/0104-topology-identity-hash.md b/docs/plans/0104-topology-identity-hash.md deleted file mode 100644 index 4c3b5c4..0000000 --- a/docs/plans/0104-topology-identity-hash.md +++ /dev/null @@ -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, - 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 { - 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 { - 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 { - 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 { - 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 { - let docs: Vec = 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 = 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 { - 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 ` / `--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 | --unwired | --content-id" - ); - std::process::exit(2); - } -``` - -with - -```rust -/// `aura graph introspect`: dispatch the read-only queries. Exactly one of -/// `--vocabulary` / `--node ` / `--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 | --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. diff --git a/docs/specs/0104-topology-identity-hash.md b/docs/specs/0104-topology-identity-hash.md deleted file mode 100644 index 55d036e..0000000 --- a/docs/specs/0104-topology-identity-hash.md +++ /dev/null @@ -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 -9f3c…e1 # byte id, path A -$ aura graph introspect --identity-id -5b77…a2 # topology id, path A -$ aura graph introspect --identity-id -5b77…a2 # SAME topology id -$ aura graph introspect --content-id -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.