Skip to main content

qualia_core_db/q42/
q42_volume.rs

1//! Unified `.q42` v3 volume — lexicon, block index, and LZ4-compressed SuperBlocks in one file.
2//!
3//! Layout (all little-endian):
4//! ```text
5//! [0..256)              Q42VolumeHeader
6//! [lex_offset ..]       Q42LEX blob (uncompressed)
7//! [bidx_offset ..]      BIDX blob (uncompressed)
8//! [block_dir_offset ..] block_count × BlockDirectoryEntry (16 bytes each)
9//! [data_offset ..]      concatenated LZ4 block payloads (lz4_flex prepend_size)
10//! ```
11//!
12//! Legacy v1 sidecars (`.q42.lex`, `.q42.bidx`) and separate `.c.q42` transport files
13//! are deprecated; new ingest writes v3 only.
14
15use std::collections::HashMap;
16use std::fs::{File, OpenOptions};
17use std::io::{self, BufWriter, Read, Write};
18use std::path::Path;
19
20use memmap2::{Mmap, MmapOptions};
21
22use crate::q42_lex::{LexError, LexiconEntry, Q42LexMmap, LEX_MAGIC};
23use crate::{NQuin, QUINS_PER_BLOCK};
24
25#[path = "volume/mod.rs"]
26mod volume;
27
28#[cfg(not(target_arch = "wasm32"))]
29pub use volume::{
30    ipfs_gateway_range_source, ipns_gateway_range_source, HttpRangeSource,
31    IpfsGatewaySegmentFactory,
32};
33pub use volume::{
34    root_relative_path, validate_exact_range_response, verify_source_sha256, BidxBlockRange,
35    BidxMatchPage, LocalFileRangeSource, Q42BlockCursor, Q42BlockMeta, Q42ByteRange,
36    Q42LexiconRangeFactory, Q42LexiconSegment, Q42ObjectMatchPage, Q42ObjectSearchCursor,
37    Q42RangeQueryCursor, Q42RangeQueryPage, Q42RangeQueryPattern, Q42RangeQueryPlan,
38    Q42RangeQueryStrategy, Q42RangeSource, Q42RangeVolume, Q42RangeVolumeSet, Q42SegmentMatchPage,
39    Q42SegmentMatchRange, Q42SegmentRangeFactory, Q42VolumeManifest, Q42VolumeSegment,
40    Q42VolumeSet, Q42VolumeSetQueryCursor, Q42VolumeSetQueryPage, StreamingQ42VolumeWriter,
41    VerifiedCarBlock, CidSha256, BlockFieldPostings, Q42InspectReport, Q42SectionReport,
42    Q42Magnet, Q42VolumeSetMagnets, Q42RolloverPublisher, Q42QueryMode, Q42VerifyReceipt,
43    Q42VerifySetReport,
44    VerifyLevel, CheckStatus, VerifyCheck, compose_magnet, sha1_hex_file,
45    classify_q42_path, classify_q42_volume, classify_q42_volume_set, deny_public_publication,
46    quin_requires_sanctuary, ClassificationCounts, PublicationIntent, Q42PublicationClass,
47    Q42PublicationVerdict, Q42Transport,
48    append_segment_to_root, verify_volume_set_from_root, DEFAULT_SEGMENT_MAX_BYTES,
49    RESIDENT_QUERY_MAX_BYTES, decode_and_verify_car, encode_raw_car,
50    extract_entity_bytes, inclusive_entity_bytes, encode_block_postings, encode_postings_section,
51    measure_bloom_false_positives, FIELD_POSTINGS_MAGIC, MAX_VOLUME_MANIFEST_BYTES,
52    compact_volume_set, verify_car_bytes_as_q42_source, verify_local_car_as_q42_source,
53    OpfsCallbackRangeSource, OpfsSliceRangeSource, VerifiedCarRangeSource,
54};
55#[cfg(not(target_arch = "wasm32"))]
56pub use volume::{write_sorted_quins_volume, write_sorted_quins_volume_with_author};
57
58pub const Q42_MAGIC: [u8; 4] = [0x51, 0x34, 0x32, 0x00]; // "Q42\0"
59pub const Q42_VERSION_V3: u16 = 3;
60pub const HEADER_SIZE: usize = 256;
61pub const SUPERBLOCK_SIZE: usize = 40_960;
62/// Conservative caller-buffer bound for an LZ4 `prepend_size` encoding of one
63/// Q42 SuperBlock.  The bound includes its four-byte decoded-size prefix.
64pub const MAX_COMPRESSED_SUPERBLOCK_SIZE: usize = SUPERBLOCK_SIZE + (SUPERBLOCK_SIZE / 255) + 36;
65pub const SUPERBLOCK_HEADER: usize = 160;
66pub const QUIN_SIZE: usize = 48;
67pub const BIDX_MAGIC: [u8; 4] = *b"BIDX";
68pub const FLAG_BLOCKS_LZ4: u16 = 0x0001;
69pub const FLAG_OBJECT_SORTED: u16 = 0x0002;
70pub const FLAG_VOLUME_ROOT: u16 = 0x0004;
71/// A per-SuperBlock subject/predicate/context range index is present in front
72/// matter.  It supplements (but does not replace) the object-sorted BIDX.
73pub const FLAG_FIELD_RANGES: u16 = 0x0008;
74/// Compact per-block S/P/C postings (or measured Bloom) are present.
75pub const FLAG_FIELD_POSTINGS: u16 = 0x0010;
76/// Affirmative Permissive Commons catalog. Required (with a clean Quin scan)
77/// before a public magnet / HTTP web-seed / IPFS pin is emitted.
78pub const FLAG_PERMISSIVE_COMMONS: u16 = 0x0020;
79/// Affirmative Sanctuary / Selfhood volume. Public hash addressing is denied.
80/// Writers also set this when any Quin is restricted, classified, medical,
81/// legal, fiduciary, or bilateral.
82pub const FLAG_SANCTUARY: u16 = 0x0040;
83pub const FIELD_RANGE_INDEX_MAGIC: [u8; 4] = *b"FIDX";
84pub const FIELD_RANGE_INDEX_HEADER_BYTES: usize = 16;
85pub const FIELD_RANGE_INDEX_ENTRY_BYTES: usize = 48;
86
87/// Exhaustive cold-path verification receipt for one physical Q42 segment.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct Q42VerificationReceipt {
90    pub blocks_verified: u64,
91    pub quins_verified: u64,
92}
93
94/// Q42 volume header — 256 bytes, `repr(C, packed)`.
95/// v3 builds hard-reject files with `version < 3` — run `q42 migrate meta` first.
96#[repr(C, packed)]
97#[derive(Clone, Copy, Debug)]
98pub struct Q42VolumeHeader {
99    pub magic: [u8; 4],
100    pub version: u16,
101    pub flags: u16,
102    pub lex_offset: u64,
103    pub lex_length: u64,
104    pub bidx_offset: u64,
105    pub bidx_length: u64,
106    pub block_dir_offset: u64,
107    pub block_dir_length: u64,
108    pub data_offset: u64,
109    pub data_length: u64,
110    pub block_count: u64,
111    pub block_size: u32,
112    pub quins_per_block: u32,
113    // v3 extension fields (carved from former _reserved[0..88]):
114    pub temporal_index_offset: u64,
115    pub temporal_index_length: u64,
116    pub merkle_root: [u8; 32], // SHA3-256 of DAG root; all-zero = no history
117    pub assertion_timestamp: u64, // ms since Unix epoch when volume was last written
118    pub dag_root_offset: u64,  // offset into file of DagNode store; 0 = absent
119    pub dag_root_length: u64,  // byte length of DagNode store section
120
121    // Governance / Identity Bifurcation
122    pub natural_person_did_offset: u64, // Offset to human-reality declarative/consent DAG
123    pub software_agent_did_offset: u64, // Offset to agent-reality logic/policy DAG
124
125    pub _reserved: [u8; 80], // remaining reserved (88 named + 72 v3 ext + 16 gov + 80 = 256 bytes)
126}
127
128const _: () = assert!(
129    std::mem::size_of::<Q42VolumeHeader>() == 256,
130    "Q42VolumeHeader must be exactly 256 bytes — matches HEADER_SIZE constant"
131);
132
133impl Q42VolumeHeader {
134    /// Reject v2 files. Call before any read/write on a mapped header.
135    pub fn verify_version(&self) -> Result<(), String> {
136        // Copy fields out of the packed struct before comparing to avoid unaligned refs.
137        let magic = self.magic;
138        let version = { self.version };
139        if magic != Q42_MAGIC {
140            return Err(format!("bad magic {magic:?}"));
141        }
142        if version != Q42_VERSION_V3 {
143            return Err(format!("Q42 file is version {version}; strict v3 required"));
144        }
145        Ok(())
146    }
147
148    fn volume_manifest_range(&self) -> Option<(u64, u64)> {
149        if self.flags & FLAG_VOLUME_ROOT == 0 {
150            return None;
151        }
152        let reserved = self._reserved;
153        Some((
154            u64::from_le_bytes(reserved[0..8].try_into().unwrap()),
155            u64::from_le_bytes(reserved[8..16].try_into().unwrap()),
156        ))
157    }
158
159    fn field_range_index_range(&self) -> Option<(u64, u64)> {
160        if self.flags & FLAG_FIELD_RANGES == 0 {
161            return None;
162        }
163        let reserved = self._reserved;
164        Some((
165            u64::from_le_bytes(reserved[16..24].try_into().unwrap()),
166            u64::from_le_bytes(reserved[24..32].try_into().unwrap()),
167        ))
168    }
169
170    fn field_postings_range(&self) -> Option<(u64, u64)> {
171        if self.flags & FLAG_FIELD_POSTINGS == 0 {
172            return None;
173        }
174        let reserved = self._reserved;
175        Some((
176            u64::from_le_bytes(reserved[32..40].try_into().unwrap()),
177            u64::from_le_bytes(reserved[40..48].try_into().unwrap()),
178        ))
179    }
180
181    /// Build a minimal valid v3 header with all extension fields zeroed.
182    pub fn new_v3(
183        lex_offset: u64,
184        lex_length: u64,
185        bidx_offset: u64,
186        bidx_length: u64,
187        block_dir_offset: u64,
188        block_dir_length: u64,
189        data_offset: u64,
190        data_length: u64,
191        block_count: u64,
192        block_size: u32,
193        quins_per_block: u32,
194    ) -> Self {
195        let assertion_timestamp = std::time::SystemTime::now()
196            .duration_since(std::time::UNIX_EPOCH)
197            .map(|d| d.as_millis() as u64)
198            .unwrap_or(0);
199        Self {
200            magic: Q42_MAGIC,
201            version: Q42_VERSION_V3,
202            flags: FLAG_BLOCKS_LZ4 | FLAG_OBJECT_SORTED,
203            lex_offset,
204            lex_length,
205            bidx_offset,
206            bidx_length,
207            block_dir_offset,
208            block_dir_length,
209            data_offset,
210            data_length,
211            block_count,
212            block_size,
213            quins_per_block,
214            temporal_index_offset: 0,
215            temporal_index_length: 0,
216            merkle_root: [0u8; 32],
217            assertion_timestamp,
218            dag_root_offset: 0,
219            dag_root_length: 0,
220            natural_person_did_offset: 0,
221            software_agent_did_offset: 0,
222            _reserved: [0u8; 80],
223        }
224    }
225}
226
227#[repr(C, packed)]
228#[derive(Clone, Copy, Debug)]
229pub struct BlockDirectoryEntry {
230    pub rel_offset: u64,
231    pub comp_len: u32,
232    pub uncomp_len: u32,
233}
234
235impl BlockDirectoryEntry {
236    pub const SIZE: usize = 16;
237
238    pub fn write_to(&self, out: &mut impl Write) -> io::Result<()> {
239        out.write_all(&self.rel_offset.to_le_bytes())?;
240        out.write_all(&self.comp_len.to_le_bytes())?;
241        out.write_all(&self.uncomp_len.to_le_bytes())?;
242        Ok(())
243    }
244
245    fn from_bytes(buf: &[u8; 16]) -> Self {
246        Self {
247            rel_offset: u64::from_le_bytes(buf[0..8].try_into().unwrap()),
248            comp_len: u32::from_le_bytes(buf[8..12].try_into().unwrap()),
249            uncomp_len: u32::from_le_bytes(buf[12..16].try_into().unwrap()),
250        }
251    }
252}
253
254/// Upgrade a v1/v2 `.q42` file to v3 by rewriting the version field and zeroing
255/// the new extension fields in the 256-byte header.  Data blocks are untouched.
256pub fn migrate_v2_to_v3(path: &Path) -> io::Result<()> {
257    use std::io::{Seek, SeekFrom};
258    let mut f = OpenOptions::new().read(true).write(true).open(path)?;
259    let mut header = [0u8; HEADER_SIZE];
260    f.read_exact(&mut header)?;
261    if &header[0..4] != &Q42_MAGIC {
262        return Err(io::Error::new(
263            io::ErrorKind::InvalidData,
264            "not a Q42 volume",
265        ));
266    }
267    let version = u16::from_le_bytes([header[4], header[5]]);
268    if version >= Q42_VERSION_V3 as u16 {
269        return Ok(()); // already v3
270    }
271    // Bump version to 3.
272    header[4..6].copy_from_slice(&(Q42_VERSION_V3 as u16).to_le_bytes());
273    // v2 only knew LZ4 + object-sorted. Later flags (root, FIDX, PIDX) must not
274    // survive a header bump with their reserved offsets zeroed.
275    let flags = u16::from_le_bytes([header[6], header[7]]) & (FLAG_BLOCKS_LZ4 | FLAG_OBJECT_SORTED);
276    header[6..8].copy_from_slice(&flags.to_le_bytes());
277    // Zero out the v3 extension fields (bytes 88..256 within the header).
278    header[88..256].fill(0);
279    f.seek(SeekFrom::Start(0))?;
280    f.write_all(&header)?;
281    f.flush()
282}
283
284/// Returns true if `path` begins with a unified volume header.
285pub fn is_unified_volume(path: &Path) -> io::Result<bool> {
286    let mut f = File::open(path)?;
287    let mut magic = [0u8; 4];
288    f.read_exact(&mut magic)?;
289    Ok(magic == Q42_MAGIC)
290}
291
292/// Encode Q42LEX bytes from a hash → string map.
293pub fn encode_lex(lex: &HashMap<u64, String>) -> Result<Vec<u8>, LexError> {
294    // Single source of truth for the Q42LEX write format. `serialize_string_lexicon` is UTF-8 and
295    // truncates over-long literals at a CHARACTER boundary (never mid-codepoint), so multilingual
296    // literals round-trip byte-intact — a plain `b.len().min(65535)` byte cut could split a codepoint
297    // and produce invalid UTF-8 that the reader then drops.
298    crate::q42_lex::serialize_paged_string_lexicon(lex, crate::q42_lex::DEFAULT_LEX_PAGE_ENTRIES)
299}
300
301/// Encode Q42LEX bytes from a hash → LexiconEntry map (supports embedded triples).
302pub fn encode_lex_with_entries(lex: &HashMap<u64, LexiconEntry>) -> Result<Vec<u8>, LexError> {
303    let mut entries: Vec<(u64, &LexiconEntry)> = lex.iter().map(|(&h, e)| (h, e)).collect();
304    entries.sort_unstable_by_key(|&(h, _)| h);
305
306    let entry_count = entries.len() as u64;
307    let strings_offset = 32 + entry_count * 16;
308
309    let mut string_blob: Vec<u8> = Vec::new();
310    let mut index = Vec::with_capacity(entries.len() * 16);
311    for (hash, entry) in &entries {
312        let str_off = string_blob.len() as u64;
313        match entry {
314            LexiconEntry::String(text) => {
315                // Write type tag
316                string_blob.push(0x01);
317                let b = text.as_bytes();
318                let len = u16::try_from(b.len()).map_err(|_| LexError::TermTooLong)?;
319                string_blob.extend_from_slice(&len.to_le_bytes());
320                string_blob.extend_from_slice(&b[..len as usize]);
321            }
322            LexiconEntry::EmbeddedTriple(triple) => {
323                // Write type tag
324                string_blob.push(0x02);
325                for &id in triple {
326                    string_blob.extend_from_slice(&id.to_le_bytes());
327                }
328            }
329            LexiconEntry::Webizen(webid) => {
330                // Write type tag
331                string_blob.push(0x03);
332                let b = webid.as_bytes();
333                let len = u16::try_from(b.len()).map_err(|_| LexError::TermTooLong)?;
334                string_blob.extend_from_slice(&len.to_le_bytes());
335                string_blob.extend_from_slice(&b[..len as usize]);
336            }
337        }
338        index.extend_from_slice(&hash.to_le_bytes());
339        index.extend_from_slice(&str_off.to_le_bytes());
340    }
341
342    let mut out = Vec::with_capacity(strings_offset as usize + string_blob.len());
343    out.extend_from_slice(&LEX_MAGIC);
344    out.extend_from_slice(&entry_count.to_le_bytes());
345    out.extend_from_slice(&strings_offset.to_le_bytes());
346    out.extend_from_slice(&1u64.to_le_bytes());
347    out.extend_from_slice(&index);
348    out.extend_from_slice(&string_blob);
349    Ok(out)
350}
351
352/// Encode BIDX bytes from per-block min/max object hashes.
353pub fn encode_bidx(ranges: &[(u64, u64)]) -> Vec<u8> {
354    let block_count = ranges.len() as u32;
355    let mut out = Vec::with_capacity(16 + ranges.len() * 16);
356    out.extend_from_slice(&BIDX_MAGIC);
357    out.extend_from_slice(&1u32.to_le_bytes());
358    out.extend_from_slice(&block_count.to_le_bytes());
359    out.extend_from_slice(&0u32.to_le_bytes());
360    for (min, max) in ranges {
361        out.extend_from_slice(&min.to_le_bytes());
362        out.extend_from_slice(&max.to_le_bytes());
363    }
364    out
365}
366
367/// Encode conservative per-block ranges for the non-object triple fields.
368/// Every range encloses all values in its block, so a range miss is safe to
369/// skip while a hit still requires normal Quin-level matching.
370pub fn encode_field_range_index(ranges: &[(u64, u64, u64, u64, u64, u64)]) -> Vec<u8> {
371    let mut out = Vec::with_capacity(
372        FIELD_RANGE_INDEX_HEADER_BYTES + ranges.len() * FIELD_RANGE_INDEX_ENTRY_BYTES,
373    );
374    out.extend_from_slice(&FIELD_RANGE_INDEX_MAGIC);
375    out.extend_from_slice(&1u32.to_le_bytes());
376    out.extend_from_slice(&(ranges.len() as u32).to_le_bytes());
377    out.extend_from_slice(&0u32.to_le_bytes());
378    for range in ranges {
379        for value in [range.0, range.1, range.2, range.3, range.4, range.5] {
380            out.extend_from_slice(&value.to_le_bytes());
381        }
382    }
383    out
384}
385
386/// Encode Q42LEX bytes from a hash → LexiconEntry map (supports embedded triples).
387pub fn encode_superblock(seq_id: u64, quins: &[NQuin]) -> [u8; SUPERBLOCK_SIZE] {
388    debug_assert!(quins.len() <= QUINS_PER_BLOCK);
389    let mut block = [0u8; SUPERBLOCK_SIZE];
390    block[0..8].copy_from_slice(&seq_id.to_le_bytes());
391    block[16..24].copy_from_slice(&(quins.len() as u64).to_le_bytes());
392    let zero = [0u8; QUIN_SIZE];
393    let mut off = SUPERBLOCK_HEADER;
394    for q in quins {
395        block[off..off + QUIN_SIZE].copy_from_slice(bytemuck::bytes_of(q));
396        off += QUIN_SIZE;
397    }
398    for _ in quins.len()..QUINS_PER_BLOCK {
399        block[off..off + QUIN_SIZE].copy_from_slice(&zero);
400        off += QUIN_SIZE;
401    }
402    block
403}
404
405/// Live Quins in a decompressed SuperBlock (160-byte header + 48-byte slots).
406pub fn decode_superblock_quins(block: &[u8]) -> io::Result<Vec<NQuin>> {
407    if block.len() < SUPERBLOCK_HEADER {
408        return Err(io::Error::new(
409            io::ErrorKind::InvalidData,
410            "SuperBlock shorter than its 160-byte header",
411        ));
412    }
413    let live = u64::from_le_bytes(block[16..24].try_into().unwrap()) as usize;
414    if live > QUINS_PER_BLOCK {
415        return Err(io::Error::new(
416            io::ErrorKind::InvalidData,
417            "SuperBlock live Quin count exceeds capacity",
418        ));
419    }
420    let mut out = Vec::with_capacity(live);
421    for index in 0..live {
422        let offset = SUPERBLOCK_HEADER + index * QUIN_SIZE;
423        let end = offset + QUIN_SIZE;
424        if end > block.len() {
425            return Err(io::Error::new(
426                io::ErrorKind::InvalidData,
427                "SuperBlock truncated inside a Quin slot",
428            ));
429        }
430        out.push(bytemuck::pod_read_unaligned(&block[offset..end]));
431    }
432    Ok(out)
433}
434
435pub fn header_to_bytes(h: &Q42VolumeHeader) -> [u8; HEADER_SIZE] {
436    let mut buf = [0u8; HEADER_SIZE];
437    // Core fields (0..88)
438    buf[0..4].copy_from_slice(&h.magic);
439    buf[4..6].copy_from_slice(&h.version.to_le_bytes());
440    buf[6..8].copy_from_slice(&h.flags.to_le_bytes());
441    buf[8..16].copy_from_slice(&h.lex_offset.to_le_bytes());
442    buf[16..24].copy_from_slice(&h.lex_length.to_le_bytes());
443    buf[24..32].copy_from_slice(&h.bidx_offset.to_le_bytes());
444    buf[32..40].copy_from_slice(&h.bidx_length.to_le_bytes());
445    buf[40..48].copy_from_slice(&h.block_dir_offset.to_le_bytes());
446    buf[48..56].copy_from_slice(&h.block_dir_length.to_le_bytes());
447    buf[56..64].copy_from_slice(&h.data_offset.to_le_bytes());
448    buf[64..72].copy_from_slice(&h.data_length.to_le_bytes());
449    buf[72..80].copy_from_slice(&h.block_count.to_le_bytes());
450    buf[80..84].copy_from_slice(&h.block_size.to_le_bytes());
451    buf[84..88].copy_from_slice(&h.quins_per_block.to_le_bytes());
452    // v3 extension fields (88..160)
453    buf[88..96].copy_from_slice(&h.temporal_index_offset.to_le_bytes());
454    buf[96..104].copy_from_slice(&h.temporal_index_length.to_le_bytes());
455    buf[104..136].copy_from_slice(&h.merkle_root);
456    buf[136..144].copy_from_slice(&h.assertion_timestamp.to_le_bytes());
457    buf[144..152].copy_from_slice(&h.dag_root_offset.to_le_bytes());
458    buf[152..160].copy_from_slice(&h.dag_root_length.to_le_bytes());
459    buf[160..168].copy_from_slice(&h.natural_person_did_offset.to_le_bytes());
460    buf[168..176].copy_from_slice(&h.software_agent_did_offset.to_le_bytes());
461    buf[176..256].copy_from_slice(&h._reserved);
462    buf
463}
464
465fn header_from_bytes(buf: &[u8; HEADER_SIZE]) -> io::Result<Q42VolumeHeader> {
466    if buf[0..4] != Q42_MAGIC {
467        return Err(io::Error::new(
468            io::ErrorKind::InvalidData,
469            "invalid Q42 magic",
470        ));
471    }
472    let version = u16::from_le_bytes(buf[4..6].try_into().unwrap());
473    if version != Q42_VERSION_V3 {
474        return Err(io::Error::new(
475            io::ErrorKind::InvalidData,
476            format!("Q42 file is version {version}; strict v3 required"),
477        ));
478    }
479    Ok(Q42VolumeHeader {
480        magic: Q42_MAGIC,
481        version,
482        flags: u16::from_le_bytes(buf[6..8].try_into().unwrap()),
483        lex_offset: u64::from_le_bytes(buf[8..16].try_into().unwrap()),
484        lex_length: u64::from_le_bytes(buf[16..24].try_into().unwrap()),
485        bidx_offset: u64::from_le_bytes(buf[24..32].try_into().unwrap()),
486        bidx_length: u64::from_le_bytes(buf[32..40].try_into().unwrap()),
487        block_dir_offset: u64::from_le_bytes(buf[40..48].try_into().unwrap()),
488        block_dir_length: u64::from_le_bytes(buf[48..56].try_into().unwrap()),
489        data_offset: u64::from_le_bytes(buf[56..64].try_into().unwrap()),
490        data_length: u64::from_le_bytes(buf[64..72].try_into().unwrap()),
491        block_count: u64::from_le_bytes(buf[72..80].try_into().unwrap()),
492        block_size: u32::from_le_bytes(buf[80..84].try_into().unwrap()),
493        quins_per_block: u32::from_le_bytes(buf[84..88].try_into().unwrap()),
494        temporal_index_offset: u64::from_le_bytes(buf[88..96].try_into().unwrap()),
495        temporal_index_length: u64::from_le_bytes(buf[96..104].try_into().unwrap()),
496        merkle_root: buf[104..136].try_into().unwrap(),
497        assertion_timestamp: u64::from_le_bytes(buf[136..144].try_into().unwrap()),
498        dag_root_offset: u64::from_le_bytes(buf[144..152].try_into().unwrap()),
499        dag_root_length: u64::from_le_bytes(buf[152..160].try_into().unwrap()),
500        natural_person_did_offset: u64::from_le_bytes(buf[160..168].try_into().unwrap()),
501        software_agent_did_offset: u64::from_le_bytes(buf[168..176].try_into().unwrap()),
502        _reserved: buf[176..256].try_into().unwrap(),
503    })
504}
505
506/// Write a unified v3 `.q42` volume.
507pub fn write_unified_volume(
508    path: &Path,
509    lex: &HashMap<u64, String>,
510    block_ranges: &[(u64, u64)],
511    blocks: &[Vec<NQuin>],
512) -> io::Result<()> {
513    if blocks.len() != block_ranges.len() {
514        return Err(io::Error::new(
515            io::ErrorKind::InvalidInput,
516            "block count mismatch",
517        ));
518    }
519    let mut writer = StreamingQ42VolumeWriter::new(lex)?;
520    for ((seq, quins), declared_range) in blocks.iter().enumerate().zip(block_ranges) {
521        let actual_range = object_range(quins)?;
522        if actual_range != *declared_range {
523            return Err(io::Error::new(
524                io::ErrorKind::InvalidInput,
525                format!(
526                    "declared BIDX range {:?} does not match block {seq} object range {:?}",
527                    declared_range, actual_range
528                ),
529            ));
530        }
531        writer.push_block(seq as u64, quins)?;
532    }
533    writer.finish(path)
534}
535
536/// Write a unified v3 .q42 volume with embedded triple support.
537///
538/// Accepts `HashMap<u64, LexiconEntry>` to support SPARQL-Star embedded triples.
539pub fn write_unified_volume_with_entries(
540    path: &Path,
541    lex: &HashMap<u64, LexiconEntry>,
542    block_ranges: &[(u64, u64)],
543    blocks: &[Vec<NQuin>],
544) -> io::Result<()> {
545    if blocks.len() != block_ranges.len() {
546        return Err(io::Error::new(
547            io::ErrorKind::InvalidInput,
548            "block count mismatch",
549        ));
550    }
551    let mut builder = UnifiedVolumeBuilder::with_lex_entries(lex).map_err(lex_error_to_io)?;
552    for ((seq, quins), declared_range) in blocks.iter().enumerate().zip(block_ranges) {
553        let actual_range = object_range(quins)?;
554        if actual_range != *declared_range {
555            return Err(io::Error::new(
556                io::ErrorKind::InvalidInput,
557                format!(
558                    "declared BIDX range {:?} does not match block {seq} object range {:?}",
559                    declared_range, actual_range
560                ),
561            ));
562        }
563        builder.push_block(seq as u64, quins)?;
564    }
565    builder.finish(path)
566}
567
568/// Publish a root Q42 segment whose front matter catalogs immutable child
569/// segments. The root is intentionally data-empty; query code opens the
570/// catalog snapshot through [`Q42VolumeSet`].
571pub fn write_volume_root(path: &Path, manifest: &Q42VolumeManifest) -> io::Result<()> {
572    UnifiedVolumeBuilder::with_empty_lex()
573        .with_volume_manifest(manifest)?
574        .finish(path)
575}
576
577/// Same as [`write_volume_root`], but marks the catalog as Permissive Commons.
578/// Use for public ontologies / knowledge graphs, never for personal volumes.
579pub fn write_volume_root_for_commons(path: &Path, manifest: &Q42VolumeManifest) -> io::Result<()> {
580    UnifiedVolumeBuilder::with_empty_lex()
581        .with_permissive_commons()
582        .with_volume_manifest(manifest)?
583        .finish(path)
584}
585
586/// Publish a logical-volume root with a lossless shared Q42LEX in its front
587/// matter. Child data segments may keep empty local lexicons because all term
588/// resolution for the snapshot is recoverable from this immutable root.
589pub fn write_volume_root_with_lex(
590    path: &Path,
591    lex: &HashMap<u64, String>,
592    manifest: &Q42VolumeManifest,
593) -> io::Result<()> {
594    UnifiedVolumeBuilder::with_lex_map(lex)
595        .map_err(lex_error_to_io)?
596        .with_volume_manifest(manifest)?
597        .finish(path)
598}
599
600fn lex_error_to_io(error: LexError) -> io::Error {
601    io::Error::new(
602        io::ErrorKind::InvalidInput,
603        format!("invalid Q42LEX: {error:?}"),
604    )
605}
606
607/// Return the object-hash interval for one canonical SuperBlock.
608///
609/// The v3 BIDX is only sound when every block is internally object-sorted and
610/// the blocks themselves form one non-decreasing stream. Rejecting malformed
611/// input here prevents a writer from advertising an index it cannot honour.
612fn object_range(quins: &[NQuin]) -> io::Result<(u64, u64)> {
613    let Some(first) = quins.first() else {
614        return Err(io::Error::new(
615            io::ErrorKind::InvalidInput,
616            "a Q42 SuperBlock must contain at least one Quin",
617        ));
618    };
619    if quins.len() > QUINS_PER_BLOCK {
620        return Err(io::Error::new(
621            io::ErrorKind::InvalidInput,
622            format!(
623                "Q42 SuperBlock contains {} Quins, exceeding capacity {QUINS_PER_BLOCK}",
624                quins.len()
625            ),
626        ));
627    }
628
629    let mut previous = first.object;
630    for quin in &quins[1..] {
631        if quin.object < previous {
632            return Err(io::Error::new(
633                io::ErrorKind::InvalidInput,
634                "Q42 SuperBlock Quins must be sorted by object hash",
635            ));
636        }
637        previous = quin.object;
638    }
639    Ok((first.object, previous))
640}
641
642/// Incremental builder for large external-sort merges (one SuperBlock at a time).
643pub struct UnifiedVolumeBuilder {
644    lex_bytes: Vec<u8>,
645    volume_manifest: Option<Vec<u8>>,
646    block_ranges: Vec<(u64, u64)>,
647    field_ranges: Vec<(u64, u64, u64, u64, u64, u64)>,
648    dir_entries: Vec<BlockDirectoryEntry>,
649    field_postings: Vec<BlockFieldPostings>,
650    data_blob: Vec<u8>,
651    /// Merkle-DAG commit history — populated as each SuperBlock is pushed.
652    dag_store: crate::git_bridge::DagStore,
653    /// DID hash of the agent performing this ingest (0 = system/anonymous).
654    author_did: u64,
655    /// Hash of the last committed DagNode; all-zero until first push.
656    last_dag_hash: [u8; 32],
657    /// Last object hash admitted to the object-sorted stream.
658    last_object_hash: Option<u64>,
659    publication_commons: bool,
660    publication_sanctuary: bool,
661    sanctuary_quin_count: u64,
662}
663
664impl UnifiedVolumeBuilder {
665    pub fn with_lex_map(lex: &HashMap<u64, String>) -> Result<Self, LexError> {
666        Ok(Self {
667            lex_bytes: encode_lex(lex)?,
668            volume_manifest: None,
669            block_ranges: Vec::new(),
670            field_ranges: Vec::new(),
671            dir_entries: Vec::new(),
672            field_postings: Vec::new(),
673            data_blob: Vec::new(),
674            dag_store: crate::git_bridge::DagStore::new(),
675            author_did: 0,
676            last_dag_hash: [0u8; 32],
677            last_object_hash: None,
678            publication_commons: false,
679            publication_sanctuary: false,
680            sanctuary_quin_count: 0,
681        })
682    }
683
684    /// Create a builder with a lexicon that supports embedded triples (LexiconEntry).
685    pub fn with_lex_entries(lex: &HashMap<u64, LexiconEntry>) -> Result<Self, LexError> {
686        Ok(Self {
687            lex_bytes: encode_lex_with_entries(lex)?,
688            volume_manifest: None,
689            block_ranges: Vec::new(),
690            field_ranges: Vec::new(),
691            dir_entries: Vec::new(),
692            field_postings: Vec::new(),
693            data_blob: Vec::new(),
694            dag_store: crate::git_bridge::DagStore::new(),
695            author_did: 0,
696            last_dag_hash: [0u8; 32],
697            last_object_hash: None,
698            publication_commons: false,
699            publication_sanctuary: false,
700            sanctuary_quin_count: 0,
701        })
702    }
703
704    pub fn with_empty_lex() -> Self {
705        Self::with_lex_map(&HashMap::new()).expect("an empty Q42LEX is valid")
706    }
707
708    /// Embed an immutable logical-volume descriptor in this root Q42 file's
709    /// front matter. Child segments remain ordinary self-contained Q42 files.
710    pub fn with_volume_manifest(mut self, manifest: &Q42VolumeManifest) -> io::Result<Self> {
711        self.volume_manifest = Some(manifest.encode()?);
712        Ok(self)
713    }
714
715    /// Set the author DID for DAG commit nodes (optional; defaults to 0 = system).
716    pub fn with_author_did(mut self, did: u64) -> Self {
717        self.author_did = did;
718        self
719    }
720
721    /// Affirm this tiny embedded volume is a Permissive Commons catalog.
722    pub fn with_permissive_commons(mut self) -> Self {
723        self.publication_commons = true;
724        self
725    }
726
727    /// Affirm this volume is Sanctuary / Selfhood.
728    pub fn with_sanctuary(mut self) -> Self {
729        self.publication_sanctuary = true;
730        self
731    }
732
733    pub fn push_block(&mut self, seq_id: u64, quins: &[NQuin]) -> io::Result<()> {
734        let (min_hash, max_hash) = object_range(quins)?;
735        if let Some(previous) = self.last_object_hash {
736            if min_hash < previous {
737                return Err(io::Error::new(
738                    io::ErrorKind::InvalidInput,
739                    format!(
740                        "block {seq_id} starts at object hash {min_hash:#018X}, before prior block maximum {previous:#018X}"
741                    ),
742                ));
743            }
744        }
745        self.block_ranges.push((min_hash, max_hash));
746        let mut subject_min = quins[0].subject;
747        let mut subject_max = quins[0].subject;
748        let mut predicate_min = quins[0].predicate;
749        let mut predicate_max = quins[0].predicate;
750        let mut context_min = quins[0].context;
751        let mut context_max = quins[0].context;
752        for quin in &quins[1..] {
753            subject_min = subject_min.min(quin.subject);
754            subject_max = subject_max.max(quin.subject);
755            predicate_min = predicate_min.min(quin.predicate);
756            predicate_max = predicate_max.max(quin.predicate);
757            context_min = context_min.min(quin.context);
758            context_max = context_max.max(quin.context);
759        }
760        self.sanctuary_quin_count += quins
761            .iter()
762            .filter(|quin| volume::quin_requires_sanctuary(quin))
763            .count() as u64;
764        self.field_ranges.push((
765            subject_min,
766            subject_max,
767            predicate_min,
768            predicate_max,
769            context_min,
770            context_max,
771        ));
772        self.field_postings
773            .push(BlockFieldPostings::from_quins(quins));
774        let raw = encode_superblock(seq_id, quins);
775        let compressed = lz4_flex::compress_prepend_size(&raw);
776        self.dir_entries.push(BlockDirectoryEntry {
777            rel_offset: self.data_blob.len() as u64,
778            comp_len: compressed.len() as u32,
779            uncomp_len: SUPERBLOCK_SIZE as u32,
780        });
781        self.data_blob.extend_from_slice(&compressed);
782
783        // Commit this SuperBlock to the Merkle-DAG.
784        let ts = std::time::SystemTime::now()
785            .duration_since(std::time::UNIX_EPOCH)
786            .map(|d| d.as_millis() as u64)
787            .unwrap_or(0);
788        let msg = format!("ingest block {seq_id}");
789        self.last_dag_hash = if self.last_dag_hash == [0u8; 32] {
790            self.dag_store
791                .genesis_node(quins, self.author_did, ts, &msg)
792        } else {
793            self.dag_store
794                .commit_node(self.last_dag_hash, quins, self.author_did, ts, &msg)
795        };
796        self.last_object_hash = Some(max_hash);
797        Ok(())
798    }
799
800    pub fn block_count(&self) -> u64 {
801        self.block_ranges.len() as u64
802    }
803
804    pub fn finish(self, path: &Path) -> io::Result<()> {
805        let bytes = self.finish_to_bytes();
806        let out = OpenOptions::new()
807            .create(true)
808            .write(true)
809            .truncate(true)
810            .open(path)?;
811        let mut w = BufWriter::new(out);
812        w.write_all(&bytes)?;
813        w.flush()?;
814        Ok(())
815    }
816
817    /// Serialise the unified v3 `.q42` volume to an in-memory byte buffer.
818    ///
819    /// Byte-for-byte the same volume [`finish`](Self::finish) writes to disk, but
820    /// returned as bytes for callers that embed a `.q42` *inside another container*
821    /// — e.g. a [`crate::bundle`] `.hmc` pack — or ship it over the wire without
822    /// touching the filesystem. `finish` is exactly this plus a file write.
823    pub fn finish_to_bytes(self) -> Vec<u8> {
824        let bidx_bytes = encode_bidx(&self.block_ranges);
825        let field_range_bytes = encode_field_range_index(&self.field_ranges);
826        let postings_bytes = encode_postings_section(&self.field_postings);
827        let block_count = self.block_ranges.len() as u64;
828        let manifest_bytes = self.volume_manifest.as_deref().unwrap_or(&[]);
829
830        let lex_offset = HEADER_SIZE as u64;
831        let manifest_offset = lex_offset + self.lex_bytes.len() as u64;
832        let bidx_offset = manifest_offset + manifest_bytes.len() as u64;
833        let field_range_offset = bidx_offset + bidx_bytes.len() as u64;
834        let postings_offset = field_range_offset + field_range_bytes.len() as u64;
835        let block_dir_offset = postings_offset + postings_bytes.len() as u64;
836        let block_dir_length = block_count * BlockDirectoryEntry::SIZE as u64;
837        let data_offset = block_dir_offset + block_dir_length;
838
839        let assertion_timestamp = std::time::SystemTime::now()
840            .duration_since(std::time::UNIX_EPOCH)
841            .map(|d| d.as_millis() as u64)
842            .unwrap_or(0);
843
844        // Serialize the Merkle-DAG and compute layout.
845        let dag_bytes = self.dag_store.serialize();
846        let dag_root_offset = if dag_bytes.is_empty() {
847            0
848        } else {
849            data_offset + self.data_blob.len() as u64
850        };
851        let dag_root_length = dag_bytes.len() as u64;
852
853        // merkle_root = SHA-256 of last committed DagNode hash (all-zero if no blocks).
854        let merkle_root = if self.last_dag_hash == [0u8; 32] {
855            [0u8; 32]
856        } else {
857            // Re-hash the tip hash so the header field is a hash-of-hash, not the raw node hash.
858            use sha2::{Digest, Sha256};
859            let mut h = Sha256::new();
860            h.update(self.last_dag_hash);
861            h.finalize().into()
862        };
863
864        let mut reserved = [0u8; 80];
865        let mut flags = FLAG_BLOCKS_LZ4 | FLAG_OBJECT_SORTED;
866        if self.publication_sanctuary || self.sanctuary_quin_count > 0 {
867            flags |= FLAG_SANCTUARY;
868        } else if self.publication_commons {
869            flags |= FLAG_PERMISSIVE_COMMONS;
870        }
871        if !self.field_ranges.is_empty() {
872            flags |= FLAG_FIELD_RANGES;
873            reserved[16..24].copy_from_slice(&field_range_offset.to_le_bytes());
874            reserved[24..32].copy_from_slice(&(field_range_bytes.len() as u64).to_le_bytes());
875        }
876        if !self.field_postings.is_empty() {
877            flags |= FLAG_FIELD_POSTINGS;
878            reserved[32..40].copy_from_slice(&postings_offset.to_le_bytes());
879            reserved[40..48].copy_from_slice(&(postings_bytes.len() as u64).to_le_bytes());
880        }
881        if !manifest_bytes.is_empty() {
882            flags |= FLAG_VOLUME_ROOT;
883            reserved[0..8].copy_from_slice(&manifest_offset.to_le_bytes());
884            reserved[8..16].copy_from_slice(&(manifest_bytes.len() as u64).to_le_bytes());
885        }
886        let header = Q42VolumeHeader {
887            magic: Q42_MAGIC,
888            version: Q42_VERSION_V3,
889            flags,
890            lex_offset,
891            lex_length: self.lex_bytes.len() as u64,
892            bidx_offset,
893            bidx_length: bidx_bytes.len() as u64,
894            block_dir_offset,
895            block_dir_length,
896            data_offset,
897            data_length: self.data_blob.len() as u64,
898            block_count,
899            block_size: SUPERBLOCK_SIZE as u32,
900            quins_per_block: QUINS_PER_BLOCK as u32,
901            temporal_index_offset: 0,
902            temporal_index_length: 0,
903            merkle_root,
904            assertion_timestamp,
905            dag_root_offset,
906            dag_root_length,
907            natural_person_did_offset: 0,
908            software_agent_did_offset: 0,
909            _reserved: reserved,
910        };
911
912        let mut out = Vec::with_capacity(
913            HEADER_SIZE
914                + self.lex_bytes.len()
915                + manifest_bytes.len()
916                + bidx_bytes.len()
917                + field_range_bytes.len()
918                + postings_bytes.len()
919                + block_dir_length as usize
920                + self.data_blob.len()
921                + dag_bytes.len(),
922        );
923        out.extend_from_slice(&header_to_bytes(&header));
924        out.extend_from_slice(&self.lex_bytes);
925        out.extend_from_slice(manifest_bytes);
926        out.extend_from_slice(&bidx_bytes);
927        out.extend_from_slice(&field_range_bytes);
928        out.extend_from_slice(&postings_bytes);
929        for entry in &self.dir_entries {
930            // Writing to a `Vec<u8>` is infallible.
931            entry.write_to(&mut out).expect("Vec<u8> write cannot fail");
932        }
933        out.extend_from_slice(&self.data_blob);
934        if !dag_bytes.is_empty() {
935            out.extend_from_slice(&dag_bytes);
936        }
937        out
938    }
939}
940
941/// Memory-mapped unified v2 volume reader.
942pub struct Q42Volume {
943    mmap: Mmap,
944    header: Q42VolumeHeader,
945}
946
947impl Q42Volume {
948    pub fn open(path: &Path) -> io::Result<Self> {
949        let file = File::open(path)?;
950        let mmap = unsafe { MmapOptions::new().map(&file)? };
951        if mmap.len() < HEADER_SIZE {
952            return Err(io::Error::new(
953                io::ErrorKind::UnexpectedEof,
954                "file too small for Q42 header",
955            ));
956        }
957        let mut hdr_buf = [0u8; HEADER_SIZE];
958        hdr_buf.copy_from_slice(&mmap[0..HEADER_SIZE]);
959        let header = header_from_bytes(&hdr_buf)?;
960        volume::validate_volume_structure(&header, &mmap)?;
961        Ok(Self { mmap, header })
962    }
963
964    pub fn header(&self) -> &Q42VolumeHeader {
965        &self.header
966    }
967
968    pub fn as_bytes(&self) -> &[u8] {
969        &self.mmap
970    }
971
972    pub fn lex_bytes(&self) -> &[u8] {
973        let start = self.header.lex_offset as usize;
974        let end = start + self.header.lex_length as usize;
975        &self.mmap[start..end]
976    }
977
978    pub fn lex_view(&self) -> Result<Q42LexMmap<'_>, LexError> {
979        Q42LexMmap::from_bytes(self.lex_bytes())
980    }
981
982    pub fn bidx_bytes(&self) -> &[u8] {
983        let start = self.header.bidx_offset as usize;
984        let end = start + self.header.bidx_length as usize;
985        &self.mmap[start..end]
986    }
987
988    /// Return the root descriptor bytes embedded between the lexicon and BIDX.
989    pub fn volume_manifest_bytes(&self) -> io::Result<Option<&[u8]>> {
990        let Some((offset, length)) = self.header.volume_manifest_range() else {
991            return Ok(None);
992        };
993        let length = usize::try_from(length).map_err(|_| {
994            io::Error::new(
995                io::ErrorKind::InvalidData,
996                "Q42 manifest length exceeds platform",
997            )
998        })?;
999        if length == 0 || length > MAX_VOLUME_MANIFEST_BYTES {
1000            return Err(io::Error::new(
1001                io::ErrorKind::InvalidData,
1002                "Q42 root has an invalid embedded volume manifest length",
1003            ));
1004        }
1005        let start = usize::try_from(offset).map_err(|_| {
1006            io::Error::new(
1007                io::ErrorKind::InvalidData,
1008                "Q42 manifest offset exceeds platform",
1009            )
1010        })?;
1011        let end = start.checked_add(length).ok_or_else(|| {
1012            io::Error::new(io::ErrorKind::InvalidData, "Q42 manifest range overflows")
1013        })?;
1014        self.mmap.get(start..end).map(Some).ok_or_else(|| {
1015            io::Error::new(
1016                io::ErrorKind::InvalidData,
1017                "Q42 manifest lies outside the file",
1018            )
1019        })
1020    }
1021
1022    /// Decode the front-embedded descriptor for a logical multi-segment Q42
1023    /// snapshot. A regular v3 Q42 returns `Ok(None)`.
1024    pub fn volume_manifest(&self) -> io::Result<Option<Q42VolumeManifest>> {
1025        self.volume_manifest_bytes()?
1026            .map(Q42VolumeManifest::decode)
1027            .transpose()
1028    }
1029
1030    pub fn block_count(&self) -> u64 {
1031        self.header.block_count
1032    }
1033
1034    /// Decode and verify every block without materialising the graph.  This
1035    /// checks SuperBlock counts, parity, per-block/global object ordering, and
1036    /// the BIDX range advertised for each block. Logical roots have no local
1037    /// records and must be verified through their manifest children.
1038    pub fn verify_all_blocks(&self) -> io::Result<Q42VerificationReceipt> {
1039        if self.volume_manifest()?.is_some() {
1040            return Err(io::Error::new(
1041                io::ErrorKind::InvalidInput,
1042                "verify a Q42 logical root through its manifest child segments",
1043            ));
1044        }
1045        let mut decoded = [0u8; SUPERBLOCK_SIZE];
1046        let mut quins_verified = 0u64;
1047        let mut previous_object = None;
1048        for block_index in 0..self.block_count() as usize {
1049            self.read_superblock_into(block_index, &mut decoded)?;
1050            let live = u64::from_le_bytes(decoded[16..24].try_into().unwrap()) as usize;
1051            if live == 0 || live > QUINS_PER_BLOCK {
1052                return Err(io::Error::new(
1053                    io::ErrorKind::InvalidData,
1054                    "Q42 SuperBlock has invalid live Quin count",
1055                ));
1056            }
1057            let bidx_offset = 16 + block_index * 16;
1058            let bidx = self.bidx_bytes();
1059            let advertised_min =
1060                u64::from_le_bytes(bidx[bidx_offset..bidx_offset + 8].try_into().unwrap());
1061            let advertised_max =
1062                u64::from_le_bytes(bidx[bidx_offset + 8..bidx_offset + 16].try_into().unwrap());
1063            let mut first = None;
1064            let mut last = 0u64;
1065            for quin_index in 0..live {
1066                let offset = SUPERBLOCK_HEADER + quin_index * QUIN_SIZE;
1067                let quin: crate::NQuin =
1068                    bytemuck::pod_read_unaligned(&decoded[offset..offset + QUIN_SIZE]);
1069                if !quin.verify_ecc_parity() {
1070                    return Err(io::Error::new(
1071                        io::ErrorKind::InvalidData,
1072                        format!("Q42 parity mismatch in block {block_index}, Quin {quin_index}"),
1073                    ));
1074                }
1075                if previous_object.is_some_and(|previous| quin.object < previous) {
1076                    return Err(io::Error::new(
1077                        io::ErrorKind::InvalidData,
1078                        format!("Q42 object order regresses in block {block_index}"),
1079                    ));
1080                }
1081                first.get_or_insert(quin.object);
1082                last = quin.object;
1083                previous_object = Some(quin.object);
1084                quins_verified += 1;
1085            }
1086            if first != Some(advertised_min) || last != advertised_max {
1087                return Err(io::Error::new(
1088                    io::ErrorKind::InvalidData,
1089                    format!("Q42 BIDX range disagrees with decoded block {block_index}"),
1090                ));
1091            }
1092        }
1093        Ok(Q42VerificationReceipt {
1094            blocks_verified: self.block_count(),
1095            quins_verified,
1096        })
1097    }
1098
1099    /// Binary-search BIDX for `object_hash`; returns block indices that may contain it.
1100    ///
1101    /// This allocating compatibility helper now returns the complete matching
1102    /// interval. New query paths should use [`Self::bidx_blocks_for_hash_into`]
1103    /// to enumerate a high-frequency object through a caller buffer.
1104    pub fn bidx_blocks_for_hash(&self, object_hash: u64) -> Vec<usize> {
1105        bidx_blocks_for_hash(self.bidx_bytes(), object_hash)
1106    }
1107
1108    /// Return the full contiguous BIDX interval that may contain `object_hash`.
1109    pub fn bidx_block_range_for_hash(
1110        &self,
1111        object_hash: u64,
1112    ) -> io::Result<Option<BidxBlockRange>> {
1113        volume::bidx_block_range_for_hash(self.bidx_bytes(), object_hash)
1114    }
1115
1116    /// Fill `out` with one page of matching BIDX block indices.
1117    ///
1118    /// `cursor` is an offset relative to the returned interval's start. Resume
1119    /// with `next_cursor` until it is `None`; an empty output buffer never
1120    /// causes a silent partial success.
1121    pub fn bidx_blocks_for_hash_into(
1122        &self,
1123        object_hash: u64,
1124        cursor: usize,
1125        out: &mut [usize],
1126    ) -> io::Result<Option<BidxMatchPage>> {
1127        volume::bidx_blocks_for_hash_into(self.bidx_bytes(), object_hash, cursor, out)
1128    }
1129
1130    /// Minimum and maximum object hash advertised by this segment's validated
1131    /// BIDX. Empty root/catalog segments return `None`.
1132    pub fn object_hash_bounds(&self) -> Option<(u64, u64)> {
1133        let count = self.block_count() as usize;
1134        if count == 0 {
1135            return None;
1136        }
1137        let bidx = self.bidx_bytes();
1138        let first = u64::from_le_bytes(bidx[16..24].try_into().ok()?);
1139        let last_offset = 16 + (count - 1) * 16;
1140        let last = u64::from_le_bytes(bidx[last_offset + 8..last_offset + 16].try_into().ok()?);
1141        Some((first, last))
1142    }
1143
1144    pub fn block_directory_entry(&self, index: usize) -> io::Result<BlockDirectoryEntry> {
1145        if index >= self.header.block_count as usize {
1146            return Err(io::Error::new(
1147                io::ErrorKind::InvalidInput,
1148                "block index out of range",
1149            ));
1150        }
1151        let start = self.header.block_dir_offset as usize + index * BlockDirectoryEntry::SIZE;
1152        let end = start + BlockDirectoryEntry::SIZE;
1153        let mut buf = [0u8; 16];
1154        buf.copy_from_slice(&self.mmap[start..end]);
1155        Ok(BlockDirectoryEntry::from_bytes(&buf))
1156    }
1157
1158    /// Decompress SuperBlock `index` into `out` (must be >= 40960 bytes).
1159    pub fn read_superblock_into(&self, index: usize, out: &mut [u8]) -> io::Result<usize> {
1160        if out.len() < SUPERBLOCK_SIZE {
1161            return Err(io::Error::new(
1162                io::ErrorKind::InvalidInput,
1163                "output buffer too small",
1164            ));
1165        }
1166        let entry = self.block_directory_entry(index)?;
1167        let data_offset = usize::try_from(self.header.data_offset).map_err(|_| {
1168            io::Error::new(
1169                io::ErrorKind::InvalidData,
1170                "Q42 data offset does not fit this platform",
1171            )
1172        })?;
1173        let rel_offset = usize::try_from(entry.rel_offset).map_err(|_| {
1174            io::Error::new(
1175                io::ErrorKind::InvalidData,
1176                "Q42 block offset does not fit this platform",
1177            )
1178        })?;
1179        let start = data_offset.checked_add(rel_offset).ok_or_else(|| {
1180            io::Error::new(
1181                io::ErrorKind::InvalidData,
1182                "Q42 block offset overflows usize",
1183            )
1184        })?;
1185        let end = start.checked_add(entry.comp_len as usize).ok_or_else(|| {
1186            io::Error::new(
1187                io::ErrorKind::InvalidData,
1188                "Q42 block length overflows usize",
1189            )
1190        })?;
1191        let compressed = &self.mmap[start..end];
1192        if compressed.len() < 4 {
1193            return Err(io::Error::new(
1194                io::ErrorKind::InvalidData,
1195                "Q42 LZ4 block is shorter than its size prefix",
1196            ));
1197        }
1198        let declared = u32::from_le_bytes(compressed[0..4].try_into().unwrap()) as usize;
1199        if declared != entry.uncomp_len as usize || declared != SUPERBLOCK_SIZE {
1200            return Err(io::Error::new(
1201                io::ErrorKind::InvalidData,
1202                "Q42 LZ4 size prefix disagrees with the block directory",
1203            ));
1204        }
1205        let decoded =
1206            lz4_flex::decompress_into(&compressed[4..], &mut out[..declared]).map_err(|e| {
1207                io::Error::new(
1208                    io::ErrorKind::InvalidData,
1209                    format!("LZ4 decompress block {index}: {e}"),
1210                )
1211            })?;
1212        if decoded != declared {
1213            return Err(io::Error::new(
1214                io::ErrorKind::InvalidData,
1215                "Q42 LZ4 block decoded to an unexpected length",
1216            ));
1217        }
1218        Ok(decoded)
1219    }
1220
1221    /// Read every live Quin from this volume's SuperBlocks into a heap `Vec`.
1222    ///
1223    /// Cold path (CLI / daemon vault load). A `.q42` is **not** a flat array of
1224    /// 48-byte records — each SuperBlock is LZ4-compressed and carries a 160-byte
1225    /// header whose bytes `[16..24]` hold the block's live quin count. Callers that
1226    /// `bytemuck::cast_slice` the raw file will panic (`OutputSliceWouldHaveSlop`)
1227    /// because the file length is not a multiple of `QUIN_SIZE`. Use this instead.
1228    pub fn read_all_quins(&self) -> io::Result<Vec<crate::NQuin>> {
1229        if self.volume_manifest()?.is_some() {
1230            return Err(io::Error::new(
1231                io::ErrorKind::InvalidInput,
1232                "Q42 logical root has no local graph blocks; open Q42VolumeSet or use read_q42_quins",
1233            ));
1234        }
1235        let rd = |b: &[u8], o: usize| u64::from_le_bytes(b[o..o + 8].try_into().unwrap());
1236        let mut out = Vec::new();
1237        let mut sb = vec![0u8; SUPERBLOCK_SIZE];
1238        for i in 0..self.block_count() as usize {
1239            self.read_superblock_into(i, &mut sb)?;
1240            let quin_count = rd(&sb, 16) as usize;
1241            for k in 0..quin_count {
1242                let o = SUPERBLOCK_HEADER + k * QUIN_SIZE;
1243                if o + QUIN_SIZE > sb.len() {
1244                    break;
1245                }
1246                out.push(crate::NQuin {
1247                    subject: rd(&sb, o),
1248                    predicate: rd(&sb, o + 8),
1249                    object: rd(&sb, o + 16),
1250                    context: rd(&sb, o + 24),
1251                    metadata: rd(&sb, o + 32),
1252                    parity: rd(&sb, o + 40),
1253                });
1254            }
1255        }
1256        Ok(out)
1257    }
1258}
1259
1260/// BIDX binary search (shared with sidecar format).
1261pub fn bidx_blocks_for_hash(bidx: &[u8], object_hash: u64) -> Vec<usize> {
1262    match volume::bidx_block_range_for_hash(bidx, object_hash) {
1263        Ok(Some(range)) => (range.start..range.end).collect(),
1264        Ok(None) | Err(_) => Vec::new(),
1265    }
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271    use crate::mini_parser::hash_token;
1272    use tempfile::{NamedTempFile, TempDir};
1273
1274    fn sample_quin(subj: &str, pred: &str, obj: &str) -> (NQuin, HashMap<u64, String>) {
1275        let mut lex = HashMap::new();
1276        let sh = hash_token(subj);
1277        let ph = hash_token(pred);
1278        let oh = hash_token(obj);
1279        lex.insert(sh, subj.to_string());
1280        lex.insert(ph, pred.to_string());
1281        lex.insert(oh, obj.to_string());
1282        let q = NQuin {
1283            subject: sh,
1284            predicate: ph,
1285            object: oh,
1286            context: 0,
1287            metadata: 0,
1288            parity: 0,
1289        };
1290        (q, lex)
1291    }
1292
1293    #[test]
1294    fn unified_volume_roundtrip() {
1295        let (q1, mut lex) = sample_quin("Patient", "fever", "True");
1296        let (q2, lex2) = sample_quin("Patient", "has", "pain");
1297        lex.extend(lex2);
1298
1299        let mut blocks = vec![vec![q1], vec![q2]];
1300        blocks.sort_by_key(|chunk| chunk[0].object);
1301
1302        let tmp = NamedTempFile::new().unwrap();
1303        let ranges: Vec<_> = blocks
1304            .iter()
1305            .map(|chunk| {
1306                let h = chunk[0].object;
1307                (h, h)
1308            })
1309            .collect();
1310        write_unified_volume(tmp.path(), &lex, &ranges, &blocks).unwrap();
1311
1312        let vol = Q42Volume::open(tmp.path()).unwrap();
1313        assert_eq!(vol.block_count(), 2);
1314        assert!(vol.lex_view().unwrap().lookup_hash(q1.object).is_some());
1315
1316        let hits = vol.bidx_blocks_for_hash(q1.object);
1317        assert!(!hits.is_empty(), "bidx miss for object hash {}", q1.object);
1318
1319        let mut block = [0u8; SUPERBLOCK_SIZE];
1320        vol.read_superblock_into(hits[0], &mut block).unwrap();
1321        let active = u64::from_le_bytes(block[16..24].try_into().unwrap());
1322        assert_eq!(active, 1);
1323    }
1324
1325    #[test]
1326    fn exhaustive_verifier_checks_decoded_quins_against_bidx() {
1327        let tmp = NamedTempFile::new().unwrap();
1328        let mut quin = NQuin {
1329            subject: 11,
1330            predicate: 22,
1331            object: 33,
1332            context: 44,
1333            metadata: 55,
1334            parity: 0,
1335        };
1336        quin.parity = NQuin::calculate_parity(
1337            quin.subject,
1338            quin.predicate,
1339            quin.object,
1340            quin.context,
1341            quin.metadata,
1342        );
1343        write_unified_volume(
1344            tmp.path(),
1345            &HashMap::new(),
1346            &[(quin.object, quin.object)],
1347            &[vec![quin]],
1348        )
1349        .unwrap();
1350        let volume = Q42Volume::open(tmp.path()).unwrap();
1351        assert_eq!(
1352            volume.verify_all_blocks().unwrap(),
1353            Q42VerificationReceipt {
1354                blocks_verified: 1,
1355                quins_verified: 1,
1356            }
1357        );
1358    }
1359
1360    #[test]
1361    fn logical_root_verifier_checks_all_manifest_children() {
1362        let dir = tempfile::TempDir::new().unwrap();
1363        let child = dir.path().join("child.q42");
1364        let root = dir.path().join("root.q42");
1365        let mut quin = NQuin {
1366            subject: 101,
1367            predicate: 202,
1368            object: 303,
1369            context: 404,
1370            metadata: 0,
1371            parity: 0,
1372        };
1373        quin.recalculate_parity();
1374        write_unified_volume(
1375            &child,
1376            &HashMap::new(),
1377            &[(quin.object, quin.object)],
1378            &[vec![quin]],
1379        )
1380        .unwrap();
1381        let manifest = Q42VolumeManifest {
1382            generation: 1,
1383            segments: vec![
1384                Q42VolumeManifest::segment_from_file(&child, "child.q42".to_string()).unwrap(),
1385            ],
1386            lexicon_segments: Vec::new(),
1387        };
1388        write_volume_root(&root, &manifest).unwrap();
1389        let set = Q42VolumeSet::open_root(&root).unwrap();
1390        assert_eq!(
1391            set.verify_all(&root).unwrap(),
1392            Q42VerificationReceipt {
1393                blocks_verified: 1,
1394                quins_verified: 1,
1395            }
1396        );
1397    }
1398
1399    #[test]
1400    fn bidx_heavy_hitter_is_complete_and_caller_buffered() {
1401        let (q, lex) = sample_quin("shared", "has", "high-frequency-object");
1402        let blocks = vec![vec![q]; 5];
1403        let ranges = vec![(q.object, q.object); 5];
1404        let tmp = NamedTempFile::new().unwrap();
1405        write_unified_volume(tmp.path(), &lex, &ranges, &blocks).unwrap();
1406
1407        let vol = Q42Volume::open(tmp.path()).unwrap();
1408        assert_eq!(vol.bidx_blocks_for_hash(q.object), vec![0, 1, 2, 3, 4]);
1409
1410        let mut page = [usize::MAX; 2];
1411        let first = vol
1412            .bidx_blocks_for_hash_into(q.object, 0, &mut page)
1413            .unwrap()
1414            .unwrap();
1415        assert_eq!(first.range, BidxBlockRange { start: 0, end: 5 });
1416        assert_eq!(first.returned, 2);
1417        assert_eq!(&page, &[0, 1]);
1418        assert_eq!(first.next_cursor, Some(2));
1419
1420        let second = vol
1421            .bidx_blocks_for_hash_into(q.object, first.next_cursor.unwrap(), &mut page)
1422            .unwrap()
1423            .unwrap();
1424        assert_eq!(&page, &[2, 3]);
1425        assert_eq!(second.next_cursor, Some(4));
1426
1427        let third = vol
1428            .bidx_blocks_for_hash_into(q.object, second.next_cursor.unwrap(), &mut page)
1429            .unwrap()
1430            .unwrap();
1431        assert_eq!(third.returned, 1);
1432        assert_eq!(page[0], 4);
1433        assert_eq!(third.next_cursor, None);
1434        assert!(vol.bidx_blocks_for_hash_into(q.object, 0, &mut []).is_err());
1435    }
1436
1437    #[test]
1438    fn embedded_root_manifest_opens_immutable_child_segments() {
1439        let dir = TempDir::new().unwrap();
1440        let root = dir.path().join("root.q42");
1441        let first_path = dir.path().join("segment-000.q42");
1442        let second_path = dir.path().join("segment-001.q42");
1443        let (first_q, first_lex) = sample_quin("s-1", "p", "o-1");
1444        let (second_q, second_lex) = sample_quin("s-2", "p", "o-2");
1445        let mut inputs = vec![(first_q, first_lex), (second_q, second_lex)];
1446        inputs.sort_unstable_by_key(|(quin, _)| quin.object);
1447        write_unified_volume(
1448            &first_path,
1449            &inputs[0].1,
1450            &[(inputs[0].0.object, inputs[0].0.object)],
1451            &[vec![inputs[0].0]],
1452        )
1453        .unwrap();
1454        write_unified_volume(
1455            &second_path,
1456            &inputs[1].1,
1457            &[(inputs[1].0.object, inputs[1].0.object)],
1458            &[vec![inputs[1].0]],
1459        )
1460        .unwrap();
1461        let manifest = Q42VolumeManifest {
1462            generation: 7,
1463            segments: vec![
1464                Q42VolumeManifest::segment_from_file(&first_path, "segment-000.q42".into())
1465                    .unwrap(),
1466                Q42VolumeManifest::segment_from_file(&second_path, "segment-001.q42".into())
1467                    .unwrap(),
1468            ],
1469            lexicon_segments: vec![],
1470        };
1471        write_volume_root(&root, &manifest).unwrap();
1472
1473        let root_volume = Q42Volume::open(&root).unwrap();
1474        assert_eq!(root_volume.block_count(), 0);
1475        assert_eq!(
1476            root_volume.volume_manifest().unwrap(),
1477            Some(manifest.clone())
1478        );
1479        let set = Q42VolumeSet::open_root(&root).unwrap();
1480        assert_eq!(set.manifest().generation, 7);
1481        assert_eq!(set.segments().len(), 2);
1482        set.verify_segment_hashes(&root).unwrap();
1483    }
1484
1485    #[test]
1486    fn open_rejects_mismatched_directory_length() {
1487        let (q, lex) = sample_quin("s", "p", "o");
1488        let tmp = NamedTempFile::new().unwrap();
1489        write_unified_volume(tmp.path(), &lex, &[(q.object, q.object)], &[vec![q]]).unwrap();
1490        let mut bytes = std::fs::read(tmp.path()).unwrap();
1491        bytes[48..56].copy_from_slice(&0u64.to_le_bytes());
1492        std::fs::write(tmp.path(), bytes).unwrap();
1493        assert!(Q42Volume::open(tmp.path()).is_err());
1494    }
1495
1496    #[test]
1497    fn decompression_rejects_directory_size_disagreement() {
1498        let (q, lex) = sample_quin("s", "p", "o");
1499        let tmp = NamedTempFile::new().unwrap();
1500        write_unified_volume(tmp.path(), &lex, &[(q.object, q.object)], &[vec![q]]).unwrap();
1501        let mut bytes = std::fs::read(tmp.path()).unwrap();
1502        let data_offset = u64::from_le_bytes(bytes[56..64].try_into().unwrap()) as usize;
1503        bytes[data_offset..data_offset + 4].copy_from_slice(&1u32.to_le_bytes());
1504        std::fs::write(tmp.path(), bytes).unwrap();
1505        let vol = Q42Volume::open(tmp.path()).unwrap();
1506        let mut out = [0u8; SUPERBLOCK_SIZE];
1507        assert!(vol.read_superblock_into(0, &mut out).is_err());
1508    }
1509
1510    /// Regression: `read_all_quins` must reconstruct every stored Quin across
1511    /// SuperBlocks. A `.q42` is not a flat 48-byte array — the CLI query path used
1512    /// to `bytemuck::cast_slice` the raw file and panic (`OutputSliceWouldHaveSlop`)
1513    /// because the file length is not a multiple of `QUIN_SIZE`.
1514    #[test]
1515    fn read_all_quins_roundtrip() {
1516        let (q1, mut lex) = sample_quin("Alice", "rdf:type", "values:NaturalPerson");
1517        let (q2, lex2) = sample_quin("Bob", "values:claims", "values:Right");
1518        let (q3, lex3) = sample_quin("AcmeCorp", "rdf:type", "values:CorporatePerson");
1519        lex.extend(lex2);
1520        lex.extend(lex3);
1521
1522        let mut blocks = vec![vec![q1], vec![q2], vec![q3]];
1523        blocks.sort_by_key(|chunk| chunk[0].object);
1524        let ranges: Vec<_> = blocks.iter().map(|c| (c[0].object, c[0].object)).collect();
1525
1526        let tmp = NamedTempFile::new().unwrap();
1527        write_unified_volume(tmp.path(), &lex, &ranges, &blocks).unwrap();
1528
1529        let vol = Q42Volume::open(tmp.path()).unwrap();
1530        let quins = vol.read_all_quins().unwrap();
1531        assert_eq!(
1532            quins.len(),
1533            3,
1534            "read_all_quins must return every stored quin"
1535        );
1536        for q in [q1, q2, q3] {
1537            assert!(
1538                quins.iter().any(|r| r.subject == q.subject
1539                    && r.predicate == q.predicate
1540                    && r.object == q.object),
1541                "read_all_quins dropped a quin: {q:?}"
1542            );
1543        }
1544    }
1545
1546    /// The streaming ingest path (`turtle_doc` → `ExternalSorter` → `merge`) must embed a
1547    /// populated Q42LEX section at the FRONT of the `.q42` — no separate `.lex` sidecar —
1548    /// so literal text and IRIs are recoverable from the volume alone.
1549    #[test]
1550    fn streaming_ingest_embeds_recoverable_lex() {
1551        use crate::external_sort::ExternalSorter;
1552        use crate::lexicon::generate_60bit_token;
1553        use crate::sparql_library::parsers::turtle_doc::parse_turtle_doc_into;
1554
1555        let doc = r#"
1556@prefix dc:     <http://purl.org/dc/terms/> .
1557@prefix values: <https://ns.webcivics.net/values/> .
1558@prefix doc:    <https://ns.webcivics.net/values/inst#> .
1559doc:article-1 a values:Undertaking ;
1560    dc:title "Article 1" ;
1561    values:originalText "Each Member undertakes to suppress forced labour." .
1562"#;
1563        let tmp_dir = std::env::temp_dir().join(format!("q42_lex_rt_{}", std::process::id()));
1564        let mut sorter = ExternalSorter::new(tmp_dir);
1565        parse_turtle_doc_into(doc.as_bytes(), 0, &mut sorter).unwrap();
1566        let out = NamedTempFile::new().unwrap();
1567        sorter.merge(out.path()).unwrap();
1568
1569        let vol = Q42Volume::open(out.path()).unwrap();
1570        let lex = vol
1571            .lex_view()
1572            .expect("volume must carry an embedded front-of-file Q42LEX section");
1573
1574        // The verbatim literal is recoverable from the .q42 alone — the CML prerequisite.
1575        let lit = "Each Member undertakes to suppress forced labour.";
1576        assert_eq!(
1577            lex.lookup_hash(generate_60bit_token(lit.as_bytes())),
1578            Some(lit)
1579        );
1580        // Expanded IRIs are recoverable too (queries become human-readable).
1581        let undertaking = "https://ns.webcivics.net/values/Undertaking";
1582        assert_eq!(
1583            lex.lookup_hash(generate_60bit_token(undertaking.as_bytes())),
1584            Some(undertaking)
1585        );
1586    }
1587
1588    /// `finish_to_bytes` must yield the same recoverable volume as `finish` writes to disk —
1589    /// the in-memory path used to embed a `.q42` inside a `.hmc` bundle.
1590    #[test]
1591    fn finish_to_bytes_matches_a_readable_on_disk_volume() {
1592        let (q1, mut lex) = sample_quin("Heart", "geo:bodySystem", "circulatory");
1593        let (q2, lex2) = sample_quin("Lung", "geo:bodySystem", "respiratory");
1594        lex.extend(lex2);
1595        let mut blocks = vec![vec![q1], vec![q2]];
1596        blocks.sort_by_key(|c| c[0].object);
1597
1598        let mut builder = UnifiedVolumeBuilder::with_lex_map(&lex).unwrap();
1599        for (seq, chunk) in blocks.iter().enumerate() {
1600            builder.push_block(seq as u64, chunk).unwrap();
1601        }
1602        let bytes = builder.finish_to_bytes();
1603
1604        assert!(bytes.starts_with(&Q42_MAGIC), "bytes are a Q42 volume");
1605        // The bytes open as a valid unified volume with every fact recoverable.
1606        let tmp = NamedTempFile::new().unwrap();
1607        std::fs::write(tmp.path(), &bytes).unwrap();
1608        let vol = Q42Volume::open(tmp.path()).unwrap();
1609        assert_eq!(vol.block_count(), 2);
1610        let quins = vol.read_all_quins().unwrap();
1611        assert_eq!(quins.len(), 2);
1612        let lexv = vol.lex_view().unwrap();
1613        let vals: Vec<&str> = quins
1614            .iter()
1615            .filter_map(|q| lexv.lookup_hash(q.object))
1616            .collect();
1617        assert!(vals.contains(&"circulatory") && vals.contains(&"respiratory"));
1618    }
1619
1620    #[test]
1621    fn v2_magic_detected() {
1622        let (q, lex) = sample_quin("a", "b", "c");
1623        let tmp = NamedTempFile::new().unwrap();
1624        write_unified_volume(tmp.path(), &lex, &[(q.object, q.object)], &[vec![q]]).unwrap();
1625        assert!(is_unified_volume(tmp.path()).unwrap());
1626    }
1627}
1628
1629/// Streaming append-only interface for Q42 Unified Volumes.
1630/// Allows continuous block accumulation without loading the entire volume in memory.
1631pub struct StreamingVolumeAppender {
1632    file: std::fs::File,
1633    header: Q42VolumeHeader,
1634    block_ranges: Vec<(u64, u64)>,
1635    dir_entries: Vec<BlockDirectoryEntry>,
1636    dag_store: crate::git_bridge::DagStore,
1637    author_did: u64,
1638    last_dag_hash: [u8; 32],
1639    last_object_hash: Option<u64>,
1640}
1641
1642impl StreamingVolumeAppender {
1643    pub fn new(path: &std::path::Path) -> std::io::Result<Self> {
1644        let mut file = std::fs::OpenOptions::new()
1645            .create(true)
1646            .read(true)
1647            .write(true)
1648            .open(path)?;
1649
1650        let header = Q42VolumeHeader {
1651            magic: Q42_MAGIC,
1652            version: Q42_VERSION_V3,
1653            flags: FLAG_BLOCKS_LZ4 | FLAG_OBJECT_SORTED,
1654            lex_offset: HEADER_SIZE as u64,
1655            lex_length: 0,
1656            bidx_offset: HEADER_SIZE as u64,
1657            bidx_length: 0,
1658            block_dir_offset: HEADER_SIZE as u64,
1659            block_dir_length: 0,
1660            data_offset: HEADER_SIZE as u64,
1661            data_length: 0,
1662            block_count: 0,
1663            block_size: SUPERBLOCK_SIZE as u32,
1664            quins_per_block: QUINS_PER_BLOCK as u32,
1665            temporal_index_offset: 0,
1666            temporal_index_length: 0,
1667            merkle_root: [0; 32],
1668            assertion_timestamp: 0,
1669            dag_root_offset: 0,
1670            dag_root_length: 0,
1671            natural_person_did_offset: 0,
1672            software_agent_did_offset: 0,
1673            _reserved: [0; 80],
1674        };
1675
1676        if file.metadata()?.len() != 0 {
1677            return Err(io::Error::new(
1678                io::ErrorKind::Unsupported,
1679                "StreamingVolumeAppender cannot reopen a Q42 volume; publish a new immutable segment",
1680            ));
1681        }
1682        {
1683            use std::io::{Seek, SeekFrom, Write};
1684            file.seek(SeekFrom::Start(0))?;
1685            file.write_all(&header_to_bytes(&header))?;
1686        }
1687
1688        Ok(Self {
1689            file,
1690            header,
1691            block_ranges: Vec::new(),
1692            dir_entries: Vec::new(),
1693            dag_store: crate::git_bridge::DagStore::new(),
1694            author_did: 0,
1695            last_dag_hash: [0u8; 32],
1696            last_object_hash: None,
1697        })
1698    }
1699
1700    pub fn with_author_did(mut self, did: u64) -> Self {
1701        self.author_did = did;
1702        self
1703    }
1704
1705    pub fn append_block(&mut self, seq_id: u64, quins: &[NQuin]) -> std::io::Result<()> {
1706        let (min_hash, max_hash) = object_range(quins)?;
1707        if let Some(previous) = self.last_object_hash {
1708            if min_hash < previous {
1709                return Err(io::Error::new(
1710                    io::ErrorKind::InvalidInput,
1711                    "Q42 streaming blocks must be globally sorted by object hash",
1712                ));
1713            }
1714        }
1715        self.block_ranges.push((min_hash, max_hash));
1716
1717        let raw = encode_superblock(seq_id, quins);
1718        let compressed = lz4_flex::compress_prepend_size(&raw);
1719
1720        use std::io::{Seek, SeekFrom, Write};
1721        let append_offset = self.header.data_offset + self.header.data_length;
1722        self.file.seek(SeekFrom::Start(append_offset))?;
1723
1724        self.dir_entries.push(BlockDirectoryEntry {
1725            rel_offset: self.header.data_length,
1726            comp_len: compressed.len() as u32,
1727            uncomp_len: SUPERBLOCK_SIZE as u32,
1728        });
1729
1730        self.file.write_all(&compressed)?;
1731        self.header.data_length += compressed.len() as u64;
1732        self.header.block_count += 1;
1733
1734        let ts = std::time::SystemTime::now()
1735            .duration_since(std::time::UNIX_EPOCH)
1736            .map(|d| d.as_millis() as u64)
1737            .unwrap_or(0);
1738        let msg = format!("runtime block {}", seq_id);
1739
1740        self.last_dag_hash = if self.last_dag_hash == [0u8; 32] {
1741            self.dag_store
1742                .genesis_node(quins, self.author_did, ts, &msg)
1743        } else {
1744            self.dag_store
1745                .commit_node(self.last_dag_hash, quins, self.author_did, ts, &msg)
1746        };
1747        self.last_object_hash = Some(max_hash);
1748
1749        // Write BIDX and Directory at the end
1750        let bidx_bytes = encode_bidx(&self.block_ranges);
1751        self.header.bidx_offset = self.header.data_offset + self.header.data_length;
1752        self.header.bidx_length = bidx_bytes.len() as u64;
1753        self.file.write_all(&bidx_bytes)?;
1754
1755        self.header.block_dir_offset = self.header.bidx_offset + self.header.bidx_length;
1756        self.header.block_dir_length = (self.dir_entries.len() * BlockDirectoryEntry::SIZE) as u64;
1757        let mut dir_bytes = Vec::with_capacity(self.header.block_dir_length as usize);
1758        for entry in &self.dir_entries {
1759            dir_bytes.extend_from_slice(&entry.rel_offset.to_le_bytes());
1760            dir_bytes.extend_from_slice(&entry.comp_len.to_le_bytes());
1761            dir_bytes.extend_from_slice(&entry.uncomp_len.to_le_bytes());
1762        }
1763        self.file.write_all(&dir_bytes)?;
1764
1765        // Write DAG
1766        let dag_bytes = self.dag_store.serialize();
1767        self.header.dag_root_offset = self.header.block_dir_offset + self.header.block_dir_length;
1768        self.header.dag_root_length = dag_bytes.len() as u64;
1769        self.file.write_all(&dag_bytes)?;
1770
1771        use sha2::{Digest, Sha256};
1772        let mut h = Sha256::new();
1773        h.update(self.last_dag_hash);
1774        self.header.merkle_root = h.finalize().into();
1775        self.header.assertion_timestamp = ts;
1776
1777        // Update header
1778        self.file.seek(SeekFrom::Start(0))?;
1779        self.file.write_all(&header_to_bytes(&self.header))?;
1780        self.file.sync_all()?;
1781
1782        Ok(())
1783    }
1784}