Skip to main content

urna_format/writer/
build.rs

1//! `UrnaFileBuilder::build_bytes` - orchestrates manifest validation,
2//! payload encoding, layout planning, buffer assembly, and final
3//! checksums + file_hash. Result is byte-deterministic for identical
4//! inputs (and `reproducible(true)`).
5
6use super::REPRODUCIBLE_CREATED;
7use super::SectionEncoding;
8use super::UrnaFileBuilder;
9use super::payload::{encode_embeddings_payload, maybe_zstd};
10use super::text_codec;
11use crate::chunk::{chunk_id, validate_chunk};
12use crate::error::UrnaError;
13use crate::layout::{
14    REQUIRED_SECTIONS, SECTION_ALIGNMENT, SECTION_BLOB_DATA, SECTION_BLOB_REFS,
15    SECTION_BLOB_SPAN_OVERLAY, SECTION_BM25_INDEX, SECTION_CHUNK_IDS, SECTION_CHUNKS_CANONICAL,
16    SECTION_CHUNKS_ORIGINAL_SPANS, SECTION_EMBEDDINGS, SECTION_ENCODING_INTPACK,
17    SECTION_ENCODING_RAW, SECTION_GRAPH_ADJACENCY, SECTION_HNSW_INDEX, SECTION_PROVENANCE,
18    SECTION_SEARCH_CONTRACT, SECTION_SPACE_TABLE, SectionEntry, URNA_FOOTER_SIZE, URNA_HEADER_SIZE,
19    URNA_SECTION_ENTRY_SIZE, UrnaFooter, UrnaHeader, align_up,
20};
21use crate::sections::{
22    OriginalSpan, SearchContract, encode_chunk_ids, encode_chunk_ids_intpack,
23    encode_chunks_canonical, encode_chunks_original_spans, encode_chunks_original_spans_intpack,
24    encode_provenance, encode_search_contract,
25};
26use sha2::{Digest, Sha256};
27
28impl UrnaFileBuilder {
29    /// Build the file in memory. Pure computation - no I/O.
30    pub fn build_bytes(mut self) -> crate::Result<Vec<u8>> {
31        if self.reproducible {
32            self.manifest.created = Some(REPRODUCIBLE_CREATED.into());
33        }
34        // 1. Validate manifest (rejects bad dtype/metric/etc up front).
35        self.manifest.validate()?;
36
37        // 2. Validate chunks against the manifest's embedding_dim and n_chunks.
38        let embedding_dim = self.manifest.embedding_dim as usize;
39        if self.chunks.len() as u64 != self.manifest.n_chunks {
40            return Err(UrnaError::ManifestInvalid(format!(
41                "n_chunks={} but builder has {} chunks",
42                self.manifest.n_chunks,
43                self.chunks.len()
44            )));
45        }
46        for c in &self.chunks {
47            validate_chunk(c, embedding_dim)?;
48        }
49
50        // 3. Derive section payloads.
51        let chunk_ids: Vec<String> = self
52            .chunks
53            .iter()
54            .map(|c| {
55                chunk_id(
56                    &c.canonical_text,
57                    &c.source_uri,
58                    c.byte_start,
59                    c.byte_end,
60                    &self.manifest.chunker_version,
61                )
62            })
63            .collect();
64
65        let canonical_texts: Vec<String> = self
66            .chunks
67            .iter()
68            .map(|c| c.canonical_text.clone())
69            .collect();
70        let original_spans: Vec<OriginalSpan> = self
71            .chunks
72            .iter()
73            .map(|c| OriginalSpan {
74                source_uri: c.source_uri.clone(),
75                byte_start: c.byte_start,
76                byte_end: c.byte_end,
77            })
78            .collect();
79
80        let embeddings_bytes = encode_embeddings_payload(self.dtype, &self.chunks, embedding_dim)?;
81
82        let contract = SearchContract {
83            metric: self.manifest.metric.clone(),
84            score_type: self.manifest.score_type.clone(),
85            normalize: self.manifest.normalize.clone(),
86            index_type: self.manifest.index_type.clone(),
87            rerank_policy: self.manifest.rerank_policy.clone(),
88        };
89
90        // (id, encoding, payload). embeddings get dtype-specific encoding;
91        // text sections honor `text_encoding`. under a compressed (zstd-text)
92        // preset, chunk_ids and spans get the `intpack` repack (encoding 4):
93        // chunk_ids to 32 raw digest bytes (always a win for high-entropy
94        // sha-256), spans to a deduped uri pool + bitpacked offsets. the span
95        // repack only beats zstd when source_uris repeat, so spans takes the
96        // SMALLER of intpack/zstd per corpus. all of these decode BYTE-
97        // IDENTICALLY to the raw payload, so content_hash is unchanged. raw-
98        // text presets (and the golden) keep raw/zstd, so they stay
99        // byte-identical.
100        let text_enc = self.text_encoding;
101        let compressed = matches!(text_enc, SectionEncoding::Zstd);
102        let mut sections: Vec<(u32, u32, Vec<u8>)> = Vec::with_capacity(8);
103
104        let chunk_ids_section = match compressed.then(|| encode_chunk_ids_intpack(&chunk_ids)) {
105            Some(Some(packed)) => (SECTION_CHUNK_IDS, SECTION_ENCODING_INTPACK, packed),
106            _ => (
107                SECTION_CHUNK_IDS,
108                SECTION_ENCODING_RAW,
109                encode_chunk_ids(&chunk_ids)?,
110            ),
111        };
112        sections.push(chunk_ids_section);
113
114        // chunks_canonical: under a compressed (zstd-text) preset, the text
115        // codec chooser takes the SMALLEST of single-frame zstd, txt_streams
116        // cold, txt_streams+trained-dict (0x0A), txt_streams+fsst, and
117        // dedup+zstd (0x0B), so the build never regresses (single-frame zstd
118        // is always in the race). every candidate decodes BYTE-IDENTICALLY to
119        // the raw payload, so content_hash is unchanged; the dict/dedup aux
120        // sections are excluded from content_hash. raw-text presets (and the
121        // golden) keep raw bytes and stay byte-identical.
122        if compressed {
123            let choice = text_codec::choose(&canonical_texts)?;
124            sections.push(choice.canonical);
125            sections.extend(choice.aux);
126        } else {
127            let canonical_raw = encode_chunks_canonical(&canonical_texts)?;
128            sections.push(maybe_zstd(
129                SECTION_CHUNKS_CANONICAL,
130                text_enc,
131                canonical_raw,
132            )?);
133        }
134
135        let spans_raw = encode_chunks_original_spans(&original_spans)?;
136        let spans_section = if compressed {
137            let zst = maybe_zstd(SECTION_CHUNKS_ORIGINAL_SPANS, text_enc, spans_raw)?;
138            let packed = encode_chunks_original_spans_intpack(&original_spans);
139            if packed.len() < zst.2.len() {
140                (
141                    SECTION_CHUNKS_ORIGINAL_SPANS,
142                    SECTION_ENCODING_INTPACK,
143                    packed,
144                )
145            } else {
146                zst
147            }
148        } else {
149            maybe_zstd(SECTION_CHUNKS_ORIGINAL_SPANS, text_enc, spans_raw)?
150        };
151        sections.push(spans_section);
152        sections.push((SECTION_EMBEDDINGS, self.dtype.encoding(), embeddings_bytes));
153        sections.push(maybe_zstd(
154            SECTION_PROVENANCE,
155            text_enc,
156            encode_provenance(&self.provenance)?,
157        )?);
158        sections.push(maybe_zstd(
159            SECTION_SEARCH_CONTRACT,
160            text_enc,
161            encode_search_contract(&contract)?,
162        )?);
163
164        if let Some(payload) = self.hnsw_index.take() {
165            // HNSW is binary, mostly random - zstd would barely help and
166            // would defeat mmap-friendly reads. Always raw.
167            sections.push((SECTION_HNSW_INDEX, SECTION_ENCODING_RAW, payload));
168        }
169        if let Some(payload) = self.bm25_index.take() {
170            // BM25 posting lists are integer-heavy; zstd usually halves
171            // them. Honor text_encoding here too.
172            sections.push(maybe_zstd(SECTION_BM25_INDEX, text_enc, payload)?);
173        }
174        if let Some(payload) = self.graph_adjacency.take() {
175            // graph_adjacency (0x0C) is a self-describing csr that already
176            // bitpacks its integer columns with intpack internally; like hnsw
177            // it stays RAW so the runtime mmaps it directly. it is OPTIONAL and
178            // EXCLUDED from content_hash (not in CANONICAL_SECTIONS).
179            sections.push((SECTION_GRAPH_ADJACENCY, SECTION_ENCODING_RAW, payload));
180        }
181        if let Some(payload) = self.blob_refs.take() {
182            // blob_refs (0x14) is a small content-hash reference table the
183            // runtime decodes eagerly at open; RAW like hnsw/graph. OPTIONAL
184            // and EXCLUDED from content_hash.
185            sections.push((SECTION_BLOB_REFS, SECTION_ENCODING_RAW, payload));
186        }
187        if let Some(payload) = self.blob_data.take() {
188            // blob_data (0x17) carries the inlined media bytes; they are
189            // already codec-compressed, so ALWAYS raw - the runtime slices
190            // individual blobs lazily off the mmap. OPTIONAL and EXCLUDED
191            // from content_hash, like its 0x14 table.
192            sections.push((SECTION_BLOB_DATA, SECTION_ENCODING_RAW, payload));
193        }
194        if let Some(payload) = self.blob_span_overlay.take() {
195            // blob_span_overlay (0x16) is the per-chunk blob-relative span
196            // overlay; RAW like its 0x14 table. OPTIONAL and EXCLUDED from
197            // content_hash, so the overlay never invalidates a citation.
198            sections.push((SECTION_BLOB_SPAN_OVERLAY, SECTION_ENCODING_RAW, payload));
199        }
200        if let Some(payload) = self.space_table.take() {
201            // space_table (0x15) is the small multimodal directory the
202            // runtime decodes eagerly at open; RAW like blob_refs. OPTIONAL
203            // and EXCLUDED from content_hash.
204            sections.push((SECTION_SPACE_TABLE, SECTION_ENCODING_RAW, payload));
205        }
206        for (band_id, encoding, payload) in std::mem::take(&mut self.space_bands) {
207            // space bands are fixed-stride vector slabs scored by the simd
208            // kernels straight off mmap; they carry their dtype encoding
209            // (raw f32 / float16 / int8 / int4), NEVER zstd. OPTIONAL and
210            // EXCLUDED from content_hash.
211            sections.push((band_id, encoding, payload));
212        }
213
214        // Sanity: every required section is present (writer never drops one).
215        debug_assert!(
216            REQUIRED_SECTIONS
217                .iter()
218                .all(|(id, _)| sections.iter().any(|s| s.0 == *id))
219        );
220
221        // 4. Manifest JSON (canonical).
222        let manifest_json = self.manifest.to_canonical_json()?;
223
224        // 5. Layout offsets. Each section starts at SECTION_ALIGNMENT.
225        sections.sort_by_key(|s| s.0);
226        let section_table_count = sections.len() as u64;
227        let section_table_size = section_table_count * URNA_SECTION_ENTRY_SIZE as u64;
228        let header_size = URNA_HEADER_SIZE as u64;
229        let manifest_offset = header_size + section_table_size;
230        let manifest_size = manifest_json.len() as u64;
231
232        let mut section_entries: Vec<SectionEntry> = Vec::with_capacity(sections.len());
233        let mut current_offset = align_up(manifest_offset + manifest_size, SECTION_ALIGNMENT);
234        for (id, encoding, data) in &sections {
235            let mut entry = SectionEntry::new(*id, current_offset, data.len() as u64);
236            entry.encoding = *encoding;
237            section_entries.push(entry);
238            let after = current_offset + data.len() as u64;
239            current_offset = align_up(after, SECTION_ALIGNMENT);
240        }
241
242        // After the last section we want the footer immediately, so use
243        // the unaligned end of the last section's data.
244        let last_section_end = match (section_entries.last(), sections.last()) {
245            (Some(entry), Some((_, _, data))) => entry.offset + data.len() as u64,
246            _ => manifest_offset + manifest_size,
247        };
248        let data_end = last_section_end as usize;
249        let file_size = data_end + URNA_FOOTER_SIZE;
250        let mut buf = vec![0u8; file_size];
251
252        // 6. Write header (placeholder; checksum recomputed at end).
253        let mut header = UrnaHeader::new(
254            self.manifest.embedding_dim,
255            self.manifest.n_chunks,
256            self.chunks.len() as u64,
257            file_size as u64,
258            header_size,
259            section_table_count,
260            manifest_offset,
261            manifest_size,
262        );
263        buf[..URNA_HEADER_SIZE].copy_from_slice(header.as_bytes());
264
265        // 7. Write section table (placeholder checksums; filled below).
266        for (i, entry) in section_entries.iter().enumerate() {
267            let off = URNA_HEADER_SIZE + i * URNA_SECTION_ENTRY_SIZE;
268            buf[off..off + URNA_SECTION_ENTRY_SIZE].copy_from_slice(entry.as_bytes());
269        }
270
271        // 8. Write manifest.
272        let manifest_off = manifest_offset as usize;
273        buf[manifest_off..manifest_off + manifest_json.len()].copy_from_slice(&manifest_json);
274
275        // 9. Write section data at its declared (aligned) offset and
276        //    compute checksums over data only - padding stays zero and
277        //    is not hashed. Section checksum hashes the **physical** bytes
278        //    on disk (so for zstd sections it's over the compressed bytes).
279        for (i, (_, _, data)) in sections.iter().enumerate() {
280            let entry_off = URNA_HEADER_SIZE + i * URNA_SECTION_ENTRY_SIZE;
281            let data_off = section_entries[i].offset as usize;
282            buf[data_off..data_off + data.len()].copy_from_slice(data);
283            let hash = Sha256::digest(data);
284            buf[entry_off + 24..entry_off + 32].copy_from_slice(&hash[..8]);
285        }
286
287        // 10. Footer hash (covers everything before footer).
288        let footer_hash = UrnaFooter::compute_file_hash(&buf[..data_end]);
289        buf[data_end..data_end + 8].copy_from_slice(&URNA_FOOTER_SIZE.to_le_bytes());
290        buf[data_end + 8..file_size].copy_from_slice(&footer_hash);
291
292        // 11. Recompute header checksum (file_size already final).
293        header.compute_checksum();
294        buf[..URNA_HEADER_SIZE].copy_from_slice(header.as_bytes());
295
296        Ok(buf)
297    }
298}