Skip to main content

microscope_memory/
spine.rs

1//! The Spine: every layer header of the stream in one shared mmap file.
2//!
3//! `spine.bin` is `Slot::WalStart + 8` pages of 16 KiB. Slot 0 carries the
4//! superblock, slots 1..=11 carry one header page per layer, slot 12 opens
5//! the write-ahead region. A process that owns the stream writes a header
6//! when its aggregates change; the `MCP`/CLI readers and the live-viewer
7//! process read those pages without going through the writer at all.
8//!
9//! # The publish protocol, in one paragraph
10//!
11//! A page write is a 16 KiB `copy_from_slice`. It is **not** an atomic
12//! operation, and `Mutex<MmapMut>` only serialises handles of **one**
13//! process: two processes mapping the same file have two mutexes, so they
14//! have no happens-before relation and a reader can copy a half-written page
15//! or pair a new stream header with an older one. That tearing is what the
16//! seqlock in this file exists to remove, and nothing else here claims to.
17//!
18//! The superblock page reserves [`SEQ_SLOT`] as its publish counter, exactly
19//! the way [`crate::consciousness_seqlock::MmapSnapshot`] reserves
20//! `sequence: AtomicU64` at offset 0 of its own file. A publisher makes the
21//! counter odd, writes the bracketed payload, then stores the next even
22//! value with `Ordering::Release`; a reader loads with `Ordering::Acquire`,
23//! spins while odd, copies, and retries if the counter moved. The reader
24//! therefore never returns a payload from inside a publish window, and it
25//! does not need the mutex to find that out.
26//!
27//! # What the counter does and does not cover
28//!
29//! Every header write participates in the counter. [`Spine::begin_publish`]
30//! holds one mapping mutex and one publisher lock across a read-modify-write
31//! batch; its first write opens the window, and commit (or drop) closes it.
32//! [`Spine::read_store_snapshot`] validates all eleven header prefixes and the
33//! superblock in one window. [`Spine::read_snapshot`] is the cheaper stream-only
34//! view. Separate legacy `read_*` calls do not form a validated snapshot.
35//!
36//! A window guarantees a coherent copy, not rollback or durability. Dropping
37//! a batch during unwinding publishes whatever was written before the panic;
38//! crash recovery likewise cannot reconstruct the pre-crash state. Aggregates
39//! refreshed less frequently than the stream may legitimately be older.
40//!
41//! # Who is allowed to be inside a window
42//!
43//! A seqlock only helps if at most one writer is in a window at a time; two
44//! overlapping windows can leave a reader validating a counter that neither
45//! writer owns. So publishing also takes `flock(LOCK_EX)` on `spine.publish`
46//! (see [`Spine::begin_publish`]), which is the piece shared pages cannot
47//! provide: mutual exclusion between publishers in different processes.
48//!
49//! A second lock file answers a different question. `spine.lock` is held
50//! *shared* for the whole life of a writer handle, and exclusively by
51//! first-time layout and by [`Spine::resize`], because remapping the file
52//! invalidates the pages every other handle is reading. It cannot double as
53//! the publish lock: a handle holding it shared would deadlock against itself
54//! trying to take it exclusively for one tick.
55//!
56//! `flock` is advisory, is not available on Windows, and does not exist on
57//! every filesystem; all of those degrade to "publishing is serialised per
58//! handle only". That is a real weakening of the guarantee, so it is
59//! reported by [`Spine::publish_lock_is_advisory`] instead of being hidden.
60//!
61//! Lock order is mapping mutex, then publisher lock. `flock` belongs to the
62//! open file description, not the thread: taking it before the mutex would
63//! let a second thread on the same handle unlock the first thread's publish.
64//! The advisory lock is released before the mapping mutex on exit.
65
66use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
67use memmap2::{MmapMut, MmapOptions};
68use parking_lot::{Mutex, MutexGuard};
69use std::fs::OpenOptions;
70use std::io::Cursor;
71use std::path::{Path, PathBuf};
72use std::sync::atomic::{AtomicU64, Ordering};
73use std::sync::Arc;
74
75pub const PAGE_SIZE: usize = 16 * 1024;
76pub const HEADER_SIZE: usize = 64;
77pub const MAGIC: u64 = 0x4D31_4353_5031_4E45;
78pub const N_LAYERS: usize = 12;
79pub const ATTENTION_LAYERS: usize = 7;
80
81/// Superblock format version.
82///
83/// Version 1 was `magic, version, page_size, wal_start_page, wal_head_page,
84/// epoch_ms` — 32 bytes, timestamp last, no publish counter. Version 2 keeps
85/// the first five fields exactly where they were, inserts `tick_seq` at
86/// bytes 24..32 and moves `epoch_ms` to 32..40, padding the payload out to
87/// [`SUPERBLOCK_BYTES`]. That is why [`Spine::open`] upgrades in place: a
88/// version-1 file decoded as version 2 would read a millisecond timestamp as
89/// a sequence number.
90pub const SUPERBLOCK_VERSION: u32 = 2;
91/// Bytes of the superblock page that are part of the published payload: the
92/// fixed-size header fields, padded out so the payload ends where the counter
93/// begins. [`Spine::write_slot`] accepts a whole page for this slot but
94/// publishes only these bytes, precisely so that a page-sized write cannot
95/// reach into `SEQ_SLOT` and reset the counter under a reader.
96pub const SUPERBLOCK_BYTES: usize = 56;
97/// Byte offset of the seqlock publish counter inside the superblock **page**
98/// (page 0, so this is also the file offset). 8-byte aligned by construction
99/// because mappings start on a page boundary.
100pub const SEQ_SLOT: usize = 56;
101const _: () = assert!(
102    SEQ_SLOT.is_multiple_of(8),
103    "the counter slot must stay 8-byte aligned"
104);
105const _: () = assert!(
106    SEQ_SLOT >= SUPERBLOCK_BYTES,
107    "the payload must end before the counter"
108);
109/// Bytes copied from each header page by a validated snapshot. The largest
110/// encoded header is Emotion (108 bytes); no page padding needs to be read.
111/// This is a format bound, not a measured performance claim.
112pub const SNAPSHOT_PREFIX_BYTES: usize = 128;
113/// Read attempts on a publish window that keeps moving under us before the
114/// reader gives up and reports `None`. Spins for the first 64, then yields,
115/// so the budget is a measure of *patience* rather than of CPU burned
116/// competing with the writer. It is large because an attempt costs a lock
117/// hand-over when a reader shares its handle with other readers: measured
118/// against three threads hammering one file, a 1024-attempt budget gave up on
119/// 0.09% of reads even though the writer's window is never longer than a
120/// microsecond, purely from losing the lock. At this budget it gives up on
121/// none.
122pub const SNAPSHOT_MAX_RETRIES: u32 = 65_536;
123/// ...and the shortest time it will keep trying, in milliseconds. An attempt
124/// is cheap, so on an oversubscribed machine the count can run out while the
125/// reader has merely been off-CPU; the deadline is what distinguishes "the
126/// writer is relentless" from "I was not scheduled". Measured on a 2-core box
127/// with three load tests and a spawned writer running at once: count alone
128/// gave up on about 1 read in 60 000, this gives up on none.
129pub const SNAPSHOT_MAX_WAIT_MS: u64 = 50;
130/// Name of the file whose advisory lock serialises publishers.
131pub const PUBLISH_LOCK_FILE_NAME: &str = "spine.publish";
132/// Name of the file whose advisory lock marks live writers for `resize`.
133pub const WRITER_LOCK_FILE_NAME: &str = "spine.lock";
134
135const _: () = assert!(SEQ_SLOT + 8 <= PAGE_SIZE);
136const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= StreamHeader::HEADER_BYTES);
137const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= HebbianHeader::HEADER_BYTES);
138const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= AttentionHeader::HEADER_BYTES);
139const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= EmotionHeader::HEADER_BYTES);
140const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= ResonanceHeader::HEADER_BYTES);
141const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= GraphHeader::HEADER_BYTES);
142const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= CacheHeader::HEADER_BYTES);
143const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= ArchetypeHeader::HEADER_BYTES);
144const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= MirrorHeader::HEADER_BYTES);
145const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= MerkleHeader::HEADER_BYTES);
146const _: () = assert!(SNAPSHOT_PREFIX_BYTES >= MetaHeader::HEADER_BYTES);
147const _: () = assert!(SNAPSHOT_PREFIX_BYTES <= PAGE_SIZE);
148const _: () = assert!(SUPERBLOCK_BYTES + 8 <= PAGE_SIZE);
149const _: () = assert!(HEADER_SIZE == 64);
150const _: () = assert!(11 * PAGE_SIZE + SNAPSHOT_PREFIX_BYTES <= 12 * PAGE_SIZE);
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153#[repr(u32)]
154pub enum Slot {
155    Superblock = 0,
156    Stream = 1,
157    Hebbian = 2,
158    Attention = 3,
159    Emotion = 4,
160    Resonance = 5,
161    Graph = 6,
162    Cache = 7,
163    Archetype = 8,
164    Mirror = 9,
165    Merkle = 10,
166    Meta = 11,
167    WalStart = 12,
168}
169impl Slot {
170    pub fn offset(&self) -> usize {
171        *self as usize * PAGE_SIZE
172    }
173    pub fn from_u32(v: u32) -> Option<Slot> {
174        Some(match v {
175            0 => Slot::Superblock,
176            1 => Slot::Stream,
177            2 => Slot::Hebbian,
178            3 => Slot::Attention,
179            4 => Slot::Emotion,
180            5 => Slot::Resonance,
181            6 => Slot::Graph,
182            7 => Slot::Cache,
183            8 => Slot::Archetype,
184            9 => Slot::Mirror,
185            10 => Slot::Merkle,
186            11 => Slot::Meta,
187            12 => Slot::WalStart,
188            _ => return None,
189        })
190    }
191}
192
193// Header structs - simplified, page-aligned, repr(C, align(64))
194
195#[derive(Debug, Clone, Copy)]
196#[repr(C, align(64))]
197pub struct StreamHeader {
198    pub cycle: u64,
199    pub surprise_level: f32,
200    pub curiosity_level: f32,
201    pub predicted_query_hash: u64,
202    pub predicted_confidence: f32,
203    pub last_query_hash: u64,
204    pub last_query_ms: u64,
205    pub hebbian_total_energy: f64,
206    pub updated_ms: u64,
207    pub _pad: [u8; PAGE_SIZE - 60],
208}
209impl StreamHeader {
210    pub const HEADER_BYTES: usize = 60;
211    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
212        let mut buf = [0u8; PAGE_SIZE];
213        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
214        c.write_u64::<LittleEndian>(self.cycle).unwrap();
215        c.write_f32::<LittleEndian>(self.surprise_level).unwrap();
216        c.write_f32::<LittleEndian>(self.curiosity_level).unwrap();
217        c.write_u64::<LittleEndian>(self.predicted_query_hash)
218            .unwrap();
219        c.write_f32::<LittleEndian>(self.predicted_confidence)
220            .unwrap();
221        c.write_u64::<LittleEndian>(self.last_query_hash).unwrap();
222        c.write_u64::<LittleEndian>(self.last_query_ms).unwrap();
223        c.write_f64::<LittleEndian>(self.hebbian_total_energy)
224            .unwrap();
225        c.write_u64::<LittleEndian>(self.updated_ms).unwrap();
226        buf
227    }
228    pub fn from_bytes(buf: &[u8]) -> Self {
229        let mut c = Cursor::new(buf);
230        Self {
231            cycle: c.read_u64::<LittleEndian>().unwrap_or(0),
232            surprise_level: c.read_f32::<LittleEndian>().unwrap_or(0.0),
233            curiosity_level: c.read_f32::<LittleEndian>().unwrap_or(0.0),
234            predicted_query_hash: c.read_u64::<LittleEndian>().unwrap_or(0),
235            predicted_confidence: c.read_f32::<LittleEndian>().unwrap_or(0.0),
236            last_query_hash: c.read_u64::<LittleEndian>().unwrap_or(0),
237            last_query_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
238            hebbian_total_energy: c.read_f64::<LittleEndian>().unwrap_or(0.0),
239            updated_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
240            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
241        }
242    }
243}
244impl Default for StreamHeader {
245    fn default() -> Self {
246        Self {
247            cycle: 0,
248            surprise_level: 0.0,
249            curiosity_level: 0.0,
250            predicted_query_hash: 0,
251            predicted_confidence: 0.0,
252            last_query_hash: 0,
253            last_query_ms: 0,
254            hebbian_total_energy: 0.0,
255            updated_ms: 0,
256            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
257        }
258    }
259}
260
261#[derive(Debug, Clone, Copy)]
262#[repr(C, align(64))]
263pub struct HebbianHeader {
264    pub block_count: u32,
265    pub activation_count: u32,
266    pub coactivation_count: u32,
267    pub fingerprint_count: u32,
268    pub delta_tail_offset: u64,
269    /// Count of hebbian energy-decay events. **There is no producer for this
270    /// field today.** `HebbianState::apply_drift` decays energies in place and
271    /// keeps no count, and nothing in the crate reads the field back; until a
272    /// producer exists, the value here is whatever the file happens to hold.
273    /// `publish_spine` used to do `decay_event_count += cycle`, which grew the
274    /// field as a sum of cycle numbers — quadratically, and unrelated to any
275    /// decay that had happened. That write is gone, which is a behaviour change
276    /// only for a reader that trusted a number with no source.
277    pub decay_event_count: u64,
278    pub last_activation_ms: u64,
279    pub _pad: [u8; PAGE_SIZE - 40],
280}
281impl HebbianHeader {
282    pub const HEADER_BYTES: usize = 40;
283    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
284        let mut buf = [0u8; PAGE_SIZE];
285        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
286        c.write_u32::<LittleEndian>(self.block_count).unwrap();
287        c.write_u32::<LittleEndian>(self.activation_count).unwrap();
288        c.write_u32::<LittleEndian>(self.coactivation_count)
289            .unwrap();
290        c.write_u32::<LittleEndian>(self.fingerprint_count).unwrap();
291        c.write_u64::<LittleEndian>(self.delta_tail_offset).unwrap();
292        c.write_u64::<LittleEndian>(self.decay_event_count).unwrap();
293        c.write_u64::<LittleEndian>(self.last_activation_ms)
294            .unwrap();
295        buf
296    }
297    pub fn from_bytes(buf: &[u8]) -> Self {
298        let mut c = Cursor::new(buf);
299        Self {
300            block_count: c.read_u32::<LittleEndian>().unwrap_or(0),
301            activation_count: c.read_u32::<LittleEndian>().unwrap_or(0),
302            coactivation_count: c.read_u32::<LittleEndian>().unwrap_or(0),
303            fingerprint_count: c.read_u32::<LittleEndian>().unwrap_or(0),
304            delta_tail_offset: c.read_u64::<LittleEndian>().unwrap_or(0),
305            decay_event_count: c.read_u64::<LittleEndian>().unwrap_or(0),
306            last_activation_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
307            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
308        }
309    }
310}
311impl Default for HebbianHeader {
312    fn default() -> Self {
313        Self {
314            block_count: 0,
315            activation_count: 0,
316            coactivation_count: 0,
317            fingerprint_count: 0,
318            delta_tail_offset: 0,
319            decay_event_count: 0,
320            last_activation_ms: 0,
321            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
322        }
323    }
324}
325
326#[derive(Debug, Clone, Copy)]
327#[repr(C, align(64))]
328pub struct AttentionHeader {
329    pub learned_weights: [f32; ATTENTION_LAYERS],
330    pub last_recall_ms: u64,
331    pub total_recalls: u32,
332    pub quality_signal: f32,
333    pub _pad: [u8; PAGE_SIZE - (4 * ATTENTION_LAYERS + 16)],
334}
335impl AttentionHeader {
336    pub const HEADER_BYTES: usize = 4 * ATTENTION_LAYERS + 16;
337    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
338        let mut buf = [0u8; PAGE_SIZE];
339        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
340        for w in &self.learned_weights {
341            c.write_f32::<LittleEndian>(*w).unwrap();
342        }
343        c.write_u64::<LittleEndian>(self.last_recall_ms).unwrap();
344        c.write_u32::<LittleEndian>(self.total_recalls).unwrap();
345        c.write_f32::<LittleEndian>(self.quality_signal).unwrap();
346        buf
347    }
348    pub fn from_bytes(buf: &[u8]) -> Self {
349        let mut c = Cursor::new(buf);
350        let mut lw = [0.0f32; ATTENTION_LAYERS];
351        for w in lw.iter_mut() {
352            *w = c.read_f32::<LittleEndian>().unwrap_or(0.0);
353        }
354        Self {
355            learned_weights: lw,
356            last_recall_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
357            total_recalls: c.read_u32::<LittleEndian>().unwrap_or(0),
358            quality_signal: c.read_f32::<LittleEndian>().unwrap_or(0.0),
359            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
360        }
361    }
362}
363impl Default for AttentionHeader {
364    fn default() -> Self {
365        Self {
366            learned_weights: [1.0 / ATTENTION_LAYERS as f32; ATTENTION_LAYERS],
367            last_recall_ms: 0,
368            total_recalls: 0,
369            quality_signal: 0.0,
370            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
371        }
372    }
373}
374
375#[derive(Debug, Clone, Copy)]
376#[repr(C, align(64))]
377pub struct EmotionHeader {
378    pub current: [f32; 21],
379    pub head: u32,
380    pub count: u32,
381    pub last_update: u64,
382    pub total_updates: u64,
383    pub _pad: [u8; PAGE_SIZE - 108],
384}
385impl EmotionHeader {
386    pub const HEADER_BYTES: usize = 4 * 21 + 24;
387    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
388        let mut buf = [0u8; PAGE_SIZE];
389        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
390        for v in &self.current {
391            c.write_f32::<LittleEndian>(*v).unwrap();
392        }
393        c.write_u32::<LittleEndian>(self.head).unwrap();
394        c.write_u32::<LittleEndian>(self.count).unwrap();
395        c.write_u64::<LittleEndian>(self.last_update).unwrap();
396        c.write_u64::<LittleEndian>(self.total_updates).unwrap();
397        buf
398    }
399    pub fn from_bytes(buf: &[u8]) -> Self {
400        let mut c = Cursor::new(buf);
401        let mut cur = [0.0f32; 21];
402        for v in cur.iter_mut() {
403            *v = c.read_f32::<LittleEndian>().unwrap_or(0.0);
404        }
405        Self {
406            current: cur,
407            head: c.read_u32::<LittleEndian>().unwrap_or(0),
408            count: c.read_u32::<LittleEndian>().unwrap_or(0),
409            last_update: c.read_u64::<LittleEndian>().unwrap_or(0),
410            total_updates: c.read_u64::<LittleEndian>().unwrap_or(0),
411            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
412        }
413    }
414}
415impl Default for EmotionHeader {
416    fn default() -> Self {
417        Self {
418            current: [0.0; 21],
419            head: 0,
420            count: 0,
421            last_update: 0,
422            total_updates: 0,
423            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
424        }
425    }
426}
427
428#[derive(Debug, Clone, Copy)]
429#[repr(C, align(64))]
430pub struct ResonanceHeader {
431    pub instance_id: u64,
432    pub field_cells: u32,
433    pub outgoing_count: u32,
434    pub incoming_count: u32,
435    pub pulses_emitted: u64,
436    pub pulses_received: u64,
437    pub last_decay_ms: u64,
438    pub _pad: [u8; PAGE_SIZE - 44],
439}
440impl ResonanceHeader {
441    pub const HEADER_BYTES: usize = 44;
442    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
443        let mut buf = [0u8; PAGE_SIZE];
444        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
445        c.write_u64::<LittleEndian>(self.instance_id).unwrap();
446        c.write_u32::<LittleEndian>(self.field_cells).unwrap();
447        c.write_u32::<LittleEndian>(self.outgoing_count).unwrap();
448        c.write_u32::<LittleEndian>(self.incoming_count).unwrap();
449        c.write_u64::<LittleEndian>(self.pulses_emitted).unwrap();
450        c.write_u64::<LittleEndian>(self.pulses_received).unwrap();
451        c.write_u64::<LittleEndian>(self.last_decay_ms).unwrap();
452        buf
453    }
454    pub fn from_bytes(buf: &[u8]) -> Self {
455        let mut c = Cursor::new(buf);
456        Self {
457            instance_id: c.read_u64::<LittleEndian>().unwrap_or(0),
458            field_cells: c.read_u32::<LittleEndian>().unwrap_or(0),
459            outgoing_count: c.read_u32::<LittleEndian>().unwrap_or(0),
460            incoming_count: c.read_u32::<LittleEndian>().unwrap_or(0),
461            pulses_emitted: c.read_u64::<LittleEndian>().unwrap_or(0),
462            pulses_received: c.read_u64::<LittleEndian>().unwrap_or(0),
463            last_decay_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
464            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
465        }
466    }
467}
468impl Default for ResonanceHeader {
469    fn default() -> Self {
470        Self {
471            instance_id: 0,
472            field_cells: 0,
473            outgoing_count: 0,
474            incoming_count: 0,
475            pulses_emitted: 0,
476            pulses_received: 0,
477            last_decay_ms: 0,
478            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
479        }
480    }
481}
482
483#[derive(Debug, Clone, Copy)]
484#[repr(C, align(64))]
485pub struct GraphHeader {
486    pub node_count: u32,
487    pub edge_count: u32,
488    pub pattern_count: u32,
489    pub current_session_id: u32,
490    pub next_pattern_id: u32,
491    pub last_node_ts: u64,
492    pub _pad: [u8; PAGE_SIZE - 28],
493}
494impl GraphHeader {
495    pub const HEADER_BYTES: usize = 28;
496    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
497        let mut buf = [0u8; PAGE_SIZE];
498        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
499        c.write_u32::<LittleEndian>(self.node_count).unwrap();
500        c.write_u32::<LittleEndian>(self.edge_count).unwrap();
501        c.write_u32::<LittleEndian>(self.pattern_count).unwrap();
502        c.write_u32::<LittleEndian>(self.current_session_id)
503            .unwrap();
504        c.write_u32::<LittleEndian>(self.next_pattern_id).unwrap();
505        c.write_u64::<LittleEndian>(self.last_node_ts).unwrap();
506        buf
507    }
508    pub fn from_bytes(buf: &[u8]) -> Self {
509        let mut c = Cursor::new(buf);
510        Self {
511            node_count: c.read_u32::<LittleEndian>().unwrap_or(0),
512            edge_count: c.read_u32::<LittleEndian>().unwrap_or(0),
513            pattern_count: c.read_u32::<LittleEndian>().unwrap_or(0),
514            current_session_id: c.read_u32::<LittleEndian>().unwrap_or(0),
515            next_pattern_id: c.read_u32::<LittleEndian>().unwrap_or(1),
516            last_node_ts: c.read_u64::<LittleEndian>().unwrap_or(0),
517            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
518        }
519    }
520}
521impl Default for GraphHeader {
522    fn default() -> Self {
523        Self {
524            node_count: 0,
525            edge_count: 0,
526            pattern_count: 0,
527            current_session_id: 0,
528            next_pattern_id: 1,
529            last_node_ts: 0,
530            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
531        }
532    }
533}
534
535#[derive(Debug, Clone, Copy)]
536#[repr(C, align(64))]
537pub struct CacheHeader {
538    pub total_predictions: u32,
539    pub total_hits: u32,
540    pub total_misses: u32,
541    pub total_partial_hits: u32,
542    pub current_predictions: u32,
543    pub avg_confidence: f32,
544    pub last_predict_ms: u64,
545    pub _pad: [u8; PAGE_SIZE - 32],
546}
547impl CacheHeader {
548    pub const HEADER_BYTES: usize = 32;
549    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
550        let mut buf = [0u8; PAGE_SIZE];
551        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
552        c.write_u32::<LittleEndian>(self.total_predictions).unwrap();
553        c.write_u32::<LittleEndian>(self.total_hits).unwrap();
554        c.write_u32::<LittleEndian>(self.total_misses).unwrap();
555        c.write_u32::<LittleEndian>(self.total_partial_hits)
556            .unwrap();
557        c.write_u32::<LittleEndian>(self.current_predictions)
558            .unwrap();
559        c.write_f32::<LittleEndian>(self.avg_confidence).unwrap();
560        c.write_u64::<LittleEndian>(self.last_predict_ms).unwrap();
561        buf
562    }
563    pub fn from_bytes(buf: &[u8]) -> Self {
564        let mut c = Cursor::new(buf);
565        Self {
566            total_predictions: c.read_u32::<LittleEndian>().unwrap_or(0),
567            total_hits: c.read_u32::<LittleEndian>().unwrap_or(0),
568            total_misses: c.read_u32::<LittleEndian>().unwrap_or(0),
569            total_partial_hits: c.read_u32::<LittleEndian>().unwrap_or(0),
570            current_predictions: c.read_u32::<LittleEndian>().unwrap_or(0),
571            avg_confidence: c.read_f32::<LittleEndian>().unwrap_or(0.0),
572            last_predict_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
573            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
574        }
575    }
576    pub fn hit_rate(&self) -> f32 {
577        let total = self.total_hits + self.total_misses + self.total_partial_hits;
578        if total == 0 {
579            return 0.0;
580        }
581        self.total_hits as f32 / total as f32
582    }
583}
584impl Default for CacheHeader {
585    fn default() -> Self {
586        Self {
587            total_predictions: 0,
588            total_hits: 0,
589            total_misses: 0,
590            total_partial_hits: 0,
591            current_predictions: 0,
592            avg_confidence: 0.0,
593            last_predict_ms: 0,
594            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
595        }
596    }
597}
598
599#[derive(Debug, Clone, Copy)]
600#[repr(C, align(64))]
601pub struct ArchetypeHeader {
602    pub active_count: u32,
603    pub next_id: u32,
604    pub emergence_events: u64,
605    /// Same status as [`HebbianHeader::decay_event_count`]: part of the page
606    /// layout, no producer (`ArchetypeState::decay` performs the event and
607    /// returns nothing), and no consumer.
608    pub decay_events: u64,
609    pub last_emergence_ms: u64,
610    pub _pad: [u8; PAGE_SIZE - 32],
611}
612impl ArchetypeHeader {
613    pub const HEADER_BYTES: usize = 32;
614    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
615        let mut buf = [0u8; PAGE_SIZE];
616        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
617        c.write_u32::<LittleEndian>(self.active_count).unwrap();
618        c.write_u32::<LittleEndian>(self.next_id).unwrap();
619        c.write_u64::<LittleEndian>(self.emergence_events).unwrap();
620        c.write_u64::<LittleEndian>(self.decay_events).unwrap();
621        c.write_u64::<LittleEndian>(self.last_emergence_ms).unwrap();
622        buf
623    }
624    pub fn from_bytes(buf: &[u8]) -> Self {
625        let mut c = Cursor::new(buf);
626        Self {
627            active_count: c.read_u32::<LittleEndian>().unwrap_or(0),
628            next_id: c.read_u32::<LittleEndian>().unwrap_or(1),
629            emergence_events: c.read_u64::<LittleEndian>().unwrap_or(0),
630            decay_events: c.read_u64::<LittleEndian>().unwrap_or(0),
631            last_emergence_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
632            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
633        }
634    }
635}
636impl Default for ArchetypeHeader {
637    fn default() -> Self {
638        Self {
639            active_count: 0,
640            next_id: 1,
641            emergence_events: 0,
642            decay_events: 0,
643            last_emergence_ms: 0,
644            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
645        }
646    }
647}
648
649#[derive(Debug, Clone, Copy)]
650#[repr(C, align(64))]
651pub struct MirrorHeader {
652    pub echo_count: u32,
653    pub block_resonance_count: u32,
654    pub strongest_similarity: f32,
655    pub total_echoes: u64,
656    pub last_mirror_ms: u64,
657    pub _pad: [u8; PAGE_SIZE - 28],
658}
659impl MirrorHeader {
660    pub const HEADER_BYTES: usize = 28;
661    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
662        let mut buf = [0u8; PAGE_SIZE];
663        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
664        c.write_u32::<LittleEndian>(self.echo_count).unwrap();
665        c.write_u32::<LittleEndian>(self.block_resonance_count)
666            .unwrap();
667        c.write_f32::<LittleEndian>(self.strongest_similarity)
668            .unwrap();
669        c.write_u64::<LittleEndian>(self.total_echoes).unwrap();
670        c.write_u64::<LittleEndian>(self.last_mirror_ms).unwrap();
671        buf
672    }
673    pub fn from_bytes(buf: &[u8]) -> Self {
674        let mut c = Cursor::new(buf);
675        Self {
676            echo_count: c.read_u32::<LittleEndian>().unwrap_or(0),
677            block_resonance_count: c.read_u32::<LittleEndian>().unwrap_or(0),
678            strongest_similarity: c.read_f32::<LittleEndian>().unwrap_or(0.0),
679            total_echoes: c.read_u64::<LittleEndian>().unwrap_or(0),
680            last_mirror_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
681            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
682        }
683    }
684}
685impl Default for MirrorHeader {
686    fn default() -> Self {
687        Self {
688            echo_count: 0,
689            block_resonance_count: 0,
690            strongest_similarity: 0.0,
691            total_echoes: 0,
692            last_mirror_ms: 0,
693            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
694        }
695    }
696}
697
698#[derive(Debug, Clone, Copy)]
699#[repr(C, align(64))]
700pub struct MerkleHeader {
701    pub root_hash: u64,
702    pub chain_height: u64,
703    pub block_count: u32,
704    pub evidence_audit_entries: u32,
705    pub last_anchor_ms: u64,
706    pub _pad: [u8; PAGE_SIZE - 32],
707}
708impl MerkleHeader {
709    pub const HEADER_BYTES: usize = 32;
710    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
711        let mut buf = [0u8; PAGE_SIZE];
712        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
713        c.write_u64::<LittleEndian>(self.root_hash).unwrap();
714        c.write_u64::<LittleEndian>(self.chain_height).unwrap();
715        c.write_u32::<LittleEndian>(self.block_count).unwrap();
716        c.write_u32::<LittleEndian>(self.evidence_audit_entries)
717            .unwrap();
718        c.write_u64::<LittleEndian>(self.last_anchor_ms).unwrap();
719        buf
720    }
721    pub fn from_bytes(buf: &[u8]) -> Self {
722        let mut c = Cursor::new(buf);
723        Self {
724            root_hash: c.read_u64::<LittleEndian>().unwrap_or(0),
725            chain_height: c.read_u64::<LittleEndian>().unwrap_or(0),
726            block_count: c.read_u32::<LittleEndian>().unwrap_or(0),
727            evidence_audit_entries: c.read_u32::<LittleEndian>().unwrap_or(0),
728            last_anchor_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
729            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
730        }
731    }
732}
733impl Default for MerkleHeader {
734    fn default() -> Self {
735        Self {
736            root_hash: 0,
737            chain_height: 0,
738            block_count: 0,
739            evidence_audit_entries: 0,
740            last_anchor_ms: 0,
741            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
742        }
743    }
744}
745
746#[derive(Debug, Clone, Copy)]
747#[repr(C, align(64))]
748pub struct MetaHeader {
749    pub block_count: u32,
750    pub append_log_len: u32,
751    pub dream_cycle_count: u32,
752    pub reps_served_count: u32,
753    pub last_dream_ms: u64,
754    pub promotion_count: u64,
755    pub _pad: [u8; PAGE_SIZE - 32],
756}
757impl MetaHeader {
758    pub const HEADER_BYTES: usize = 32;
759    pub fn to_bytes(&self) -> [u8; PAGE_SIZE] {
760        let mut buf = [0u8; PAGE_SIZE];
761        let mut c = Cursor::new(&mut buf[..Self::HEADER_BYTES]);
762        c.write_u32::<LittleEndian>(self.block_count).unwrap();
763        c.write_u32::<LittleEndian>(self.append_log_len).unwrap();
764        c.write_u32::<LittleEndian>(self.dream_cycle_count).unwrap();
765        c.write_u32::<LittleEndian>(self.reps_served_count).unwrap();
766        c.write_u64::<LittleEndian>(self.last_dream_ms).unwrap();
767        c.write_u64::<LittleEndian>(self.promotion_count).unwrap();
768        buf
769    }
770    pub fn from_bytes(buf: &[u8]) -> Self {
771        let mut c = Cursor::new(buf);
772        Self {
773            block_count: c.read_u32::<LittleEndian>().unwrap_or(0),
774            append_log_len: c.read_u32::<LittleEndian>().unwrap_or(0),
775            dream_cycle_count: c.read_u32::<LittleEndian>().unwrap_or(0),
776            reps_served_count: c.read_u32::<LittleEndian>().unwrap_or(0),
777            last_dream_ms: c.read_u64::<LittleEndian>().unwrap_or(0),
778            promotion_count: c.read_u64::<LittleEndian>().unwrap_or(0),
779            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
780        }
781    }
782}
783impl Default for MetaHeader {
784    fn default() -> Self {
785        Self {
786            block_count: 0,
787            append_log_len: 0,
788            dream_cycle_count: 0,
789            reps_served_count: 0,
790            last_dream_ms: 0,
791            promotion_count: 0,
792            _pad: [0; PAGE_SIZE - Self::HEADER_BYTES],
793        }
794    }
795}
796
797/// Superblock, as it lives in the first [`SUPERBLOCK_BYTES`] bytes of page 0.
798///
799/// `tick_seq` is a publish sequence number, **not** a timestamp: it counts
800/// publishes from 1 upwards, so two ticks in the same millisecond still get
801/// distinct values. `epoch_ms` stays for the "when was this last touched"
802/// question, which is a wall-clock question and is answered by the clock.
803#[derive(Debug, Clone, Copy)]
804pub struct Superblock {
805    pub magic: u64,
806    pub version: u32,
807    pub page_size: u32,
808    pub wal_start_page: u32,
809    pub wal_head_page: u32,
810    pub tick_seq: u64,
811    pub epoch_ms: i64,
812}
813impl Default for Superblock {
814    fn default() -> Self {
815        Self::ZEROED
816    }
817}
818impl Superblock {
819    const ZEROED: Self = Self {
820        magic: 0,
821        version: SUPERBLOCK_VERSION,
822        page_size: 0,
823        wal_start_page: 0,
824        wal_head_page: 0,
825        tick_seq: 0,
826        epoch_ms: 0,
827    };
828    pub fn new() -> Self {
829        Self {
830            magic: MAGIC,
831            version: SUPERBLOCK_VERSION,
832            page_size: PAGE_SIZE as u32,
833            wal_start_page: Slot::WalStart as u32,
834            wal_head_page: Slot::WalStart as u32,
835            tick_seq: 0,
836            epoch_ms: now_ms(),
837        }
838    }
839    /// The published payload. Byte 56..64 of the page is the seqlock counter
840    /// and is deliberately **not** part of it, so a rewrite of the payload
841    /// can never clear the counter out from under a reader.
842    pub fn to_payload(&self) -> [u8; SUPERBLOCK_BYTES] {
843        let mut buf = [0u8; SUPERBLOCK_BYTES];
844        let mut c = Cursor::new(&mut buf[..]);
845        c.write_u64::<LittleEndian>(self.magic).unwrap();
846        c.write_u32::<LittleEndian>(self.version).unwrap();
847        c.write_u32::<LittleEndian>(self.page_size).unwrap();
848        c.write_u32::<LittleEndian>(self.wal_start_page).unwrap();
849        c.write_u32::<LittleEndian>(self.wal_head_page).unwrap();
850        c.write_u64::<LittleEndian>(self.tick_seq).unwrap();
851        c.write_i64::<LittleEndian>(self.epoch_ms).unwrap();
852        buf
853    }
854    /// `None` when the page is not a superblock at all. A version other than
855    /// the current one is still decoded: `magic` says whose file this is and
856    /// the reader decides what to do with the older fields.
857    pub fn from_payload(buf: &[u8]) -> Option<Self> {
858        let mut c = Cursor::new(buf);
859        let magic = c.read_u64::<LittleEndian>().ok()?;
860        if magic != MAGIC {
861            return None;
862        }
863        let version = c.read_u32::<LittleEndian>().ok()?;
864        if version != SUPERBLOCK_VERSION {
865            return None;
866        }
867        Some(Self {
868            magic,
869            version,
870            page_size: c.read_u32::<LittleEndian>().ok()?,
871            wal_start_page: c.read_u32::<LittleEndian>().ok()?,
872            wal_head_page: c.read_u32::<LittleEndian>().ok()?,
873            tick_seq: c.read_u64::<LittleEndian>().ok()?,
874            epoch_ms: c.read_i64::<LittleEndian>().ok()?,
875        })
876    }
877    /// Decode a version-1 superblock, whose `epoch_ms` sat at bytes 24..32
878    /// and which had no publish counter at all. Only the fields that are
879    /// still meaningful are carried over; [`Spine::open`] republishes the
880    /// result as version 2.
881    fn from_v1_payload(buf: &[u8]) -> Option<Self> {
882        let mut c = Cursor::new(buf);
883        let magic = c.read_u64::<LittleEndian>().ok()?;
884        if magic != MAGIC {
885            return None;
886        }
887        let version = c.read_u32::<LittleEndian>().ok()?;
888        if version != 1 {
889            return None;
890        }
891        Some(Self {
892            magic,
893            version,
894            page_size: c.read_u32::<LittleEndian>().ok()?,
895            wal_start_page: c.read_u32::<LittleEndian>().ok()?,
896            wal_head_page: c.read_u32::<LittleEndian>().ok()?,
897            tick_seq: 0,
898            epoch_ms: c.read_i64::<LittleEndian>().ok()?,
899        })
900    }
901}
902
903fn now_ms() -> i64 {
904    std::time::SystemTime::now()
905        .duration_since(std::time::UNIX_EPOCH)
906        .map(|d| d.as_millis() as i64)
907        .unwrap_or(0)
908}
909
910/// One published snapshot: the stream header plus the superblock payload
911/// that was in the file at the same moment — the two things the seqlock
912/// brackets. Their agreement is the guarantee; see [`Spine::read_snapshot`].
913#[derive(Debug, Clone, Copy)]
914pub struct PublishSnapshot {
915    pub stream: StreamHeader,
916    /// Counter recorded by the last stream tick (distinct for two ticks in
917    /// one millisecond). Standalone layer writes advance `sequence`, not this
918    /// field; explicit superblock writes may replace the record.
919    pub tick_seq: u64,
920    /// Wall-clock milliseconds at publish time. Freshness, not ordering.
921    pub epoch_ms: i64,
922    /// The seqlock value this snapshot was read under; always even.
923    pub sequence: u64,
924}
925
926/// All header pages and the publish record copied under one counter value.
927/// Layer aggregates can be older than the stream, but none was copied midway
928/// through a write. Use a single `Publish` batch to update related headers.
929#[derive(Debug, Clone, Copy)]
930pub struct StoreSnapshot {
931    pub stream: StreamHeader,
932    pub hebbian: HebbianHeader,
933    pub attention: AttentionHeader,
934    pub emotion: EmotionHeader,
935    pub resonance: ResonanceHeader,
936    pub graph: GraphHeader,
937    pub cache: CacheHeader,
938    pub archetype: ArchetypeHeader,
939    pub mirror: MirrorHeader,
940    pub merkle: MerkleHeader,
941    pub meta: MetaHeader,
942    pub tick_seq: u64,
943    pub epoch_ms: i64,
944    pub sequence: u64,
945}
946
947struct SnapshotBytes<const N: usize> {
948    headers: [[u8; SNAPSHOT_PREFIX_BYTES]; N],
949    payload: [u8; SUPERBLOCK_BYTES],
950    sequence: u64,
951}
952
953/// One exclusive read-modify-write batch. Do not call methods on the owning
954/// `Spine` while this guard lives: its mapping mutex is non-reentrant. Use the
955/// guard's `read_*` / `write_*` methods instead. Reads alone do not advance the
956/// counter; the first write opens the window and commit/drop closes it.
957///
958/// This is not a rollback transaction. Unwinding closes the window around the
959/// partial update, so callers must not interpret a panic as an aborted batch.
960#[must_use = "hold the batch while updating headers, then commit or drop it"]
961pub struct Publish<'a> {
962    // Field order matters: unlock flock before releasing the mapping mutex.
963    _advisory: PublishGuard<'a>,
964    mmap: MutexGuard<'a, MmapMut>,
965    window: Option<u64>,
966}
967
968impl Drop for Publish<'_> {
969    fn drop(&mut self) {
970        if let Some(n) = self.window.take() {
971            seq_end(&mut self.mmap, n);
972        }
973    }
974}
975
976impl Publish<'_> {
977    fn begin_write(&mut self) -> u64 {
978        if let Some(n) = self.window {
979            return n;
980        }
981        let n = seq_begin(&mut self.mmap);
982        self.window = Some(n);
983        n
984    }
985
986    fn read_prefix(&self, slot: Slot) -> [u8; SNAPSHOT_PREFIX_BYTES] {
987        let mut bytes = [0; SNAPSHOT_PREFIX_BYTES];
988        let off = slot.offset();
989        let end = (off + SNAPSHOT_PREFIX_BYTES).min(self.mmap.len());
990        if off < end {
991            bytes[..end - off].copy_from_slice(&self.mmap[off..end]);
992        }
993        bytes
994    }
995
996    fn write_slot(&mut self, slot: Slot, bytes: &[u8; PAGE_SIZE]) {
997        let off = slot.offset();
998        let len = if slot == Slot::Superblock {
999            SUPERBLOCK_BYTES
1000        } else {
1001            PAGE_SIZE
1002        };
1003        if off + len > self.mmap.len() {
1004            return;
1005        }
1006        self.begin_write();
1007        self.mmap[off..off + len].copy_from_slice(&bytes[..len]);
1008    }
1009
1010    /// Whether this batch acquired cross-handle/process publisher exclusion.
1011    pub fn is_advisory(&self) -> bool {
1012        self._advisory.is_advisory()
1013    }
1014
1015    /// Finish the window and release both locks. Drop has the same effect.
1016    pub fn commit(self) {}
1017
1018    pub fn read_superblock(&self) -> Option<Superblock> {
1019        Superblock::from_payload(&self.mmap[..SUPERBLOCK_BYTES])
1020    }
1021
1022    /// Copy only the payload; the atomic counter is never overwritten.
1023    pub fn write_superblock(&mut self, h: &Superblock) {
1024        self.begin_write();
1025        self.mmap[..SUPERBLOCK_BYTES].copy_from_slice(&h.to_payload());
1026    }
1027
1028    pub fn read_stream(&self) -> StreamHeader {
1029        StreamHeader::from_bytes(&self.read_prefix(Slot::Stream))
1030    }
1031
1032    pub fn write_stream(&mut self, h: &StreamHeader) {
1033        self.write_slot(Slot::Stream, &h.to_bytes());
1034    }
1035
1036    pub fn read_hebbian(&self) -> HebbianHeader {
1037        HebbianHeader::from_bytes(&self.read_prefix(Slot::Hebbian))
1038    }
1039
1040    pub fn write_hebbian(&mut self, h: &HebbianHeader) {
1041        self.write_slot(Slot::Hebbian, &h.to_bytes());
1042    }
1043
1044    pub fn read_attention(&self) -> AttentionHeader {
1045        AttentionHeader::from_bytes(&self.read_prefix(Slot::Attention))
1046    }
1047
1048    pub fn write_attention(&mut self, h: &AttentionHeader) {
1049        self.write_slot(Slot::Attention, &h.to_bytes());
1050    }
1051
1052    pub fn read_emotion(&self) -> EmotionHeader {
1053        EmotionHeader::from_bytes(&self.read_prefix(Slot::Emotion))
1054    }
1055
1056    pub fn write_emotion(&mut self, h: &EmotionHeader) {
1057        self.write_slot(Slot::Emotion, &h.to_bytes());
1058    }
1059
1060    pub fn read_resonance(&self) -> ResonanceHeader {
1061        ResonanceHeader::from_bytes(&self.read_prefix(Slot::Resonance))
1062    }
1063
1064    pub fn write_resonance(&mut self, h: &ResonanceHeader) {
1065        self.write_slot(Slot::Resonance, &h.to_bytes());
1066    }
1067
1068    pub fn read_graph(&self) -> GraphHeader {
1069        GraphHeader::from_bytes(&self.read_prefix(Slot::Graph))
1070    }
1071
1072    pub fn write_graph(&mut self, h: &GraphHeader) {
1073        self.write_slot(Slot::Graph, &h.to_bytes());
1074    }
1075
1076    pub fn read_cache(&self) -> CacheHeader {
1077        CacheHeader::from_bytes(&self.read_prefix(Slot::Cache))
1078    }
1079
1080    pub fn write_cache(&mut self, h: &CacheHeader) {
1081        self.write_slot(Slot::Cache, &h.to_bytes());
1082    }
1083
1084    pub fn read_archetype(&self) -> ArchetypeHeader {
1085        ArchetypeHeader::from_bytes(&self.read_prefix(Slot::Archetype))
1086    }
1087
1088    pub fn write_archetype(&mut self, h: &ArchetypeHeader) {
1089        self.write_slot(Slot::Archetype, &h.to_bytes());
1090    }
1091
1092    pub fn read_mirror(&self) -> MirrorHeader {
1093        MirrorHeader::from_bytes(&self.read_prefix(Slot::Mirror))
1094    }
1095
1096    pub fn write_mirror(&mut self, h: &MirrorHeader) {
1097        self.write_slot(Slot::Mirror, &h.to_bytes());
1098    }
1099
1100    pub fn read_merkle(&self) -> MerkleHeader {
1101        MerkleHeader::from_bytes(&self.read_prefix(Slot::Merkle))
1102    }
1103
1104    pub fn write_merkle(&mut self, h: &MerkleHeader) {
1105        self.write_slot(Slot::Merkle, &h.to_bytes());
1106    }
1107
1108    pub fn read_meta(&self) -> MetaHeader {
1109        MetaHeader::from_bytes(&self.read_prefix(Slot::Meta))
1110    }
1111
1112    pub fn write_meta(&mut self, h: &MetaHeader) {
1113        self.write_slot(Slot::Meta, &h.to_bytes());
1114    }
1115
1116    /// Update the stream and publish record using one caller-supplied time.
1117    /// The batch may update other headers before committing the same window.
1118    #[allow(clippy::too_many_arguments)]
1119    pub fn tick_stream(
1120        &mut self,
1121        cycle: u64,
1122        surprise: f32,
1123        curiosity: f32,
1124        predicted_hash: u64,
1125        predicted_confidence: f32,
1126        last_query_hash: u64,
1127        last_query_ms: u64,
1128        hebbian_total_energy: f64,
1129        epoch_ms: i64,
1130    ) {
1131        if self.mmap.len() < Slot::Stream.offset() + PAGE_SIZE {
1132            return;
1133        }
1134        let h = StreamHeader {
1135            cycle,
1136            surprise_level: surprise,
1137            curiosity_level: curiosity,
1138            predicted_query_hash: predicted_hash,
1139            predicted_confidence,
1140            last_query_hash,
1141            last_query_ms,
1142            hebbian_total_energy,
1143            updated_ms: epoch_ms as u64,
1144            _pad: [0; PAGE_SIZE - StreamHeader::HEADER_BYTES],
1145        };
1146        let n = self.begin_write();
1147        let payload = Superblock {
1148            tick_seq: n.wrapping_add(1) >> 1,
1149            epoch_ms,
1150            ..current_superblock(&self.mmap)
1151        }
1152        .to_payload();
1153        self.mmap[..SUPERBLOCK_BYTES].copy_from_slice(&payload);
1154        self.write_stream(&h);
1155    }
1156}
1157
1158/// One publisher's turn. `Drop` releases the advisory lock, so a publish that
1159/// panics part-way through cannot leave the store un-writable.
1160pub struct PublishGuard<'a> {
1161    file: Option<&'a std::fs::File>,
1162    held: bool,
1163}
1164
1165impl Drop for PublishGuard<'_> {
1166    fn drop(&mut self) {
1167        #[cfg(unix)]
1168        if let (true, Some(file)) = (self.held, self.file) {
1169            use std::os::fd::AsRawFd;
1170            // Best effort: closing the descriptor would release the lock too,
1171            // and a caller can do nothing useful with a failed unlock.
1172            let _ = writer_lock::try_op(file.as_raw_fd(), writer_lock::UN);
1173        }
1174    }
1175}
1176
1177impl PublishGuard<'_> {
1178    /// Whether this guard actually excluded other publishers. `false` means
1179    /// the platform or filesystem gave us nothing to lock, so the publish was
1180    /// serialised against this handle only.
1181    pub fn is_advisory(&self) -> bool {
1182        self.held
1183    }
1184}
1185
1186/// Spine file handle. `Clone`-like sharing is by `Arc` inside
1187/// [`crate::consciousness_stream`]; a second process opens its own handle
1188/// over the same file and coordinates through the seqlock.
1189pub struct Spine {
1190    output_dir: PathBuf,
1191    mmap: Arc<Mutex<MmapMut>>,
1192    /// Held open for the lifetime of a *writer* handle so the advisory lock
1193    /// stays claimed. `None` on a read-only handle, which neither claims
1194    /// writer status nor may `resize`.
1195    lock_file: Option<std::fs::File>,
1196    /// Opened per handle and deliberately *not* held: locking and unlocking
1197    /// this descriptor around each publish is what serialises publishers.
1198    /// `None` when the file could not be opened, which costs serialisation.
1199    publish_lock_file: Option<std::fs::File>,
1200    /// `false` once the publish lock turns out to be unavailable, so nothing
1201    /// claims an exclusion it does not have.
1202    publish_lock_advisory: bool,
1203}
1204
1205/// The seqlock counter as an atomic over the live mapping.
1206///
1207/// # Safety
1208/// `mmap` must be mapped, writable, at least `SEQ_SLOT + 8` bytes long, and
1209/// its base address page-aligned (mappings from `mmap(2)` always are, which
1210/// is what makes `SEQ_SLOT` — a multiple of 8 — an aligned address). No code
1211/// may write those 8 bytes non-atomically: [`Spine::write_slot`] is the only
1212/// path that writes page 0 wholesale and it refuses to.
1213/// The counter is at a fixed offset in a live mapping, so this is the one
1214/// place that has to hold the two invariants every caller would otherwise have
1215/// to remember: the mapping covers `SEQ_SLOT..SEQ_SLOT + 8`, and the address is
1216/// 8-byte aligned. Alignment holds because `mmap` starts on a page boundary and
1217/// `SEQ_SLOT` is a multiple of 8; the length is checked here, in release too,
1218/// so a caller that forgets its guard panics instead of being undefined.
1219unsafe fn seq_atomic(mmap: &mut MmapMut) -> &AtomicU64 {
1220    assert!(
1221        mmap.len() >= SEQ_SLOT + 8,
1222        "spine mapping is {} bytes, too short to carry a counter at {SEQ_SLOT}",
1223        mmap.len()
1224    );
1225    let base = mmap.as_mut_ptr();
1226    // The only alignment the atomic needs is 8, and `mmap(2)` hands out
1227    // page-aligned bases (4096 at the smallest), so `SEQ_SLOT` — a multiple
1228    // of 8 — lands on an aligned address. Assert that directly rather than
1229    // assuming the spine's own 16 KiB page size is the OS page size.
1230    debug_assert_eq!(
1231        (base as usize + SEQ_SLOT) % std::mem::align_of::<AtomicU64>(),
1232        0,
1233        "publish counter is not AtomicU64-aligned"
1234    );
1235    // SAFETY: the caller of this function upheld the invariants above, and
1236    // `AtomicU64::from_ptr` is `unsafe` exactly because it cannot check them.
1237    unsafe { AtomicU64::from_ptr(base.add(SEQ_SLOT).cast::<u64>()) }
1238}
1239
1240/// Read the publish counter with acquire ordering.
1241fn seq_load(mmap: &mut MmapMut) -> u64 {
1242    // SAFETY: bounds and alignment per `seq_atomic`; the borrow ends here.
1243    unsafe { seq_atomic(mmap).load(Ordering::Acquire) }
1244}
1245
1246/// What a reader does between two attempts: spin briefly, then hand the core
1247/// to whoever is publishing, which is the only way the counter can settle.
1248fn snapshot_backoff(attempt: u32) {
1249    if attempt < 64 {
1250        std::hint::spin_loop();
1251    } else {
1252        std::thread::yield_now();
1253    }
1254}
1255
1256/// Open a publish window: the counter goes odd, which is what tells a reader
1257/// "anything you copy now may be mid-write, retry". Returns the odd value, so
1258/// this publish's serial number is `(n + 1) >> 1`.
1259fn seq_begin(mmap: &mut MmapMut) -> u64 {
1260    // A crashed writer may have left it odd. OR, rather than increment, avoids
1261    // exposing an even counter while replacing that writer's partial payload.
1262    // SAFETY: as in `seq_load`.
1263    unsafe { seq_atomic(mmap).fetch_or(1, Ordering::AcqRel) | 1 }
1264}
1265
1266/// Close the window opened by `seq_begin`. The `Release` store is what makes
1267/// every byte written in between visible to a reader that observes the even
1268/// value — in this process or in another one.
1269fn seq_end(mmap: &mut MmapMut, n: u64) {
1270    // SAFETY: as in `seq_load`; `n + 1` is even because `n` was forced odd.
1271    unsafe { seq_atomic(mmap).store(n.wrapping_add(1), Ordering::Release) }
1272}
1273
1274/// Advisory writer lock (`flock(2)` on the sidecar `spine.lock`, so the data
1275/// file descriptor itself stays uninvolved).
1276#[cfg(unix)]
1277mod writer_lock {
1278    use std::io;
1279
1280    pub const SH: i32 = 1;
1281    pub const EX: i32 = 2;
1282    pub const NB: i32 = 4;
1283    pub const UN: i32 = 8;
1284
1285    unsafe extern "C" {
1286        fn flock(fd: i32, operation: i32) -> i32;
1287    }
1288
1289    /// `Err(true)` == somebody else holds it right now, retrying may help.
1290    /// `Err(false)` == retrying will not help.
1291    pub fn try_op(fd: i32, operation: i32) -> Result<(), (bool, io::Error)> {
1292        // SAFETY: `fd` is an open descriptor owned by our caller.
1293        if unsafe { flock(fd, operation) } == 0 {
1294            return Ok(());
1295        }
1296        let err = io::Error::last_os_error();
1297        // EWOULDBLOCK (35) and EAGAIN (11) both mean "held elsewhere".
1298        let contended = operation & NB != 0 && matches!(err.raw_os_error(), Some(11) | Some(35));
1299        Err((contended, err))
1300    }
1301}
1302
1303/// No `flock(2)` on these targets. Only same-handle publishers are excluded;
1304/// multiple handles/processes must not publish concurrently. `resize` also
1305/// cannot ask other processes to stand back, so it declines.
1306#[cfg(not(unix))]
1307mod writer_lock {
1308    use std::io;
1309
1310    // Nothing to lock here, so the operations callers pass are unused.
1311    #[allow(dead_code)]
1312    pub const SH: i32 = 1;
1313    #[allow(dead_code)]
1314    pub const EX: i32 = 2;
1315    #[allow(dead_code)]
1316    pub const NB: i32 = 4;
1317    #[allow(dead_code)]
1318    pub const UN: i32 = 8;
1319
1320    pub fn try_op(_fd: i32, _operation: i32) -> Result<(), (bool, io::Error)> {
1321        Err((
1322            false,
1323            io::Error::other("advisory writer locks are unavailable on this target"),
1324        ))
1325    }
1326}
1327
1328impl Spine {
1329    /// Both coordination files live beside `spine.bin`; the data file never
1330    /// becomes a lock file, so a lock leak cannot corrupt the store.
1331    ///
1332    /// A reader that cannot open `spine.publish` (read-only directory) still
1333    /// reads correctly: it loses only the ability to serialise *against* the
1334    /// writers, which it never had. A writer that cannot create it fails
1335    /// loudly, because it would otherwise publish unserialised and believe
1336    /// otherwise.
1337    fn open_publish_lock(
1338        dir: &Path,
1339        create: bool,
1340    ) -> std::io::Result<(Option<std::fs::File>, bool)> {
1341        let path = dir.join(PUBLISH_LOCK_FILE_NAME);
1342        let opened = if create {
1343            OpenOptions::new()
1344                .read(true)
1345                .write(true)
1346                .truncate(false)
1347                .create(true)
1348                .open(&path)
1349        } else {
1350            OpenOptions::new().read(true).write(true).open(&path)
1351        };
1352        match opened {
1353            Ok(file) => Ok((Some(file), cfg!(unix))),
1354            Err(_) if !create => Ok((None, false)),
1355            Err(e) => Err(e),
1356        }
1357    }
1358
1359    /// Start an exclusive batch. Only use the returned guard while it lives;
1360    /// calling back into this `Spine` would deadlock its mapping mutex.
1361    pub fn begin_publish(&self) -> Publish<'_> {
1362        let mmap = self.mmap.lock();
1363        let advisory = self.publish_guard();
1364        Publish {
1365            _advisory: advisory,
1366            mmap,
1367            window: None,
1368        }
1369    }
1370
1371    /// Must be taken with the mapping mutex held, and released before it.
1372    /// Blocking: skipping the queue would permit overlapping publish windows.
1373    fn publish_guard(&self) -> PublishGuard<'_> {
1374        #[cfg(unix)]
1375        if let Some(file) = self.publish_lock_file.as_ref() {
1376            use std::os::fd::AsRawFd;
1377            // `LOCK_EX` with no `LOCK_NB`: wait our turn. A publisher that
1378            // died holding it takes the lock down with it, so the wait cannot
1379            // stick on a dead process.
1380            if writer_lock::try_op(file.as_raw_fd(), writer_lock::EX).is_ok() {
1381                return PublishGuard {
1382                    file: Some(file),
1383                    held: true,
1384                };
1385            }
1386        }
1387        PublishGuard {
1388            file: None,
1389            held: false,
1390        }
1391    }
1392
1393    /// Whether this platform supports the publisher lock and its sidecar was
1394    /// opened. A runtime lock failure is reported by `PublishGuard::is_advisory`.
1395    /// Without exclusion, independent handles must not publish concurrently.
1396    pub fn publish_lock_is_advisory(&self) -> bool {
1397        self.publish_lock_advisory
1398    }
1399
1400    /// Open for writing: create the file, lay out or upgrade the superblock,
1401    /// and mark this process as a live writer.
1402    pub fn open(output_dir: impl AsRef<Path>) -> std::io::Result<Self> {
1403        let dir = output_dir.as_ref().to_path_buf();
1404        std::fs::create_dir_all(&dir)?;
1405        let path = dir.join("spine.bin");
1406        let file = OpenOptions::new()
1407            .read(true)
1408            .write(true)
1409            .truncate(false)
1410            .create(true)
1411            .open(&path)?;
1412        let min_len = (Slot::WalStart as usize + 8) * PAGE_SIZE;
1413        if file.metadata()?.len() < min_len as u64 {
1414            file.set_len(min_len as u64)?;
1415        }
1416
1417        let lock_file = OpenOptions::new()
1418            .read(true)
1419            .write(true)
1420            .truncate(false)
1421            .create(true)
1422            .open(dir.join(WRITER_LOCK_FILE_NAME))?;
1423        Self::mark_shared(&lock_file)?;
1424        let (publish_lock_file, publish_lock_advisory) = Self::open_publish_lock(&dir, true)?;
1425
1426        // SAFETY: the mapping covers every published slot, and it is only
1427        // replaced by `resize`, which holds the lock exclusively.
1428        // SAFETY: the file outlives the mapping, and `resize` is the only
1429        // place that replaces it.
1430        let mmap = unsafe {
1431            MmapOptions::new()
1432                .map_mut(&file)
1433                .map_err(std::io::Error::other)?
1434        };
1435        let spine = Self {
1436            output_dir: dir,
1437            mmap: Arc::new(Mutex::new(mmap)),
1438            lock_file: Some(lock_file),
1439            publish_lock_file,
1440            publish_lock_advisory,
1441        };
1442        spine.init_superblock()?;
1443        Ok(spine)
1444    }
1445
1446    /// Open an existing spine for reading: no create, no sizing, no writer
1447    /// mark, and no `resize`. Readers need no lock to be safe, because
1448    /// [`Spine::read_snapshot`] validates against the seqlock instead.
1449    pub fn open_ro(output_dir: impl AsRef<Path>) -> std::io::Result<Self> {
1450        let dir = output_dir.as_ref().to_path_buf();
1451        let file = OpenOptions::new()
1452            .read(true)
1453            .write(true)
1454            .truncate(false)
1455            .open(dir.join("spine.bin"))?;
1456        // SAFETY: a read-only handle never writes through this mapping.
1457        let mmap = unsafe {
1458            MmapOptions::new()
1459                .map_mut(&file)
1460                .map_err(std::io::Error::other)?
1461        };
1462        if mmap.len() < SEQ_SLOT + 8 {
1463            return Err(std::io::Error::new(
1464                std::io::ErrorKind::InvalidInput,
1465                "spine.bin is shorter than the publish header",
1466            ));
1467        }
1468        let (publish_lock_file, publish_lock_advisory) = Self::open_publish_lock(&dir, false)?;
1469        Ok(Self {
1470            output_dir: dir,
1471            mmap: Arc::new(Mutex::new(mmap)),
1472            lock_file: None,
1473            publish_lock_file,
1474            publish_lock_advisory,
1475        })
1476    }
1477
1478    /// Shared ("a writer is live") mark, retried because the exclusive holder
1479    /// is only ever another process's init or `resize`, both short.
1480    #[cfg(not(unix))]
1481    fn mark_shared(_lock_file: &std::fs::File) -> std::io::Result<()> {
1482        Ok(())
1483    }
1484
1485    #[cfg(unix)]
1486    fn mark_shared(lock_file: &std::fs::File) -> std::io::Result<()> {
1487        use std::os::fd::AsRawFd;
1488        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
1489        loop {
1490            match writer_lock::try_op(lock_file.as_raw_fd(), writer_lock::SH | writer_lock::NB) {
1491                Ok(()) => return Ok(()),
1492                Err((true, _)) if std::time::Instant::now() < deadline => {
1493                    std::thread::sleep(std::time::Duration::from_millis(2));
1494                }
1495                Err((true, _)) => {
1496                    return Err(std::io::Error::new(
1497                        std::io::ErrorKind::WouldBlock,
1498                        "another process holds the spine lock exclusively",
1499                    ))
1500                }
1501                Err((false, e)) => return Err(e),
1502            }
1503        }
1504    }
1505
1506    /// `flock(LOCK_EX | LOCK_NB)` until a deadline, for the callers that must
1507    /// not proceed while any other writer handle is alive (`resize`, first-time
1508    /// layout on a shared filesystem). Err(WouldBlock) on timeout.
1509    #[cfg(unix)]
1510    fn acquire_exclusive_until(lock: &std::fs::File) -> std::io::Result<()> {
1511        use std::os::fd::AsRawFd;
1512        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
1513        loop {
1514            match writer_lock::try_op(lock.as_raw_fd(), writer_lock::EX | writer_lock::NB) {
1515                Ok(()) => return Ok(()),
1516                Err((true, _)) if std::time::Instant::now() < deadline => {
1517                    std::thread::sleep(std::time::Duration::from_millis(2));
1518                }
1519                Err((true, _)) => {
1520                    return Err(std::io::Error::new(
1521                        std::io::ErrorKind::WouldBlock,
1522                        "another process holds the spine lock; resize would remap its pages",
1523                    ))
1524                }
1525                Err((false, e)) => return Err(e),
1526            }
1527        }
1528    }
1529
1530    /// Lay out the superblock, or upgrade a version-1 one. The payload goes
1531    /// out inside a seqlock window so a concurrent reader sees either the old
1532    /// superblock or the new one, never a mix. Both writers here write the
1533    /// same bytes, but still take the publisher lock to exclude their writes.
1534    fn init_superblock(&self) -> std::io::Result<()> {
1535        let mut m = self.mmap.lock();
1536        let publish = self.publish_guard();
1537        if Superblock::from_payload(&m[0..SUPERBLOCK_BYTES]).is_some() {
1538            return Ok(()); // already the current layout
1539        }
1540        // Either an older layout worth carrying forward, or no spine here at
1541        // all. `from_v1_payload` only accepts a matching magic, so an
1542        // uninitialised or foreign page falls through to a fresh superblock.
1543        let fresh = match Superblock::from_v1_payload(&m[0..SUPERBLOCK_BYTES]) {
1544            Some(old) => Superblock {
1545                version: SUPERBLOCK_VERSION,
1546                ..old
1547            },
1548            // Not a spine file, or not one we recognise: lay it out from
1549            // scratch. Note this is `Superblock::new`, not `Default`, whose
1550            // ZEROED superblock is the *absent* magic on purpose.
1551            None => Superblock::new(),
1552        };
1553        let n = seq_begin(&mut m); // odd: publishing
1554        m[0..SUPERBLOCK_BYTES].copy_from_slice(&fresh.to_payload());
1555        seq_end(&mut m, n); // even: published
1556        drop(publish);
1557        drop(m);
1558        self.sync()?; // layout must be on disk before a peer maps it
1559        Ok(())
1560    }
1561
1562    // Legacy single-header reads are not validated against peer writes.
1563    // Prefer read_store_snapshot when consistency matters.
1564    fn read_slot(&self, slot: Slot) -> [u8; SNAPSHOT_PREFIX_BYTES] {
1565        let off = slot.offset();
1566        let m = self.mmap.lock();
1567        let mut buf = [0u8; SNAPSHOT_PREFIX_BYTES];
1568        let end = (off + SNAPSHOT_PREFIX_BYTES).min(m.len());
1569        if off < end {
1570            buf[..end - off].copy_from_slice(&m[off..end]);
1571        }
1572        buf
1573    }
1574
1575    /// Publish one page in its own window. For related updates, use
1576    /// `begin_publish` instead. Superblock writes exclude the counter bytes.
1577    pub fn write_slot(&self, slot: Slot, bytes: &[u8; PAGE_SIZE]) {
1578        self.begin_publish().write_slot(slot, bytes);
1579    }
1580
1581    pub fn read_stream(&self) -> StreamHeader {
1582        StreamHeader::from_bytes(&self.read_slot(Slot::Stream))
1583    }
1584    pub fn write_stream(&self, h: &StreamHeader) {
1585        self.write_slot(Slot::Stream, &h.to_bytes());
1586    }
1587
1588    /// Publish the stream and superblock in one exclusive window.
1589    /// Use `begin_publish` to include layer headers in the same window.
1590    #[allow(clippy::too_many_arguments)]
1591    pub fn tick(
1592        &self,
1593        cycle: u64,
1594        surprise: f32,
1595        curiosity: f32,
1596        predicted_hash: u64,
1597        predicted_confidence: f32,
1598        last_query_hash: u64,
1599        last_query_ms: u64,
1600        hebbian_total_energy: f64,
1601    ) {
1602        let epoch_ms = now_ms();
1603        let mut p = self.begin_publish();
1604        p.tick_stream(
1605            cycle,
1606            surprise,
1607            curiosity,
1608            predicted_hash,
1609            predicted_confidence,
1610            last_query_hash,
1611            last_query_ms,
1612            hebbian_total_energy,
1613            epoch_ms,
1614        );
1615        p.commit();
1616    }
1617
1618    /// The stream and superblock copied under one even counter value.
1619    /// Returns `None` after the retry/time budget or for an invalid payload.
1620    pub fn read_snapshot(&self) -> Option<PublishSnapshot> {
1621        let bytes = self.read_prefixes([Slot::Stream])?;
1622        let sb = Superblock::from_payload(&bytes.payload)?;
1623        Some(PublishSnapshot {
1624            stream: StreamHeader::from_bytes(&bytes.headers[0]),
1625            tick_seq: sb.tick_seq,
1626            epoch_ms: sb.epoch_ms,
1627            sequence: bytes.sequence,
1628        })
1629    }
1630
1631    /// All eleven headers and the superblock from one validated copy.
1632    /// This does not imply equal freshness of infrequently updated layers.
1633    pub fn read_store_snapshot(&self) -> Option<StoreSnapshot> {
1634        let bytes = self.read_prefixes([
1635            Slot::Stream,
1636            Slot::Hebbian,
1637            Slot::Attention,
1638            Slot::Emotion,
1639            Slot::Resonance,
1640            Slot::Graph,
1641            Slot::Cache,
1642            Slot::Archetype,
1643            Slot::Mirror,
1644            Slot::Merkle,
1645            Slot::Meta,
1646        ])?;
1647        let sb = Superblock::from_payload(&bytes.payload)?;
1648        Some(StoreSnapshot {
1649            stream: StreamHeader::from_bytes(&bytes.headers[0]),
1650            hebbian: HebbianHeader::from_bytes(&bytes.headers[1]),
1651            attention: AttentionHeader::from_bytes(&bytes.headers[2]),
1652            emotion: EmotionHeader::from_bytes(&bytes.headers[3]),
1653            resonance: ResonanceHeader::from_bytes(&bytes.headers[4]),
1654            graph: GraphHeader::from_bytes(&bytes.headers[5]),
1655            cache: CacheHeader::from_bytes(&bytes.headers[6]),
1656            archetype: ArchetypeHeader::from_bytes(&bytes.headers[7]),
1657            mirror: MirrorHeader::from_bytes(&bytes.headers[8]),
1658            merkle: MerkleHeader::from_bytes(&bytes.headers[9]),
1659            meta: MetaHeader::from_bytes(&bytes.headers[10]),
1660            tick_seq: sb.tick_seq,
1661            epoch_ms: sb.epoch_ms,
1662            sequence: bytes.sequence,
1663        })
1664    }
1665
1666    fn read_prefixes<const N: usize>(&self, slots: [Slot; N]) -> Option<SnapshotBytes<N>> {
1667        let required_len = slots
1668            .iter()
1669            .map(|s| s.offset() + SNAPSHOT_PREFIX_BYTES)
1670            .max()
1671            .unwrap_or(SEQ_SLOT + 8)
1672            .max(SEQ_SLOT + 8);
1673        if self.mmap.lock().len() < required_len {
1674            return None;
1675        }
1676        let mut started = None;
1677        let mut attempt = 0u32;
1678        loop {
1679            if let Some(bytes) = self.read_prefixes_once(&slots) {
1680                return Some(bytes);
1681            }
1682            if self.snapshot_patience(&mut started, attempt) {
1683                break;
1684            }
1685            snapshot_backoff(attempt);
1686            attempt = attempt.saturating_add(1);
1687        }
1688        if self.recover_stalled_publish() {
1689            return self.read_prefixes_once(&slots);
1690        }
1691        None
1692    }
1693
1694    fn read_prefixes_once<const N: usize>(&self, slots: &[Slot; N]) -> Option<SnapshotBytes<N>> {
1695        let mut m = self.mmap.lock();
1696        if m.len() < SEQ_SLOT + 8
1697            || slots
1698                .iter()
1699                .any(|s| s.offset() + SNAPSHOT_PREFIX_BYTES > m.len())
1700        {
1701            return None;
1702        }
1703        let s1 = seq_load(&mut m);
1704        if s1 & 1 == 1 {
1705            return None;
1706        }
1707        let mut bytes = SnapshotBytes {
1708            headers: [[0; SNAPSHOT_PREFIX_BYTES]; N],
1709            payload: [0; SUPERBLOCK_BYTES],
1710            sequence: s1,
1711        };
1712        for (prefix, slot) in bytes.headers.iter_mut().zip(slots) {
1713            let off = slot.offset();
1714            prefix.copy_from_slice(&m[off..off + SNAPSHOT_PREFIX_BYTES]);
1715        }
1716        bytes.payload.copy_from_slice(&m[..SUPERBLOCK_BYTES]);
1717        // Keep payload loads before the final counter check.
1718        std::sync::atomic::fence(Ordering::Acquire);
1719        if seq_load(&mut m) != s1 {
1720            return None;
1721        }
1722        Some(bytes)
1723    }
1724
1725    /// `true` when the read should give up: both the attempt budget and the
1726    /// time budget spent. `started` is only initialised on the first retry, so
1727    /// an uninterrupted read costs no clock reading at all.
1728    fn snapshot_patience(&self, started: &mut Option<std::time::Instant>, attempt: u32) -> bool {
1729        if attempt < SNAPSHOT_MAX_RETRIES {
1730            return false;
1731        }
1732        let t0 = *started.get_or_insert_with(std::time::Instant::now);
1733        t0.elapsed() >= std::time::Duration::from_millis(SNAPSHOT_MAX_WAIT_MS)
1734    }
1735
1736    /// Whether the store's publish window is open: while a writer is inside
1737    /// one, and — the reason this is worth exposing — while a writer that was
1738    /// killed leaves one open. [`Spine::recover_stalled_publish`] clears the
1739    /// second case.
1740    pub fn publish_window_open(&self) -> bool {
1741        self.publish_in_flight()
1742    }
1743
1744    /// Close a publish window whose writer is gone.
1745    ///
1746    /// A `SIGKILL` between the odd store and the even one leaves the counter
1747    /// odd, and every reader would then spend its whole budget on a window that
1748    /// never closes, forever. This is the repair: take the publisher lock
1749    /// (blocking, which is also what proves no live writer is inside a window)
1750    /// and, if the counter is still the odd value found without the lock,
1751    /// close it.
1752    ///
1753    /// Returns `true` only when a window was actually closed. There is no
1754    /// timeout involved, so a writer that is merely slow is never interrupted:
1755    /// `tick()` opens and closes its window with the publisher lock held, and
1756    /// the lock is what makes that argument work.
1757    pub fn recover_stalled_publish(&self) -> bool {
1758        let mut m = self.mmap.lock();
1759        if m.len() < SEQ_SLOT + 8 {
1760            return false;
1761        }
1762        // Cheap check first: the overwhelmingly common case is "nothing to do",
1763        // and it must not cost an flock round trip.
1764        if seq_load(&mut m) & 1 == 0 {
1765            return false;
1766        }
1767        let _publish = self.publish_guard();
1768        let n = seq_load(&mut m);
1769        if n & 1 == 0 {
1770            return false; // a live writer closed it while we waited for the lock
1771        }
1772        if !_publish.is_advisory() {
1773            // Without the publisher lock there is no way to tell a dead writer
1774            // from a live one, and closing the window mid-write would hand a
1775            // reader a half-updated page as if it were a publish.
1776            return false;
1777        }
1778        seq_end(&mut m, n);
1779        true
1780    }
1781
1782    /// Publish counter right now. `0` on a file too short to carry one.
1783    pub fn publish_sequence(&self) -> u64 {
1784        let mut m = self.mmap.lock();
1785        if m.len() < SEQ_SLOT + 8 {
1786            return 0;
1787        }
1788        seq_load(&mut m)
1789    }
1790
1791    /// True while some writer is mid-publish. Readers never have to act on
1792    /// it — `read_snapshot` retries on its own — but the live viewer uses it
1793    /// to explain a stalled `tick_seq` without claiming a torn read.
1794    pub fn publish_in_flight(&self) -> bool {
1795        let mut m = self.mmap.lock();
1796        if m.len() < SEQ_SLOT + 8 {
1797            return false;
1798        }
1799        seq_load(&mut m) & 1 == 1
1800    }
1801
1802    pub fn read_hebbian(&self) -> HebbianHeader {
1803        HebbianHeader::from_bytes(&self.read_slot(Slot::Hebbian))
1804    }
1805    pub fn write_hebbian(&self, h: &HebbianHeader) {
1806        self.write_slot(Slot::Hebbian, &h.to_bytes());
1807    }
1808    pub fn read_attention(&self) -> AttentionHeader {
1809        AttentionHeader::from_bytes(&self.read_slot(Slot::Attention))
1810    }
1811    pub fn write_attention(&self, h: &AttentionHeader) {
1812        self.write_slot(Slot::Attention, &h.to_bytes());
1813    }
1814    pub fn read_emotion(&self) -> EmotionHeader {
1815        EmotionHeader::from_bytes(&self.read_slot(Slot::Emotion))
1816    }
1817    pub fn write_emotion(&self, h: &EmotionHeader) {
1818        self.write_slot(Slot::Emotion, &h.to_bytes());
1819    }
1820    pub fn read_resonance(&self) -> ResonanceHeader {
1821        ResonanceHeader::from_bytes(&self.read_slot(Slot::Resonance))
1822    }
1823    pub fn write_resonance(&self, h: &ResonanceHeader) {
1824        self.write_slot(Slot::Resonance, &h.to_bytes());
1825    }
1826    pub fn read_graph(&self) -> GraphHeader {
1827        GraphHeader::from_bytes(&self.read_slot(Slot::Graph))
1828    }
1829    pub fn write_graph(&self, h: &GraphHeader) {
1830        self.write_slot(Slot::Graph, &h.to_bytes());
1831    }
1832    pub fn read_cache(&self) -> CacheHeader {
1833        CacheHeader::from_bytes(&self.read_slot(Slot::Cache))
1834    }
1835    pub fn write_cache(&self, h: &CacheHeader) {
1836        self.write_slot(Slot::Cache, &h.to_bytes());
1837    }
1838    pub fn read_archetype(&self) -> ArchetypeHeader {
1839        ArchetypeHeader::from_bytes(&self.read_slot(Slot::Archetype))
1840    }
1841    pub fn write_archetype(&self, h: &ArchetypeHeader) {
1842        self.write_slot(Slot::Archetype, &h.to_bytes());
1843    }
1844    pub fn read_mirror(&self) -> MirrorHeader {
1845        MirrorHeader::from_bytes(&self.read_slot(Slot::Mirror))
1846    }
1847    pub fn write_mirror(&self, h: &MirrorHeader) {
1848        self.write_slot(Slot::Mirror, &h.to_bytes());
1849    }
1850    pub fn read_merkle(&self) -> MerkleHeader {
1851        MerkleHeader::from_bytes(&self.read_slot(Slot::Merkle))
1852    }
1853    pub fn write_merkle(&self, h: &MerkleHeader) {
1854        self.write_slot(Slot::Merkle, &h.to_bytes());
1855    }
1856    pub fn read_meta(&self) -> MetaHeader {
1857        MetaHeader::from_bytes(&self.read_slot(Slot::Meta))
1858    }
1859    pub fn write_meta(&self, h: &MetaHeader) {
1860        self.write_slot(Slot::Meta, &h.to_bytes());
1861    }
1862
1863    /// The superblock as stored. Prefer [`Spine::read_snapshot`] when the
1864    /// value has to agree with a stream header.
1865    pub fn read_superblock(&self) -> Option<Superblock> {
1866        let m = self.mmap.lock();
1867        if m.len() < SUPERBLOCK_BYTES {
1868            return None;
1869        }
1870        Superblock::from_payload(&m[0..SUPERBLOCK_BYTES])
1871    }
1872
1873    pub fn sync(&self) -> std::io::Result<()> {
1874        self.mmap.lock().flush()
1875    }
1876    pub fn size_bytes(&self) -> usize {
1877        self.mmap.lock().len()
1878    }
1879    pub fn output_dir(&self) -> &Path {
1880        &self.output_dir
1881    }
1882    pub fn path(&self) -> PathBuf {
1883        self.output_dir.join("spine.bin")
1884    }
1885
1886    /// Grow the file and remap. Refuses unless this handle is a writer and no
1887    /// peer holds the lock exclusively, because remapping invalidates page 0
1888    /// — including the seqlock counter — for every other mapping.
1889    pub fn resize(&self, new_len: usize) -> std::io::Result<()> {
1890        #[cfg(unix)]
1891        use std::os::fd::AsRawFd;
1892        let Some(lock) = self.lock_file.as_ref() else {
1893            return Err(std::io::Error::new(
1894                std::io::ErrorKind::PermissionDenied,
1895                "read-only spine handle cannot resize",
1896            ));
1897        };
1898        #[cfg(unix)]
1899        {
1900            // Drop the shared mark so the exclusive one can be taken at all.
1901            let _ = writer_lock::try_op(lock.as_raw_fd(), writer_lock::UN);
1902            if let Err(e) = Self::acquire_exclusive_until(lock) {
1903                // The mark has to go back on the failure path too: a writer
1904                // handle that quietly lost it would let a peer resize the file
1905                // under this mapping.
1906                let _ = Self::mark_shared(lock);
1907                return Err(e);
1908            }
1909        }
1910        #[cfg(not(unix))]
1911        {
1912            let _ = lock;
1913            return Err(std::io::Error::other(
1914                "resize needs advisory file locks, unavailable on this target",
1915            ));
1916        }
1917
1918        let result = (|| -> std::io::Result<()> {
1919            {
1920                let f = OpenOptions::new()
1921                    .read(true)
1922                    .write(true)
1923                    .truncate(false)
1924                    .open(self.path())?;
1925                f.set_len(new_len as u64)?;
1926            }
1927            let file = OpenOptions::new()
1928                .read(true)
1929                .write(true)
1930                .truncate(false)
1931                .open(self.path())?;
1932            // SAFETY: exclusive lock held; no peer mapping is live mid-resize.
1933            let new_mmap = unsafe {
1934                MmapOptions::new()
1935                    .map_mut(&file)
1936                    .map_err(std::io::Error::other)?
1937            };
1938            *self.mmap.lock() = new_mmap;
1939            Ok(())
1940        })();
1941
1942        #[cfg(unix)]
1943        {
1944            let _ = writer_lock::try_op(lock.as_raw_fd(), writer_lock::UN);
1945            let _ = Self::mark_shared(lock);
1946        }
1947        result
1948    }
1949}
1950
1951/// Read the current superblock payload from a locked mapping, so a tick
1952/// rewrites only what it owns and keeps the WAL coordinates.
1953fn current_superblock(mmap: &MmapMut) -> Superblock {
1954    match Superblock::from_payload(&mmap[0..SUPERBLOCK_BYTES]) {
1955        Some(sb) => sb,
1956        None => Superblock::new(), // as above: `new`, not the zeroed default
1957    }
1958}
1959
1960/// Human-readable dump of the spine.
1961///
1962/// All displayed headers come from one validated store snapshot. Failure is
1963/// reported explicitly rather than falling back to potentially mixed reads.
1964pub fn format_summary(spine: &Spine) -> String {
1965    let Some(snap) = spine.read_store_snapshot() else {
1966        return "Spine: snapshot unavailable (busy, short or invalid mapping)".to_owned();
1967    };
1968    let s = snap.stream;
1969    let tick_seq = snap.tick_seq;
1970    let epoch_ms = snap.epoch_ms;
1971    let h = snap.hebbian;
1972    let a = snap.attention;
1973    let e = snap.emotion;
1974    let r = snap.resonance;
1975    let g = snap.graph;
1976    let c = snap.cache;
1977    let ar = snap.archetype;
1978    let mi = snap.mirror;
1979    let me = snap.merkle;
1980    let meta = snap.meta;
1981    format!(
1982        "Spine: {} pages mapped ({} KB)\n\
1983        Publish:  seq={} epoch_ms={} in_flight={}\n\
1984        Stream:   cycle={} surprise={:.3} curiosity={:.3} predict_conf={:.3}\n\
1985        Hebbian:  {} activations, {} coactivations, delta_tail={}\n\
1986        Attention: {} recalls, quality={:.2}\n\
1987        Emotion:  {} updates, dominant_idx={}\n\
1988        Resonance: {} cells, {} emitted, {} received\n\
1989        Graph:    {} nodes, {} edges, {} patterns\n\
1990        Cache:    {} predictions, hit_rate={:.1}%\n\
1991        Archetype: {} active, {} emerged\n\
1992        Mirror:   {} echoes, strongest={:.3}\n\
1993        Merkle:   height={}, {} blocks\n\
1994        Meta:     {} blocks, {} dream cycles",
1995        spine.size_bytes() / PAGE_SIZE,
1996        spine.size_bytes() / 1024,
1997        tick_seq,
1998        epoch_ms,
1999        spine.publish_in_flight(),
2000        s.cycle,
2001        s.surprise_level,
2002        s.curiosity_level,
2003        s.predicted_confidence,
2004        h.activation_count,
2005        h.coactivation_count,
2006        h.delta_tail_offset,
2007        a.total_recalls,
2008        a.quality_signal,
2009        e.total_updates,
2010        e.current[0],
2011        r.field_cells,
2012        r.pulses_emitted,
2013        r.pulses_received,
2014        g.node_count,
2015        g.edge_count,
2016        g.pattern_count,
2017        c.total_predictions,
2018        c.hit_rate() * 100.0,
2019        ar.active_count,
2020        ar.emergence_events,
2021        mi.echo_count,
2022        mi.strongest_similarity,
2023        me.chain_height,
2024        me.block_count,
2025        meta.block_count,
2026        meta.dream_cycle_count
2027    )
2028}
2029
2030#[cfg(test)]
2031mod tests {
2032    use super::*;
2033    fn temp_spine(tag: &str) -> Spine {
2034        let dir = std::env::temp_dir().join(format!("m1c_spine_{}_{}", tag, std::process::id()));
2035        let _ = std::fs::remove_dir_all(&dir);
2036        Spine::open(&dir).unwrap()
2037    }
2038    #[test]
2039    fn test_read_only_batch_does_not_publish() {
2040        let spine = temp_spine("read_batch");
2041        let before = spine.publish_sequence();
2042        let p = spine.begin_publish();
2043        assert_eq!(p.read_stream().cycle, 0);
2044        assert!(p.read_superblock().is_some());
2045        p.commit();
2046        assert_eq!(spine.publish_sequence(), before);
2047    }
2048
2049    #[test]
2050    fn test_batch_is_one_window_and_excludes_partial_reads() {
2051        let spine = temp_spine("batch_window");
2052        let peer = Spine::open_ro(spine.output_dir()).unwrap();
2053        let before = spine.publish_sequence();
2054        let mut p = spine.begin_publish();
2055        p.tick_stream(9, 0.0, 0.0, !9, 0.0, 0, 0, 9.0, 1234);
2056        // A second mapping sees the intermediate bytes, but its validated
2057        // read must reject them while the batch is still open.
2058        assert_eq!(peer.read_stream().cycle, 9);
2059        assert_eq!(peer.read_graph().node_count, 0);
2060        assert!(peer
2061            .read_prefixes_once(&[Slot::Stream, Slot::Graph])
2062            .is_none());
2063        let mut graph = p.read_graph();
2064        graph.node_count = 9;
2065        p.write_graph(&graph);
2066        let mut emotion = p.read_emotion();
2067        emotion.current[20] = 9.0;
2068        emotion.total_updates = 9;
2069        p.write_emotion(&emotion);
2070        p.commit();
2071        assert_eq!(spine.publish_sequence(), before + 2);
2072        let snap = peer.read_store_snapshot().unwrap();
2073        assert_eq!(snap.stream.cycle, 9);
2074        assert_eq!(snap.graph.node_count, 9);
2075        // Both values lie past byte 64: a stream-sized prefix loses them.
2076        assert_eq!(snap.emotion.current[20], 9.0);
2077        assert_eq!(snap.emotion.total_updates, 9);
2078        assert_eq!(snap.epoch_ms, 1234);
2079        assert_eq!(snap.stream.updated_ms, 1234);
2080    }
2081
2082    #[test]
2083    fn test_drop_and_unwinding_close_the_batch_without_rollback() {
2084        let spine = temp_spine("batch_drop");
2085        let before = spine.publish_sequence();
2086        {
2087            let mut p = spine.begin_publish();
2088            let mut h = p.read_meta();
2089            h.block_count = 7;
2090            p.write_meta(&h);
2091        }
2092        assert_eq!(spine.publish_sequence(), before + 2);
2093        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2094            let mut p = spine.begin_publish();
2095            let mut h = p.read_graph();
2096            h.node_count = 13;
2097            p.write_graph(&h);
2098            panic!("batch interrupted");
2099        }));
2100        assert!(result.is_err());
2101        assert_eq!(spine.publish_sequence(), before + 4);
2102        let snap = spine.read_store_snapshot().unwrap();
2103        assert_eq!(snap.meta.block_count, 7);
2104        assert_eq!(snap.graph.node_count, 13);
2105        spine.tick(2, 0.0, 0.0, 0, 0.0, 0, 0, 0.0);
2106        assert!(!spine.publish_window_open());
2107    }
2108
2109    #[test]
2110    fn test_each_legacy_header_write_opens_a_window() {
2111        let spine = temp_spine("legacy_windows");
2112        let before = spine.publish_sequence();
2113        let h = spine.read_hebbian();
2114        spine.write_hebbian(&h);
2115        let g = spine.read_graph();
2116        spine.write_graph(&g);
2117        let m = spine.read_meta();
2118        spine.write_meta(&m);
2119        assert_eq!(spine.publish_sequence(), before + 6);
2120        assert!(spine.read_store_snapshot().is_some());
2121    }
2122
2123    #[test]
2124    fn test_store_snapshot_rejects_short_and_invalid_mappings() {
2125        let spine = temp_spine("short_snapshot");
2126        // A separate file, so no live peer mapping is truncated underneath it.
2127        let path = spine.output_dir().join("short.bin");
2128        let file = OpenOptions::new()
2129            .read(true)
2130            .write(true)
2131            .create(true)
2132            .truncate(true)
2133            .open(path)
2134            .unwrap();
2135        file.set_len((Slot::Stream.offset() + SNAPSHOT_PREFIX_BYTES) as u64)
2136            .unwrap();
2137        // SAFETY: this private file outlives the mapping, and is never truncated.
2138        let mut short = unsafe { MmapOptions::new().map_mut(&file).unwrap() };
2139        short[..SUPERBLOCK_BYTES].copy_from_slice(&Superblock::new().to_payload());
2140        *spine.mmap.lock() = short;
2141        assert!(spine.read_snapshot().is_some());
2142        assert!(spine.read_store_snapshot().is_none());
2143        assert!(format_summary(&spine).contains("snapshot unavailable"));
2144        spine.mmap.lock()[..SUPERBLOCK_BYTES].fill(0);
2145        assert!(spine.read_snapshot().is_none());
2146    }
2147
2148    #[test]
2149    fn test_republishing_after_a_stall_never_marks_the_window_even() {
2150        let spine = temp_spine("republish_stall");
2151        let mut m = spine.mmap.lock();
2152        let odd = seq_begin(&mut m);
2153        assert_eq!(seq_begin(&mut m), odd);
2154        assert_eq!(seq_load(&mut m), odd);
2155        seq_end(&mut m, odd);
2156        assert_eq!(seq_load(&mut m), odd + 1);
2157    }
2158
2159    #[test]
2160    fn test_open_creates_superblock() {
2161        let spine = temp_spine("sb");
2162        let sb = spine.read_superblock().unwrap();
2163        assert_eq!(sb.magic, MAGIC);
2164        assert_eq!(sb.page_size as usize, PAGE_SIZE);
2165    }
2166    #[test]
2167    fn test_stream_roundtrip() {
2168        let spine = temp_spine("stream");
2169        spine.tick(42, 0.5, 0.7, 0xdeadbeef, 0.9, 0xcafebabe, 12345, 1337.5);
2170        let s = spine.read_stream();
2171        assert_eq!(s.cycle, 42);
2172        assert!((s.surprise_level - 0.5).abs() < 1e-6);
2173        assert_eq!(s.predicted_query_hash, 0xdeadbeef);
2174        assert!((s.hebbian_total_energy - 1337.5).abs() < 1e-6);
2175        assert!(s.updated_ms > 0);
2176    }
2177    #[test]
2178    fn test_layer_headers_roundtrip() {
2179        let spine = temp_spine("layers");
2180        let mut h = spine.read_hebbian();
2181        h.activation_count = 999;
2182        h.delta_tail_offset = 123456789;
2183        h.decay_event_count = 0;
2184        spine.write_hebbian(&h);
2185        assert_eq!(spine.read_hebbian().activation_count, 999);
2186        assert_eq!(spine.read_hebbian().delta_tail_offset, 123456789);
2187        let mut a = spine.read_attention();
2188        a.learned_weights[0] = 0.25;
2189        a.total_recalls = 17;
2190        spine.write_attention(&a);
2191        assert!((spine.read_attention().learned_weights[0] - 0.25).abs() < 1e-6);
2192        assert_eq!(spine.read_attention().total_recalls, 17);
2193        let mut e = spine.read_emotion();
2194        e.current[3] = 0.8;
2195        e.total_updates = 100;
2196        spine.write_emotion(&e);
2197        assert!((spine.read_emotion().current[3] - 0.8).abs() < 1e-6);
2198        let mut g = spine.read_graph();
2199        g.node_count = 500;
2200        spine.write_graph(&g);
2201        assert_eq!(spine.read_graph().node_count, 500);
2202        let mut c = spine.read_cache();
2203        c.total_hits = 10;
2204        c.total_misses = 2;
2205        spine.write_cache(&c);
2206        let rc = spine.read_cache();
2207        assert!((rc.hit_rate() - 10.0 / 12.0).abs() < 1e-5);
2208    }
2209    #[test]
2210    fn test_all_slots_distinct() {
2211        let spine = temp_spine("slots");
2212        spine.tick(1, 1.0, 1.0, 1, 1.0, 1, 1, 1.0);
2213        let mut g = spine.read_graph();
2214        g.node_count = 42;
2215        spine.write_graph(&g);
2216        let mut m = spine.read_mirror();
2217        m.echo_count = 7;
2218        spine.write_mirror(&m);
2219        assert_eq!(spine.read_stream().cycle, 1);
2220        assert_eq!(spine.read_graph().node_count, 42);
2221        assert_eq!(spine.read_mirror().echo_count, 7);
2222        assert_eq!(spine.read_archetype().active_count, 0);
2223    }
2224    #[test]
2225    fn test_reopen_preserves_all_layers() {
2226        let dir = std::env::temp_dir().join(format!("m1c_spine_reopen_{}", std::process::id()));
2227        let _ = std::fs::remove_dir_all(&dir);
2228        {
2229            let spine = Spine::open(&dir).unwrap();
2230            spine.tick(7, 0.3, 0.4, 5, 6.0, 7, 8, 9.0);
2231            let mut a = spine.read_attention();
2232            a.total_recalls = 55;
2233            spine.write_attention(&a);
2234            spine.sync().unwrap();
2235        }
2236        let spine = Spine::open(&dir).unwrap();
2237        assert_eq!(spine.read_stream().cycle, 7);
2238        assert!((spine.read_stream().surprise_level - 0.3).abs() < 1e-6);
2239        assert_eq!(spine.read_attention().total_recalls, 55);
2240    }
2241    #[test]
2242    fn test_superblock_payload_carries_tick_seq() {
2243        let spine = temp_spine("sb_payload");
2244        assert_eq!(spine.read_superblock().unwrap().tick_seq, 0);
2245        spine.tick(1, 0.0, 0.0, 0, 0.0, 0, 0, 0.0);
2246        let after_first = spine.read_superblock().unwrap().tick_seq;
2247        spine.tick(2, 0.0, 0.0, 0, 0.0, 0, 0, 0.0);
2248        let after_second = spine.read_superblock().unwrap().tick_seq;
2249        // A sequence number, not a timestamp: two publishes in the same
2250        // millisecond still get two different values.
2251        assert!(after_first > 0, "first publish did not bump the sequence");
2252        assert_eq!(after_second, after_first + 1);
2253    }
2254
2255    #[test]
2256    fn test_snapshot_pairs_stream_and_superblock() {
2257        let spine = temp_spine("snapshot");
2258        for cycle in 100..110 {
2259            spine.tick(cycle, 0.25, 0.5, cycle, 1.0, cycle, cycle, cycle as f64);
2260            let snap = spine
2261                .read_snapshot()
2262                .expect("seqlock should not starve a same-process reader");
2263            assert_eq!(snap.stream.cycle, cycle);
2264            assert_eq!(snap.epoch_ms, snap.stream.updated_ms as i64);
2265            assert_eq!(
2266                snap.sequence % 2,
2267                0,
2268                "a published snapshot is never taken mid-window"
2269            );
2270        }
2271    }
2272
2273    #[test]
2274    fn test_write_slot_on_superblock_keeps_the_counter() {
2275        let spine = temp_spine("sb_slot");
2276        spine.tick(7, 0.0, 0.0, 0, 0.0, 0, 0, 0.0);
2277        let before = spine.publish_sequence();
2278        assert!(before > 0);
2279        // A page-wide write to slot 0 is truncated to the payload, so it can
2280        // never reset the counter that lives at the tail of that page.
2281        let junk = [0xABu8; PAGE_SIZE];
2282        spine.write_slot(Slot::Superblock, &junk);
2283        let after = spine.publish_sequence();
2284        // Writing slot 0 *is* a publish, so the counter advances by one
2285        // window — what must not happen is a reset, or an odd value left
2286        // behind that would wedge every reader.
2287        assert_eq!(after, before + 2, "counter did not advance as a publish");
2288        assert_eq!(after % 2, 0, "counter left inside a publish window");
2289        // The junk page has no magic, so the superblock stops decoding — the
2290        // point is that the *counter* is intact and even, and that a reader
2291        // sees a complete page (junk) rather than a mix of old and new.
2292        assert!(spine.read_superblock().is_none());
2293        assert!(!spine.publish_in_flight());
2294    }
2295
2296    #[test]
2297    fn test_open_upgrades_a_version_one_superblock() {
2298        let dir = std::env::temp_dir().join(format!("m1c_spine_v1_{}", std::process::id()));
2299        let _ = std::fs::remove_dir_all(&dir);
2300        {
2301            let spine = Spine::open(&dir).unwrap();
2302            // Forge a version-1 payload by hand: magic, version 1, the WAL
2303            // coordinates, and epoch_ms at 24..32 — where tick_seq now lives.
2304            let mut payload = [0u8; SUPERBLOCK_BYTES];
2305            payload[0..8].copy_from_slice(&MAGIC.to_le_bytes());
2306            payload[8..12].copy_from_slice(&1u32.to_le_bytes());
2307            payload[12..16].copy_from_slice(&(PAGE_SIZE as u32).to_le_bytes());
2308            payload[16..20].copy_from_slice(&(Slot::WalStart as u32).to_le_bytes());
2309            payload[20..24].copy_from_slice(&(Slot::WalStart as u32).to_le_bytes());
2310            payload[24..32].copy_from_slice(&1_700_000_000_000i64.to_le_bytes());
2311            let mut page = [0u8; PAGE_SIZE];
2312            page[..SUPERBLOCK_BYTES].copy_from_slice(&payload);
2313            // `write_slot` on slot 0 publishes the first `SUPERBLOCK_BYTES`
2314            // of the page verbatim, which is how a v1 layout gets seeded.
2315            spine.write_slot(Slot::Superblock, &page);
2316        }
2317        // `Spine::open` reads page 0, sees version 1, and republishes it as
2318        // version 2 with the WAL fields carried over.
2319        let spine = Spine::open(&dir).unwrap();
2320        let sb = spine.read_superblock().unwrap();
2321        assert_eq!(sb.version, SUPERBLOCK_VERSION);
2322        assert_eq!(sb.wal_start_page, Slot::WalStart as u32);
2323        assert_eq!(sb.tick_seq, 0);
2324        assert_eq!(sb.epoch_ms, 1_700_000_000_000);
2325    }
2326
2327    #[test]
2328    fn test_read_only_handle_sees_the_writers_publish() {
2329        let dir = std::env::temp_dir().join(format!("m1c_spine_ro_{}", std::process::id()));
2330        let _ = std::fs::remove_dir_all(&dir);
2331        let writer = Spine::open(&dir).unwrap();
2332        let reader = Spine::open_ro(&dir).unwrap();
2333        for cycle in 1..6 {
2334            writer.tick(cycle, 0.0, 0.0, cycle, 0.0, 0, 0, cycle as f64);
2335            let snap = reader
2336                .read_snapshot()
2337                .expect("read-only handle reads published snapshots");
2338            assert_eq!(snap.stream.cycle, cycle);
2339            assert_eq!(snap.stream.predicted_query_hash, cycle);
2340        }
2341        assert!(
2342            reader.resize(1 << 20).is_err(),
2343            "a read-only handle must not remap the file"
2344        );
2345    }
2346
2347    /// The length check inside [`seq_atomic`] is the belt for every `seq_*`
2348    /// caller, so it is tested on its own: a mapping too short for the counter
2349    /// has to panic, not hand out an out-of-bounds `&AtomicU64`.
2350    #[test]
2351    #[should_panic(expected = "too short to carry a counter")]
2352    fn test_counter_access_on_a_short_mapping_panics() {
2353        use std::io::Write;
2354        let dir = std::env::temp_dir().join(format!("m1c_spine_short_{}", std::process::id()));
2355        let _ = std::fs::remove_dir_all(&dir);
2356        std::fs::create_dir_all(&dir).expect("create dir");
2357        let path = dir.join("tiny.bin");
2358        {
2359            let mut f = std::fs::OpenOptions::new()
2360                .read(true)
2361                .write(true)
2362                .create(true)
2363                .truncate(true)
2364                .open(&path)
2365                .expect("create tiny file");
2366            f.write_all(&[0u8; 16]).expect("write");
2367        }
2368        let file = std::fs::OpenOptions::new()
2369            .read(true)
2370            .write(true)
2371            .open(&path)
2372            .expect("open tiny file");
2373        // SAFETY: a private 16-byte mapping of a file this test owns; the
2374        // point is that the helper refuses to read a counter out of it.
2375        let mut mmap = unsafe { MmapOptions::new().map_mut(&file).expect("map") };
2376        let _ = seq_load(&mut mmap);
2377        let _ = std::fs::remove_dir_all(&dir);
2378    }
2379
2380    #[test]
2381    fn test_stalled_publish_window_is_recoverable() {
2382        let spine = temp_spine("stall");
2383        spine.tick(1, 0.0, 0.0, 0, 0.0, 0, 0, 0.0);
2384        // A writer killed between the two counter stores leaves the window open;
2385        // the only thing that can tell that apart from a writer that is merely
2386        // slow is the publisher lock, so that is what recovery takes.
2387        {
2388            let mut m = spine.mmap.lock();
2389            let _n = seq_begin(&mut m);
2390        }
2391        assert!(spine.publish_window_open(), "window should read as open");
2392        let before = spine.read_stream().cycle;
2393        assert!(spine.recover_stalled_publish());
2394        assert!(
2395            !spine.publish_window_open(),
2396            "recovery should close the window"
2397        );
2398        assert!(!spine.recover_stalled_publish(), "nothing to recover twice");
2399        assert_eq!(
2400            spine.read_stream().cycle,
2401            before,
2402            "recovery must not publish"
2403        );
2404        // And a wedged window is what `read_snapshot` itself recovers from,
2405        // not only the explicit call: without that, one killed writer would
2406        // leave every reader returning `None` for the life of the file.
2407        {
2408            let mut m = spine.mmap.lock();
2409            let _n = seq_begin(&mut m);
2410        }
2411        assert!(
2412            spine.read_snapshot().is_some(),
2413            "read_snapshot must heal a stalled window"
2414        );
2415        assert!(!spine.publish_window_open());
2416        spine.tick(2, 0.0, 0.0, 0, 0.0, 0, 0, 0.0);
2417        assert_eq!(spine.read_snapshot().unwrap().stream.cycle, 2);
2418    }
2419
2420    #[test]
2421    fn test_summary_reports_the_publish_record() {
2422        let spine = temp_spine("summary_publish");
2423        spine.tick(11, 0.5, 0.6, 0, 0.7, 0, 0, 100.0);
2424        let out = format_summary(&spine);
2425        assert!(out.contains("Publish:"), "{out}");
2426        let seq = spine.read_snapshot().unwrap().tick_seq;
2427        assert!(
2428            out.contains(&format!("seq={seq}")),
2429            "the summary must print the counter, not a literal: {out}"
2430        );
2431    }
2432
2433    #[test]
2434    fn test_summary_formats() {
2435        let spine = temp_spine("summary");
2436        spine.tick(10, 0.5, 0.6, 0, 0.7, 0, 0, 100.0);
2437        let out = format_summary(&spine);
2438        assert!(out.contains("Spine:"));
2439        assert!(out.contains("cycle=10"));
2440    }
2441}