Skip to main content

qualia_core_db/
q42_lex.rs

1//! Read `.q42.lex` reverse-lexicon sidecars (Q42LEX format from qualia-cli ingest).
2
3use std::collections::HashMap;
4use std::fs::File;
5use std::io::Read;
6use std::path::Path;
7
8#[cfg(not(target_arch = "wasm32"))]
9use memmap2::Mmap;
10
11pub const LEX_MAGIC: [u8; 8] = *b"Q42LEX\0\0";
12const MAGIC: &[u8; 8] = &LEX_MAGIC;
13pub const LEX_HEADER_SIZE: usize = 32;
14const HEADER_SIZE: usize = LEX_HEADER_SIZE;
15const INDEX_ENTRY_SIZE: usize = 16;
16pub const LEX_VERSION_PAGED: u64 = 2;
17pub const PAGED_DIRECTORY_HEADER_SIZE: usize = 8;
18pub const PAGED_DIRECTORY_ENTRY_SIZE: usize = 32;
19pub const PAGED_PAGE_HEADER_SIZE: usize = 16;
20/// Fixed upper bound used by the canonical writer.  Pages are independently
21/// addressable, so an HTTP/IPFS reader only needs this many dictionary records
22/// (plus their strings) for one hash lookup.
23pub const DEFAULT_LEX_PAGE_ENTRIES: usize = 4_096;
24
25/// Type tags for lexicon entries (1-byte prefix in payload)
26const LEX_TAG_STRING: u8 = 0x01; // UTF-8 string
27const LEX_TAG_EMBEDDED: u8 = 0x02; // Embedded triple [u64; 3]
28const LEX_TAG_WEBIZEN: u8 = 0x03; // Authoritative Webizen identity
29
30/// Serialize a hash → string map into the canonical `Q42LEX` byte layout that [`Q42LexMmap`] reads
31/// back (magic, sorted 16-byte index, tagged string blob). This is the **write** side of the lexicon:
32/// the RDF ingest calls it in lossless ("Complete") mode so every subject/predicate URI and every
33/// literal is recoverable via [`Q42LexMmap::lookup_hash`], instead of being hashed away.
34///
35/// Strings are stored as **UTF-8** (`LEX_TAG_STRING`), preserving the full Unicode range — this is
36/// essential, not cosmetic: a lexicon that only kept ASCII would silently erase most of the world's
37/// languages (WordNet alone carries Finnish, Thai, and dozens more). A string longer than the 16-bit
38/// length field can express is truncated at a UTF-8 **character boundary** (never mid-codepoint), so a
39/// multi-byte grapheme is never split into invalid bytes.
40///
41/// The map is keyed by the same value stored in the quin field (for objects that is the
42/// `OBJECT_HASH_MASK`-masked hash), so `lookup_hash(quin.object)` resolves directly. Entries are sorted
43/// by hash for the reader's binary search; the caller's map has already de-duplicated by hash.
44/// Returns [`LexError::TermTooLong`] rather than silently truncating a term.
45pub fn serialize_string_lexicon(entries: &HashMap<u64, String>) -> Result<Vec<u8>, LexError> {
46    let mut sorted: Vec<(&u64, &String)> = entries.iter().collect();
47    sorted.sort_unstable_by_key(|(h, _)| **h);
48    let entry_count = sorted.len() as u64;
49    let strings_offset = HEADER_SIZE as u64 + entry_count * INDEX_ENTRY_SIZE as u64;
50
51    let mut index: Vec<u8> = Vec::with_capacity(sorted.len() * INDEX_ENTRY_SIZE);
52    let mut blob: Vec<u8> = Vec::new();
53    for (hash, text) in &sorted {
54        let str_off = blob.len() as u64;
55        if text.len() > u16::MAX as usize {
56            return Err(LexError::TermTooLong);
57        }
58        blob.push(LEX_TAG_STRING);
59        blob.extend_from_slice(&(text.len() as u16).to_le_bytes());
60        blob.extend_from_slice(text.as_bytes());
61        index.extend_from_slice(&hash.to_le_bytes());
62        index.extend_from_slice(&str_off.to_le_bytes());
63    }
64
65    let mut out = Vec::with_capacity(HEADER_SIZE + index.len() + blob.len());
66    out.extend_from_slice(MAGIC);
67    out.extend_from_slice(&entry_count.to_le_bytes());
68    out.extend_from_slice(&strings_offset.to_le_bytes());
69    out.extend_from_slice(&1u64.to_le_bytes()); // format version
70    out.extend_from_slice(&index);
71    out.extend_from_slice(&blob);
72    Ok(out)
73}
74
75/// Serialize the v2 paged Q42LEX representation.
76///
77/// Unlike the original monolithic index, this stores a small page directory at
78/// the front followed by independently valid pages.  Pages deliberately stay
79/// uncompressed in v2: they can be returned as borrowed `&str` values by the
80/// existing zero-allocation resolver.  Compression is a transport concern for
81/// a whole Q42 range response; keeping page contents directly addressable is
82/// what preserves the ABI and HTTP-range streamability today.
83pub fn serialize_paged_string_lexicon(
84    entries: &HashMap<u64, String>,
85    page_entries: usize,
86) -> Result<Vec<u8>, LexError> {
87    if page_entries == 0 {
88        return Err(LexError::BadIndex);
89    }
90    let mut sorted: Vec<(&u64, &String)> = entries.iter().collect();
91    sorted.sort_unstable_by_key(|(hash, _)| **hash);
92    let page_count = (sorted.len() + page_entries - 1) / page_entries;
93    let directory_len = PAGED_DIRECTORY_HEADER_SIZE
94        .checked_add(
95            page_count
96                .checked_mul(PAGED_DIRECTORY_ENTRY_SIZE)
97                .ok_or(LexError::Truncated)?,
98        )
99        .ok_or(LexError::Truncated)?;
100    let mut out = Vec::with_capacity(HEADER_SIZE + directory_len + entries.len() * 24);
101    out.extend_from_slice(MAGIC);
102    out.extend_from_slice(&(sorted.len() as u64).to_le_bytes());
103    out.extend_from_slice(&(HEADER_SIZE as u64).to_le_bytes());
104    out.extend_from_slice(&LEX_VERSION_PAGED.to_le_bytes());
105    out.extend_from_slice(&(page_count as u64).to_le_bytes());
106    out.resize(HEADER_SIZE + directory_len, 0);
107
108    for (page_index, chunk) in sorted.chunks(page_entries).enumerate() {
109        let page_offset = out.len() as u64;
110        let page_start = out.len();
111        out.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
112        out.extend_from_slice(&0u32.to_le_bytes());
113        let blob_offset = PAGED_PAGE_HEADER_SIZE
114            .checked_add(
115                chunk
116                    .len()
117                    .checked_mul(INDEX_ENTRY_SIZE)
118                    .ok_or(LexError::Truncated)?,
119            )
120            .ok_or(LexError::Truncated)?;
121        out.extend_from_slice(&(blob_offset as u64).to_le_bytes());
122        let index_start = out.len();
123        out.resize(index_start + chunk.len() * INDEX_ENTRY_SIZE, 0);
124        for (entry_index, (hash, text)) in chunk.iter().enumerate() {
125            if text.len() > u16::MAX as usize {
126                return Err(LexError::TermTooLong);
127            }
128            let relative = (out.len() - page_start - blob_offset) as u64;
129            out.push(LEX_TAG_STRING);
130            out.extend_from_slice(&(text.len() as u16).to_le_bytes());
131            out.extend_from_slice(text.as_bytes());
132            let index_offset = index_start + entry_index * INDEX_ENTRY_SIZE;
133            out[index_offset..index_offset + 8].copy_from_slice(&hash.to_le_bytes());
134            out[index_offset + 8..index_offset + 16].copy_from_slice(&relative.to_le_bytes());
135        }
136        let page_length = (out.len() - page_start) as u64;
137        let directory =
138            HEADER_SIZE + PAGED_DIRECTORY_HEADER_SIZE + page_index * PAGED_DIRECTORY_ENTRY_SIZE;
139        out[directory..directory + 8].copy_from_slice(&chunk[0].0.to_le_bytes());
140        out[directory + 8..directory + 16].copy_from_slice(&page_offset.to_le_bytes());
141        out[directory + 16..directory + 24].copy_from_slice(&page_length.to_le_bytes());
142        out[directory + 24..directory + 28].copy_from_slice(&(chunk.len() as u32).to_le_bytes());
143    }
144    Ok(out)
145}
146
147/// Largest UTF-8 prefix of `s` that fits in `max_bytes`, never splitting a codepoint.
148/// Zero-allocation lexicon key for in-memory lookups
149pub enum LexiconKey<'a> {
150    /// UTF-8 string reference
151    Str(&'a str),
152    /// Embedded triple reference
153    Triple(&'a [u64; 3]),
154}
155
156/// Lexicon entry payload for serialization
157#[derive(Debug, Clone)]
158pub enum LexiconEntry {
159    /// UTF-8 string
160    String(String),
161    /// Embedded triple [subject, predicate, object]
162    EmbeddedTriple([u64; 3]),
163    /// Webizen identity (future implementation)
164    Webizen(String),
165}
166
167/// In-memory hash → UTF-8 string map from a `.q42.lex` file (cold-path loader).
168#[derive(Debug, Default)]
169pub struct Q42Lexicon {
170    pub entries: HashMap<u64, String>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum LexError {
175    InvalidMagic,
176    Truncated,
177    BadStringOffset,
178    BadIndex,
179    BadEntry,
180    InvalidUtf8,
181    TermTooLong,
182}
183
184/// Zero-allocation view over a memory-mapped `.q42.lex` slice (sorted hash index).
185#[derive(Debug, Clone, Copy)]
186pub struct Q42LexMmap<'a> {
187    data: &'a [u8],
188    entry_count: usize,
189    strings_offset: usize,
190    format_version: u64,
191    page_count: usize,
192}
193
194impl<'a> Q42LexMmap<'a> {
195    /// Parse a Q42LEX byte slice (typically from `mmap`).
196    pub fn from_bytes(data: &'a [u8]) -> Result<Self, LexError> {
197        if data.len() < HEADER_SIZE {
198            return Err(LexError::Truncated);
199        }
200        if data[0..8] != *MAGIC {
201            return Err(LexError::InvalidMagic);
202        }
203        let format_version = u64::from_le_bytes(data[24..32].try_into().unwrap());
204        let entry_count = usize::try_from(u64::from_le_bytes(data[8..16].try_into().unwrap()))
205            .map_err(|_| LexError::Truncated)?;
206        let strings_offset = usize::try_from(u64::from_le_bytes(data[16..24].try_into().unwrap()))
207            .map_err(|_| LexError::Truncated)?;
208        let index_end = HEADER_SIZE
209            .checked_add(
210                entry_count
211                    .checked_mul(INDEX_ENTRY_SIZE)
212                    .ok_or(LexError::Truncated)?,
213            )
214            .ok_or(LexError::Truncated)?;
215        let (page_count, flat) = match format_version {
216            1 => (0, true),
217            LEX_VERSION_PAGED => {
218                let page_count_end = strings_offset
219                    .checked_add(PAGED_DIRECTORY_HEADER_SIZE)
220                    .ok_or(LexError::Truncated)?;
221                if page_count_end > data.len() {
222                    return Err(LexError::Truncated);
223                }
224                let page_count = usize::try_from(u64::from_le_bytes(
225                    data[strings_offset..page_count_end].try_into().unwrap(),
226                ))
227                .map_err(|_| LexError::Truncated)?;
228                let directory_end = page_count_end
229                    .checked_add(
230                        page_count
231                            .checked_mul(PAGED_DIRECTORY_ENTRY_SIZE)
232                            .ok_or(LexError::Truncated)?,
233                    )
234                    .ok_or(LexError::Truncated)?;
235                if strings_offset != HEADER_SIZE || directory_end > data.len() {
236                    return Err(LexError::Truncated);
237                }
238                (page_count, false)
239            }
240            _ => return Err(LexError::BadIndex),
241        };
242        if flat && (index_end > data.len() || strings_offset != index_end) {
243            return Err(LexError::Truncated);
244        }
245        let view = Self {
246            data,
247            entry_count,
248            strings_offset,
249            format_version,
250            page_count,
251        };
252        view.validate_entries()?;
253        Ok(view)
254    }
255
256    #[inline]
257    pub fn entry_count(&self) -> usize {
258        self.entry_count
259    }
260
261    /// Hash at sorted ordinal `i`, independent of whether the lexicon is the
262    /// legacy flat layout or the paged v2 layout. Cold loaders use this rather
263    /// than reaching into an on-disk index with v1 assumptions.
264    pub fn hash_at(&self, i: usize) -> Option<u64> {
265        if i >= self.entry_count {
266            return None;
267        }
268        if self.format_version == LEX_VERSION_PAGED {
269            let mut base = 0usize;
270            for page in 0..self.page_count {
271                let (_, offset, _, count) = self.page_directory_entry(page)?;
272                if i < base + count {
273                    let entry = offset + PAGED_PAGE_HEADER_SIZE + (i - base) * INDEX_ENTRY_SIZE;
274                    return Some(u64::from_le_bytes(
275                        self.data.get(entry..entry + 8)?.try_into().ok()?,
276                    ));
277                }
278                base += count;
279            }
280            return None;
281        }
282        let off = HEADER_SIZE + i * INDEX_ENTRY_SIZE;
283        Some(u64::from_le_bytes(
284            self.data.get(off..off + 8)?.try_into().ok()?,
285        ))
286    }
287
288    /// Binary search for `hash` in the sorted index; returns the UTF-8 lexeme slice.
289    pub fn lookup_hash(&self, hash: u64) -> Option<&'a str> {
290        if self.format_version == LEX_VERSION_PAGED {
291            return self.lookup_hash_paged(hash);
292        }
293        let mut lo = 0usize;
294        let mut hi = self.entry_count;
295        while lo < hi {
296            let mid = lo + (hi - lo) / 2;
297            let off = HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
298            let entry_hash = u64::from_le_bytes(self.data[off..off + 8].try_into().ok()?);
299            match entry_hash.cmp(&hash) {
300                std::cmp::Ordering::Less => lo = mid + 1,
301                std::cmp::Ordering::Greater => hi = mid,
302                std::cmp::Ordering::Equal => {
303                    let str_off =
304                        u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
305                    return Self::read_string_at(self.data, self.strings_offset, str_off);
306                }
307            }
308        }
309        None
310    }
311
312    /// The lexeme string of the `i`-th index entry (`0..entry_count`), if it is a UTF-8 string entry
313    /// (not an embedded-triple / Webizen entry). Enables iterating ALL lexicon strings without knowing
314    /// their hashes — e.g. to assemble a calibration corpus from a WordNet q42's gloss text.
315    pub fn string_at(&self, i: usize) -> Option<&'a str> {
316        if i >= self.entry_count {
317            return None;
318        }
319        if self.format_version == LEX_VERSION_PAGED {
320            let mut base = 0usize;
321            for page in 0..self.page_count {
322                let (_, offset, length, count) = self.page_directory_entry(page)?;
323                if i < base + count {
324                    return self.page_string_at(offset, length, i - base);
325                }
326                base += count;
327            }
328            return None;
329        }
330        let off = HEADER_SIZE + i * INDEX_ENTRY_SIZE;
331        let str_off = u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
332        Self::read_string_at(self.data, self.strings_offset, str_off)
333    }
334
335    /// Validate every index entry and its tagged payload while the full byte
336    /// slice is available. This keeps corrupt lexicon sections from becoming
337    /// deferred `None` values during query execution.
338    fn validate_entries(&self) -> Result<(), LexError> {
339        if self.format_version == LEX_VERSION_PAGED {
340            return self.validate_paged_entries();
341        }
342        let mut previous_hash = None;
343        for i in 0..self.entry_count {
344            let off = HEADER_SIZE + i * INDEX_ENTRY_SIZE;
345            let hash = u64::from_le_bytes(
346                self.data[off..off + 8]
347                    .try_into()
348                    .map_err(|_| LexError::BadIndex)?,
349            );
350            if let Some(previous) = previous_hash {
351                if hash <= previous {
352                    return Err(LexError::BadIndex);
353                }
354            }
355            previous_hash = Some(hash);
356            let rel_off = usize::try_from(u64::from_le_bytes(
357                self.data[off + 8..off + 16]
358                    .try_into()
359                    .map_err(|_| LexError::BadIndex)?,
360            ))
361            .map_err(|_| LexError::BadStringOffset)?;
362            let start = self
363                .strings_offset
364                .checked_add(rel_off)
365                .ok_or(LexError::BadStringOffset)?;
366            let tag = *self.data.get(start).ok_or(LexError::BadStringOffset)?;
367            match tag {
368                LEX_TAG_STRING | LEX_TAG_WEBIZEN => {
369                    let length_end = start.checked_add(3).ok_or(LexError::BadEntry)?;
370                    let length_bytes = self
371                        .data
372                        .get(start + 1..length_end)
373                        .ok_or(LexError::BadEntry)?;
374                    let length = u16::from_le_bytes(
375                        length_bytes.try_into().map_err(|_| LexError::BadEntry)?,
376                    ) as usize;
377                    let text_start = length_end;
378                    let text_end = text_start.checked_add(length).ok_or(LexError::BadEntry)?;
379                    let text = self
380                        .data
381                        .get(text_start..text_end)
382                        .ok_or(LexError::BadEntry)?;
383                    std::str::from_utf8(text).map_err(|_| LexError::InvalidUtf8)?;
384                }
385                LEX_TAG_EMBEDDED => {
386                    let end = start.checked_add(25).ok_or(LexError::BadEntry)?;
387                    self.data.get(start..end).ok_or(LexError::BadEntry)?;
388                }
389                _ => return Err(LexError::BadEntry),
390            }
391        }
392        Ok(())
393    }
394
395    fn page_directory_entry(&self, index: usize) -> Option<(u64, usize, usize, usize)> {
396        if index >= self.page_count {
397            return None;
398        }
399        let offset =
400            self.strings_offset + PAGED_DIRECTORY_HEADER_SIZE + index * PAGED_DIRECTORY_ENTRY_SIZE;
401        let first_hash = u64::from_le_bytes(self.data.get(offset..offset + 8)?.try_into().ok()?);
402        let page_offset = usize::try_from(u64::from_le_bytes(
403            self.data.get(offset + 8..offset + 16)?.try_into().ok()?,
404        ))
405        .ok()?;
406        let page_length = usize::try_from(u64::from_le_bytes(
407            self.data.get(offset + 16..offset + 24)?.try_into().ok()?,
408        ))
409        .ok()?;
410        let count =
411            u32::from_le_bytes(self.data.get(offset + 24..offset + 28)?.try_into().ok()?) as usize;
412        Some((first_hash, page_offset, page_length, count))
413    }
414
415    fn lookup_hash_paged(&self, hash: u64) -> Option<&'a str> {
416        let mut lo = 0usize;
417        let mut hi = self.page_count;
418        while lo < hi {
419            let mid = lo + (hi - lo) / 2;
420            if self.page_directory_entry(mid)?.0 <= hash {
421                lo = mid + 1;
422            } else {
423                hi = mid;
424            }
425        }
426        let page = lo.checked_sub(1)?;
427        let (_, offset, length, count) = self.page_directory_entry(page)?;
428        let mut left = 0usize;
429        let mut right = count;
430        while left < right {
431            let mid = left + (right - left) / 2;
432            let entry = offset + PAGED_PAGE_HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
433            let entry_hash = u64::from_le_bytes(self.data.get(entry..entry + 8)?.try_into().ok()?);
434            match entry_hash.cmp(&hash) {
435                std::cmp::Ordering::Less => left = mid + 1,
436                std::cmp::Ordering::Greater => right = mid,
437                std::cmp::Ordering::Equal => return self.page_string_at(offset, length, mid),
438            }
439        }
440        None
441    }
442
443    fn page_string_at(
444        &self,
445        page_offset: usize,
446        page_length: usize,
447        index: usize,
448    ) -> Option<&'a str> {
449        let page_end = page_offset.checked_add(page_length)?;
450        let count = u32::from_le_bytes(
451            self.data
452                .get(page_offset..page_offset + 4)?
453                .try_into()
454                .ok()?,
455        ) as usize;
456        let blob_offset = usize::try_from(u64::from_le_bytes(
457            self.data
458                .get(page_offset + 8..page_offset + 16)?
459                .try_into()
460                .ok()?,
461        ))
462        .ok()?;
463        if index >= count {
464            return None;
465        }
466        let entry = page_offset + PAGED_PAGE_HEADER_SIZE + index * INDEX_ENTRY_SIZE;
467        let relative = usize::try_from(u64::from_le_bytes(
468            self.data.get(entry + 8..entry + 16)?.try_into().ok()?,
469        ))
470        .ok()?;
471        let start = page_offset
472            .checked_add(blob_offset)?
473            .checked_add(relative)?;
474        if start + 3 > page_end || self.data[start] != LEX_TAG_STRING {
475            return None;
476        }
477        let length = u16::from_le_bytes(self.data[start + 1..start + 3].try_into().ok()?) as usize;
478        let end = start.checked_add(3)?.checked_add(length)?;
479        if end > page_end {
480            return None;
481        }
482        std::str::from_utf8(&self.data[start + 3..end]).ok()
483    }
484
485    fn validate_paged_entries(&self) -> Result<(), LexError> {
486        let mut total = 0usize;
487        let mut previous = None;
488        for page in 0..self.page_count {
489            let (first, offset, length, count) =
490                self.page_directory_entry(page).ok_or(LexError::BadIndex)?;
491            if count == 0
492                || offset < HEADER_SIZE
493                || offset
494                    .checked_add(length)
495                    .is_none_or(|end| end > self.data.len())
496            {
497                return Err(LexError::BadIndex);
498            }
499            if previous.is_some_and(|value| first <= value) {
500                return Err(LexError::BadIndex);
501            }
502            let actual_count =
503                u32::from_le_bytes(self.data[offset..offset + 4].try_into().unwrap()) as usize;
504            let blob = usize::try_from(u64::from_le_bytes(
505                self.data[offset + 8..offset + 16].try_into().unwrap(),
506            ))
507            .map_err(|_| LexError::BadIndex)?;
508            if actual_count != count
509                || blob != PAGED_PAGE_HEADER_SIZE + count * INDEX_ENTRY_SIZE
510                || blob > length
511            {
512                return Err(LexError::BadIndex);
513            }
514            for item in 0..count {
515                let entry = offset + PAGED_PAGE_HEADER_SIZE + item * INDEX_ENTRY_SIZE;
516                let hash = u64::from_le_bytes(self.data[entry..entry + 8].try_into().unwrap());
517                if item == 0 && hash != first {
518                    return Err(LexError::BadIndex);
519                }
520                if previous.is_some_and(|value| hash <= value) {
521                    return Err(LexError::BadIndex);
522                }
523                previous = Some(hash);
524                if self.page_string_at(offset, length, item).is_none() {
525                    return Err(LexError::BadEntry);
526                }
527            }
528            total = total.checked_add(count).ok_or(LexError::BadIndex)?;
529        }
530        if total != self.entry_count {
531            return Err(LexError::BadIndex);
532        }
533        Ok(())
534    }
535
536    fn read_string_at(data: &[u8], blob_base: usize, rel_off: usize) -> Option<&str> {
537        let start = blob_base.checked_add(rel_off)?;
538        if start.checked_add(3)? > data.len() {
539            return None;
540        }
541        // Check type tag
542        if data[start] != LEX_TAG_STRING {
543            return None;
544        }
545        let len = u16::from_le_bytes(data[start + 1..start + 3].try_into().ok()?) as usize;
546        let text_start = start + 3;
547        let text_end = text_start.checked_add(len)?;
548        if text_end > data.len() {
549            return None;
550        }
551        std::str::from_utf8(&data[text_start..text_end]).ok()
552    }
553
554    /// Binary search for `hash` in the sorted index; returns the embedded triple [subject, predicate, object].
555    ///
556    /// Used by SPARQL-Star Virtual ID resolution: a Virtual ID is the FNV-1a hash of an embedded
557    /// triple, stored in the lexicon with tag `LEX_TAG_EMBEDDED` instead of `LEX_TAG_STRING`.
558    pub fn lookup_embedded_triple(&self, hash: u64) -> Option<[u64; 3]> {
559        if self.format_version == LEX_VERSION_PAGED {
560            return None;
561        }
562        let mut lo = 0usize;
563        let mut hi = self.entry_count;
564        while lo < hi {
565            let mid = lo + (hi - lo) / 2;
566            let off = HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
567            let entry_hash = u64::from_le_bytes(self.data[off..off + 8].try_into().ok()?);
568            match entry_hash.cmp(&hash) {
569                std::cmp::Ordering::Less => lo = mid + 1,
570                std::cmp::Ordering::Greater => hi = mid,
571                std::cmp::Ordering::Equal => {
572                    let str_off =
573                        u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
574                    return Self::read_embedded_triple_at(self.data, self.strings_offset, str_off)
575                        .copied();
576                }
577            }
578        }
579        None
580    }
581
582    /// Binary search for `hash`; returns the authoritative Webizen identity string.
583    pub fn lookup_webizen_identity(&self, hash: u64) -> Option<&'a str> {
584        if self.format_version == LEX_VERSION_PAGED {
585            return None;
586        }
587        let mut lo = 0usize;
588        let mut hi = self.entry_count;
589        while lo < hi {
590            let mid = lo + (hi - lo) / 2;
591            let off = HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
592            let entry_hash = u64::from_le_bytes(self.data[off..off + 8].try_into().ok()?);
593            match entry_hash.cmp(&hash) {
594                std::cmp::Ordering::Less => lo = mid + 1,
595                std::cmp::Ordering::Greater => hi = mid,
596                std::cmp::Ordering::Equal => {
597                    let str_off =
598                        u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
599                    return Self::read_webizen_at(self.data, self.strings_offset, str_off);
600                }
601            }
602        }
603        None
604    }
605
606    fn read_webizen_at(data: &[u8], blob_base: usize, rel_off: usize) -> Option<&str> {
607        let start = blob_base.checked_add(rel_off)?;
608        if start.checked_add(3)? > data.len() || data[start] != LEX_TAG_WEBIZEN {
609            return None;
610        }
611        let len = u16::from_le_bytes(data[start + 1..start + 3].try_into().ok()?) as usize;
612        let text_start = start + 3;
613        let text_end = text_start.checked_add(len)?;
614        if text_end > data.len() {
615            return None;
616        }
617        std::str::from_utf8(&data[text_start..text_end]).ok()
618    }
619
620    /// Reads a 24-byte embedded triple [u64; 3] at the given offset.
621    ///
622    /// Format: [TAG_EMBEDDED (1 byte)] + [24-byte triple]
623    fn read_embedded_triple_at(data: &[u8], blob_base: usize, rel_off: usize) -> Option<&[u64; 3]> {
624        let start = blob_base.saturating_add(rel_off);
625        if start + 1 + 24 > data.len() {
626            return None;
627        }
628        // Check type tag
629        if data[start] != LEX_TAG_EMBEDDED {
630            return None;
631        }
632        // Skip type tag and read 24-byte triple
633        let triple_start = start + 1;
634        let bytes = &data[triple_start..triple_start + 24];
635        let ptr = bytes.as_ptr() as *const [u64; 3];
636        unsafe { Some(&*ptr) }
637    }
638}
639
640/// Memory-mapped `.q42.lex` file handle (native targets).
641#[cfg(not(target_arch = "wasm32"))]
642pub struct Q42LexFile {
643    mmap: Mmap,
644}
645
646#[cfg(not(target_arch = "wasm32"))]
647impl Q42LexFile {
648    pub fn open(path: &Path) -> std::io::Result<Self> {
649        let file = File::open(path)?;
650        let mmap = unsafe { Mmap::map(&file)? };
651        Q42LexMmap::from_bytes(&mmap)
652            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
653        Ok(Self { mmap })
654    }
655
656    #[inline]
657    pub fn view(&self) -> Q42LexMmap<'_> {
658        Q42LexMmap::from_bytes(&self.mmap).expect("validated at open")
659    }
660}
661
662impl Q42Lexicon {
663    /// Load lexicon embedded in a unified v2 `.q42` volume or a legacy `.q42.lex` sidecar.
664    #[cfg(not(target_arch = "wasm32"))]
665    pub fn load_for_q42(q42_path: &Path) -> std::io::Result<Self> {
666        if crate::q42_volume::is_unified_volume(q42_path)? {
667            let vol = crate::q42_volume::Q42Volume::open(q42_path)?;
668            if vol.volume_manifest()?.is_some() {
669                let set = crate::q42_volume::Q42VolumeSet::open_root(q42_path)?;
670                let mut lexicon = Self::load_from_lex_bytes(set.root().lex_bytes())?;
671                for shard in set.lexicon_segments() {
672                    let entries = Self::load_from_lex_bytes(shard.lex_bytes())?;
673                    lexicon.entries.extend(entries.entries);
674                }
675                return Ok(lexicon);
676            }
677            return Self::load_from_lex_bytes(vol.lex_bytes());
678        }
679        let sidecar = q42_path.with_extension("q42.lex");
680        if sidecar.is_file() {
681            return Self::load(&sidecar);
682        }
683        Err(std::io::Error::new(
684            std::io::ErrorKind::NotFound,
685            format!(
686                "no lexicon in {} or sidecar {}",
687                q42_path.display(),
688                sidecar.display()
689            ),
690        ))
691    }
692
693    /// Build an in-memory lexicon from a Q42LEX byte slice (embedded or sidecar).
694    pub fn load_from_lex_bytes(data: &[u8]) -> std::io::Result<Self> {
695        let view = Q42LexMmap::from_bytes(data)
696            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
697        let mut entries = HashMap::with_capacity(view.entry_count());
698        for i in 0..view.entry_count() {
699            let Some(hash) = view.hash_at(i) else {
700                break;
701            };
702            if let Some(text) = view.lookup_hash(hash) {
703                entries.insert(hash, text.to_string());
704            }
705        }
706        Ok(Self { entries })
707    }
708
709    pub fn load(path: &Path) -> std::io::Result<Self> {
710        let mut file = File::open(path)?;
711        let mut header = [0u8; 32];
712        file.read_exact(&mut header)?;
713        if header[0..8] != *MAGIC {
714            return Err(std::io::Error::new(
715                std::io::ErrorKind::InvalidData,
716                "invalid Q42LEX magic",
717            ));
718        }
719        let entry_count = u64::from_le_bytes(header[8..16].try_into().unwrap()) as usize;
720        let strings_offset = u64::from_le_bytes(header[16..24].try_into().unwrap()) as usize;
721
722        let mut index_buf = vec![0u8; entry_count * 16];
723        file.read_exact(&mut index_buf)?;
724
725        let mut blob = Vec::new();
726        file.seek(std::io::SeekFrom::Start(strings_offset as u64))?;
727        file.read_to_end(&mut blob)?;
728
729        let mut entries = HashMap::with_capacity(entry_count);
730        for i in 0..entry_count {
731            let off = i * 16;
732            let hash = u64::from_le_bytes(index_buf[off..off + 8].try_into().unwrap());
733            let str_off =
734                u64::from_le_bytes(index_buf[off + 8..off + 16].try_into().unwrap()) as usize;
735            if str_off + 2 > blob.len() {
736                continue;
737            }
738            let len = u16::from_le_bytes(blob[str_off..str_off + 2].try_into().unwrap()) as usize;
739            let start = str_off + 2;
740            let end = start.saturating_add(len).min(blob.len());
741            if let Ok(text) = std::str::from_utf8(&blob[start..end]) {
742                entries.insert(hash, text.to_string());
743            }
744        }
745
746        Ok(Self { entries })
747    }
748
749    pub fn lookup(&self, hash: u64) -> Option<&str> {
750        self.entries.get(&hash).map(|s| s.as_str())
751    }
752
753    /// Find first lexicon entry whose lowercase text equals `needle`.
754    pub fn find_literal(&self, needle: &str) -> Option<u64> {
755        let needle = needle.to_lowercase();
756        self.entries
757            .iter()
758            .find(|(_, v)| v.to_lowercase() == needle)
759            .map(|(h, _)| *h)
760    }
761
762    /// Entries whose text contains `sub` (case-insensitive), capped.
763    pub fn search_contains(&self, sub: &str, limit: usize) -> Vec<(u64, String)> {
764        let sub = sub.to_lowercase();
765        let mut out = Vec::new();
766        for (h, v) in &self.entries {
767            if v.to_lowercase().contains(&sub) {
768                out.push((*h, v.clone()));
769                if out.len() >= limit {
770                    break;
771                }
772            }
773        }
774        out
775    }
776}
777
778use std::io::Seek;
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use std::io::Write;
784    use tempfile::NamedTempFile;
785
786    fn write_lex_bytes(entries: &[(u64, &str)]) -> Vec<u8> {
787        let mut sorted: Vec<(u64, &str)> = entries.to_vec();
788        sorted.sort_unstable_by_key(|(h, _)| *h);
789        let entry_count = sorted.len() as u64;
790        let strings_offset = 32 + entry_count * 16;
791        let mut blob = Vec::new();
792        let mut index = Vec::new();
793        for (hash, text) in &sorted {
794            let str_off = blob.len() as u64;
795            // Write type tag
796            blob.push(LEX_TAG_STRING);
797            let b = text.as_bytes();
798            let len = b.len().min(65535) as u16;
799            blob.extend_from_slice(&len.to_le_bytes());
800            blob.extend_from_slice(&b[..len as usize]);
801            index.extend_from_slice(&hash.to_le_bytes());
802            index.extend_from_slice(&str_off.to_le_bytes());
803        }
804        let mut out = Vec::new();
805        out.extend_from_slice(MAGIC);
806        out.extend_from_slice(&entry_count.to_le_bytes());
807        out.extend_from_slice(&strings_offset.to_le_bytes());
808        out.extend_from_slice(&1u64.to_le_bytes());
809        out.extend_from_slice(&index);
810        out.extend_from_slice(&blob);
811        out
812    }
813
814    #[test]
815    fn mmap_lex_binary_search() {
816        let h1 = crate::q_hash("Patient");
817        let h2 = crate::q_hash("fever");
818        let bytes = write_lex_bytes(&[(h1, "Patient"), (h2, "fever")]);
819        let lex = Q42LexMmap::from_bytes(&bytes).unwrap();
820        assert_eq!(lex.lookup_hash(h1), Some("Patient"));
821        assert_eq!(lex.lookup_hash(h2), Some("fever"));
822        assert_eq!(lex.lookup_hash(0xDEAD), None);
823    }
824
825    /// The write side ([`serialize_string_lexicon`]) round-trips through the read side for the full
826    /// Unicode range — multilingual literals (non-Latin scripts, combining marks, emoji) come back
827    /// byte-identical. This is the property that makes lossless ingest actually lossless for the
828    /// world's languages, not just ASCII.
829    #[test]
830    fn serialize_lexicon_round_trips_unicode() {
831        let samples = [
832            "carefully",                        // ASCII (WordNet eng gloss word)
833            "välinpitämättömästi",              // Finnish (WordNet fin)
834            "อย่างสะเพร่า",                       // Thai
835            "不注意に",                         // Japanese
836            "بلا مبالاة",                         // Arabic (RTL)
837            "невнимательно",                    // Russian (Cyrillic)
838            "a definition — with em-dash & 😀", // punctuation + emoji (4-byte codepoint)
839        ];
840        let mut map = HashMap::new();
841        for s in samples {
842            map.insert(crate::q_hash(s), s.to_string());
843        }
844        let bytes = serialize_string_lexicon(&map).unwrap();
845        let lex = Q42LexMmap::from_bytes(&bytes).unwrap();
846        assert_eq!(lex.entry_count(), samples.len());
847        for s in samples {
848            assert_eq!(
849                lex.lookup_hash(crate::q_hash(s)),
850                Some(s),
851                "multilingual lexeme must round-trip byte-identical: {s:?}"
852            );
853        }
854    }
855
856    /// A literal longer than the 16-bit length field is truncated at a char boundary, never
857    /// mid-codepoint — so the stored bytes are always valid UTF-8 and the reader returns `Some`.
858    #[test]
859    fn serialize_lexicon_rejects_overlong_term() {
860        // 22-000 three-byte codepoints ≈ 66 000 bytes > u16::MAX (65 535); the cut lands between
861        // codepoints, so from_utf8 on read still succeeds.
862        let long: String = "あ".repeat(22_000);
863        let h = crate::q_hash(&long);
864        let mut map = HashMap::new();
865        map.insert(h, long);
866        assert_eq!(serialize_string_lexicon(&map), Err(LexError::TermTooLong));
867    }
868
869    #[test]
870    fn paged_lexicon_binary_search_crosses_page_boundaries() {
871        let mut map = HashMap::new();
872        for value in 0u64..9 {
873            map.insert(value * 10 + 3, format!("urn:q42:paged:{value}"));
874        }
875        let bytes = serialize_paged_string_lexicon(&map, 2).unwrap();
876        assert_eq!(
877            u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
878            LEX_VERSION_PAGED
879        );
880        let view = Q42LexMmap::from_bytes(&bytes).unwrap();
881        assert_eq!(view.entry_count(), 9);
882        assert_eq!(view.lookup_hash(3), Some("urn:q42:paged:0"));
883        assert_eq!(view.lookup_hash(83), Some("urn:q42:paged:8"));
884        assert_eq!(view.string_at(4), Some("urn:q42:paged:4"));
885        assert_eq!(view.lookup_hash(4), None);
886        let cold = Q42Lexicon::load_from_lex_bytes(&bytes).unwrap();
887        assert_eq!(cold.lookup(43), Some("urn:q42:paged:4"));
888    }
889
890    #[test]
891    fn mmap_lex_file_roundtrip() {
892        let h = crate::q_hash("Entity");
893        let bytes = write_lex_bytes(&[(h, "Entity")]);
894        let mut tmp = NamedTempFile::new().unwrap();
895        tmp.write_all(&bytes).unwrap();
896        let file = Q42LexFile::open(tmp.path()).unwrap();
897        assert_eq!(file.view().lookup_hash(h), Some("Entity"));
898    }
899}