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