Skip to main content

moine_ja/
unidic.rs

1use std::borrow::Cow;
2use std::collections::{btree_map::Entry, BTreeSet, HashMap};
3use std::error::Error;
4use std::fmt;
5use std::fmt::Write as _;
6use std::fs::File;
7use std::io::{Read, Write};
8use std::path::Path;
9use std::string::FromUtf8Error;
10use std::sync::Arc;
11
12use fst::{Map, MapBuilder, Streamer};
13use moine_core::Lattice;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use crate::romaji::{
18    can_build_direct_romaji_path, romaji_lattice_from_segmented_readings,
19    romaji_paths_from_segmented_readings, JaLatticeError, RomajiSegmentMode,
20};
21
22const SURFACE_COLUMN: usize = 0;
23const POS1_COLUMN: usize = 4;
24const LFORM_COLUMN: usize = 10;
25const PRON_COLUMN: usize = 13;
26const ARTIFACT_PAYLOAD_SCHEMA_VERSION: u32 = 1;
27const ARTIFACT_PAYLOAD_TYPE: &str = "moine.unidic.reading-index.surface-readings";
28const BINARY_ARTIFACT_MAGIC: &[u8; 8] = b"MOINEU01";
29const BINARY_ARTIFACT_VERSION: u32 = 1;
30const INDEXED_ARTIFACT_MAGIC: &[u8; 8] = b"MOINEI01";
31const INDEXED_ARTIFACT_VERSION: u32 = 1;
32const INDEXED_ARTIFACT_HEADER_LEN: usize = 40;
33const MAX_ARTIFACT_PAYLOAD_BYTES: u64 = 512 * 1024 * 1024;
34const MAX_ARTIFACT_ENTRIES: usize = 3_000_000;
35const MAX_ARTIFACT_READINGS_PER_ENTRY: usize = 256;
36const MAX_ARTIFACT_STRING_BYTES: usize = 16 * 1024;
37const DEFAULT_QUERY_SPAN_CHARS: usize = 8;
38const DEFAULT_QUERY_PATHS: usize = 1024;
39// Query expansion is clone-heavy; keep hard caps as bounded multiples of the
40// defaults so malformed metadata cannot silently request unbounded work.
41const MAX_QUERY_SPAN_CHARS: usize = DEFAULT_QUERY_SPAN_CHARS * 32;
42const MAX_QUERY_PATHS: usize = DEFAULT_QUERY_PATHS * 16;
43const MAX_QUERY_READINGS_PER_SEGMENT: usize = MAX_ARTIFACT_READINGS_PER_ENTRY;
44/// Current canonical checksum algorithm for normalized UniDic payload content.
45pub const ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM: &str = "sha256-canonical-v1";
46/// Legacy canonical checksum algorithm accepted for older UniDic artifacts.
47pub const LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM: &str = "fnv1a64-canonical-v1";
48/// File digest algorithm used to verify payload bytes before loading.
49pub const ARTIFACT_PAYLOAD_FILE_DIGEST_ALGORITHM: &str = "sha256-file-v1";
50
51/// UniDic-derived surface-to-reading index.
52#[derive(Clone, Debug)]
53pub struct UnidicReadingIndex {
54    storage: UnidicReadingStorage,
55}
56
57#[derive(Clone, Debug)]
58enum UnidicReadingStorage {
59    Eager(HashMap<String, Vec<String>>),
60    Indexed(IndexedUnidicPayload),
61}
62
63impl Default for UnidicReadingIndex {
64    fn default() -> Self {
65        Self {
66            storage: UnidicReadingStorage::Eager(HashMap::new()),
67        }
68    }
69}
70
71impl PartialEq for UnidicReadingIndex {
72    fn eq(&self, other: &Self) -> bool {
73        self.artifact_payload() == other.artifact_payload()
74    }
75}
76
77impl Eq for UnidicReadingIndex {}
78
79#[derive(Clone, Debug)]
80struct IndexedUnidicPayload {
81    bytes: Arc<[u8]>,
82    map: Map<Vec<u8>>,
83    readings_start: usize,
84    entries: usize,
85}
86
87/// Header for indexed FST UniDic payloads.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct UnidicIndexedArtifactPayloadHeader {
90    /// Indexed payload format version.
91    pub version: u32,
92    /// Number of entries in the payload.
93    pub entries: usize,
94    /// Length of the embedded FST section in bytes.
95    pub fst_len: usize,
96    /// Length of the reading blob section in bytes.
97    pub readings_len: usize,
98}
99
100/// Header for legacy binary UniDic payloads.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub struct UnidicBinaryArtifactPayloadHeader {
103    /// Binary payload format version.
104    pub version: u32,
105    /// Number of entries in the payload.
106    pub entries: usize,
107}
108
109/// Controls dictionary reading-path expansion.
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
111pub struct DictionaryReadingOptions {
112    /// Maximum surface span length considered for one dictionary segment.
113    pub max_span_chars: usize,
114    /// Maximum complete reading paths to keep.
115    pub max_paths: usize,
116    /// Prefer the longest dictionary span when multiple spans start together.
117    pub longest_match_only: bool,
118    /// Optional cap on readings used per dictionary segment.
119    pub max_readings_per_segment: Option<usize>,
120}
121
122/// One surface segment and its selected UniDic reading.
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct DictionaryReadingSegment {
125    /// Surface text covered by the segment.
126    pub surface: String,
127    /// Reading selected for the segment.
128    pub reading: String,
129    /// Whether the segment came from the dictionary or direct surface fallback.
130    pub source: DictionaryReadingSegmentSource,
131}
132
133/// Source of one Japanese reading-path segment.
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub enum DictionaryReadingSegmentSource {
136    /// Segment was backed by a dictionary entry.
137    Dictionary,
138    /// Segment was copied from direct kana/romaji surface fallback.
139    Direct,
140}
141
142/// One complete segmentation and joined reading for an input string.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct DictionaryReadingPath {
145    /// Ordered dictionary/direct segments in the path.
146    pub segments: Vec<DictionaryReadingSegment>,
147    /// Segment readings concatenated into one reading string.
148    pub joined_reading: String,
149}
150
151/// Reading-path expansion result plus pruning statistics.
152#[derive(Clone, Debug, Default, Eq, PartialEq)]
153pub struct DictionaryReadingExpansion {
154    /// Expanded reading paths.
155    pub paths: Vec<DictionaryReadingPath>,
156    /// Statistics gathered during expansion.
157    pub stats: DictionaryReadingStats,
158}
159
160/// Counters describing dictionary reading-path expansion.
161#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
162pub struct DictionaryReadingStats {
163    /// Dictionary spans matched during expansion.
164    pub matched_spans: usize,
165    /// Direct fallback spans used when no dictionary span matched.
166    pub direct_fallback_spans: usize,
167    /// Candidate spans pruned by longest-match mode.
168    pub longest_match_pruned_spans: usize,
169    /// Raw readings seen before per-segment pruning.
170    pub raw_segment_readings: usize,
171    /// Readings retained after per-segment pruning.
172    pub used_segment_readings: usize,
173    /// Readings removed by per-segment pruning.
174    pub pruned_segment_readings: usize,
175    /// Candidate path combinations considered.
176    pub candidate_combinations: usize,
177    /// Unique complete reading paths retained.
178    pub unique_paths: usize,
179    /// Duplicate joined readings removed.
180    pub duplicate_joined_readings: usize,
181    /// Number of times the `max_paths` cap was hit.
182    pub max_paths_hit_count: usize,
183}
184
185/// Builds a compact romaji lattice from dictionary reading paths.
186pub fn romaji_lattice_from_reading_paths(
187    paths: &[DictionaryReadingPath],
188) -> Result<Lattice, JaLatticeError> {
189    if paths.is_empty() {
190        return Err(JaLatticeError::EmptyReadings);
191    }
192
193    romaji_lattice_from_segmented_readings(
194        paths
195            .iter()
196            .map(|path| path.segments.iter().map(romaji_segment_input)),
197    )
198}
199
200/// Expands dictionary reading paths into explicit romaji strings.
201pub fn romaji_paths_from_reading_paths(
202    paths: &[DictionaryReadingPath],
203) -> Result<Vec<String>, JaLatticeError> {
204    if paths.is_empty() {
205        return Err(JaLatticeError::EmptyReadings);
206    }
207
208    romaji_paths_from_segmented_readings(
209        paths
210            .iter()
211            .map(|path| path.segments.iter().map(romaji_segment_input)),
212    )
213}
214
215fn romaji_segment_input(segment: &DictionaryReadingSegment) -> (&str, RomajiSegmentMode) {
216    let mode = match segment.source {
217        DictionaryReadingSegmentSource::Dictionary => RomajiSegmentMode::Reading,
218        DictionaryReadingSegmentSource::Direct => RomajiSegmentMode::Surface,
219    };
220    (segment.reading.as_str(), mode)
221}
222
223/// UniDic CSV field used as the source reading.
224#[derive(Clone, Copy, Debug, Eq, PartialEq)]
225pub enum UnidicReadingField {
226    /// Lemma-form reading column.
227    LForm,
228    /// Pronunciation column.
229    Pron,
230}
231
232/// Metadata stored in a UniDic dictionary bundle.
233#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
234pub struct UnidicArtifactMetadata {
235    /// Metadata schema version.
236    pub schema_version: u32,
237    /// Artifact type identifier.
238    pub artifact_type: String,
239    /// Human-readable artifact name.
240    pub artifact_name: String,
241    /// Tool or command that generated the artifact.
242    pub generator: String,
243    /// Payload file metadata.
244    pub payload: UnidicArtifactPayload,
245    /// Source dictionary metadata.
246    pub source: UnidicArtifactSource,
247    /// Build-time options and counts.
248    pub build: UnidicArtifactBuild,
249    /// Default query options for this artifact.
250    pub query_defaults: UnidicArtifactQueryDefaults,
251    /// License metadata and references.
252    pub license: UnidicArtifactLicense,
253}
254
255/// Payload file metadata for a UniDic dictionary bundle.
256#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
257pub struct UnidicArtifactPayload {
258    /// Bundle-relative payload file path.
259    pub path: String,
260    /// Payload serialization format.
261    pub format: String,
262    /// Optional digest algorithm for the raw payload file.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub file_digest_algorithm: Option<String>,
265    /// Optional digest of the raw payload file.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub file_digest: Option<String>,
268    /// Canonical payload checksum algorithm.
269    pub checksum_algorithm: String,
270    /// Canonical payload checksum.
271    pub checksum: String,
272}
273
274/// Source dictionary metadata for a UniDic artifact.
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub struct UnidicArtifactSource {
277    /// Source dictionary name.
278    pub name: String,
279    /// Source dictionary version.
280    pub version: String,
281    /// Source `lex.csv` path used to build the artifact.
282    pub lex_csv: String,
283}
284
285/// Build settings and counts recorded in UniDic artifact metadata.
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287pub struct UnidicArtifactBuild {
288    /// UniDic reading field used for entries.
289    pub reading_field: String,
290    /// Optional cap applied to readings stored per surface.
291    pub max_readings_per_surface: Option<usize>,
292    /// Whether ASCII-only surfaces were excluded.
293    pub exclude_ascii_surfaces: bool,
294    /// Whether symbol part-of-speech entries were excluded.
295    pub exclude_symbol_pos: bool,
296    /// Whether source normalized-form aliases were added as lookup surfaces.
297    #[serde(default, skip_serializing_if = "is_false")]
298    pub include_normalized_surfaces: bool,
299    /// Whether readings unsupported by the romaji converter were excluded.
300    #[serde(default, skip_serializing_if = "is_false")]
301    pub exclude_unsupported_readings: bool,
302    /// Number of entries in the generated payload.
303    pub entries: usize,
304}
305
306/// Default reading-path query settings stored in an artifact.
307#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub struct UnidicArtifactQueryDefaults {
309    /// Maximum surface span length considered for one segment.
310    pub max_span_chars: usize,
311    /// Maximum complete reading paths to keep.
312    pub max_paths: usize,
313    /// Whether longest-match-only expansion should be used by default.
314    pub longest_match_only: bool,
315    /// Optional cap on readings used per segment.
316    pub max_readings_per_segment: Option<usize>,
317}
318
319/// License metadata for a UniDic-derived artifact.
320#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
321pub struct UnidicArtifactLicense {
322    /// Selected license label for the artifact.
323    pub selected_license: String,
324    /// Bundle-relative license or notice files.
325    pub references: Vec<UnidicArtifactLicenseReference>,
326}
327
328/// One license or notice file referenced by artifact metadata.
329#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
330pub struct UnidicArtifactLicenseReference {
331    /// Human-readable reference label.
332    pub label: String,
333    /// Bundle-relative file path.
334    pub path: String,
335}
336
337/// Portable YAML representation of a UniDic reading index.
338#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
339pub struct UnidicReadingIndexPayload {
340    /// Payload schema version.
341    pub schema_version: u32,
342    /// Payload type identifier.
343    pub payload_type: String,
344    /// Surface entries and readings.
345    pub entries: Vec<UnidicReadingIndexPayloadEntry>,
346}
347
348/// One surface entry in a UniDic reading-index payload.
349#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
350pub struct UnidicReadingIndexPayloadEntry {
351    /// Surface form.
352    pub surface: String,
353    /// Readings associated with the surface form.
354    pub readings: Vec<String>,
355}
356
357/// Inputs used to generate artifact metadata for an index.
358#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct UnidicArtifactMetadataOptions {
360    /// Human-readable artifact name.
361    pub artifact_name: String,
362    /// Tool or command that generated the artifact.
363    pub generator: String,
364    /// Bundle-relative payload file name.
365    pub payload_file_name: String,
366    /// Payload serialization format.
367    pub payload_format: String,
368    /// Source dictionary name.
369    pub source_name: String,
370    /// Source dictionary version.
371    pub source_version: String,
372    /// Source `lex.csv` path.
373    pub source_lex_csv: String,
374    /// Index build settings.
375    pub index_options: UnidicIndexOptions,
376    /// Default query settings.
377    pub query_defaults: DictionaryReadingOptions,
378    /// License metadata and references.
379    pub license: UnidicArtifactLicense,
380}
381
382/// Options used while building a UniDic reading index.
383#[derive(Clone, Copy, Debug, Eq, PartialEq)]
384pub struct UnidicIndexOptions {
385    /// UniDic CSV field used as the source reading.
386    pub reading_field: UnidicReadingField,
387    /// Optional cap on readings stored for each surface form.
388    pub max_readings_per_surface: Option<usize>,
389    /// Exclude ASCII-only dictionary surfaces.
390    pub exclude_ascii_surfaces: bool,
391    /// Exclude entries whose coarse part of speech is a symbol.
392    pub exclude_symbol_pos: bool,
393}
394
395impl UnidicReadingField {
396    fn column(self) -> usize {
397        match self {
398            Self::LForm => LFORM_COLUMN,
399            Self::Pron => PRON_COLUMN,
400        }
401    }
402
403    /// Returns the stable artifact string for this reading field.
404    pub fn as_str(self) -> &'static str {
405        match self {
406            Self::LForm => "lform",
407            Self::Pron => "pron",
408        }
409    }
410}
411
412impl Default for UnidicIndexOptions {
413    fn default() -> Self {
414        Self {
415            reading_field: UnidicReadingField::Pron,
416            max_readings_per_surface: None,
417            exclude_ascii_surfaces: true,
418            exclude_symbol_pos: true,
419        }
420    }
421}
422
423impl Default for DictionaryReadingOptions {
424    fn default() -> Self {
425        Self {
426            max_span_chars: DEFAULT_QUERY_SPAN_CHARS,
427            max_paths: DEFAULT_QUERY_PATHS,
428            longest_match_only: false,
429            max_readings_per_segment: None,
430        }
431    }
432}
433
434impl DictionaryReadingOptions {
435    /// Validates expansion options before they are used at public or metadata
436    /// trust boundaries.
437    pub fn validate(self) -> Result<Self, UnidicArtifactPayloadError> {
438        check_limit("max_span_chars", self.max_span_chars, MAX_QUERY_SPAN_CHARS)?;
439        check_limit("max_paths", self.max_paths, MAX_QUERY_PATHS)?;
440        if let Some(max_readings) = self.max_readings_per_segment {
441            check_limit(
442                "max_readings_per_segment",
443                max_readings,
444                MAX_QUERY_READINGS_PER_SEGMENT,
445            )?;
446        }
447        Ok(self)
448    }
449}
450
451impl Default for UnidicArtifactLicense {
452    fn default() -> Self {
453        Self {
454            selected_license: "BSD-3-Clause".to_string(),
455            references: vec![
456                UnidicArtifactLicenseReference {
457                    label: "BSD".to_string(),
458                    path: "license/BSD".to_string(),
459                },
460                UnidicArtifactLicenseReference {
461                    label: "COPYING".to_string(),
462                    path: "license/COPYING".to_string(),
463                },
464            ],
465        }
466    }
467}
468
469/// Errors returned while reading UniDic CSV resources.
470#[derive(Debug)]
471pub enum UnidicCsvError {
472    /// CSV parser error.
473    Csv(csv::Error),
474    /// Filesystem or reader error.
475    Io(std::io::Error),
476    /// A required CSV column was missing.
477    MissingColumn {
478        /// Zero-based record index.
479        record_index: u64,
480        /// Required column index.
481        column: usize,
482        /// Number of columns in the record.
483        len: usize,
484    },
485}
486
487/// Errors returned while reading or validating UniDic artifact payloads.
488#[derive(Debug)]
489pub enum UnidicArtifactPayloadError {
490    /// Filesystem or reader error.
491    Io(std::io::Error),
492    /// YAML parser error.
493    Yaml(serde_yaml::Error),
494    /// Binary payload magic did not match the expected value.
495    InvalidBinaryMagic {
496        /// Magic bytes read from the payload.
497        magic: [u8; 8],
498    },
499    /// Binary payload version is not supported.
500    UnsupportedBinaryVersion {
501        /// Version read from the payload.
502        version: u32,
503    },
504    /// Reserved binary header field was non-zero.
505    NonZeroBinaryReserved {
506        /// Reserved value read from the payload.
507        value: u32,
508    },
509    /// Binary payload ended before a field could be read.
510    TruncatedBinary {
511        /// Field being read.
512        field: &'static str,
513    },
514    /// Binary payload contained invalid UTF-8.
515    InvalidBinaryUtf8 {
516        /// Field being decoded.
517        field: &'static str,
518        /// UTF-8 conversion error.
519        source: FromUtf8Error,
520    },
521    /// Binary field length exceeded supported bounds.
522    BinaryValueTooLarge {
523        /// Field being read.
524        field: &'static str,
525        /// Field length.
526        len: usize,
527    },
528    /// Binary payload entry count exceeded supported bounds.
529    BinaryEntryCountTooLarge {
530        /// Entry count read from the payload.
531        entries: u64,
532    },
533    /// Artifact payload exceeded a configured safety limit.
534    ArtifactLimitExceeded {
535        /// Field whose length or count exceeded the limit.
536        field: &'static str,
537        /// Observed length or count.
538        len: u64,
539        /// Maximum allowed length or count.
540        max: u64,
541    },
542    /// Indexed payload magic did not match the expected value.
543    InvalidIndexedMagic {
544        /// Magic bytes read from the payload.
545        magic: [u8; 8],
546    },
547    /// Indexed payload version is not supported.
548    UnsupportedIndexedVersion {
549        /// Version read from the payload.
550        version: u32,
551    },
552    /// Reserved indexed header field was non-zero.
553    NonZeroIndexedReserved {
554        /// Reserved value read from the payload.
555        value: u32,
556    },
557    /// Indexed payload ended before a section could be read.
558    TruncatedIndexed {
559        /// Field or section being read.
560        field: &'static str,
561    },
562    /// Indexed payload contained an invalid FST section.
563    InvalidIndexedFst {
564        /// FST error message.
565        message: String,
566    },
567    /// Indexed payload section length exceeded supported bounds.
568    IndexedSectionTooLarge {
569        /// Section name.
570        field: &'static str,
571        /// Section length.
572        len: u64,
573    },
574    /// Indexed payload referenced an invalid readings offset.
575    InvalidIndexedOffset {
576        /// Offset read from the FST value.
577        offset: u64,
578    },
579    /// Indexed payload contained invalid UTF-8.
580    InvalidIndexedUtf8 {
581        /// Field being decoded.
582        field: &'static str,
583        /// UTF-8 conversion error.
584        source: std::str::Utf8Error,
585    },
586    /// Indexed header entry count disagreed with the FST entry count.
587    IndexedEntryCountMismatch {
588        /// Entry count recorded in the header.
589        header_entries: usize,
590        /// Entry count decoded from the FST.
591        fst_entries: usize,
592    },
593    /// YAML payload schema version is not supported.
594    UnsupportedSchemaVersion {
595        /// Version read from the payload.
596        version: u32,
597    },
598    /// YAML payload type is not a UniDic reading index.
599    UnsupportedPayloadType {
600        /// Payload type read from the payload.
601        payload_type: String,
602    },
603    /// Payload entry had an empty surface form.
604    EmptySurface {
605        /// Zero-based entry index.
606        entry_index: usize,
607    },
608    /// Surface form appeared more than once.
609    DuplicateSurface {
610        /// Duplicated surface form.
611        surface: String,
612    },
613    /// Payload entry had no readings.
614    EmptyReadings {
615        /// Surface form for the invalid entry.
616        surface: String,
617    },
618    /// Payload entry contained an empty reading.
619    EmptyReading {
620        /// Surface form for the invalid entry.
621        surface: String,
622        /// Zero-based reading index.
623        reading_index: usize,
624    },
625    /// Payload entry contained the same reading more than once.
626    DuplicateReading {
627        /// Surface form for the invalid entry.
628        surface: String,
629        /// Duplicated reading.
630        reading: String,
631    },
632}
633
634impl fmt::Display for UnidicCsvError {
635    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
636        match self {
637            Self::Csv(err) => write!(f, "invalid UniDic CSV: {err}"),
638            Self::Io(err) => write!(f, "failed to read UniDic CSV: {err}"),
639            Self::MissingColumn {
640                record_index,
641                column,
642                len,
643            } => write!(
644                f,
645                "UniDic CSV record {record_index} has no column {column}; record has {len} columns"
646            ),
647        }
648    }
649}
650
651impl Error for UnidicCsvError {}
652
653impl fmt::Display for UnidicArtifactPayloadError {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        match self {
656            Self::Io(err) => write!(f, "failed to read UniDic artifact payload: {err}"),
657            Self::Yaml(err) => write!(f, "invalid UniDic artifact payload YAML: {err}"),
658            Self::InvalidBinaryMagic { magic } => {
659                write!(f, "invalid UniDic binary artifact magic {magic:?}")
660            }
661            Self::UnsupportedBinaryVersion { version } => {
662                write!(f, "unsupported UniDic binary artifact version {version}")
663            }
664            Self::NonZeroBinaryReserved { value } => {
665                write!(f, "UniDic binary artifact reserved header field is {value}")
666            }
667            Self::TruncatedBinary { field } => {
668                write!(f, "truncated UniDic binary artifact while reading {field}")
669            }
670            Self::InvalidBinaryUtf8 { field, source } => {
671                write!(f, "invalid UTF-8 in UniDic binary artifact {field}: {source}")
672            }
673            Self::BinaryValueTooLarge { field, len } => write!(
674                f,
675                "UniDic binary artifact {field} length {len} exceeds u32::MAX"
676            ),
677            Self::BinaryEntryCountTooLarge { entries } => write!(
678                f,
679                "UniDic binary artifact entry count {entries} exceeds usize::MAX"
680            ),
681            Self::ArtifactLimitExceeded { field, len, max } => write!(
682                f,
683                "UniDic artifact {field} length/count {len} exceeds limit {max}"
684            ),
685            Self::InvalidIndexedMagic { magic } => {
686                write!(f, "invalid UniDic indexed artifact magic {magic:?}")
687            }
688            Self::UnsupportedIndexedVersion { version } => {
689                write!(f, "unsupported UniDic indexed artifact version {version}")
690            }
691            Self::NonZeroIndexedReserved { value } => {
692                write!(f, "UniDic indexed artifact reserved header field is {value}")
693            }
694            Self::TruncatedIndexed { field } => {
695                write!(f, "truncated UniDic indexed artifact while reading {field}")
696            }
697            Self::InvalidIndexedFst { message } => {
698                write!(f, "invalid UniDic indexed artifact FST: {message}")
699            }
700            Self::IndexedSectionTooLarge { field, len } => write!(
701                f,
702                "UniDic indexed artifact {field} length {len} exceeds usize::MAX"
703            ),
704            Self::InvalidIndexedOffset { offset } => {
705                write!(f, "invalid UniDic indexed artifact readings offset {offset}")
706            }
707            Self::InvalidIndexedUtf8 { field, source } => {
708                write!(f, "invalid UTF-8 in UniDic indexed artifact {field}: {source}")
709            }
710            Self::IndexedEntryCountMismatch {
711                header_entries,
712                fst_entries,
713            } => write!(
714                f,
715                "UniDic indexed artifact header entry count {header_entries} does not match FST entry count {fst_entries}"
716            ),
717            Self::UnsupportedSchemaVersion { version } => write!(
718                f,
719                "unsupported UniDic artifact payload schema version {version}"
720            ),
721            Self::UnsupportedPayloadType { payload_type } => {
722                write!(f, "unsupported UniDic artifact payload type {payload_type:?}")
723            }
724            Self::EmptySurface { entry_index } => write!(
725                f,
726                "UniDic artifact payload entry {entry_index} has an empty surface"
727            ),
728            Self::DuplicateSurface { surface } => {
729                write!(f, "UniDic artifact payload has duplicate surface {surface:?}")
730            }
731            Self::EmptyReadings { surface } => write!(
732                f,
733                "UniDic artifact payload surface {surface:?} has no readings"
734            ),
735            Self::EmptyReading {
736                surface,
737                reading_index,
738            } => write!(
739                f,
740                "UniDic artifact payload surface {surface:?} has an empty reading at index {reading_index}"
741            ),
742            Self::DuplicateReading { surface, reading } => write!(
743                f,
744                "UniDic artifact payload surface {surface:?} has duplicate reading {reading:?}"
745            ),
746        }
747    }
748}
749
750impl Error for UnidicArtifactPayloadError {
751    fn source(&self) -> Option<&(dyn Error + 'static)> {
752        match self {
753            Self::Io(err) => Some(err),
754            Self::Yaml(err) => Some(err),
755            Self::InvalidBinaryUtf8 { source, .. } => Some(source),
756            Self::InvalidIndexedUtf8 { source, .. } => Some(source),
757            _ => None,
758        }
759    }
760}
761
762impl From<csv::Error> for UnidicCsvError {
763    fn from(err: csv::Error) -> Self {
764        Self::Csv(err)
765    }
766}
767
768impl From<std::io::Error> for UnidicCsvError {
769    fn from(err: std::io::Error) -> Self {
770        Self::Io(err)
771    }
772}
773
774impl From<std::io::Error> for UnidicArtifactPayloadError {
775    fn from(err: std::io::Error) -> Self {
776        Self::Io(err)
777    }
778}
779
780impl From<serde_yaml::Error> for UnidicArtifactPayloadError {
781    fn from(err: serde_yaml::Error) -> Self {
782        Self::Yaml(err)
783    }
784}
785
786impl UnidicReadingIndex {
787    /// Builds an index from a UniDic `lex.csv` file.
788    pub fn from_lex_csv_path(path: impl AsRef<Path>) -> Result<Self, UnidicCsvError> {
789        Self::from_lex_csv_path_with_options(path, UnidicIndexOptions::default())
790    }
791
792    /// Builds an index from a UniDic `lex.csv` file using a specific reading field.
793    pub fn from_lex_csv_path_with_field(
794        path: impl AsRef<Path>,
795        field: UnidicReadingField,
796    ) -> Result<Self, UnidicCsvError> {
797        Self::from_lex_csv_path_with_options(
798            path,
799            UnidicIndexOptions {
800                reading_field: field,
801                ..UnidicIndexOptions::default()
802            },
803        )
804    }
805
806    /// Builds an index from a UniDic `lex.csv` file with custom options.
807    pub fn from_lex_csv_path_with_options(
808        path: impl AsRef<Path>,
809        options: UnidicIndexOptions,
810    ) -> Result<Self, UnidicCsvError> {
811        let file = File::open(path)?;
812        Self::from_lex_csv_reader_with_options(file, options)
813    }
814
815    /// Builds an index from a reader containing UniDic `lex.csv` data.
816    pub fn from_lex_csv_reader(reader: impl Read) -> Result<Self, UnidicCsvError> {
817        Self::from_lex_csv_reader_with_options(reader, UnidicIndexOptions::default())
818    }
819
820    /// Builds an index from a UniDic `lex.csv` reader using a specific reading field.
821    pub fn from_lex_csv_reader_with_field(
822        reader: impl Read,
823        reading_field: UnidicReadingField,
824    ) -> Result<Self, UnidicCsvError> {
825        Self::from_lex_csv_reader_with_options(
826            reader,
827            UnidicIndexOptions {
828                reading_field,
829                ..UnidicIndexOptions::default()
830            },
831        )
832    }
833
834    /// Builds an index from a UniDic `lex.csv` reader with custom options.
835    pub fn from_lex_csv_reader_with_options(
836        reader: impl Read,
837        options: UnidicIndexOptions,
838    ) -> Result<Self, UnidicCsvError> {
839        let mut by_surface = HashMap::<String, BTreeSet<String>>::new();
840        for record in lex_csv_reader(reader).records() {
841            let record = record?;
842            let surface = field(&record, SURFACE_COLUMN)?;
843            let reading = field(&record, options.reading_field.column())?;
844
845            if surface == "*" || reading == "*" {
846                continue;
847            }
848            if options.exclude_ascii_surfaces && surface.is_ascii() {
849                continue;
850            }
851            if options.exclude_symbol_pos && is_symbol_pos(field(&record, POS1_COLUMN)?) {
852                continue;
853            }
854
855            insert_surface_reading(&mut by_surface, surface, reading);
856            if let Some(normalized_surface) = normalize_ascii_width(surface) {
857                insert_surface_reading(&mut by_surface, &normalized_surface, reading);
858            }
859        }
860
861        let readings_by_surface = by_surface
862            .into_iter()
863            .map(|(surface, readings)| {
864                let mut readings = readings.into_iter().collect::<Vec<_>>();
865                if let Some(max_readings) = options.max_readings_per_surface {
866                    readings.truncate(max_readings);
867                }
868                (surface, readings)
869            })
870            .filter(|(_, readings)| !readings.is_empty())
871            .collect();
872
873        Ok(Self::from_readings_by_surface(readings_by_surface))
874    }
875
876    /// Loads a YAML artifact payload from a file path.
877    pub fn from_artifact_payload_path(
878        path: impl AsRef<Path>,
879    ) -> Result<Self, UnidicArtifactPayloadError> {
880        let path = path.as_ref();
881        check_payload_file_size(path)?;
882        let file = File::open(path)?;
883        Self::from_artifact_payload_reader(file)
884    }
885
886    /// Loads a YAML artifact payload from a reader.
887    pub fn from_artifact_payload_reader(
888        reader: impl Read,
889    ) -> Result<Self, UnidicArtifactPayloadError> {
890        let payload = serde_yaml::from_reader(reader)?;
891        Self::from_artifact_payload(payload)
892    }
893
894    /// Builds an index from a deserialized artifact payload.
895    pub fn from_artifact_payload(
896        payload: UnidicReadingIndexPayload,
897    ) -> Result<Self, UnidicArtifactPayloadError> {
898        validate_artifact_payload_header(&payload)?;
899        check_limit("entry_count", payload.entries.len(), MAX_ARTIFACT_ENTRIES)?;
900
901        let mut readings_by_surface = HashMap::new();
902        for (entry_index, entry) in payload.entries.into_iter().enumerate() {
903            check_limit(
904                "surface_bytes",
905                entry.surface.len(),
906                MAX_ARTIFACT_STRING_BYTES,
907            )?;
908            check_limit(
909                "reading_count",
910                entry.readings.len(),
911                MAX_ARTIFACT_READINGS_PER_ENTRY,
912            )?;
913            if entry.surface.is_empty() {
914                return Err(UnidicArtifactPayloadError::EmptySurface { entry_index });
915            }
916            if entry.readings.is_empty() {
917                return Err(UnidicArtifactPayloadError::EmptyReadings {
918                    surface: entry.surface,
919                });
920            }
921
922            let mut seen_readings = BTreeSet::new();
923            for (reading_index, reading) in entry.readings.iter().enumerate() {
924                check_limit("reading_bytes", reading.len(), MAX_ARTIFACT_STRING_BYTES)?;
925                if reading.is_empty() {
926                    return Err(UnidicArtifactPayloadError::EmptyReading {
927                        surface: entry.surface,
928                        reading_index,
929                    });
930                }
931                if !seen_readings.insert(reading) {
932                    return Err(UnidicArtifactPayloadError::DuplicateReading {
933                        surface: entry.surface,
934                        reading: reading.clone(),
935                    });
936                }
937            }
938
939            if readings_by_surface
940                .insert(entry.surface.clone(), entry.readings)
941                .is_some()
942            {
943                return Err(UnidicArtifactPayloadError::DuplicateSurface {
944                    surface: entry.surface,
945                });
946            }
947        }
948
949        Ok(Self::from_readings_by_surface(readings_by_surface))
950    }
951
952    /// Loads a binary artifact payload from a file path.
953    pub fn from_binary_artifact_payload_path(
954        path: impl AsRef<Path>,
955    ) -> Result<Self, UnidicArtifactPayloadError> {
956        let path = path.as_ref();
957        check_payload_file_size(path)?;
958        let file = File::open(path)?;
959        Self::from_binary_artifact_payload_reader(file)
960    }
961
962    /// Loads a binary artifact payload from a reader.
963    pub fn from_binary_artifact_payload_reader(
964        mut reader: impl Read,
965    ) -> Result<Self, UnidicArtifactPayloadError> {
966        let header = read_binary_artifact_payload_header(&mut reader)?;
967        check_limit("entry_count", header.entries, MAX_ARTIFACT_ENTRIES)?;
968        let mut entries = Vec::with_capacity(header.entries);
969        for _ in 0..header.entries {
970            let surface = read_binary_string(&mut reader, "surface")?;
971            let reading_count = read_u32_le(&mut reader, "reading_count")?;
972            let reading_count = usize::try_from(reading_count).expect("u32 fits usize");
973            check_limit(
974                "reading_count",
975                reading_count,
976                MAX_ARTIFACT_READINGS_PER_ENTRY,
977            )?;
978            let mut readings = Vec::with_capacity(reading_count);
979            for _ in 0..reading_count {
980                readings.push(read_binary_string(&mut reader, "reading")?);
981            }
982            entries.push(UnidicReadingIndexPayloadEntry { surface, readings });
983        }
984
985        Self::from_artifact_payload(UnidicReadingIndexPayload {
986            schema_version: ARTIFACT_PAYLOAD_SCHEMA_VERSION,
987            payload_type: ARTIFACT_PAYLOAD_TYPE.to_string(),
988            entries,
989        })
990    }
991
992    /// Loads an indexed FST artifact payload from a file path.
993    ///
994    /// The payload is copied into owned immutable bytes before the index is
995    /// returned, so later changes to the source file cannot affect lookups.
996    pub fn from_indexed_artifact_payload_path(
997        path: impl AsRef<Path>,
998    ) -> Result<Self, UnidicArtifactPayloadError> {
999        let path = path.as_ref();
1000        let bytes = read_payload_file_bounded(path)?;
1001        Self::from_indexed_owned_bytes(bytes)
1002    }
1003
1004    /// Loads an indexed artifact payload from bytes.
1005    ///
1006    /// The payload is copied into owned immutable bytes and decoded lazily
1007    /// during lookup. This is intended for environments such as WebAssembly
1008    /// where file-path loading is not available.
1009    ///
1010    /// # Errors
1011    ///
1012    /// Returns an error when the payload is too large, malformed, truncated,
1013    /// has an invalid FST section, or fails canonical artifact validation.
1014    pub fn from_indexed_artifact_payload_bytes(
1015        bytes: &[u8],
1016    ) -> Result<Self, UnidicArtifactPayloadError> {
1017        if bytes.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
1018            return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
1019                field: "payload_bytes",
1020                len: bytes.len() as u64,
1021                max: MAX_ARTIFACT_PAYLOAD_BYTES,
1022            });
1023        }
1024        Self::from_indexed_owned_bytes(bytes.to_vec())
1025    }
1026
1027    fn from_indexed_owned_bytes(bytes: Vec<u8>) -> Result<Self, UnidicArtifactPayloadError> {
1028        let bytes: Arc<[u8]> = Arc::from(bytes);
1029        if bytes.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
1030            return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
1031                field: "payload_bytes",
1032                len: bytes.len() as u64,
1033                max: MAX_ARTIFACT_PAYLOAD_BYTES,
1034            });
1035        }
1036        let header = read_indexed_artifact_payload_header_bytes(bytes.as_ref())?;
1037        let fst_start = INDEXED_ARTIFACT_HEADER_LEN;
1038        let fst_end = fst_start.checked_add(header.fst_len).ok_or(
1039            UnidicArtifactPayloadError::TruncatedIndexed {
1040                field: "fst_section",
1041            },
1042        )?;
1043        let readings_end = fst_end.checked_add(header.readings_len).ok_or(
1044            UnidicArtifactPayloadError::TruncatedIndexed {
1045                field: "readings_section",
1046            },
1047        )?;
1048        if bytes.len() < readings_end {
1049            return Err(UnidicArtifactPayloadError::TruncatedIndexed {
1050                field: "indexed_payload",
1051            });
1052        }
1053
1054        let map = Map::new(bytes[fst_start..fst_end].to_vec()).map_err(|err| {
1055            UnidicArtifactPayloadError::InvalidIndexedFst {
1056                message: err.to_string(),
1057            }
1058        })?;
1059        let fst_entries = map.len();
1060        if fst_entries != header.entries {
1061            return Err(UnidicArtifactPayloadError::IndexedEntryCountMismatch {
1062                header_entries: header.entries,
1063                fst_entries,
1064            });
1065        }
1066
1067        let indexed = IndexedUnidicPayload {
1068            bytes,
1069            map,
1070            readings_start: fst_end,
1071            entries: header.entries,
1072        };
1073        indexed.validate()?;
1074        Ok(Self {
1075            storage: UnidicReadingStorage::Indexed(indexed),
1076        })
1077    }
1078
1079    /// Reads only the header from a binary artifact payload file.
1080    pub fn binary_artifact_payload_header_path(
1081        path: impl AsRef<Path>,
1082    ) -> Result<UnidicBinaryArtifactPayloadHeader, UnidicArtifactPayloadError> {
1083        let file = File::open(path)?;
1084        Self::binary_artifact_payload_header_reader(file)
1085    }
1086
1087    /// Reads only the header from a binary artifact payload reader.
1088    pub fn binary_artifact_payload_header_reader(
1089        mut reader: impl Read,
1090    ) -> Result<UnidicBinaryArtifactPayloadHeader, UnidicArtifactPayloadError> {
1091        read_binary_artifact_payload_header(&mut reader)
1092    }
1093
1094    pub(crate) fn from_readings_by_surface(
1095        readings_by_surface: HashMap<String, Vec<String>>,
1096    ) -> Self {
1097        Self {
1098            storage: UnidicReadingStorage::Eager(readings_by_surface),
1099        }
1100    }
1101
1102    /// Returns readings for `surface`, if present.
1103    ///
1104    /// For indexed artifacts, decode errors are treated the same as a missing
1105    /// surface for backward compatibility. Use [`Self::try_readings`] at trust
1106    /// boundaries when artifact corruption must be reported distinctly.
1107    pub fn readings(&self, surface: &str) -> Option<Cow<'_, [String]>> {
1108        self.try_readings(surface).ok().flatten()
1109    }
1110
1111    /// Returns readings for `surface` and preserves indexed artifact decode
1112    /// errors.
1113    pub fn try_readings(
1114        &self,
1115        surface: &str,
1116    ) -> Result<Option<Cow<'_, [String]>>, UnidicArtifactPayloadError> {
1117        if let Some(readings) = self.try_readings_exact(surface)? {
1118            return Ok(Some(readings));
1119        }
1120
1121        let Some(normalized_surface) = normalize_ascii_width(surface) else {
1122            return Ok(None);
1123        };
1124        if normalized_surface == surface {
1125            return Ok(None);
1126        }
1127
1128        self.try_readings_exact(&normalized_surface)
1129    }
1130
1131    fn try_readings_exact(
1132        &self,
1133        surface: &str,
1134    ) -> Result<Option<Cow<'_, [String]>>, UnidicArtifactPayloadError> {
1135        match &self.storage {
1136            UnidicReadingStorage::Eager(readings_by_surface) => Ok(readings_by_surface
1137                .get(surface)
1138                .map(|readings| Cow::Borrowed(readings.as_slice()))),
1139            UnidicReadingStorage::Indexed(indexed) => indexed
1140                .readings(surface)
1141                .map(|readings| readings.map(Cow::Owned)),
1142        }
1143    }
1144
1145    /// Returns the number of indexed surface forms.
1146    pub fn len(&self) -> usize {
1147        match &self.storage {
1148            UnidicReadingStorage::Eager(readings_by_surface) => readings_by_surface.len(),
1149            UnidicReadingStorage::Indexed(indexed) => indexed.entries,
1150        }
1151    }
1152
1153    /// Returns `true` when the index contains no surface forms.
1154    pub fn is_empty(&self) -> bool {
1155        self.len() == 0
1156    }
1157
1158    /// Builds bundle metadata for the current index and caller-provided
1159    /// provenance.
1160    ///
1161    /// The returned metadata includes a canonical payload checksum computed
1162    /// from the normalized payload view.
1163    pub fn artifact_metadata(
1164        &self,
1165        options: UnidicArtifactMetadataOptions,
1166    ) -> UnidicArtifactMetadata {
1167        let build = UnidicArtifactBuild {
1168            reading_field: options.index_options.reading_field.as_str().to_string(),
1169            max_readings_per_surface: options.index_options.max_readings_per_surface,
1170            exclude_ascii_surfaces: options.index_options.exclude_ascii_surfaces,
1171            exclude_symbol_pos: options.index_options.exclude_symbol_pos,
1172            include_normalized_surfaces: false,
1173            exclude_unsupported_readings: false,
1174            entries: self.len(),
1175        };
1176        self.artifact_metadata_with_build(options, build)
1177    }
1178
1179    /// Builds bundle metadata with caller-provided build provenance.
1180    ///
1181    /// This is used by Japanese dictionary sources that reuse the same reading
1182    /// payload format but do not share UniDic's CSV field layout.
1183    pub fn artifact_metadata_with_build(
1184        &self,
1185        options: UnidicArtifactMetadataOptions,
1186        mut build: UnidicArtifactBuild,
1187    ) -> UnidicArtifactMetadata {
1188        build.entries = self.len();
1189        UnidicArtifactMetadata {
1190            schema_version: 1,
1191            artifact_type: "moine.unidic.reading-index".to_string(),
1192            artifact_name: options.artifact_name,
1193            generator: options.generator,
1194            payload: UnidicArtifactPayload {
1195                path: options.payload_file_name,
1196                format: options.payload_format,
1197                file_digest_algorithm: None,
1198                file_digest: None,
1199                checksum_algorithm: ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM.to_string(),
1200                checksum: self.artifact_payload_checksum(),
1201            },
1202            source: UnidicArtifactSource {
1203                name: options.source_name,
1204                version: options.source_version,
1205                lex_csv: options.source_lex_csv,
1206            },
1207            build,
1208            query_defaults: UnidicArtifactQueryDefaults {
1209                max_span_chars: options.query_defaults.max_span_chars,
1210                max_paths: options.query_defaults.max_paths,
1211                longest_match_only: options.query_defaults.longest_match_only,
1212                max_readings_per_segment: options.query_defaults.max_readings_per_segment,
1213            },
1214            license: options.license,
1215        }
1216    }
1217
1218    /// Returns the normalized YAML-compatible payload view for this index.
1219    ///
1220    /// Entries are sorted by surface form so serialization and checksums are
1221    /// deterministic regardless of the index storage backend.
1222    pub fn artifact_payload(&self) -> UnidicReadingIndexPayload {
1223        let entries = match &self.storage {
1224            UnidicReadingStorage::Eager(readings_by_surface) => {
1225                let mut entries = readings_by_surface
1226                    .iter()
1227                    .map(|(surface, readings)| UnidicReadingIndexPayloadEntry {
1228                        surface: surface.clone(),
1229                        readings: readings.clone(),
1230                    })
1231                    .collect::<Vec<_>>();
1232                entries.sort_by(|left, right| left.surface.cmp(&right.surface));
1233                entries
1234            }
1235            UnidicReadingStorage::Indexed(indexed) => indexed
1236                .entries()
1237                .expect("validated indexed artifact should decode"),
1238        };
1239
1240        UnidicReadingIndexPayload {
1241            schema_version: ARTIFACT_PAYLOAD_SCHEMA_VERSION,
1242            payload_type: ARTIFACT_PAYLOAD_TYPE.to_string(),
1243            entries,
1244        }
1245    }
1246
1247    /// Returns the canonical checksum for the normalized payload.
1248    pub fn artifact_payload_checksum(&self) -> String {
1249        self.artifact_payload_checksum_for_algorithm(ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
1250            .expect("default artifact checksum algorithm should be supported")
1251    }
1252
1253    /// Returns a canonical payload checksum for `algorithm`.
1254    ///
1255    /// Supported values are [`ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM`] and
1256    /// [`LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM`]. Unknown algorithms return
1257    /// `None`.
1258    pub fn artifact_payload_checksum_for_algorithm(&self, algorithm: &str) -> Option<String> {
1259        let payload = self.artifact_payload();
1260        let bytes = canonical_payload_bytes(&payload);
1261        match algorithm {
1262            ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM => Some(sha256_hex(&bytes)),
1263            LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM => Some(format!("{:016x}", fnv1a64(&bytes))),
1264            _ => None,
1265        }
1266    }
1267
1268    /// Writes the legacy binary artifact payload format.
1269    ///
1270    /// Prefer [`Self::write_indexed_artifact_payload`] for newly generated
1271    /// bundles; this format is kept for compatibility with older artifacts.
1272    pub fn write_artifact_binary_payload(
1273        &self,
1274        mut writer: impl Write,
1275    ) -> Result<(), UnidicArtifactPayloadError> {
1276        let payload = self.artifact_payload();
1277        writer.write_all(BINARY_ARTIFACT_MAGIC)?;
1278        writer.write_all(&BINARY_ARTIFACT_VERSION.to_le_bytes())?;
1279        writer.write_all(&0_u32.to_le_bytes())?;
1280        writer.write_all(&(payload.entries.len() as u64).to_le_bytes())?;
1281
1282        for entry in &payload.entries {
1283            write_binary_string(&mut writer, "surface", &entry.surface)?;
1284            write_u32_len(&mut writer, "reading_count", entry.readings.len())?;
1285            for reading in &entry.readings {
1286                write_binary_string(&mut writer, "reading", reading)?;
1287            }
1288        }
1289
1290        Ok(())
1291    }
1292
1293    /// Writes the indexed FST-backed artifact payload format.
1294    ///
1295    /// The payload stores a finite-state transducer from surface form to an
1296    /// offset in a compact reading blob and can be loaded with
1297    /// [`Self::from_indexed_artifact_payload_path`].
1298    pub fn write_indexed_artifact_payload(
1299        &self,
1300        mut writer: impl Write,
1301    ) -> Result<(), UnidicArtifactPayloadError> {
1302        let payload = self.artifact_payload();
1303        let mut fst_bytes = Vec::new();
1304        let mut readings_bytes = Vec::new();
1305        {
1306            let mut builder = MapBuilder::new(&mut fst_bytes).map_err(|err| {
1307                UnidicArtifactPayloadError::InvalidIndexedFst {
1308                    message: err.to_string(),
1309                }
1310            })?;
1311            for entry in &payload.entries {
1312                let offset = readings_bytes.len() as u64;
1313                builder.insert(&entry.surface, offset).map_err(|err| {
1314                    UnidicArtifactPayloadError::InvalidIndexedFst {
1315                        message: err.to_string(),
1316                    }
1317                })?;
1318                write_indexed_reading_block(&mut readings_bytes, &entry.readings)?;
1319            }
1320            builder
1321                .finish()
1322                .map_err(|err| UnidicArtifactPayloadError::InvalidIndexedFst {
1323                    message: err.to_string(),
1324                })?;
1325        }
1326
1327        writer.write_all(INDEXED_ARTIFACT_MAGIC)?;
1328        writer.write_all(&INDEXED_ARTIFACT_VERSION.to_le_bytes())?;
1329        writer.write_all(&0_u32.to_le_bytes())?;
1330        writer.write_all(&(payload.entries.len() as u64).to_le_bytes())?;
1331        writer.write_all(&(fst_bytes.len() as u64).to_le_bytes())?;
1332        writer.write_all(&(readings_bytes.len() as u64).to_le_bytes())?;
1333        writer.write_all(&fst_bytes)?;
1334        writer.write_all(&readings_bytes)?;
1335        Ok(())
1336    }
1337
1338    /// Expands `text` into joined kana reading strings.
1339    ///
1340    /// This is a compatibility helper over [`Self::reading_paths`]. It drops
1341    /// segment boundaries and treats indexed artifact decode errors or invalid
1342    /// expansion options as an empty expansion.
1343    pub fn reading_sequences(&self, text: &str, options: DictionaryReadingOptions) -> Vec<String> {
1344        self.reading_sequences_with_stats_inner(text, options, false)
1345            .unwrap_or_default()
1346            .paths
1347    }
1348
1349    /// Expands `text` into dictionary-only reading paths.
1350    ///
1351    /// Every returned path contains surface/reading segment boundaries plus the
1352    /// joined kana reading. Use [`Self::try_reading_paths_with_stats`] when
1353    /// indexed artifact corruption or invalid expansion options must be
1354    /// reported.
1355    pub fn reading_paths(
1356        &self,
1357        text: &str,
1358        options: DictionaryReadingOptions,
1359    ) -> Vec<DictionaryReadingPath> {
1360        self.reading_paths_with_stats(text, options).paths
1361    }
1362
1363    /// Expands dictionary reading paths and treats artifact decode errors or
1364    /// invalid expansion options as an empty expansion for backward
1365    /// compatibility.
1366    ///
1367    /// Use [`Self::try_reading_paths_with_stats`] when loading indexed
1368    /// artifacts from outside the process trust boundary.
1369    pub fn reading_paths_with_stats(
1370        &self,
1371        text: &str,
1372        options: DictionaryReadingOptions,
1373    ) -> DictionaryReadingExpansion {
1374        self.try_reading_paths_with_stats(text, options)
1375            .unwrap_or_default()
1376    }
1377
1378    /// Expands dictionary reading paths and preserves indexed artifact decode
1379    /// and option validation errors.
1380    pub fn try_reading_paths_with_stats(
1381        &self,
1382        text: &str,
1383        options: DictionaryReadingOptions,
1384    ) -> Result<DictionaryReadingExpansion, UnidicArtifactPayloadError> {
1385        self.reading_paths_with_stats_inner(text, options, false)
1386    }
1387
1388    /// Expands `text` into reading paths with direct fallback segments.
1389    ///
1390    /// Dictionary matches are preferred, but kana and ASCII spans can pass
1391    /// through directly so mixed dictionary/direct input can still form a full
1392    /// path.
1393    pub fn hybrid_reading_paths(
1394        &self,
1395        text: &str,
1396        options: DictionaryReadingOptions,
1397    ) -> Vec<DictionaryReadingPath> {
1398        self.hybrid_reading_paths_with_stats(text, options).paths
1399    }
1400
1401    /// Expands hybrid dictionary/direct reading paths and treats artifact
1402    /// decode errors or invalid expansion options as an empty expansion for
1403    /// backward compatibility.
1404    ///
1405    /// Use [`Self::try_hybrid_reading_paths_with_stats`] when loading indexed
1406    /// artifacts from outside the process trust boundary.
1407    pub fn hybrid_reading_paths_with_stats(
1408        &self,
1409        text: &str,
1410        options: DictionaryReadingOptions,
1411    ) -> DictionaryReadingExpansion {
1412        self.try_hybrid_reading_paths_with_stats(text, options)
1413            .unwrap_or_default()
1414    }
1415
1416    /// Expands hybrid dictionary/direct reading paths and preserves indexed
1417    /// artifact decode and option validation errors.
1418    pub fn try_hybrid_reading_paths_with_stats(
1419        &self,
1420        text: &str,
1421        options: DictionaryReadingOptions,
1422    ) -> Result<DictionaryReadingExpansion, UnidicArtifactPayloadError> {
1423        self.reading_paths_with_stats_inner(text, options, true)
1424    }
1425
1426    fn reading_paths_with_stats_inner(
1427        &self,
1428        text: &str,
1429        options: DictionaryReadingOptions,
1430        allow_direct_fallback: bool,
1431    ) -> Result<DictionaryReadingExpansion, UnidicArtifactPayloadError> {
1432        let options = options.validate()?;
1433        if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1434            return Ok(DictionaryReadingExpansion::default());
1435        }
1436
1437        let mut stats = DictionaryReadingStats::default();
1438        let boundaries = char_boundaries(text);
1439        let char_len = boundaries.len() - 1;
1440        let mut suffix_paths = vec![Vec::<DictionaryReadingPath>::new(); char_len + 1];
1441        suffix_paths[char_len].push(DictionaryReadingPath {
1442            segments: Vec::new(),
1443            joined_reading: String::new(),
1444        });
1445
1446        for start in (0..char_len).rev() {
1447            let mut paths_by_reading = std::collections::BTreeMap::new();
1448            let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1449            let mut matching_ends = Vec::new();
1450
1451            for end in start + 1..=end_limit {
1452                let surface = &text[boundaries[start]..boundaries[end]];
1453                if !suffix_paths[end].is_empty() {
1454                    if let Some(surface_readings) = self.try_readings(surface)? {
1455                        matching_ends.push((end, surface_readings));
1456                    }
1457                }
1458            }
1459            stats.matched_spans += matching_ends.len();
1460
1461            if options.longest_match_only && !allow_direct_fallback {
1462                let pruned_spans = matching_ends.len().saturating_sub(1);
1463                if let Some(longest_match) = matching_ends.pop() {
1464                    stats.longest_match_pruned_spans += pruned_spans;
1465                    matching_ends.clear();
1466                    matching_ends.push(longest_match);
1467                }
1468            }
1469
1470            for (end, surface_readings) in matching_ends {
1471                let surface = &text[boundaries[start]..boundaries[end]];
1472
1473                stats.raw_segment_readings += surface_readings.len();
1474                let raw_surface_reading_count = surface_readings.len();
1475                let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1476                stats.used_segment_readings += surface_readings.len();
1477                stats.pruned_segment_readings += raw_surface_reading_count - surface_readings.len();
1478                for surface_reading in surface_readings {
1479                    for suffix in &suffix_paths[end] {
1480                        stats.candidate_combinations += 1;
1481                        let mut reading = String::with_capacity(
1482                            surface_reading.len() + suffix.joined_reading.len(),
1483                        );
1484                        reading.push_str(surface_reading);
1485                        reading.push_str(&suffix.joined_reading);
1486
1487                        let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1488                        segments.push(DictionaryReadingSegment {
1489                            surface: surface.to_string(),
1490                            reading: surface_reading.to_string(),
1491                            source: DictionaryReadingSegmentSource::Dictionary,
1492                        });
1493                        segments.extend(suffix.segments.iter().cloned());
1494
1495                        match paths_by_reading.entry(reading.clone()) {
1496                            Entry::Vacant(entry) => {
1497                                entry.insert(DictionaryReadingPath {
1498                                    segments,
1499                                    joined_reading: reading,
1500                                });
1501                                stats.unique_paths += 1;
1502                            }
1503                            Entry::Occupied(_) => {
1504                                stats.duplicate_joined_readings += 1;
1505                            }
1506                        }
1507
1508                        if paths_by_reading.len() >= options.max_paths {
1509                            stats.max_paths_hit_count += 1;
1510                            break;
1511                        }
1512                    }
1513
1514                    if paths_by_reading.len() >= options.max_paths {
1515                        break;
1516                    }
1517                }
1518
1519                if paths_by_reading.len() >= options.max_paths {
1520                    break;
1521                }
1522            }
1523
1524            if allow_direct_fallback && paths_by_reading.len() < options.max_paths {
1525                if let Some(end) = direct_fallback_end(text, &boundaries, start, char_len) {
1526                    if !suffix_paths[end].is_empty() {
1527                        stats.direct_fallback_spans += 1;
1528                        let surface = &text[boundaries[start]..boundaries[end]];
1529                        for suffix in &suffix_paths[end] {
1530                            stats.candidate_combinations += 1;
1531                            let mut reading =
1532                                String::with_capacity(surface.len() + suffix.joined_reading.len());
1533                            reading.push_str(surface);
1534                            reading.push_str(&suffix.joined_reading);
1535
1536                            let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1537                            segments.push(DictionaryReadingSegment {
1538                                surface: surface.to_string(),
1539                                reading: surface.to_string(),
1540                                source: DictionaryReadingSegmentSource::Direct,
1541                            });
1542                            segments.extend(suffix.segments.iter().cloned());
1543
1544                            match paths_by_reading.entry(reading.clone()) {
1545                                Entry::Vacant(entry) => {
1546                                    entry.insert(DictionaryReadingPath {
1547                                        segments,
1548                                        joined_reading: reading,
1549                                    });
1550                                    stats.unique_paths += 1;
1551                                }
1552                                Entry::Occupied(_) => {
1553                                    stats.duplicate_joined_readings += 1;
1554                                }
1555                            }
1556
1557                            if paths_by_reading.len() >= options.max_paths {
1558                                stats.max_paths_hit_count += 1;
1559                                break;
1560                            }
1561                        }
1562                    }
1563                }
1564            }
1565
1566            suffix_paths[start] = paths_by_reading.into_values().collect();
1567        }
1568
1569        Ok(DictionaryReadingExpansion {
1570            paths: suffix_paths.remove(0),
1571            stats,
1572        })
1573    }
1574
1575    fn reading_sequences_with_stats_inner(
1576        &self,
1577        text: &str,
1578        options: DictionaryReadingOptions,
1579        allow_direct_fallback: bool,
1580    ) -> Result<DictionaryReadingSequenceExpansion, UnidicArtifactPayloadError> {
1581        let options = options.validate()?;
1582        if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1583            return Ok(DictionaryReadingSequenceExpansion::default());
1584        }
1585
1586        let mut stats = DictionaryReadingStats::default();
1587        let boundaries = char_boundaries(text);
1588        let char_len = boundaries.len() - 1;
1589        let mut suffix_paths = vec![Vec::<String>::new(); char_len + 1];
1590        suffix_paths[char_len].push(String::new());
1591
1592        for start in (0..char_len).rev() {
1593            let mut paths_by_reading = BTreeSet::new();
1594            let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1595            let mut matching_ends = Vec::new();
1596
1597            for end in start + 1..=end_limit {
1598                let surface = &text[boundaries[start]..boundaries[end]];
1599                if !suffix_paths[end].is_empty() {
1600                    if let Some(surface_readings) = self.try_readings(surface)? {
1601                        matching_ends.push((end, surface_readings));
1602                    }
1603                }
1604            }
1605            stats.matched_spans += matching_ends.len();
1606
1607            if options.longest_match_only && !allow_direct_fallback {
1608                let pruned_spans = matching_ends.len().saturating_sub(1);
1609                if let Some(longest_match) = matching_ends.pop() {
1610                    stats.longest_match_pruned_spans += pruned_spans;
1611                    matching_ends.clear();
1612                    matching_ends.push(longest_match);
1613                }
1614            }
1615
1616            for (end, surface_readings) in matching_ends {
1617                stats.raw_segment_readings += surface_readings.len();
1618                let raw_surface_reading_count = surface_readings.len();
1619                let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1620                stats.used_segment_readings += surface_readings.len();
1621                stats.pruned_segment_readings += raw_surface_reading_count - surface_readings.len();
1622                for surface_reading in surface_readings {
1623                    for suffix in &suffix_paths[end] {
1624                        stats.candidate_combinations += 1;
1625                        let mut reading =
1626                            String::with_capacity(surface_reading.len() + suffix.len());
1627                        reading.push_str(surface_reading);
1628                        reading.push_str(suffix);
1629
1630                        if paths_by_reading.insert(reading) {
1631                            stats.unique_paths += 1;
1632                        } else {
1633                            stats.duplicate_joined_readings += 1;
1634                        }
1635
1636                        if paths_by_reading.len() >= options.max_paths {
1637                            stats.max_paths_hit_count += 1;
1638                            break;
1639                        }
1640                    }
1641
1642                    if paths_by_reading.len() >= options.max_paths {
1643                        break;
1644                    }
1645                }
1646
1647                if paths_by_reading.len() >= options.max_paths {
1648                    break;
1649                }
1650            }
1651
1652            if allow_direct_fallback && paths_by_reading.len() < options.max_paths {
1653                if let Some(end) = direct_fallback_end(text, &boundaries, start, char_len) {
1654                    if !suffix_paths[end].is_empty() {
1655                        stats.direct_fallback_spans += 1;
1656                        let surface = &text[boundaries[start]..boundaries[end]];
1657                        for suffix in &suffix_paths[end] {
1658                            stats.candidate_combinations += 1;
1659                            let mut reading = String::with_capacity(surface.len() + suffix.len());
1660                            reading.push_str(surface);
1661                            reading.push_str(suffix);
1662
1663                            if paths_by_reading.insert(reading) {
1664                                stats.unique_paths += 1;
1665                            } else {
1666                                stats.duplicate_joined_readings += 1;
1667                            }
1668
1669                            if paths_by_reading.len() >= options.max_paths {
1670                                stats.max_paths_hit_count += 1;
1671                                break;
1672                            }
1673                        }
1674                    }
1675                }
1676            }
1677
1678            suffix_paths[start] = paths_by_reading.into_iter().collect();
1679        }
1680
1681        Ok(DictionaryReadingSequenceExpansion {
1682            paths: suffix_paths.remove(0),
1683            stats,
1684        })
1685    }
1686
1687    /// Builds a romaji lattice from dictionary-only readings of `text`.
1688    ///
1689    /// Returns `Ok(None)` when the dictionary cannot cover the entire input.
1690    /// Indexed artifact decode errors are reported as
1691    /// [`JaLatticeError::ArtifactPayload`].
1692    pub fn romaji_lattice(
1693        &self,
1694        text: &str,
1695        options: DictionaryReadingOptions,
1696    ) -> Result<Option<Lattice>, JaLatticeError> {
1697        let readings = if options.longest_match_only {
1698            self.reading_sequences_longest_only(text, options)
1699                .map_err(|err| JaLatticeError::ArtifactPayload(err.to_string()))?
1700        } else {
1701            self.reading_sequences_with_stats_inner(text, options, false)
1702                .map_err(|err| JaLatticeError::ArtifactPayload(err.to_string()))?
1703                .paths
1704        };
1705        if readings.is_empty() {
1706            return Ok(None);
1707        }
1708
1709        crate::romaji::romaji_lattice_from_supported_readings(readings)
1710    }
1711
1712    /// Builds a romaji lattice with dictionary readings and direct fallback.
1713    ///
1714    /// This is the preferred lattice builder for mixed Japanese text where
1715    /// kana or ASCII spans may appear beside dictionary-backed surfaces.
1716    pub fn hybrid_romaji_lattice(
1717        &self,
1718        text: &str,
1719        options: DictionaryReadingOptions,
1720    ) -> Result<Option<Lattice>, JaLatticeError> {
1721        let readings = self
1722            .reading_sequences_with_stats_inner(text, options, true)
1723            .map_err(|err| JaLatticeError::ArtifactPayload(err.to_string()))?;
1724        if readings.paths.is_empty() {
1725            return Ok(None);
1726        }
1727
1728        crate::romaji::romaji_lattice_from_supported_readings(readings.paths)
1729    }
1730
1731    fn reading_sequences_longest_only(
1732        &self,
1733        text: &str,
1734        options: DictionaryReadingOptions,
1735    ) -> Result<Vec<String>, UnidicArtifactPayloadError> {
1736        let options = options.validate()?;
1737        if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1738            return Ok(Vec::new());
1739        }
1740
1741        let boundaries = char_boundaries(text);
1742        let char_len = boundaries.len() - 1;
1743        let mut suffix_paths = vec![Vec::<String>::new(); char_len + 1];
1744        suffix_paths[char_len].push(String::new());
1745
1746        for start in (0..char_len).rev() {
1747            let mut paths_by_reading = BTreeSet::new();
1748            let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1749
1750            for end in (start + 1..=end_limit).rev() {
1751                if suffix_paths[end].is_empty() {
1752                    continue;
1753                }
1754                let surface = &text[boundaries[start]..boundaries[end]];
1755                let Some(surface_readings) = self.try_readings(surface)? else {
1756                    continue;
1757                };
1758                let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1759                for surface_reading in surface_readings {
1760                    for suffix in &suffix_paths[end] {
1761                        let mut reading =
1762                            String::with_capacity(surface_reading.len() + suffix.len());
1763                        reading.push_str(surface_reading);
1764                        reading.push_str(suffix);
1765                        paths_by_reading.insert(reading);
1766                        if paths_by_reading.len() >= options.max_paths {
1767                            break;
1768                        }
1769                    }
1770
1771                    if paths_by_reading.len() >= options.max_paths {
1772                        break;
1773                    }
1774                }
1775                break;
1776            }
1777
1778            suffix_paths[start] = paths_by_reading.into_iter().collect();
1779        }
1780
1781        Ok(suffix_paths.remove(0))
1782    }
1783}
1784
1785#[derive(Clone, Debug, Default, Eq, PartialEq)]
1786struct DictionaryReadingSequenceExpansion {
1787    paths: Vec<String>,
1788    stats: DictionaryReadingStats,
1789}
1790
1791fn char_boundaries(text: &str) -> Vec<usize> {
1792    text.char_indices()
1793        .map(|(index, _)| index)
1794        .chain(std::iter::once(text.len()))
1795        .collect()
1796}
1797
1798pub(crate) fn insert_surface_reading(
1799    by_surface: &mut HashMap<String, BTreeSet<String>>,
1800    surface: &str,
1801    reading: &str,
1802) {
1803    by_surface
1804        .entry(surface.to_string())
1805        .or_default()
1806        .insert(reading.to_string());
1807}
1808
1809pub(crate) fn normalize_ascii_width(input: &str) -> Option<String> {
1810    let mut normalized = String::with_capacity(input.len());
1811    let mut changed = false;
1812
1813    for ch in input.chars() {
1814        let normalized_ch = normalize_ascii_width_char(ch);
1815        changed |= normalized_ch != ch;
1816        normalized.push(normalized_ch);
1817    }
1818
1819    changed.then_some(normalized)
1820}
1821
1822fn normalize_ascii_width_char(ch: char) -> char {
1823    match ch {
1824        '\u{3000}' => ' ',
1825        '\u{ff01}'..='\u{ff5e}' => {
1826            char::from_u32(ch as u32 - 0xfee0).expect("fullwidth ASCII maps to ASCII")
1827        }
1828        _ => ch,
1829    }
1830}
1831
1832pub(crate) fn lex_csv_reader(reader: impl Read) -> csv::Reader<impl Read> {
1833    csv::ReaderBuilder::new()
1834        .has_headers(false)
1835        .flexible(true)
1836        .from_reader(reader)
1837}
1838
1839pub(crate) fn field(record: &csv::StringRecord, column: usize) -> Result<&str, UnidicCsvError> {
1840    record
1841        .get(column)
1842        .ok_or_else(|| UnidicCsvError::MissingColumn {
1843            record_index: record
1844                .position()
1845                .map(|position| position.record())
1846                .unwrap_or(0),
1847            column,
1848            len: record.len(),
1849        })
1850}
1851
1852pub(crate) fn is_symbol_pos(pos1: &str) -> bool {
1853    pos1.contains("記号")
1854}
1855
1856fn is_false(value: &bool) -> bool {
1857    !*value
1858}
1859
1860fn limited_surface_readings(readings: &[String], options: DictionaryReadingOptions) -> &[String] {
1861    if let Some(max_readings) = options.max_readings_per_segment {
1862        &readings[..readings.len().min(max_readings)]
1863    } else {
1864        readings
1865    }
1866}
1867
1868fn direct_fallback_end(
1869    text: &str,
1870    boundaries: &[usize],
1871    start: usize,
1872    char_len: usize,
1873) -> Option<usize> {
1874    let mut end = start;
1875    while end < char_len {
1876        let surface = &text[boundaries[start]..boundaries[end + 1]];
1877        if !can_build_direct_romaji_path(surface) {
1878            break;
1879        }
1880        end += 1;
1881    }
1882
1883    (end > start).then_some(end)
1884}
1885
1886fn write_binary_string(
1887    writer: &mut impl Write,
1888    field: &'static str,
1889    value: &str,
1890) -> Result<(), UnidicArtifactPayloadError> {
1891    write_u32_len(writer, field, value.len())?;
1892    writer.write_all(value.as_bytes())?;
1893    Ok(())
1894}
1895
1896fn write_u32_len(
1897    writer: &mut impl Write,
1898    field: &'static str,
1899    len: usize,
1900) -> Result<(), UnidicArtifactPayloadError> {
1901    let len = u32::try_from(len)
1902        .map_err(|_| UnidicArtifactPayloadError::BinaryValueTooLarge { field, len })?;
1903    writer.write_all(&len.to_le_bytes())?;
1904    Ok(())
1905}
1906
1907fn read_binary_string(
1908    reader: &mut impl Read,
1909    field: &'static str,
1910) -> Result<String, UnidicArtifactPayloadError> {
1911    let len = read_u32_le(reader, field)? as usize;
1912    check_limit(field, len, MAX_ARTIFACT_STRING_BYTES)?;
1913    let mut bytes = vec![0_u8; len];
1914    read_exact_binary(reader, &mut bytes, field)?;
1915    String::from_utf8(bytes)
1916        .map_err(|source| UnidicArtifactPayloadError::InvalidBinaryUtf8 { field, source })
1917}
1918
1919fn read_u32_le(
1920    reader: &mut impl Read,
1921    field: &'static str,
1922) -> Result<u32, UnidicArtifactPayloadError> {
1923    let mut bytes = [0_u8; 4];
1924    read_exact_binary(reader, &mut bytes, field)?;
1925    Ok(u32::from_le_bytes(bytes))
1926}
1927
1928fn read_u64_le(
1929    reader: &mut impl Read,
1930    field: &'static str,
1931) -> Result<u64, UnidicArtifactPayloadError> {
1932    let mut bytes = [0_u8; 8];
1933    read_exact_binary(reader, &mut bytes, field)?;
1934    Ok(u64::from_le_bytes(bytes))
1935}
1936
1937fn read_exact_binary(
1938    reader: &mut impl Read,
1939    bytes: &mut [u8],
1940    field: &'static str,
1941) -> Result<(), UnidicArtifactPayloadError> {
1942    match reader.read_exact(bytes) {
1943        Ok(()) => Ok(()),
1944        Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {
1945            Err(UnidicArtifactPayloadError::TruncatedBinary { field })
1946        }
1947        Err(err) => Err(UnidicArtifactPayloadError::Io(err)),
1948    }
1949}
1950
1951fn read_binary_artifact_payload_header(
1952    reader: &mut impl Read,
1953) -> Result<UnidicBinaryArtifactPayloadHeader, UnidicArtifactPayloadError> {
1954    let mut magic = [0_u8; 8];
1955    read_exact_binary(reader, &mut magic, "magic")?;
1956    if &magic != BINARY_ARTIFACT_MAGIC {
1957        return Err(UnidicArtifactPayloadError::InvalidBinaryMagic { magic });
1958    }
1959
1960    let version = read_u32_le(reader, "version")?;
1961    if version != BINARY_ARTIFACT_VERSION {
1962        return Err(UnidicArtifactPayloadError::UnsupportedBinaryVersion { version });
1963    }
1964
1965    let reserved = read_u32_le(reader, "reserved")?;
1966    if reserved != 0 {
1967        return Err(UnidicArtifactPayloadError::NonZeroBinaryReserved { value: reserved });
1968    }
1969
1970    let entry_count = read_u64_le(reader, "entry_count")?;
1971    let entries = usize::try_from(entry_count).map_err(|_| {
1972        UnidicArtifactPayloadError::BinaryEntryCountTooLarge {
1973            entries: entry_count,
1974        }
1975    })?;
1976    check_limit("entry_count", entries, MAX_ARTIFACT_ENTRIES)?;
1977
1978    Ok(UnidicBinaryArtifactPayloadHeader { version, entries })
1979}
1980
1981fn read_indexed_artifact_payload_header_bytes(
1982    bytes: &[u8],
1983) -> Result<UnidicIndexedArtifactPayloadHeader, UnidicArtifactPayloadError> {
1984    if bytes.len() < INDEXED_ARTIFACT_HEADER_LEN {
1985        return Err(UnidicArtifactPayloadError::TruncatedIndexed { field: "header" });
1986    }
1987    let mut magic = [0_u8; 8];
1988    magic.copy_from_slice(&bytes[..8]);
1989    if &magic != INDEXED_ARTIFACT_MAGIC {
1990        return Err(UnidicArtifactPayloadError::InvalidIndexedMagic { magic });
1991    }
1992
1993    let version = read_u32_le_bytes(bytes, 8, "version")?;
1994    if version != INDEXED_ARTIFACT_VERSION {
1995        return Err(UnidicArtifactPayloadError::UnsupportedIndexedVersion { version });
1996    }
1997    let reserved = read_u32_le_bytes(bytes, 12, "reserved")?;
1998    if reserved != 0 {
1999        return Err(UnidicArtifactPayloadError::NonZeroIndexedReserved { value: reserved });
2000    }
2001    let entry_count = read_u64_le_bytes(bytes, 16, "entry_count")?;
2002    let fst_len = read_u64_le_bytes(bytes, 24, "fst_len")?;
2003    let readings_len = read_u64_le_bytes(bytes, 32, "readings_len")?;
2004    let entries = checked_indexed_usize("entry_count", entry_count)?;
2005    check_limit("entry_count", entries, MAX_ARTIFACT_ENTRIES)?;
2006    Ok(UnidicIndexedArtifactPayloadHeader {
2007        version,
2008        entries,
2009        fst_len: checked_indexed_usize("fst_len", fst_len)?,
2010        readings_len: checked_indexed_usize("readings_len", readings_len)?,
2011    })
2012}
2013
2014fn read_u32_le_bytes(
2015    bytes: &[u8],
2016    offset: usize,
2017    field: &'static str,
2018) -> Result<u32, UnidicArtifactPayloadError> {
2019    let end = offset
2020        .checked_add(4)
2021        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2022    let chunk = bytes
2023        .get(offset..end)
2024        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2025    Ok(u32::from_le_bytes(
2026        chunk.try_into().expect("slice length is 4"),
2027    ))
2028}
2029
2030fn read_u64_le_bytes(
2031    bytes: &[u8],
2032    offset: usize,
2033    field: &'static str,
2034) -> Result<u64, UnidicArtifactPayloadError> {
2035    let end = offset
2036        .checked_add(8)
2037        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2038    let chunk = bytes
2039        .get(offset..end)
2040        .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field })?;
2041    Ok(u64::from_le_bytes(
2042        chunk.try_into().expect("slice length is 8"),
2043    ))
2044}
2045
2046fn checked_indexed_usize(
2047    field: &'static str,
2048    len: u64,
2049) -> Result<usize, UnidicArtifactPayloadError> {
2050    usize::try_from(len)
2051        .map_err(|_| UnidicArtifactPayloadError::IndexedSectionTooLarge { field, len })
2052}
2053
2054fn check_payload_file_size(path: &Path) -> Result<(), UnidicArtifactPayloadError> {
2055    let len = std::fs::metadata(path)?.len();
2056    if len > MAX_ARTIFACT_PAYLOAD_BYTES {
2057        return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
2058            field: "payload_bytes",
2059            len,
2060            max: MAX_ARTIFACT_PAYLOAD_BYTES,
2061        });
2062    }
2063    Ok(())
2064}
2065
2066fn read_payload_file_bounded(path: &Path) -> Result<Vec<u8>, UnidicArtifactPayloadError> {
2067    let metadata = std::fs::metadata(path)?;
2068    if !metadata.file_type().is_file() {
2069        return Err(std::io::Error::new(
2070            std::io::ErrorKind::InvalidInput,
2071            "artifact payload path is not a regular file",
2072        )
2073        .into());
2074    }
2075    if metadata.len() > MAX_ARTIFACT_PAYLOAD_BYTES {
2076        return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
2077            field: "payload_bytes",
2078            len: metadata.len(),
2079            max: MAX_ARTIFACT_PAYLOAD_BYTES,
2080        });
2081    }
2082    let file = File::open(path)?;
2083    read_payload_reader_bounded(file, MAX_ARTIFACT_PAYLOAD_BYTES)
2084}
2085
2086fn read_payload_reader_bounded(
2087    reader: impl Read,
2088    max: u64,
2089) -> Result<Vec<u8>, UnidicArtifactPayloadError> {
2090    let mut reader = reader.take(max.saturating_add(1));
2091    let mut bytes = Vec::new();
2092    let mut chunk = [0_u8; 8192];
2093    loop {
2094        let len = reader.read(&mut chunk)?;
2095        if len == 0 {
2096            return Ok(bytes);
2097        }
2098        let next_len = bytes.len() as u64 + len as u64;
2099        if next_len > max {
2100            return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
2101                field: "payload_bytes",
2102                len: next_len,
2103                max,
2104            });
2105        }
2106        bytes.extend_from_slice(&chunk[..len]);
2107    }
2108}
2109
2110fn check_limit(
2111    field: &'static str,
2112    len: usize,
2113    max: usize,
2114) -> Result<(), UnidicArtifactPayloadError> {
2115    if len > max {
2116        return Err(UnidicArtifactPayloadError::ArtifactLimitExceeded {
2117            field,
2118            len: len as u64,
2119            max: max as u64,
2120        });
2121    }
2122    Ok(())
2123}
2124
2125fn write_indexed_reading_block(
2126    writer: &mut Vec<u8>,
2127    readings: &[String],
2128) -> Result<(), UnidicArtifactPayloadError> {
2129    write_u32_len(writer, "reading_count", readings.len())?;
2130    for reading in readings {
2131        write_binary_string(writer, "reading", reading)?;
2132    }
2133    Ok(())
2134}
2135
2136impl IndexedUnidicPayload {
2137    fn validate(&self) -> Result<(), UnidicArtifactPayloadError> {
2138        let mut stream = self.map.stream();
2139        while let Some((surface, offset)) = stream.next() {
2140            let surface = std::str::from_utf8(surface).map_err(|source| {
2141                UnidicArtifactPayloadError::InvalidIndexedUtf8 {
2142                    field: "surface",
2143                    source,
2144                }
2145            })?;
2146            if surface.is_empty() {
2147                return Err(UnidicArtifactPayloadError::EmptySurface { entry_index: 0 });
2148            }
2149            check_limit("surface_bytes", surface.len(), MAX_ARTIFACT_STRING_BYTES)?;
2150            let readings = self.readings_at(offset)?;
2151            if readings.is_empty() {
2152                return Err(UnidicArtifactPayloadError::EmptyReadings {
2153                    surface: surface.to_string(),
2154                });
2155            }
2156            let mut seen = BTreeSet::new();
2157            for (reading_index, reading) in readings.iter().enumerate() {
2158                if reading.is_empty() {
2159                    return Err(UnidicArtifactPayloadError::EmptyReading {
2160                        surface: surface.to_string(),
2161                        reading_index,
2162                    });
2163                }
2164                if !seen.insert(reading) {
2165                    return Err(UnidicArtifactPayloadError::DuplicateReading {
2166                        surface: surface.to_string(),
2167                        reading: reading.clone(),
2168                    });
2169                }
2170            }
2171        }
2172        Ok(())
2173    }
2174
2175    fn readings(&self, surface: &str) -> Result<Option<Vec<String>>, UnidicArtifactPayloadError> {
2176        self.map
2177            .get(surface)
2178            .map(|offset| self.readings_at(offset))
2179            .transpose()
2180    }
2181
2182    fn entries(&self) -> Result<Vec<UnidicReadingIndexPayloadEntry>, UnidicArtifactPayloadError> {
2183        let mut entries = Vec::with_capacity(self.entries);
2184        let mut stream = self.map.stream();
2185        while let Some((surface, offset)) = stream.next() {
2186            let surface = std::str::from_utf8(surface)
2187                .map_err(|source| UnidicArtifactPayloadError::InvalidIndexedUtf8 {
2188                    field: "surface",
2189                    source,
2190                })?
2191                .to_string();
2192            let readings = self.readings_at(offset)?;
2193            entries.push(UnidicReadingIndexPayloadEntry { surface, readings });
2194        }
2195        Ok(entries)
2196    }
2197
2198    fn readings_at(&self, offset: u64) -> Result<Vec<String>, UnidicArtifactPayloadError> {
2199        read_indexed_readings_at_bytes(self.bytes.as_ref(), self.readings_start, offset)
2200    }
2201}
2202
2203fn read_indexed_readings_at_bytes(
2204    bytes: &[u8],
2205    readings_start: usize,
2206    offset: u64,
2207) -> Result<Vec<String>, UnidicArtifactPayloadError> {
2208    let offset = usize::try_from(offset)
2209        .map_err(|_| UnidicArtifactPayloadError::InvalidIndexedOffset { offset })?;
2210    let start = readings_start.checked_add(offset).ok_or(
2211        UnidicArtifactPayloadError::InvalidIndexedOffset {
2212            offset: offset as u64,
2213        },
2214    )?;
2215    if start >= bytes.len() {
2216        return Err(UnidicArtifactPayloadError::InvalidIndexedOffset {
2217            offset: offset as u64,
2218        });
2219    }
2220    let mut cursor = start;
2221    let reading_count = read_u32_le_bytes(bytes, cursor, "reading_count")? as usize;
2222    check_limit(
2223        "reading_count",
2224        reading_count,
2225        MAX_ARTIFACT_READINGS_PER_ENTRY,
2226    )?;
2227    cursor += 4;
2228    let mut readings = Vec::with_capacity(reading_count);
2229    for _ in 0..reading_count {
2230        let len = read_u32_le_bytes(bytes, cursor, "reading_len")? as usize;
2231        check_limit("reading_bytes", len, MAX_ARTIFACT_STRING_BYTES)?;
2232        cursor += 4;
2233        let end = cursor
2234            .checked_add(len)
2235            .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
2236        let reading_bytes = bytes
2237            .get(cursor..end)
2238            .ok_or(UnidicArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
2239        let reading = std::str::from_utf8(reading_bytes)
2240            .map_err(|source| UnidicArtifactPayloadError::InvalidIndexedUtf8 {
2241                field: "reading",
2242                source,
2243            })?
2244            .to_string();
2245        readings.push(reading);
2246        cursor = end;
2247    }
2248    Ok(readings)
2249}
2250
2251/// Computes the SHA-256 file digest string for a UniDic artifact payload file.
2252pub fn artifact_file_digest_path(path: impl AsRef<Path>) -> Result<String, std::io::Error> {
2253    let file = File::open(path)?;
2254    artifact_file_digest_reader(file)
2255}
2256
2257/// Computes the SHA-256 file digest string from a reader.
2258pub fn artifact_file_digest_reader(mut reader: impl Read) -> Result<String, std::io::Error> {
2259    let mut hasher = Sha256::new();
2260    let mut buffer = [0_u8; 64 * 1024];
2261    loop {
2262        let read = reader.read(&mut buffer)?;
2263        if read == 0 {
2264            break;
2265        }
2266        hasher.update(&buffer[..read]);
2267    }
2268    Ok(sha256_digest_hex(hasher.finalize()))
2269}
2270
2271fn validate_artifact_payload_header(
2272    payload: &UnidicReadingIndexPayload,
2273) -> Result<(), UnidicArtifactPayloadError> {
2274    if payload.schema_version != ARTIFACT_PAYLOAD_SCHEMA_VERSION {
2275        return Err(UnidicArtifactPayloadError::UnsupportedSchemaVersion {
2276            version: payload.schema_version,
2277        });
2278    }
2279    if payload.payload_type != ARTIFACT_PAYLOAD_TYPE {
2280        return Err(UnidicArtifactPayloadError::UnsupportedPayloadType {
2281            payload_type: payload.payload_type.clone(),
2282        });
2283    }
2284    Ok(())
2285}
2286
2287fn canonical_payload_bytes(payload: &UnidicReadingIndexPayload) -> Vec<u8> {
2288    let mut bytes = Vec::new();
2289    bytes.extend_from_slice(b"moine.unidic.reading-index.surface-readings/v1\n");
2290    for entry in &payload.entries {
2291        push_len_prefixed(&mut bytes, b"S", &entry.surface);
2292        bytes.extend_from_slice(format!("R{}\n", entry.readings.len()).as_bytes());
2293        for reading in &entry.readings {
2294            push_len_prefixed(&mut bytes, b"r", reading);
2295        }
2296    }
2297    bytes
2298}
2299
2300fn push_len_prefixed(bytes: &mut Vec<u8>, tag: &[u8], value: &str) {
2301    bytes.extend_from_slice(tag);
2302    bytes.extend_from_slice(value.len().to_string().as_bytes());
2303    bytes.push(b'\n');
2304    bytes.extend_from_slice(value.as_bytes());
2305    bytes.push(b'\n');
2306}
2307
2308fn fnv1a64(bytes: &[u8]) -> u64 {
2309    let mut hash = 0xcbf29ce484222325_u64;
2310    for byte in bytes {
2311        hash ^= u64::from(*byte);
2312        hash = hash.wrapping_mul(0x100000001b3);
2313    }
2314    hash
2315}
2316
2317fn sha256_hex(bytes: &[u8]) -> String {
2318    sha256_digest_hex(Sha256::digest(bytes))
2319}
2320
2321fn sha256_digest_hex(digest: impl IntoIterator<Item = u8>) -> String {
2322    let mut output = String::with_capacity(64);
2323    for byte in digest {
2324        write!(&mut output, "{byte:02x}").expect("writing to String should not fail");
2325    }
2326    output
2327}
2328
2329#[cfg(test)]
2330mod tests {
2331    use super::*;
2332
2333    fn indexed_payload_with_surface(
2334        surface: &str,
2335        readings: &[String],
2336    ) -> Result<Vec<u8>, UnidicArtifactPayloadError> {
2337        let mut fst_bytes = Vec::new();
2338        let mut readings_bytes = Vec::new();
2339        {
2340            let mut builder = MapBuilder::new(&mut fst_bytes).map_err(|err| {
2341                UnidicArtifactPayloadError::InvalidIndexedFst {
2342                    message: err.to_string(),
2343                }
2344            })?;
2345            builder.insert(surface, 0).map_err(|err| {
2346                UnidicArtifactPayloadError::InvalidIndexedFst {
2347                    message: err.to_string(),
2348                }
2349            })?;
2350            builder
2351                .finish()
2352                .map_err(|err| UnidicArtifactPayloadError::InvalidIndexedFst {
2353                    message: err.to_string(),
2354                })?;
2355        }
2356        write_indexed_reading_block(&mut readings_bytes, readings)?;
2357
2358        let mut bytes = Vec::new();
2359        bytes.extend_from_slice(INDEXED_ARTIFACT_MAGIC);
2360        bytes.extend_from_slice(&INDEXED_ARTIFACT_VERSION.to_le_bytes());
2361        bytes.extend_from_slice(&0_u32.to_le_bytes());
2362        bytes.extend_from_slice(&1_u64.to_le_bytes());
2363        bytes.extend_from_slice(&(fst_bytes.len() as u64).to_le_bytes());
2364        bytes.extend_from_slice(&(readings_bytes.len() as u64).to_le_bytes());
2365        bytes.extend_from_slice(&fst_bytes);
2366        bytes.extend_from_slice(&readings_bytes);
2367        Ok(bytes)
2368    }
2369
2370    #[test]
2371    fn bounded_payload_reader_rejects_data_past_limit() {
2372        let err = read_payload_reader_bounded(std::io::Cursor::new([0_u8; 4]), 3).unwrap_err();
2373
2374        assert!(matches!(
2375            err,
2376            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2377                field: "payload_bytes",
2378                len: 4,
2379                max: 3
2380            }
2381        ));
2382    }
2383
2384    #[test]
2385    fn builds_surface_to_readings_index() {
2386        let csv = "\
2387印刷,18331,19434,9138,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢,*,*,*,*,*,*,体,インサツ,インサツ,インサツ,インサツ,0,C2,*,752349454934528,2737
2388刃,18521,20041,11551,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和,ハ濁,基本形,*,*,*,*,体,ハ,ハ,ハ,ハ,1,C3,*,8060803244761600,29325
2389刃,18419,19578,12664,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和,*,*,*,*,*,*,体,ヤイバ,ヤイバ,ヤイバ,ヤイバ,\"1,0\",C1,*,18677687522566656,67949
2390";
2391        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2392
2393        assert_eq!(
2394            index.readings("印刷").as_deref(),
2395            Some(&["インサツ".to_string()][..])
2396        );
2397        assert_eq!(
2398            index.readings("刃").as_deref(),
2399            Some(&["ハ".to_string(), "ヤイバ".to_string()][..])
2400        );
2401    }
2402
2403    #[test]
2404    fn skips_star_readings() {
2405        let csv = "記号,1,2,3,補助記号,一般,*,*,*,*,*,記号,記号,*,記号,*,記号\n";
2406        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2407
2408        assert!(index.is_empty());
2409    }
2410
2411    #[test]
2412    fn excludes_ascii_and_symbol_surfaces_by_default() {
2413        let csv = "\
2414a,1,2,3,記号,文字,*,*,*,*,エー,a,a,エー,a,エー,外
2415!,1,2,3,補助記号,一般,*,*,*,*,!,!,!,!,!,!,記号
2416印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2417";
2418        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2419
2420        assert_eq!(index.readings("a"), None);
2421        assert_eq!(index.readings("!"), None);
2422        assert_eq!(
2423            index.readings("印刷").as_deref(),
2424            Some(&["インサツ".to_string()][..])
2425        );
2426    }
2427
2428    #[test]
2429    fn can_keep_ascii_surfaces_when_requested() {
2430        let csv = "a,1,2,3,名詞,普通名詞,一般,*,*,*,エー,a,a,エー,a,エー,外\n";
2431        let index = UnidicReadingIndex::from_lex_csv_reader_with_options(
2432            csv.as_bytes(),
2433            UnidicIndexOptions {
2434                exclude_ascii_surfaces: false,
2435                ..UnidicIndexOptions::default()
2436            },
2437        )
2438        .unwrap();
2439
2440        assert_eq!(
2441            index.readings("a").as_deref(),
2442            Some(&["エー".to_string()][..])
2443        );
2444    }
2445
2446    #[test]
2447    fn limits_readings_per_surface_when_requested() {
2448        let csv = "\
2449刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
2450刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2451刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2452";
2453        let index = UnidicReadingIndex::from_lex_csv_reader_with_options(
2454            csv.as_bytes(),
2455            UnidicIndexOptions {
2456                max_readings_per_surface: Some(2),
2457                ..UnidicIndexOptions::default()
2458            },
2459        )
2460        .unwrap();
2461
2462        assert_eq!(
2463            index.readings("刃").as_deref(),
2464            Some(&["ジン".to_string(), "ハ".to_string()][..])
2465        );
2466    }
2467
2468    #[test]
2469    fn can_limit_readings_per_segment_at_query_time() {
2470        let csv = "\
2471刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
2472刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2473刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2474";
2475        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2476        let readings = index.reading_sequences(
2477            "刃",
2478            DictionaryReadingOptions {
2479                max_readings_per_segment: Some(2),
2480                ..DictionaryReadingOptions::default()
2481            },
2482        );
2483
2484        assert_eq!(readings, vec!["ジン".to_string(), "ハ".to_string()]);
2485    }
2486
2487    #[test]
2488    fn builds_artifact_metadata_from_index_and_options() {
2489        let csv = "\
2490刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
2491刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2492";
2493        let index_options = UnidicIndexOptions {
2494            reading_field: UnidicReadingField::Pron,
2495            max_readings_per_surface: Some(1),
2496            exclude_ascii_surfaces: true,
2497            exclude_symbol_pos: true,
2498        };
2499        let index =
2500            UnidicReadingIndex::from_lex_csv_reader_with_options(csv.as_bytes(), index_options)
2501                .unwrap();
2502
2503        let metadata = index.artifact_metadata(UnidicArtifactMetadataOptions {
2504            artifact_name: "moine-unidic-cwj-202512".to_string(),
2505            generator: "moine-cli".to_string(),
2506            payload_file_name: "moine-unidic-cwj-202512.readings.yaml".to_string(),
2507            payload_format: "yaml.surface-readings.v1".to_string(),
2508            source_name: "UniDic-CWJ".to_string(),
2509            source_version: "2025.12".to_string(),
2510            source_lex_csv: "unidic-cwj-202512_full/lex.csv".to_string(),
2511            index_options,
2512            query_defaults: DictionaryReadingOptions {
2513                longest_match_only: true,
2514                max_readings_per_segment: Some(16),
2515                ..DictionaryReadingOptions::default()
2516            },
2517            license: UnidicArtifactLicense::default(),
2518        });
2519
2520        assert_eq!(metadata.schema_version, 1);
2521        assert_eq!(metadata.artifact_type, "moine.unidic.reading-index");
2522        assert_eq!(
2523            metadata.payload.path,
2524            "moine-unidic-cwj-202512.readings.yaml"
2525        );
2526        assert_eq!(metadata.payload.format, "yaml.surface-readings.v1");
2527        assert_eq!(
2528            metadata.payload.checksum_algorithm,
2529            ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM
2530        );
2531        assert_eq!(metadata.payload.checksum.len(), 64);
2532        assert_eq!(metadata.source.version, "2025.12");
2533        assert_eq!(metadata.build.reading_field, "pron");
2534        assert_eq!(metadata.build.entries, 1);
2535        assert_eq!(metadata.build.max_readings_per_surface, Some(1));
2536        assert!(metadata.query_defaults.longest_match_only);
2537        assert_eq!(metadata.query_defaults.max_readings_per_segment, Some(16));
2538        assert_eq!(metadata.license.selected_license, "BSD-3-Clause");
2539    }
2540
2541    #[test]
2542    fn builds_deterministic_payload_entries() {
2543        let csv = "\
2544刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2545印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2546刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2547";
2548        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2549        let payload = index.artifact_payload();
2550
2551        assert_eq!(payload.schema_version, 1);
2552        assert_eq!(
2553            payload.payload_type,
2554            "moine.unidic.reading-index.surface-readings"
2555        );
2556        assert_eq!(
2557            payload.entries,
2558            vec![
2559                UnidicReadingIndexPayloadEntry {
2560                    surface: "刃".to_string(),
2561                    readings: vec!["ハ".to_string(), "ヤイバ".to_string()],
2562                },
2563                UnidicReadingIndexPayloadEntry {
2564                    surface: "印刷".to_string(),
2565                    readings: vec!["インサツ".to_string()],
2566                },
2567            ]
2568        );
2569    }
2570
2571    #[test]
2572    fn payload_checksum_changes_with_payload_content() {
2573        let first = UnidicReadingIndex::from_lex_csv_reader(
2574            "刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和\n".as_bytes(),
2575        )
2576        .unwrap();
2577        let second = UnidicReadingIndex::from_lex_csv_reader(
2578            "刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和\n".as_bytes(),
2579        )
2580        .unwrap();
2581
2582        assert_eq!(first.artifact_payload_checksum().len(), 64);
2583        assert_eq!(
2584            first.artifact_payload_checksum(),
2585            first
2586                .artifact_payload_checksum_for_algorithm(ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
2587                .unwrap()
2588        );
2589        assert_eq!(
2590            first
2591                .artifact_payload_checksum_for_algorithm(LEGACY_ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
2592                .unwrap()
2593                .len(),
2594            16
2595        );
2596        assert_ne!(
2597            first.artifact_payload_checksum(),
2598            second.artifact_payload_checksum()
2599        );
2600    }
2601
2602    #[test]
2603    fn loads_artifact_payload_back_into_index() {
2604        let payload = UnidicReadingIndexPayload {
2605            schema_version: 1,
2606            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2607            entries: vec![UnidicReadingIndexPayloadEntry {
2608                surface: "印刷".to_string(),
2609                readings: vec!["インサツ".to_string()],
2610            }],
2611        };
2612
2613        let index = UnidicReadingIndex::from_artifact_payload(payload).unwrap();
2614
2615        assert_eq!(index.len(), 1);
2616        assert_eq!(
2617            index.readings("印刷").as_deref(),
2618            Some(&["インサツ".to_string()][..])
2619        );
2620    }
2621
2622    #[test]
2623    fn loads_artifact_payload_reader() {
2624        let yaml = "\
2625schema_version: 1
2626payload_type: moine.unidic.reading-index.surface-readings
2627entries:
2628- surface: 刃
2629  readings:
2630  - ハ
2631  - ヤイバ
2632";
2633
2634        let index = UnidicReadingIndex::from_artifact_payload_reader(yaml.as_bytes()).unwrap();
2635
2636        assert_eq!(
2637            index.readings("刃").as_deref(),
2638            Some(&["ハ".to_string(), "ヤイバ".to_string()][..])
2639        );
2640    }
2641
2642    #[test]
2643    fn binary_artifact_payload_round_trips_to_equivalent_index() {
2644        let csv = "\
2645刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2646刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2647印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2648";
2649        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2650        let mut bytes = Vec::new();
2651
2652        index.write_artifact_binary_payload(&mut bytes).unwrap();
2653        let loaded = UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice())
2654            .expect("binary payload should load");
2655        let header = UnidicReadingIndex::binary_artifact_payload_header_reader(bytes.as_slice())
2656            .expect("binary payload header should load");
2657
2658        assert_eq!(
2659            header,
2660            UnidicBinaryArtifactPayloadHeader {
2661                version: 1,
2662                entries: 2,
2663            }
2664        );
2665        assert_eq!(loaded.artifact_payload(), index.artifact_payload());
2666        assert_eq!(
2667            loaded.artifact_payload_checksum(),
2668            index.artifact_payload_checksum()
2669        );
2670    }
2671
2672    #[test]
2673    fn indexed_artifact_payload_round_trips_and_supports_lookup() {
2674        let csv = "\
2675刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2676刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2677印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2678";
2679        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2680        let mut bytes = Vec::new();
2681        index.write_indexed_artifact_payload(&mut bytes).unwrap();
2682
2683        let unique = std::time::SystemTime::now()
2684            .duration_since(std::time::UNIX_EPOCH)
2685            .unwrap()
2686            .as_nanos();
2687        let path = std::env::temp_dir().join(format!(
2688            "moine-indexed-test-{}-{}.moineidx",
2689            std::process::id(),
2690            unique
2691        ));
2692        std::fs::write(&path, &bytes).unwrap();
2693        let loaded = UnidicReadingIndex::from_indexed_artifact_payload_path(&path)
2694            .expect("indexed payload should load");
2695        std::fs::write(&path, b"truncated after load").unwrap();
2696        assert_eq!(
2697            loaded.readings("印刷").as_deref(),
2698            Some(&["インサツ".to_string()][..])
2699        );
2700        let _ = std::fs::remove_file(&path);
2701        let loaded_from_bytes = UnidicReadingIndex::from_indexed_artifact_payload_bytes(&bytes)
2702            .expect("indexed payload bytes should load");
2703
2704        assert_eq!(loaded.len(), 2);
2705        assert_eq!(
2706            loaded.readings("刃").as_deref(),
2707            Some(&["ハ".to_string(), "ヤイバ".to_string()][..])
2708        );
2709        assert_eq!(
2710            loaded_from_bytes.artifact_payload(),
2711            index.artifact_payload()
2712        );
2713        assert_eq!(loaded.artifact_payload(), index.artifact_payload());
2714        assert_eq!(
2715            loaded.artifact_payload_checksum(),
2716            index.artifact_payload_checksum()
2717        );
2718        assert_eq!(
2719            loaded.reading_sequences("印刷", DictionaryReadingOptions::default()),
2720            vec!["インサツ".to_string()]
2721        );
2722    }
2723
2724    #[test]
2725    fn binary_artifact_payload_uses_stable_little_endian_layout() {
2726        let csv = "\
2727刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
2728刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
2729印刷,1,2,3,名詞,普通名詞,サ変可能,*,*,*,インサツ,印刷,印刷,インサツ,印刷,インサツ,漢
2730";
2731        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
2732        let mut bytes = Vec::new();
2733
2734        index.write_artifact_binary_payload(&mut bytes).unwrap();
2735
2736        #[rustfmt::skip]
2737        let expected = vec![
2738            b'M', b'O', b'I', b'N', b'E', b'U', b'0', b'1',
2739            1, 0, 0, 0,
2740            0, 0, 0, 0,
2741            2, 0, 0, 0, 0, 0, 0, 0,
2742            3, 0, 0, 0, 0xe5, 0x88, 0x83,
2743            2, 0, 0, 0,
2744            3, 0, 0, 0, 0xe3, 0x83, 0x8f,
2745            9, 0, 0, 0, 0xe3, 0x83, 0xa4, 0xe3, 0x82, 0xa4, 0xe3, 0x83, 0x90,
2746            6, 0, 0, 0, 0xe5, 0x8d, 0xb0, 0xe5, 0x88, 0xb7,
2747            1, 0, 0, 0,
2748            12, 0, 0, 0, 0xe3, 0x82, 0xa4, 0xe3, 0x83, 0xb3, 0xe3, 0x82, 0xb5, 0xe3, 0x83, 0x84,
2749        ];
2750        assert_eq!(bytes, expected);
2751    }
2752
2753    #[test]
2754    fn rejects_binary_artifact_bad_magic() {
2755        let bytes = *b"NOTMOINE";
2756        let err =
2757            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2758
2759        assert!(matches!(
2760            err,
2761            UnidicArtifactPayloadError::InvalidBinaryMagic { .. }
2762        ));
2763    }
2764
2765    #[test]
2766    fn rejects_binary_artifact_unsupported_version() {
2767        let mut bytes = Vec::new();
2768        bytes.extend_from_slice(b"MOINEU01");
2769        bytes.extend_from_slice(&2_u32.to_le_bytes());
2770        bytes.extend_from_slice(&0_u32.to_le_bytes());
2771        bytes.extend_from_slice(&0_u64.to_le_bytes());
2772
2773        let err =
2774            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2775
2776        assert!(matches!(
2777            err,
2778            UnidicArtifactPayloadError::UnsupportedBinaryVersion { version: 2 }
2779        ));
2780    }
2781
2782    #[test]
2783    fn rejects_binary_artifact_truncated_string() {
2784        let mut bytes = Vec::new();
2785        bytes.extend_from_slice(b"MOINEU01");
2786        bytes.extend_from_slice(&1_u32.to_le_bytes());
2787        bytes.extend_from_slice(&0_u32.to_le_bytes());
2788        bytes.extend_from_slice(&1_u64.to_le_bytes());
2789        bytes.extend_from_slice(&4_u32.to_le_bytes());
2790        bytes.extend_from_slice("刃".as_bytes());
2791
2792        let err =
2793            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2794
2795        assert!(matches!(
2796            err,
2797            UnidicArtifactPayloadError::TruncatedBinary { field: "surface" }
2798        ));
2799    }
2800
2801    #[test]
2802    fn rejects_binary_artifact_invalid_utf8() {
2803        let mut bytes = Vec::new();
2804        bytes.extend_from_slice(b"MOINEU01");
2805        bytes.extend_from_slice(&1_u32.to_le_bytes());
2806        bytes.extend_from_slice(&0_u32.to_le_bytes());
2807        bytes.extend_from_slice(&1_u64.to_le_bytes());
2808        bytes.extend_from_slice(&1_u32.to_le_bytes());
2809        bytes.push(0xff);
2810        bytes.extend_from_slice(&0_u32.to_le_bytes());
2811
2812        let err =
2813            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2814
2815        assert!(matches!(
2816            err,
2817            UnidicArtifactPayloadError::InvalidBinaryUtf8 {
2818                field: "surface",
2819                ..
2820            }
2821        ));
2822    }
2823
2824    #[test]
2825    fn rejects_binary_artifact_excessive_entry_count() {
2826        let mut bytes = Vec::new();
2827        bytes.extend_from_slice(b"MOINEU01");
2828        bytes.extend_from_slice(&1_u32.to_le_bytes());
2829        bytes.extend_from_slice(&0_u32.to_le_bytes());
2830        bytes.extend_from_slice(&((MAX_ARTIFACT_ENTRIES as u64) + 1).to_le_bytes());
2831
2832        let err =
2833            UnidicReadingIndex::from_binary_artifact_payload_reader(bytes.as_slice()).unwrap_err();
2834
2835        assert!(matches!(
2836            err,
2837            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2838                field: "entry_count",
2839                ..
2840            }
2841        ));
2842    }
2843
2844    #[test]
2845    fn rejects_excessive_dictionary_reading_options() {
2846        let index = UnidicReadingIndex::default();
2847        let err = index
2848            .try_reading_paths_with_stats(
2849                "印刷",
2850                DictionaryReadingOptions {
2851                    max_span_chars: MAX_QUERY_SPAN_CHARS + 1,
2852                    ..DictionaryReadingOptions::default()
2853                },
2854            )
2855            .unwrap_err();
2856
2857        assert!(matches!(
2858            err,
2859            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2860                field: "max_span_chars",
2861                ..
2862            }
2863        ));
2864
2865        let err = index
2866            .try_reading_paths_with_stats(
2867                "印刷",
2868                DictionaryReadingOptions {
2869                    max_paths: MAX_QUERY_PATHS + 1,
2870                    ..DictionaryReadingOptions::default()
2871                },
2872            )
2873            .unwrap_err();
2874
2875        assert!(matches!(
2876            err,
2877            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2878                field: "max_paths",
2879                ..
2880            }
2881        ));
2882
2883        let err = index
2884            .try_reading_paths_with_stats(
2885                "印刷",
2886                DictionaryReadingOptions {
2887                    max_readings_per_segment: Some(MAX_QUERY_READINGS_PER_SEGMENT + 1),
2888                    ..DictionaryReadingOptions::default()
2889                },
2890            )
2891            .unwrap_err();
2892
2893        assert!(matches!(
2894            err,
2895            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2896                field: "max_readings_per_segment",
2897                ..
2898            }
2899        ));
2900    }
2901
2902    #[test]
2903    fn rejects_artifact_payload_duplicate_surfaces() {
2904        let payload = UnidicReadingIndexPayload {
2905            schema_version: 1,
2906            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2907            entries: vec![
2908                UnidicReadingIndexPayloadEntry {
2909                    surface: "刃".to_string(),
2910                    readings: vec!["ハ".to_string()],
2911                },
2912                UnidicReadingIndexPayloadEntry {
2913                    surface: "刃".to_string(),
2914                    readings: vec!["ヤイバ".to_string()],
2915                },
2916            ],
2917        };
2918
2919        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2920
2921        assert!(matches!(
2922            err,
2923            UnidicArtifactPayloadError::DuplicateSurface { surface } if surface == "刃"
2924        ));
2925    }
2926
2927    #[test]
2928    fn rejects_artifact_payload_duplicate_readings() {
2929        let payload = UnidicReadingIndexPayload {
2930            schema_version: 1,
2931            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2932            entries: vec![UnidicReadingIndexPayloadEntry {
2933                surface: "刃".to_string(),
2934                readings: vec!["ハ".to_string(), "ハ".to_string()],
2935            }],
2936        };
2937
2938        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2939
2940        assert!(matches!(
2941            err,
2942            UnidicArtifactPayloadError::DuplicateReading { surface, reading }
2943                if surface == "刃" && reading == "ハ"
2944        ));
2945    }
2946
2947    #[test]
2948    fn rejects_artifact_payload_excessive_reading_count() {
2949        let payload = UnidicReadingIndexPayload {
2950            schema_version: 1,
2951            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2952            entries: vec![UnidicReadingIndexPayloadEntry {
2953                surface: "刃".to_string(),
2954                readings: vec!["ハ".to_string(); MAX_ARTIFACT_READINGS_PER_ENTRY + 1],
2955            }],
2956        };
2957
2958        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2959
2960        assert!(matches!(
2961            err,
2962            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2963                field: "reading_count",
2964                ..
2965            }
2966        ));
2967    }
2968
2969    #[test]
2970    fn rejects_indexed_artifact_payload_excessive_surface_bytes() {
2971        let bytes = indexed_payload_with_surface(
2972            &"刃".repeat(MAX_ARTIFACT_STRING_BYTES + 1),
2973            &["ハ".to_string()],
2974        )
2975        .unwrap();
2976
2977        let err = UnidicReadingIndex::from_indexed_artifact_payload_bytes(&bytes).unwrap_err();
2978
2979        assert!(matches!(
2980            err,
2981            UnidicArtifactPayloadError::ArtifactLimitExceeded {
2982                field: "surface_bytes",
2983                ..
2984            }
2985        ));
2986    }
2987
2988    #[test]
2989    fn rejects_artifact_payload_schema_mismatch() {
2990        let payload = UnidicReadingIndexPayload {
2991            schema_version: 2,
2992            payload_type: "moine.unidic.reading-index.surface-readings".to_string(),
2993            entries: Vec::new(),
2994        };
2995
2996        let err = UnidicReadingIndex::from_artifact_payload(payload).unwrap_err();
2997
2998        assert!(matches!(
2999            err,
3000            UnidicArtifactPayloadError::UnsupportedSchemaVersion { version: 2 }
3001        ));
3002    }
3003
3004    #[test]
3005    fn reports_reading_expansion_stats() {
3006        let csv = "\
3007刃,1,2,3,名詞,普通名詞,一般,*,*,*,ジン,刃,刃,ジン,刃,ジン,漢
3008刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
3009刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
3010";
3011        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3012        let expansion = index.reading_paths_with_stats(
3013            "刃",
3014            DictionaryReadingOptions {
3015                max_readings_per_segment: Some(2),
3016                ..DictionaryReadingOptions::default()
3017            },
3018        );
3019
3020        assert_eq!(expansion.paths.len(), 2);
3021        assert_eq!(
3022            expansion.stats,
3023            DictionaryReadingStats {
3024                matched_spans: 1,
3025                direct_fallback_spans: 0,
3026                longest_match_pruned_spans: 0,
3027                raw_segment_readings: 3,
3028                used_segment_readings: 2,
3029                pruned_segment_readings: 1,
3030                candidate_combinations: 2,
3031                unique_paths: 2,
3032                duplicate_joined_readings: 0,
3033                max_paths_hit_count: 0,
3034            }
3035        );
3036    }
3037
3038    #[test]
3039    fn reports_longest_match_and_path_limit_stats() {
3040        let csv = "\
3041茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
3042道,1,2,3,名詞,普通名詞,一般,*,*,*,ミチ,道,道,ミチ,道,ミチ,和
3043道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,和
3044具,1,2,3,名詞,普通名詞,一般,*,*,*,グ,具,具,グ,具,グ,和
3045";
3046        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3047        let expansion = index.reading_paths_with_stats(
3048            "茶道具",
3049            DictionaryReadingOptions {
3050                longest_match_only: true,
3051                max_paths: 1,
3052                ..DictionaryReadingOptions::default()
3053            },
3054        );
3055
3056        assert_eq!(expansion.paths.len(), 1);
3057        assert!(expansion.stats.longest_match_pruned_spans > 0);
3058        assert!(expansion.stats.max_paths_hit_count > 0);
3059    }
3060
3061    #[test]
3062    fn hybrid_reading_paths_use_direct_fallback_for_kana_ascii_spans() {
3063        let csv = "\
3064印,1,2,3,名詞,普通名詞,一般,*,*,*,イン,印,印,イン,印,イン,漢
3065";
3066        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3067        let expansion =
3068            index.hybrid_reading_paths_with_stats("印さt", DictionaryReadingOptions::default());
3069
3070        assert_eq!(
3071            expansion.paths,
3072            vec![DictionaryReadingPath {
3073                joined_reading: "インさt".to_string(),
3074                segments: vec![
3075                    DictionaryReadingSegment {
3076                        surface: "印".to_string(),
3077                        reading: "イン".to_string(),
3078                        source: DictionaryReadingSegmentSource::Dictionary,
3079                    },
3080                    DictionaryReadingSegment {
3081                        surface: "さt".to_string(),
3082                        reading: "さt".to_string(),
3083                        source: DictionaryReadingSegmentSource::Direct,
3084                    },
3085                ],
3086            }]
3087        );
3088        assert_eq!(expansion.stats.direct_fallback_spans, 2);
3089    }
3090
3091    #[test]
3092    fn hybrid_reading_paths_keep_shorter_dictionary_spans_for_direct_tail() {
3093        let csv = "\
3094印,1,2,3,名詞,普通名詞,一般,*,*,*,イン,印,印,イン,印,イン,漢
3095印さ,1,2,3,動詞,一般,*,*,*,*,シルス,印す,印す,シルス,印す,シルス,和
3096";
3097        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3098        let expansion = index.hybrid_reading_paths_with_stats(
3099            "印さt",
3100            DictionaryReadingOptions {
3101                longest_match_only: true,
3102                ..DictionaryReadingOptions::default()
3103            },
3104        );
3105
3106        assert!(expansion
3107            .paths
3108            .iter()
3109            .any(|path| path.joined_reading == "インさt"));
3110        assert_eq!(expansion.stats.longest_match_pruned_spans, 0);
3111    }
3112
3113    #[test]
3114    fn hybrid_reading_paths_still_reject_uncovered_kanji() {
3115        let index = UnidicReadingIndex::default();
3116        let expansion =
3117            index.hybrid_reading_paths_with_stats("未知z", DictionaryReadingOptions::default());
3118
3119        assert!(expansion.paths.is_empty());
3120        assert_eq!(expansion.stats.direct_fallback_spans, 1);
3121    }
3122
3123    #[test]
3124    fn can_use_pron_instead_of_lform() {
3125        let csv = "\
3126刃,18521,20041,11551,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和,ハ濁,基本形,*,*,*,*,体,ハ,ハ,ハ,ハ,1,C3,*,8060803244761600,29325
3127刃,18521,20055,14836,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,バ,刃,バ,和,ハ濁,濁音形,*,*,*,*,体,バ,バ,バ,ハ,1,C3,*,8060803244769792,29325
3128";
3129        let index = UnidicReadingIndex::from_lex_csv_reader_with_field(
3130            csv.as_bytes(),
3131            UnidicReadingField::Pron,
3132        )
3133        .unwrap();
3134
3135        assert_eq!(
3136            index.readings("刃").as_deref(),
3137            Some(&["ハ".to_string(), "バ".to_string()][..])
3138        );
3139    }
3140
3141    #[test]
3142    fn fullwidth_ascii_surfaces_are_indexed_under_halfwidth_aliases() {
3143        let csv = "\
3144WHISKY,1,2,3,名詞,普通名詞,一般,*,*,*,ウイスキー,WHISKY,WHISKY,ウイスキー,WHISKY,ウイスキー,外
3145WHISKEY,1,2,3,名詞,普通名詞,一般,*,*,*,ウイスキー,WHISKEY,WHISKEY,ウイスキー,WHISKEY,ウイスキー,外
3146MALT,1,2,3,名詞,普通名詞,一般,*,*,*,モルト,MALT,MALT,モルト,MALT,モルト,外
3147abc,1,2,3,名詞,固有名詞,一般,*,*,*,エービーシー,abc,abc,エービーシー,abc,エービーシー,外
3148";
3149        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3150
3151        assert_eq!(
3152            index.readings("WHISKY").as_deref(),
3153            Some(&["ウイスキー".to_string()][..])
3154        );
3155        assert_eq!(
3156            index.readings("WHISKY").as_deref(),
3157            Some(&["ウイスキー".to_string()][..])
3158        );
3159        assert_eq!(
3160            index.readings("WHISKEY").as_deref(),
3161            Some(&["ウイスキー".to_string()][..])
3162        );
3163        assert_eq!(
3164            index.readings("WHISKEY").as_deref(),
3165            Some(&["ウイスキー".to_string()][..])
3166        );
3167        assert_eq!(
3168            index.readings("MALT").as_deref(),
3169            Some(&["モルト".to_string()][..])
3170        );
3171        assert_eq!(index.readings("abc"), None);
3172    }
3173
3174    #[test]
3175    fn builds_reading_sequences_from_dictionary_segments() {
3176        let csv = "\
3177鬼滅,1,2,3,名詞,普通名詞,一般,*,*,*,キメツ,鬼滅,鬼滅,キメツ,鬼滅,キメツ,固
3178の,1,2,3,助詞,格助詞,*,*,*,*,ノ,の,の,ノ,の,ノ,和
3179刃,1,2,3,名詞,普通名詞,一般,*,*,*,ハ,刃,刃,ハ,刃,ハ,和
3180刃,1,2,3,名詞,普通名詞,一般,*,*,*,ヤイバ,刃,刃,ヤイバ,刃,ヤイバ,和
3181";
3182        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3183        let readings = index.reading_sequences("鬼滅の刃", DictionaryReadingOptions::default());
3184
3185        assert_eq!(
3186            readings,
3187            vec!["キメツノハ".to_string(), "キメツノヤイバ".to_string()]
3188        );
3189    }
3190
3191    #[test]
3192    fn reading_paths_keep_segmentation_and_segment_readings() {
3193        let csv = "\
3194茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
3195道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,漢
3196";
3197        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3198        let paths = index.reading_paths(
3199            "茶道具",
3200            DictionaryReadingOptions {
3201                longest_match_only: true,
3202                ..DictionaryReadingOptions::default()
3203            },
3204        );
3205
3206        assert_eq!(
3207            paths,
3208            vec![DictionaryReadingPath {
3209                joined_reading: "チャドーグ".to_string(),
3210                segments: vec![
3211                    DictionaryReadingSegment {
3212                        surface: "茶".to_string(),
3213                        reading: "チャ".to_string(),
3214                        source: DictionaryReadingSegmentSource::Dictionary,
3215                    },
3216                    DictionaryReadingSegment {
3217                        surface: "道具".to_string(),
3218                        reading: "ドーグ".to_string(),
3219                        source: DictionaryReadingSegmentSource::Dictionary,
3220                    },
3221                ],
3222            }]
3223        );
3224    }
3225
3226    #[test]
3227    fn builds_romaji_lattice_from_dictionary_segments() {
3228        let csv = "\
3229茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
3230道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,和
3231";
3232        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3233        let lattice = index
3234            .romaji_lattice("茶道具", DictionaryReadingOptions::default())
3235            .unwrap()
3236            .unwrap();
3237
3238        assert_eq!(
3239            moine_core::distance(&lattice, &Lattice::from_paths(["chadougu"])),
3240            0
3241        );
3242    }
3243
3244    #[test]
3245    fn builds_romaji_lattice_directly_from_reading_paths() {
3246        let paths = vec![
3247            DictionaryReadingPath {
3248                joined_reading: "チャドウグ".to_string(),
3249                segments: vec![
3250                    DictionaryReadingSegment {
3251                        surface: "茶".to_string(),
3252                        reading: "チャ".to_string(),
3253                        source: DictionaryReadingSegmentSource::Dictionary,
3254                    },
3255                    DictionaryReadingSegment {
3256                        surface: "道具".to_string(),
3257                        reading: "ドウグ".to_string(),
3258                        source: DictionaryReadingSegmentSource::Dictionary,
3259                    },
3260                ],
3261            },
3262            DictionaryReadingPath {
3263                joined_reading: "チャドーグ".to_string(),
3264                segments: vec![
3265                    DictionaryReadingSegment {
3266                        surface: "茶".to_string(),
3267                        reading: "チャ".to_string(),
3268                        source: DictionaryReadingSegmentSource::Dictionary,
3269                    },
3270                    DictionaryReadingSegment {
3271                        surface: "道具".to_string(),
3272                        reading: "ドーグ".to_string(),
3273                        source: DictionaryReadingSegmentSource::Dictionary,
3274                    },
3275                ],
3276            },
3277        ];
3278        let lattice = romaji_lattice_from_reading_paths(&paths).unwrap();
3279
3280        assert_eq!(
3281            moine_core::distance(&lattice, &Lattice::from_paths(["chadougu"])),
3282            0
3283        );
3284        assert_eq!(
3285            moine_core::distance(&lattice, &Lattice::from_paths(["chadoogu"])),
3286            0
3287        );
3288    }
3289
3290    #[test]
3291    fn structured_reading_paths_keep_cross_segment_context() {
3292        let paths = vec![DictionaryReadingPath {
3293            joined_reading: "マッチャ".to_string(),
3294            segments: vec![
3295                DictionaryReadingSegment {
3296                    surface: "抹".to_string(),
3297                    reading: "マッ".to_string(),
3298                    source: DictionaryReadingSegmentSource::Dictionary,
3299                },
3300                DictionaryReadingSegment {
3301                    surface: "茶".to_string(),
3302                    reading: "チャ".to_string(),
3303                    source: DictionaryReadingSegmentSource::Dictionary,
3304                },
3305            ],
3306        }];
3307        let lattice = romaji_lattice_from_reading_paths(&paths).unwrap();
3308
3309        assert_eq!(
3310            moine_core::distance(&lattice, &Lattice::from_paths(["maccha"])),
3311            0
3312        );
3313        assert_eq!(
3314            moine_core::distance(&lattice, &Lattice::from_paths(["mattya"])),
3315            0
3316        );
3317    }
3318
3319    #[test]
3320    fn can_restrict_reading_sequences_to_longest_matches() {
3321        let csv = "\
3322茶,1,2,3,名詞,普通名詞,一般,*,*,*,チャ,茶,茶,チャ,茶,チャ,和
3323道,1,2,3,名詞,普通名詞,一般,*,*,*,ミチ,道,道,ミチ,道,ミチ,和
3324道具,1,2,3,名詞,普通名詞,一般,*,*,*,ドウグ,道具,道具,ドーグ,道具,ドーグ,和
3325具,1,2,3,名詞,普通名詞,一般,*,*,*,グ,具,具,グ,具,グ,和
3326";
3327        let index = UnidicReadingIndex::from_lex_csv_reader(csv.as_bytes()).unwrap();
3328        let readings = index.reading_sequences(
3329            "茶道具",
3330            DictionaryReadingOptions {
3331                longest_match_only: true,
3332                ..DictionaryReadingOptions::default()
3333            },
3334        );
3335
3336        assert_eq!(readings, vec!["チャドーグ".to_string()]);
3337    }
3338}