diff --git a/CHANGELOG.md b/CHANGELOG.md index c71b9016..c418b6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ ## Unreleased +### feat(warp-core): Wire up TTD domain logic from ttd-spec branch + +- **Exported** `compute_tick_commit_hash_v2`, `compute_op_emission_index_digest`, + and `OpEmissionEntry` from `warp-core` public API (previously `dead_code`). +- **Wired** `LocalProvenanceStore::append_with_writes()` to actually store + atom writes instead of discarding them. +- **Added** `LocalProvenanceStore::atom_writes(w, tick)` — query atom writes + for a specific tick (TTD "Show Me Why" provenance). +- **Added** `LocalProvenanceStore::atom_history(w, atom)` — causal cone walk + using `out_slots` (Paper III `Out(μ)`) to filter ticks that wrote to + the atom, with early termination at creation. O(history) scan, no reverse index. +- **Fixed** `LocalProvenanceStore::fork()` to copy `atom_writes` alongside + patches, expected hashes, and outputs. +- **Added** 12 tests covering atom write storage, queries, filtering, fork, + causal-cone walk, skip behavior, early termination, within-tick ordering, + and `SlotId::Node(atom)` provenance path. + +### test(echo-dind-harness): Golden-vector coverage for TTD digest surface + +- **Added** `digest_golden_vectors.rs` — DIND-level golden-hash tests that + exercise `compute_emissions_digest`, `compute_op_emission_index_digest`, + and `compute_tick_commit_hash_v2` through warp-core's crate-root re-exports. +- **Pinned** 3 golden vectors: individual emission/op-emission-index digests + plus a full hash chain (emissions → op-index → tick-commit). Any wire format + drift in the public digest surface is now caught outside module-local tests. + +### fix(warp-core): Preserve within-tick write order in atom_history + +- **Fixed** `atom_history()` within-tick write ordering: the backward tick + walk collected per-tick writes in forward order, so the final `reverse()` + flipped within-tick execution sequence. Iterate `tick_writes.iter().rev()` + so the global reverse restores original order. +- **Fixed** creation truncation: if a single tick had `[create, mutate]` for + the same atom, forward iteration hit `is_create()` first and returned early, + losing the subsequent mutation. +- **Updated** `fork()` rustdoc to mention `atom_writes` in the copied fields. +- **Documented** `append_with_writes()` invariant: atom writes must reference + atoms declared in `patch.out_slots` for `atom_history()` visibility. + ### fix: Ban-globals regex for macro patterns - **Fixed** `scripts/ban-globals.sh`: the `\bthread_local!\b` and diff --git a/crates/echo-dind-harness/tests/digest_golden_vectors.rs b/crates/echo-dind-harness/tests/digest_golden_vectors.rs new file mode 100644 index 00000000..a69ef12b --- /dev/null +++ b/crates/echo-dind-harness/tests/digest_golden_vectors.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +#![allow(clippy::unwrap_used)] +//! Golden-vector tests for the TTD digest public API surface. +//! +//! These tests exercise `compute_emissions_digest`, `compute_op_emission_index_digest`, +//! and `compute_tick_commit_hash_v2` through warp-core's **crate-root re-exports** — +//! not via internal module paths. If a re-export is removed or a wire format changes, +//! these tests catch it. +//! +//! Each function is pinned to a known-good hash. The full chain is then exercised: +//! emissions → op-emission-index → tick-commit, so drift in any layer is detected. + +use warp_core::materialization::{make_channel_id, FinalizedChannel}; +use warp_core::{ + compute_emissions_digest, compute_op_emission_index_digest, compute_tick_commit_hash_v2, + OpEmissionEntry, WorldlineId, +}; + +// ─── Test vectors ──────────────────────────────────────────────────────────── + +/// Creates a 32-byte hash where every byte position is distinguishable. +/// This catches serializer bugs that drop, reorder, or endian-flip tail bytes +/// (a uniform [n, 0, 0, ...] fixture would mask such issues). +fn make_hash(seed: u8) -> [u8; 32] { + #[expect(clippy::cast_possible_truncation, reason = "i ∈ 0..32, fits in u8")] + core::array::from_fn(|i| seed.wrapping_add((i as u8).wrapping_mul(17))) +} + +// ─── compute_emissions_digest ──────────────────────────────────────────────── + +#[test] +fn emissions_digest_golden_vector() { + let ch_a = make_channel_id("alpha"); + let ch_b = make_channel_id("beta"); + + let channels = vec![ + FinalizedChannel { + channel: ch_a, + data: vec![1, 2, 3], + }, + FinalizedChannel { + channel: ch_b, + data: vec![4, 5], + }, + ]; + + let digest = compute_emissions_digest(&channels); + let hex = hex::encode(digest); + + // Pinned golden value. If this changes, the emissions wire format changed. + assert_eq!( + hex, "9cd163d40fd2b8b089c5bed80c328ffc5695926cdbe2aaf1a99c83adf0bbe2ea", + "emissions_digest golden vector mismatch — wire format may have changed!\nactual: {hex}" + ); +} + +// ─── compute_op_emission_index_digest ──────────────────────────────────────── + +#[test] +fn op_emission_index_digest_golden_vector() { + let ch_a = make_channel_id("alpha"); + let ch_b = make_channel_id("beta"); + + let entries = vec![ + OpEmissionEntry { + op_id: make_hash(0xAA), + channels: vec![ch_a, ch_b], + }, + OpEmissionEntry { + op_id: make_hash(0xBB), + channels: vec![ch_a], + }, + ]; + + let digest = compute_op_emission_index_digest(&entries); + let hex = hex::encode(digest); + + // Pinned golden value. If this changes, the op-emission-index wire format changed. + assert_eq!( + hex, "cbb1f5f73d0b5da137b0bde7b7d242fb44b8b6d0f5d4f8391105b6c36aa7a974", + "op_emission_index_digest golden vector mismatch — wire format may have changed!\nactual: {hex}" + ); +} + +// ─── compute_tick_commit_hash_v2 (full chain) ──────────────────────────────── + +#[test] +fn tick_commit_hash_v2_full_chain_golden_vector() { + // Build the hash chain: emissions → op-emission-index → tick-commit. + // This exercises the full TTD provenance digest surface end-to-end. + + let ch_a = make_channel_id("alpha"); + let ch_b = make_channel_id("beta"); + + // Step 1: Compute emissions digest + let channels = vec![ + FinalizedChannel { + channel: ch_a, + data: vec![1, 2, 3], + }, + FinalizedChannel { + channel: ch_b, + data: vec![4, 5], + }, + ]; + let emissions_digest = compute_emissions_digest(&channels); + + // Step 2: Compute op-emission-index digest + let entries = vec![OpEmissionEntry { + op_id: make_hash(0xAA), + channels: vec![ch_a, ch_b], + }]; + let op_emission_index_digest = compute_op_emission_index_digest(&entries); + + // Step 3: Compute tick commit hash using the above digests + let schema_hash = make_hash(0xAB); + let worldline_id = WorldlineId(make_hash(0xCD)); + let tick = 42u64; + let parent = make_hash(0x11); + let patch_digest = make_hash(0x22); + let state_root = make_hash(0x33); + + let commit_hash = compute_tick_commit_hash_v2( + &schema_hash, + &worldline_id, + tick, + &[parent], + &patch_digest, + Some(&state_root), + &emissions_digest, + Some(&op_emission_index_digest), + ); + let hex = hex::encode(commit_hash); + + // Pinned golden value for the full chain. If this changes, any layer's + // wire format may have changed — check individual tests above to isolate. + assert_eq!( + hex, "8a769a8d8dd847be4ff546f1214a44d49446f05a0a10450b5d0ec21bd68613e9", + "tick_commit_hash_v2 full-chain golden vector mismatch!\nactual: {hex}" + ); +} diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index 7fa8be75..23f6ac0a 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -190,7 +190,8 @@ pub use serializable::{ SerializableReceipt, SerializableReceiptEntry, SerializableSnapshot, SerializableTick, }; pub use snapshot::{ - compute_commit_hash_v2, compute_emissions_digest, compute_state_root_for_warp_store, Snapshot, + compute_commit_hash_v2, compute_emissions_digest, compute_op_emission_index_digest, + compute_state_root_for_warp_store, compute_tick_commit_hash_v2, OpEmissionEntry, Snapshot, }; pub use telemetry::{NullTelemetrySink, TelemetrySink}; pub use tick_delta::{DeltaStats, OpOrigin, ScopedDelta, TickDelta}; diff --git a/crates/warp-core/src/provenance_store.rs b/crates/warp-core/src/provenance_store.rs index 08be8570..55f6a3e1 100644 --- a/crates/warp-core/src/provenance_store.rs +++ b/crates/warp-core/src/provenance_store.rs @@ -32,7 +32,9 @@ use crate::graph::GraphStore; use crate::ident::{Hash, WarpId}; use crate::snapshot::compute_state_root_for_warp_store; -use super::worldline::{HashTriplet, OutputFrameSet, WorldlineId, WorldlineTickPatchV1}; +use super::worldline::{ + AtomWrite, AtomWriteSet, HashTriplet, OutputFrameSet, WorldlineId, WorldlineTickPatchV1, +}; /// Errors that can occur when accessing worldline history. #[derive(Debug, Clone, PartialEq, Eq, Error)] @@ -182,6 +184,8 @@ struct WorldlineHistory { expected: Vec, // Recorded outputs in tick order. outputs: Vec, + // Atom writes in tick order (for provenance tracking). + atom_writes: Vec, // Checkpoints for fast seeking. checkpoints: Vec, } @@ -194,7 +198,7 @@ struct WorldlineHistory { /// /// # Invariant /// -/// For each worldline: `patches.len() == expected.len() == outputs.len()`. +/// For each worldline: `patches.len() == expected.len() == outputs.len() == atom_writes.len()`. /// This maintains index alignment so tick N's data is at index N. #[derive(Debug, Clone, Default)] pub struct LocalProvenanceStore { @@ -239,6 +243,7 @@ impl LocalProvenanceStore { patches: Vec::new(), expected: Vec::new(), outputs: Vec::new(), + atom_writes: Vec::new(), checkpoints: Vec::new(), }); Ok(()) @@ -267,20 +272,38 @@ impl LocalProvenanceStore { self.append_with_writes(w, patch, expected, outputs, Vec::new()) } - /// Appends a tick's data including atom write provenance to a worldline's history. + /// Appends a tick's data to a worldline's history, including atom write provenance. + /// + /// The tick number must equal the current length (append-only, no gaps). + /// + /// # Arguments + /// + /// * `w` - The worldline to append to + /// * `patch` - The tick patch data + /// * `expected` - The expected hash triplet for verification + /// * `outputs` - Channel outputs emitted during this tick + /// * `atom_writes` - Atom writes for provenance tracking (rule→atom attribution). + /// **Invariant**: Each entry's `tick` must equal `patch.global_tick()`, and each + /// `AtomWrite` must reference an atom whose slot appears in + /// `patch.out_slots` (either as `SlotId::Attachment(node_alpha(atom))` or + /// `SlotId::Node(atom)`). Writes to atoms not declared in `out_slots` will be + /// stored and retrievable via [`atom_writes()`](Self::atom_writes), but will be + /// invisible to [`atom_history()`](Self::atom_history) which uses `out_slots` as + /// its causal cone index. The engine enforces this structurally — atom writes are + /// derived from the same footprint that produces `out_slots`. /// /// # Errors /// /// - Returns [`HistoryError::WorldlineNotFound`] if the worldline hasn't been registered. /// - Returns [`HistoryError::TickGap`] if the patch's `global_tick` doesn't equal the - /// current history length. + /// current history length (the expected next tick). pub fn append_with_writes( &mut self, w: WorldlineId, patch: WorldlineTickPatchV1, expected: HashTriplet, outputs: OutputFrameSet, - _writes: Vec, + atom_writes: AtomWriteSet, ) -> Result<(), HistoryError> { let history = self .worldlines @@ -296,14 +319,135 @@ impl LocalProvenanceStore { }); } + // Debug-only: validate atom write invariants. Zero cost in release builds. + #[cfg(debug_assertions)] + { + for aw in &atom_writes { + // Each AtomWrite.tick must match the enclosing patch tick. + debug_assert_eq!( + aw.tick, got_tick, + "AtomWrite tick {} does not match enclosing patch tick {}", + aw.tick, got_tick, + ); + // Each AtomWrite must reference an atom declared in out_slots. + let att = crate::tick_patch::SlotId::Attachment( + crate::attachment::AttachmentKey::node_alpha(aw.atom), + ); + let node = crate::tick_patch::SlotId::Node(aw.atom); + debug_assert!( + patch.out_slots.contains(&att) || patch.out_slots.contains(&node), + "AtomWrite for {:?} at tick {} not declared in out_slots — \ + atom_history() will not find this write", + aw.atom, + got_tick, + ); + } + } + history.patches.push(patch); history.expected.push(expected); history.outputs.push(outputs); - // Note: Atom writes are currently ignored in the local store implementation - // but the API is restored for TTD compatibility. + history.atom_writes.push(atom_writes); Ok(()) } + /// Returns the atom writes for a specific tick. + /// + /// This enables the TTD "Show Me Why" provenance feature: tracing which + /// rules wrote which atoms during a tick. + /// + /// # Errors + /// + /// - [`HistoryError::WorldlineNotFound`] if the worldline doesn't exist. + /// - [`HistoryError::HistoryUnavailable`] if the tick is out of range or pruned. + pub fn atom_writes(&self, w: WorldlineId, tick: u64) -> Result { + let history = self + .worldlines + .get(&w) + .ok_or(HistoryError::WorldlineNotFound(w))?; + + // SAFETY: cast_possible_truncation — on 32-bit targets (WASM), tick values + // above usize::MAX would truncate and index the wrong element. Guard with a + // bounds check against the actual length before casting. + if tick >= history.atom_writes.len() as u64 { + return Err(HistoryError::HistoryUnavailable { tick }); + } + Ok(history.atom_writes[tick as usize].clone()) + } + + /// Returns the atom write history for a specific atom by walking its causal cone. + /// + /// Walks backwards through the worldline's patch history, using the declared + /// `out_slots` (Paper III's `Out(μ)`) to filter which ticks' atom writes are + /// examined. Only ticks whose `out_slots` declare the atom's slot have their + /// writes collected. The walk terminates early when a creation write is found + /// (the atom's origin). + /// + /// This implements the derivation graph `D(v)` from Paper III (§3.2), restricted + /// to the target atom's slot dependencies. + /// + /// The returned writes are in tick order (oldest first). + /// + /// # Errors + /// + /// Returns [`HistoryError::WorldlineNotFound`] if the worldline doesn't exist. + pub fn atom_history( + &self, + w: WorldlineId, + atom: &crate::ident::NodeKey, + ) -> Result, HistoryError> { + let history = self + .worldlines + .get(&w) + .ok_or(HistoryError::WorldlineNotFound(w))?; + + // The atom's attachment slot — this is what Out(μ) contains when a + // tick writes to an atom's payload (SetAttachment on the node's α plane). + let attachment_slot = crate::tick_patch::SlotId::Attachment( + crate::attachment::AttachmentKey::node_alpha(*atom), + ); + // The node skeleton slot — Out(μ) contains this for UpsertNode/DeleteNode. + let node_slot = crate::tick_patch::SlotId::Node(*atom); + + // Walk backwards from tip, collecting writes in reverse chronological order. + let mut writes_rev: Vec = Vec::new(); + let len = history.patches.len(); + + for tick_idx in (0..len).rev() { + let patch = &history.patches[tick_idx]; + + // Check Out(μ): did this tick produce the atom's slot? + let touched = patch + .out_slots + .iter() + .any(|s| *s == attachment_slot || *s == node_slot); + + if !touched { + continue; + } + + // This tick wrote to the atom — collect matching AtomWrites. + // Iterate in reverse so the final writes_rev.reverse() preserves + // within-tick execution order (forward iteration would flip it). + if let Some(tick_writes) = history.atom_writes.get(tick_idx) { + for aw in tick_writes.iter().rev() { + if &aw.atom == atom { + let is_creation = aw.is_create(); + writes_rev.push(aw.clone()); + if is_creation { + // Reached the atom's origin — stop walking. + writes_rev.reverse(); + return Ok(writes_rev); + } + } + } + } + } + + writes_rev.reverse(); + Ok(writes_rev) + } + /// Records a checkpoint for a worldline. /// /// Checkpoints are stored in tick order for efficient binary search. @@ -372,8 +516,8 @@ impl LocalProvenanceStore { /// Creates a new worldline that is a prefix-copy of the source up to `fork_tick`. /// /// The new worldline shares the same U0 reference as the source and contains - /// copies of all history data (patches, expected hashes, outputs, checkpoints) - /// from tick 0 through `fork_tick` inclusive. + /// copies of all history data (patches, expected hashes, outputs, atom writes, + /// and checkpoints) from tick 0 through `fork_tick` inclusive. /// /// # Errors /// @@ -413,6 +557,7 @@ impl LocalProvenanceStore { patches: source_history.patches[..end_idx].to_vec(), expected: source_history.expected[..end_idx].to_vec(), outputs: source_history.outputs[..end_idx].to_vec(), + atom_writes: source_history.atom_writes[..end_idx].to_vec(), checkpoints: source_history .checkpoints .iter() @@ -750,4 +895,423 @@ mod tests { "checkpoint_before should be strictly less than the query tick" ); } + + // ─── AtomWrite Tests ───────────────────────────────────────────────────── + + use crate::attachment::AttachmentKey; + use crate::ident::{make_node_id, NodeKey}; + use crate::tick_patch::SlotId; + use crate::worldline::AtomWrite; + + fn test_node_key() -> NodeKey { + NodeKey { + warp_id: test_warp_id(), + local_id: make_node_id("test-atom"), + } + } + + fn test_rule_id() -> [u8; 32] { + [42u8; 32] + } + + /// Creates a patch with out_slots declaring which atoms were written. + /// This mirrors how the engine populates Out(μ) from footprints. + fn test_patch_with_atom_slots(tick: u64, atoms: &[NodeKey]) -> WorldlineTickPatchV1 { + let mut patch = test_patch(tick); + for atom in atoms { + // Atom values are attachments on the node's α plane (Paper III: Out(μ)) + patch + .out_slots + .push(SlotId::Attachment(AttachmentKey::node_alpha(*atom))); + } + patch + } + + /// Creates a patch with both in_slots and out_slots for a mutation. + /// A mutation reads (In) the previous value and writes (Out) the new one. + fn test_patch_with_atom_mutation(tick: u64, atoms: &[NodeKey]) -> WorldlineTickPatchV1 { + let mut patch = test_patch(tick); + for atom in atoms { + let slot = SlotId::Attachment(AttachmentKey::node_alpha(*atom)); + patch.in_slots.push(slot); + patch.out_slots.push(slot); + } + patch + } + + #[test] + fn append_with_writes_stores_atom_writes() { + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let atom_write = AtomWrite::new( + test_node_key(), + test_rule_id(), + 0, + None, // create + vec![1, 2, 3], + ); + + store + .append_with_writes( + w, + test_patch_with_atom_slots(0, &[test_node_key()]), + test_triplet(0), + vec![], + vec![atom_write.clone()], + ) + .unwrap(); + + let writes = store.atom_writes(w, 0).unwrap(); + assert_eq!(writes.len(), 1); + assert_eq!(writes[0], atom_write); + } + + #[test] + fn atom_writes_unavailable_for_missing_tick() { + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let result = store.atom_writes(w, 0); + assert!(matches!( + result, + Err(HistoryError::HistoryUnavailable { tick: 0 }) + )); + } + + #[test] + fn atom_history_walks_causal_cone() { + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let atom_key = test_node_key(); + + // Tick 0: create atom (Out(μ) declares the atom slot) + let write0 = AtomWrite::new(atom_key, test_rule_id(), 0, None, vec![1]); + store + .append_with_writes( + w, + test_patch_with_atom_slots(0, &[atom_key]), + test_triplet(0), + vec![], + vec![write0.clone()], + ) + .unwrap(); + + // Tick 1: mutate atom (In + Out declare read-then-write) + let write1 = AtomWrite::new(atom_key, test_rule_id(), 1, Some(vec![1]), vec![2]); + store + .append_with_writes( + w, + test_patch_with_atom_mutation(1, &[atom_key]), + test_triplet(1), + vec![], + vec![write1.clone()], + ) + .unwrap(); + + // Tick 2: mutate atom again + let write2 = AtomWrite::new(atom_key, test_rule_id(), 2, Some(vec![2]), vec![3]); + store + .append_with_writes( + w, + test_patch_with_atom_mutation(2, &[atom_key]), + test_triplet(2), + vec![], + vec![write2.clone()], + ) + .unwrap(); + + let history = store.atom_history(w, &atom_key).unwrap(); + assert_eq!(history.len(), 3); + assert_eq!(history[0], write0); + assert_eq!(history[1], write1); + assert_eq!(history[2], write2); + } + + #[test] + fn atom_history_skips_ticks_not_in_causal_cone() { + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let atom_a = test_node_key(); + let atom_b = NodeKey { + warp_id: test_warp_id(), + local_id: make_node_id("other-atom"), + }; + + // Tick 0: create atom_a (Out(μ) = {atom_a}) + let write_a = AtomWrite::new(atom_a, test_rule_id(), 0, None, vec![1]); + store + .append_with_writes( + w, + test_patch_with_atom_slots(0, &[atom_a]), + test_triplet(0), + vec![], + vec![write_a.clone()], + ) + .unwrap(); + + // Tick 1: only touches atom_b — atom_a's causal cone skips this tick + let write_b = AtomWrite::new(atom_b, test_rule_id(), 1, None, vec![100]); + store + .append_with_writes( + w, + test_patch_with_atom_slots(1, &[atom_b]), + test_triplet(1), + vec![], + vec![write_b], + ) + .unwrap(); + + // Tick 2: mutate atom_a again (Out(μ) = {atom_a}) + let write_a2 = AtomWrite::new(atom_a, test_rule_id(), 2, Some(vec![1]), vec![2]); + store + .append_with_writes( + w, + test_patch_with_atom_mutation(2, &[atom_a]), + test_triplet(2), + vec![], + vec![write_a2.clone()], + ) + .unwrap(); + + // atom_a's history should skip tick 1 entirely + let history_a = store.atom_history(w, &atom_a).unwrap(); + assert_eq!(history_a.len(), 2); + assert_eq!(history_a[0], write_a); + assert_eq!(history_a[1], write_a2); + } + + #[test] + fn atom_history_terminates_at_creation() { + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let atom_key = test_node_key(); + + // Ticks 0-4: unrelated work (no atom_key in out_slots) + for tick in 0..5 { + store + .append_with_writes(w, test_patch(tick), test_triplet(tick), vec![], Vec::new()) + .unwrap(); + } + + // Tick 5: create atom_key + let write5 = AtomWrite::new(atom_key, test_rule_id(), 5, None, vec![1]); + store + .append_with_writes( + w, + test_patch_with_atom_slots(5, &[atom_key]), + test_triplet(5), + vec![], + vec![write5.clone()], + ) + .unwrap(); + + // Tick 6: mutate atom_key + let write6 = AtomWrite::new(atom_key, test_rule_id(), 6, Some(vec![1]), vec![2]); + store + .append_with_writes( + w, + test_patch_with_atom_mutation(6, &[atom_key]), + test_triplet(6), + vec![], + vec![write6.clone()], + ) + .unwrap(); + + let history = store.atom_history(w, &atom_key).unwrap(); + assert_eq!( + history.len(), + 2, + "should find creation at tick 5 and mutation at tick 6" + ); + assert_eq!(history[0], write5); + assert_eq!(history[1], write6); + } + + #[test] + fn atom_history_preserves_within_tick_order() { + // Regression: if a single tick has [create, mutate] for the same atom, + // atom_history must return both in execution order (create then mutate). + // A naive forward iteration over tick_writes would hit is_create() first + // and early-return, losing the subsequent mutation. + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let atom_key = test_node_key(); + + // Single tick with two writes: create then mutate (same atom, same tick). + // This can happen when a rule creates an atom and immediately sets its value. + let create_write = AtomWrite::new(atom_key, test_rule_id(), 0, None, vec![1]); + let mutate_write = AtomWrite::new(atom_key, test_rule_id(), 0, Some(vec![1]), vec![2]); + + store + .append_with_writes( + w, + test_patch_with_atom_slots(0, &[atom_key]), + test_triplet(0), + vec![], + vec![create_write.clone(), mutate_write.clone()], + ) + .unwrap(); + + let history = store.atom_history(w, &atom_key).unwrap(); + assert_eq!( + history.len(), + 2, + "both create and mutate must be captured from the same tick" + ); + assert_eq!( + history[0], create_write, + "create must come first (execution order)" + ); + assert_eq!( + history[1], mutate_write, + "mutate must come second (execution order)" + ); + } + + #[test] + fn atom_history_filters_by_atom() { + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let atom_a = test_node_key(); + let atom_b = NodeKey { + warp_id: test_warp_id(), + local_id: make_node_id("other-atom"), + }; + + // Both atoms written at tick 0 (both in Out(μ)) + let write_a = AtomWrite::new(atom_a, test_rule_id(), 0, None, vec![1]); + let write_b = AtomWrite::new(atom_b, test_rule_id(), 0, None, vec![100]); + store + .append_with_writes( + w, + test_patch_with_atom_slots(0, &[atom_a, atom_b]), + test_triplet(0), + vec![], + vec![write_a.clone(), write_b], + ) + .unwrap(); + + // Query only atom_a's history + let history_a = store.atom_history(w, &atom_a).unwrap(); + assert_eq!(history_a.len(), 1); + assert_eq!(history_a[0], write_a); + } + + #[test] + fn atom_write_is_create() { + let create_write = AtomWrite::new(test_node_key(), test_rule_id(), 0, None, vec![1]); + assert!(create_write.is_create()); + + let mutation_write = + AtomWrite::new(test_node_key(), test_rule_id(), 1, Some(vec![1]), vec![2]); + assert!(!mutation_write.is_create()); + } + + #[test] + fn atom_write_is_mutation() { + // Create is a mutation (value changed from nothing to something) + let create_write = AtomWrite::new(test_node_key(), test_rule_id(), 0, None, vec![1]); + assert!(create_write.is_mutation()); + + // Actual value change + let change_write = + AtomWrite::new(test_node_key(), test_rule_id(), 1, Some(vec![1]), vec![2]); + assert!(change_write.is_mutation()); + + // No-op (same value) + let noop_write = AtomWrite::new(test_node_key(), test_rule_id(), 2, Some(vec![1]), vec![1]); + assert!(!noop_write.is_mutation()); + } + + /// Creates a patch with `SlotId::Node(atom)` out_slots instead of + /// `SlotId::Attachment`. This exercises the skeleton-level provenance path + /// (UpsertNode/DeleteNode) as opposed to the payload-level path (SetAttachment). + fn test_patch_with_node_slots(tick: u64, atoms: &[NodeKey]) -> WorldlineTickPatchV1 { + let mut patch = test_patch(tick); + for atom in atoms { + patch.out_slots.push(SlotId::Node(*atom)); + } + patch + } + + #[test] + fn atom_history_via_node_slot() { + let mut store = LocalProvenanceStore::new(); + let w = test_worldline_id(); + store.register_worldline(w, test_warp_id()).unwrap(); + + let atom = test_node_key(); + + // Tick 0: create via SlotId::Node (skeleton-level write, e.g. UpsertNode) + let create_write = AtomWrite::new(atom, test_rule_id(), 0, None, vec![1]); + store + .append_with_writes( + w, + test_patch_with_node_slots(0, &[atom]), + test_triplet(0), + vec![], + vec![create_write.clone()], + ) + .unwrap(); + + // Tick 1: mutate via SlotId::Node + let mutate_write = AtomWrite::new(atom, test_rule_id(), 1, Some(vec![1]), vec![2]); + store + .append_with_writes( + w, + test_patch_with_node_slots(1, &[atom]), + test_triplet(1), + vec![], + vec![mutate_write.clone()], + ) + .unwrap(); + + // atom_history should find both writes through the SlotId::Node path + let history = store.atom_history(w, &atom).unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0], create_write, "oldest first: creation"); + assert_eq!(history[1], mutate_write, "oldest first: mutation"); + } + + #[test] + fn fork_copies_atom_writes() { + let mut store = LocalProvenanceStore::new(); + let source = test_worldline_id(); + let target = WorldlineId([99u8; 32]); + let warp = test_warp_id(); + + store.register_worldline(source, warp).unwrap(); + + let atom_write = AtomWrite::new(test_node_key(), test_rule_id(), 0, None, vec![1, 2, 3]); + store + .append_with_writes( + source, + test_patch_with_atom_slots(0, &[test_node_key()]), + test_triplet(0), + vec![], + vec![atom_write.clone()], + ) + .unwrap(); + + // Fork at tick 0 + store.fork(source, 0, target).unwrap(); + + // Target should have the same atom writes + let target_writes = store.atom_writes(target, 0).unwrap(); + assert_eq!(target_writes.len(), 1); + assert_eq!(target_writes[0], atom_write); + } } diff --git a/crates/warp-core/src/snapshot.rs b/crates/warp-core/src/snapshot.rs index 9365564b..b661d565 100644 --- a/crates/warp-core/src/snapshot.rs +++ b/crates/warp-core/src/snapshot.rs @@ -476,7 +476,7 @@ use crate::worldline::WorldlineId; /// ``` // Allow many arguments: this signature matches the TTD spec (docs/plans/ttd-app.md §3.3) // exactly. A builder pattern would obscure the wire format correspondence. -#[allow(clippy::too_many_arguments, dead_code)] +#[allow(clippy::too_many_arguments)] pub fn compute_tick_commit_hash_v2( schema_hash: &Hash, worldline_id: &WorldlineId, @@ -570,7 +570,6 @@ use crate::materialization::{ChannelId, FinalizedChannel}; /// let report = bus.finalize(); /// let digest = compute_emissions_digest(&report.channels); /// ``` -#[allow(dead_code)] pub fn compute_emissions_digest(channels: &[FinalizedChannel]) -> Hash { let mut h = Hasher::new(); @@ -599,7 +598,6 @@ pub fn compute_emissions_digest(channels: &[FinalizedChannel]) -> Hash { /// This is used by [`compute_op_emission_index_digest`] to track which /// operations triggered which channel emissions within a tick. #[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] pub struct OpEmissionEntry { /// The operation ID (opcode hash) that triggered emissions. pub op_id: Hash, @@ -643,7 +641,6 @@ pub struct OpEmissionEntry { /// ]; /// let digest = compute_op_emission_index_digest(&entries); /// ``` -#[allow(dead_code)] pub fn compute_op_emission_index_digest(entries: &[OpEmissionEntry]) -> Hash { let mut h = Hasher::new();