Skip to main content

moine_zh/
lib.rs

1//! Chinese pinyin and CC-CEDICT adapters for `moine`.
2//!
3//! The current adapter indexes simplified and traditional written Chinese forms
4//! with Mandarin pinyin readings from CC-CEDICT. The default public artifact
5//! view is no-tone pinyin; `tone3` is an explicit tone-aware artifact view.
6//! Cantonese, Jyutping, and non-Mandarin readings are outside this crate's
7//! current scope.
8//!
9//! Dictionary artifacts are external input. Prefer `try_*` lookup and expansion
10//! APIs at trust boundaries so indexed-payload decode errors are reported as
11//! [`ZhArtifactPayloadError`] instead of being collapsed into empty lookup
12//! results for backward-compatible convenience APIs.
13//!
14//! ```
15//! use moine_zh::{
16//!     compare_with_zh_index, PinyinReadingOptions, ZhReadingIndex, ZhReadingIndexPayload,
17//!     ZhReadingIndexPayloadEntry,
18//! };
19//!
20//! let payload = ZhReadingIndexPayload {
21//!     schema_version: 1,
22//!     payload_type: "moine.zh.reading-index.surface-readings".to_string(),
23//!     pinyin_view: "no-tone".to_string(),
24//!     entries: vec![ZhReadingIndexPayloadEntry {
25//!         surface: "威士忌".to_string(),
26//!         readings: vec!["weishiji".to_string()],
27//!     }],
28//! };
29//! let index = ZhReadingIndex::from_artifact_payload(payload).unwrap();
30//!
31//! assert_eq!(
32//!     compare_with_zh_index("weishiji", "威士忌", &index, PinyinReadingOptions::default())
33//!         .unwrap()
34//!         .lattice,
35//!     0,
36//! );
37//! ```
38//!
39#![deny(missing_docs)]
40
41mod pinyin;
42
43use std::borrow::Cow;
44use std::collections::{btree_map::Entry, BTreeMap, BTreeSet, HashMap};
45use std::error::Error;
46use std::fmt;
47use std::fmt::Write as _;
48use std::fs::File;
49use std::io::{BufRead, BufReader, Read, Write};
50use std::path::Path;
51use std::string::FromUtf8Error;
52use std::sync::Arc;
53
54use fst::{Map, MapBuilder, Streamer};
55use moine_core::{
56    levenshtein_str, normalized_similarity_from_distance, try_damerau_distance,
57    try_damerau_levenshtein_str, try_distance, DistanceError, Lattice, StringDistanceWorkspace,
58};
59use serde::{Deserialize, Serialize};
60use sha2::{Digest, Sha256};
61
62use pinyin::{
63    can_build_direct_pinyin_input, direct_pinyin_lattice, normalize_artifact_reading,
64    normalize_direct_pinyin_input,
65};
66pub use pinyin::{normalize_pinyin, pinyin_lattice_from_reading_paths};
67
68const ARTIFACT_PAYLOAD_SCHEMA_VERSION: u32 = 1;
69const ARTIFACT_PAYLOAD_TYPE: &str = "moine.zh.reading-index.surface-readings";
70const INDEXED_ARTIFACT_MAGIC: &[u8; 8] = b"MOINEZ01";
71const INDEXED_ARTIFACT_VERSION: u32 = 1;
72const INDEXED_ARTIFACT_HEADER_LEN: usize = 40;
73const MAX_ARTIFACT_PAYLOAD_BYTES: u64 = 512 * 1024 * 1024;
74const MAX_ARTIFACT_ENTRIES: usize = 2_000_000;
75const MAX_ARTIFACT_READINGS_PER_ENTRY: usize = 256;
76const MAX_ARTIFACT_STRING_BYTES: usize = 16 * 1024;
77const DEFAULT_QUERY_SPAN_CHARS: usize = 8;
78const DEFAULT_QUERY_PATHS: usize = 1024;
79// Query expansion is clone-heavy; keep hard caps as bounded multiples of the
80// defaults so malformed metadata cannot silently request unbounded work.
81const MAX_QUERY_SPAN_CHARS: usize = DEFAULT_QUERY_SPAN_CHARS * 32;
82const MAX_QUERY_PATHS: usize = DEFAULT_QUERY_PATHS * 16;
83const MAX_QUERY_READINGS_PER_SEGMENT: usize = MAX_ARTIFACT_READINGS_PER_ENTRY;
84/// Current canonical checksum algorithm for normalized Chinese payload content.
85pub const ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM: &str = "sha256-canonical-v1";
86/// File digest algorithm used to verify payload bytes before loading.
87pub const ARTIFACT_PAYLOAD_FILE_DIGEST_ALGORITHM: &str = "sha256-file-v1";
88
89/// Pinyin representation used by a Chinese reading index.
90#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
91pub enum PinyinView {
92    /// Pinyin without tone marks or tone numbers.
93    #[default]
94    NoTone,
95    /// Pinyin with tone numbers, such as `zhong1`.
96    Tone3,
97}
98
99/// Options used while building a CC-CEDICT reading index.
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct CedictIndexOptions {
102    /// Pinyin representation to store.
103    pub pinyin_view: PinyinView,
104    /// Optional cap on readings stored for each surface form.
105    pub max_readings_per_surface: Option<usize>,
106}
107
108/// Controls Chinese dictionary reading-path expansion.
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub struct PinyinReadingOptions {
111    /// Maximum surface span length considered for one dictionary segment.
112    pub max_span_chars: usize,
113    /// Maximum complete reading paths to keep.
114    pub max_paths: usize,
115    /// Prefer the longest dictionary span when multiple spans start together.
116    pub longest_match_only: bool,
117    /// Optional cap on readings used per dictionary segment.
118    pub max_readings_per_segment: Option<usize>,
119}
120
121/// One Chinese surface segment and its selected pinyin reading.
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct PinyinReadingSegment {
124    /// Surface text covered by the segment.
125    pub surface: String,
126    /// Pinyin reading selected for the segment.
127    pub reading: String,
128    /// Whether the segment came from the dictionary or direct pinyin fallback.
129    pub source: PinyinReadingSegmentSource,
130}
131
132/// Source of one Chinese pinyin reading-path segment.
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum PinyinReadingSegmentSource {
135    /// Segment was backed by a dictionary entry.
136    Dictionary,
137    /// Segment was copied from direct pinyin/punctuation fallback.
138    Direct,
139}
140
141/// One complete segmentation and joined pinyin reading for an input string.
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct PinyinReadingPath {
144    /// Ordered dictionary/direct segments in the path.
145    pub segments: Vec<PinyinReadingSegment>,
146    /// Segment readings concatenated into one pinyin string.
147    pub joined_reading: String,
148}
149
150/// Reading-path expansion result plus pruning statistics.
151#[derive(Clone, Debug, Default, Eq, PartialEq)]
152pub struct PinyinReadingExpansion {
153    /// Expanded pinyin paths.
154    pub paths: Vec<PinyinReadingPath>,
155    /// Statistics gathered during expansion.
156    pub stats: PinyinReadingStats,
157}
158
159/// Counters describing Chinese reading-path expansion.
160#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
161pub struct PinyinReadingStats {
162    /// Dictionary spans matched during expansion.
163    pub matched_spans: usize,
164    /// Direct fallback spans used when no dictionary span matched.
165    pub direct_fallback_spans: usize,
166    /// Candidate spans pruned by longest-match mode.
167    pub longest_match_pruned_spans: usize,
168    /// Raw readings seen before per-segment pruning.
169    pub raw_segment_readings: usize,
170    /// Readings retained after per-segment pruning.
171    pub used_segment_readings: usize,
172    /// Readings removed by per-segment pruning.
173    pub pruned_segment_readings: usize,
174    /// Candidate path combinations considered.
175    pub candidate_combinations: usize,
176    /// Unique complete pinyin paths retained.
177    pub unique_paths: usize,
178    /// Duplicate joined readings removed.
179    pub duplicate_joined_readings: usize,
180    /// Number of times the `max_paths` cap was hit.
181    pub max_paths_hit_count: usize,
182}
183
184/// Distances computed for one Chinese comparison.
185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub struct ChineseDistance {
187    /// Plain Levenshtein distance over the original strings.
188    pub surface_levenshtein: usize,
189    /// Plain Damerau-Levenshtein distance over the original strings.
190    pub surface_damerau: usize,
191    /// Lattice Path Edit Distance over pinyin reading lattices.
192    pub lattice: usize,
193    /// Lattice-aware Damerau-Levenshtein distance over reading lattices.
194    pub lattice_damerau: usize,
195    /// Minimum of surface Damerau-Levenshtein and non-Damerau LPED.
196    ///
197    /// This intentionally does not include `lattice_damerau`; use that field
198    /// directly when lattice-side adjacent transpositions should count as one
199    /// edit.
200    pub combined: usize,
201}
202
203/// Public alias for the Chinese reading index type.
204pub type ZhReadingIndex = CedictReadingIndex;
205
206/// CC-CEDICT-derived surface-to-pinyin reading index.
207#[derive(Clone, Debug)]
208pub struct CedictReadingIndex {
209    storage: ZhReadingStorage,
210    pinyin_view: PinyinView,
211}
212
213#[derive(Clone, Debug)]
214enum ZhReadingStorage {
215    Eager(HashMap<String, Vec<String>>),
216    Indexed(IndexedZhPayload),
217}
218
219impl Default for CedictReadingIndex {
220    fn default() -> Self {
221        Self {
222            storage: ZhReadingStorage::Eager(HashMap::new()),
223            pinyin_view: PinyinView::default(),
224        }
225    }
226}
227
228impl PartialEq for CedictReadingIndex {
229    fn eq(&self, other: &Self) -> bool {
230        self.pinyin_view == other.pinyin_view && self.artifact_payload() == other.artifact_payload()
231    }
232}
233
234impl Eq for CedictReadingIndex {}
235
236#[derive(Clone, Debug)]
237struct IndexedZhPayload {
238    bytes: Arc<[u8]>,
239    map: Map<Vec<u8>>,
240    readings_start: usize,
241    entries: usize,
242}
243
244/// Header for indexed FST Chinese payloads.
245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
246pub struct ZhIndexedArtifactPayloadHeader {
247    /// Indexed payload format version.
248    pub version: u32,
249    /// Pinyin representation stored in the payload.
250    pub pinyin_view: PinyinView,
251    /// Number of entries in the payload.
252    pub entries: usize,
253    /// Length of the embedded FST section in bytes.
254    pub fst_len: usize,
255    /// Length of the reading blob section in bytes.
256    pub readings_len: usize,
257}
258
259/// Metadata stored in a Chinese dictionary bundle.
260#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
261pub struct ZhArtifactMetadata {
262    /// Metadata schema version.
263    pub schema_version: u32,
264    /// Artifact type identifier.
265    pub artifact_type: String,
266    /// Human-readable artifact name.
267    pub artifact_name: String,
268    /// Tool or command that generated the artifact.
269    pub generator: String,
270    /// Payload file metadata.
271    pub payload: ZhArtifactPayload,
272    /// Source dictionary metadata.
273    pub source: ZhArtifactSource,
274    /// Build-time options and counts.
275    pub build: ZhArtifactBuild,
276    /// Default query options for this artifact.
277    pub query_defaults: ZhArtifactQueryDefaults,
278    /// License metadata and references.
279    pub license: ZhArtifactLicense,
280}
281
282/// Payload metadata stored in a Chinese dictionary bundle.
283#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
284pub struct ZhArtifactPayload {
285    /// Relative payload path inside the bundle.
286    pub path: String,
287    /// Payload format identifier.
288    pub format: String,
289    /// Optional digest algorithm for the payload file bytes.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub file_digest_algorithm: Option<String>,
292    /// Optional digest of the payload file bytes.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub file_digest: Option<String>,
295    /// Canonical payload checksum algorithm.
296    pub checksum_algorithm: String,
297    /// Canonical payload checksum.
298    pub checksum: String,
299}
300
301/// Source dictionary metadata for a Chinese artifact.
302#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
303pub struct ZhArtifactSource {
304    /// Source dictionary name.
305    pub name: String,
306    /// Source dictionary version or release date.
307    pub version: String,
308    /// Path or label for the CC-CEDICT source file.
309    pub cedict: String,
310}
311
312/// Build-time settings recorded in Chinese artifact metadata.
313#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
314pub struct ZhArtifactBuild {
315    /// Pinyin representation stored in the artifact.
316    pub pinyin_view: String,
317    /// Maximum readings retained for each surface form, if capped.
318    pub max_readings_per_surface: Option<usize>,
319    /// Number of surface entries in the payload.
320    pub entries: usize,
321}
322
323/// Default reading expansion options recorded in Chinese artifact metadata.
324#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
325pub struct ZhArtifactQueryDefaults {
326    /// Maximum surface span length considered for one dictionary segment.
327    pub max_span_chars: usize,
328    /// Maximum complete reading paths retained.
329    pub max_paths: usize,
330    /// Whether longest-match mode is enabled by default.
331    pub longest_match_only: bool,
332    /// Optional default cap on readings used per dictionary segment.
333    pub max_readings_per_segment: Option<usize>,
334}
335
336/// License metadata for a Chinese dictionary artifact.
337#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
338pub struct ZhArtifactLicense {
339    /// Selected license expression for the distributed artifact.
340    pub selected_license: String,
341    /// License files or notices bundled with the artifact.
342    pub references: Vec<ZhArtifactLicenseReference>,
343}
344
345/// One license reference stored in Chinese artifact metadata.
346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347pub struct ZhArtifactLicenseReference {
348    /// Human-readable license reference label.
349    pub label: String,
350    /// Relative path to the bundled license or notice file.
351    pub path: String,
352}
353
354/// Normalized Chinese reading-index payload.
355#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
356pub struct ZhReadingIndexPayload {
357    /// Payload schema version.
358    pub schema_version: u32,
359    /// Payload type identifier.
360    pub payload_type: String,
361    /// Pinyin representation used by all readings in the payload.
362    pub pinyin_view: String,
363    /// Surface-to-reading entries.
364    pub entries: Vec<ZhReadingIndexPayloadEntry>,
365}
366
367/// One surface form and its normalized pinyin readings.
368#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
369pub struct ZhReadingIndexPayloadEntry {
370    /// Simplified or traditional surface form.
371    pub surface: String,
372    /// Normalized pinyin readings for the surface form.
373    pub readings: Vec<String>,
374}
375
376/// Inputs used to build Chinese artifact metadata from an index.
377#[derive(Clone, Debug, Eq, PartialEq)]
378pub struct ZhArtifactMetadataOptions {
379    /// Human-readable artifact name.
380    pub artifact_name: String,
381    /// Tool or command that generated the artifact.
382    pub generator: String,
383    /// Payload file name recorded in metadata.
384    pub payload_file_name: String,
385    /// Payload format recorded in metadata.
386    pub payload_format: String,
387    /// Source dictionary name.
388    pub source_name: String,
389    /// Source dictionary version or release date.
390    pub source_version: String,
391    /// Path or label for the CC-CEDICT source file.
392    pub source_cedict: String,
393    /// Index build options to record.
394    pub index_options: CedictIndexOptions,
395    /// Default query options to record.
396    pub query_defaults: PinyinReadingOptions,
397    /// License metadata to record.
398    pub license: ZhArtifactLicense,
399}
400
401/// Errors returned while parsing CC-CEDICT source text.
402#[derive(Debug)]
403pub enum CedictError {
404    /// Filesystem or reader access failed.
405    Io(std::io::Error),
406    /// A non-comment CC-CEDICT line could not be parsed.
407    InvalidEntry {
408        /// One-based input line number.
409        line: usize,
410        /// Parse failure detail.
411        message: String,
412    },
413}
414
415/// Errors returned while loading or validating Chinese artifact payloads.
416#[derive(Debug)]
417pub enum ZhArtifactPayloadError {
418    /// Filesystem or reader access failed.
419    Io(std::io::Error),
420    /// YAML payload deserialization failed.
421    Yaml(serde_yaml::Error),
422    /// Indexed payload magic bytes do not match the Chinese artifact format.
423    InvalidIndexedMagic {
424        /// Magic bytes read from the payload.
425        magic: [u8; 8],
426    },
427    /// Indexed payload version is not supported.
428    UnsupportedIndexedVersion {
429        /// Version read from the payload header.
430        version: u32,
431    },
432    /// Indexed payload pinyin-view tag is not supported.
433    UnsupportedIndexedPinyinView {
434        /// Numeric pinyin-view tag from the payload header.
435        value: u32,
436    },
437    /// An indexed payload section length cannot fit in memory on this target.
438    IndexedSectionTooLarge {
439        /// Section field name.
440        field: &'static str,
441        /// Section length from the payload.
442        len: u64,
443    },
444    /// A configured artifact safety limit was exceeded.
445    ArtifactLimitExceeded {
446        /// Limited field name.
447        field: &'static str,
448        /// Observed length or count.
449        len: u64,
450        /// Maximum accepted length or count.
451        max: u64,
452    },
453    /// Reserved indexed-payload header bytes were non-zero.
454    NonZeroIndexedReserved {
455        /// Reserved header value.
456        value: u32,
457    },
458    /// The indexed payload ended before a required section was complete.
459    TruncatedIndexed {
460        /// Section or field being read.
461        field: &'static str,
462    },
463    /// The embedded FST section is invalid.
464    InvalidIndexedFst {
465        /// FST validation failure detail.
466        message: String,
467    },
468    /// Header entry count and FST entry count disagree.
469    IndexedEntryCountMismatch {
470        /// Entry count recorded in the header.
471        header_entries: usize,
472        /// Entry count observed in the FST.
473        fst_entries: usize,
474    },
475    /// A reading block offset points outside the reading section.
476    InvalidIndexedOffset {
477        /// Invalid offset value.
478        offset: u64,
479    },
480    /// Indexed payload bytes were not valid UTF-8.
481    InvalidIndexedUtf8 {
482        /// Field being decoded.
483        field: &'static str,
484        /// Underlying UTF-8 error.
485        source: FromUtf8Error,
486    },
487    /// YAML payload schema version is not supported.
488    UnsupportedSchemaVersion {
489        /// Schema version read from the payload.
490        version: u32,
491    },
492    /// YAML payload type does not identify a Chinese reading index.
493    UnsupportedPayloadType {
494        /// Payload type read from YAML.
495        payload_type: String,
496    },
497    /// Payload pinyin view is not supported.
498    UnsupportedPinyinView {
499        /// Pinyin-view string read from the payload.
500        pinyin_view: String,
501    },
502    /// A payload entry has an empty surface form.
503    EmptySurface {
504        /// Zero-based entry index.
505        entry_index: usize,
506    },
507    /// The payload contains a duplicate surface form.
508    DuplicateSurface {
509        /// Duplicate surface form.
510        surface: String,
511    },
512    /// A surface form has no readings.
513    EmptyReadings {
514        /// Surface form with no readings.
515        surface: String,
516    },
517    /// A surface form has an empty reading.
518    EmptyReading {
519        /// Surface form containing the empty reading.
520        surface: String,
521        /// Zero-based reading index.
522        reading_index: usize,
523    },
524    /// A surface form has a duplicate reading.
525    DuplicateReading {
526        /// Surface form containing the duplicate.
527        surface: String,
528        /// Duplicate reading.
529        reading: String,
530    },
531    /// A reading was not normalized for the artifact pinyin view.
532    ReadingNotNormalized {
533        /// Surface form containing the invalid reading.
534        surface: String,
535        /// Reading as stored in the payload.
536        reading: String,
537        /// Expected normalized reading.
538        normalized: String,
539    },
540}
541
542/// Errors returned while building Chinese pinyin lattices.
543#[derive(Debug, Eq, PartialEq)]
544pub enum CnLatticeError {
545    /// No pinyin readings were provided.
546    EmptyReadings,
547    /// Input cannot be interpreted as direct pinyin and has no dictionary path.
548    UnsupportedDirectInput {
549        /// Unsupported input surface.
550        surface: String,
551    },
552    /// Artifact loading or indexed payload decoding failed.
553    ArtifactPayload(String),
554    /// Distance computation exceeded the configured matrix-size limit.
555    Distance(DistanceError),
556}
557
558impl PinyinView {
559    /// Returns the stable artifact string for this pinyin view.
560    pub fn as_str(self) -> &'static str {
561        match self {
562            Self::NoTone => "no-tone",
563            Self::Tone3 => "tone3",
564        }
565    }
566}
567
568impl TryFrom<&str> for PinyinView {
569    type Error = ();
570
571    fn try_from(value: &str) -> Result<Self, Self::Error> {
572        match value {
573            "no-tone" | "notone" | "normal" => Ok(Self::NoTone),
574            "tone3" => Ok(Self::Tone3),
575            _ => Err(()),
576        }
577    }
578}
579
580impl Default for CedictIndexOptions {
581    fn default() -> Self {
582        Self {
583            pinyin_view: PinyinView::NoTone,
584            max_readings_per_surface: None,
585        }
586    }
587}
588
589impl Default for PinyinReadingOptions {
590    fn default() -> Self {
591        Self {
592            max_span_chars: DEFAULT_QUERY_SPAN_CHARS,
593            max_paths: DEFAULT_QUERY_PATHS,
594            longest_match_only: false,
595            max_readings_per_segment: None,
596        }
597    }
598}
599
600impl PinyinReadingOptions {
601    /// Validates expansion options before they are used at public or metadata
602    /// trust boundaries.
603    pub fn validate(self) -> Result<Self, ZhArtifactPayloadError> {
604        check_limit("max_span_chars", self.max_span_chars, MAX_QUERY_SPAN_CHARS)?;
605        check_limit("max_paths", self.max_paths, MAX_QUERY_PATHS)?;
606        if let Some(max_readings) = self.max_readings_per_segment {
607            check_limit(
608                "max_readings_per_segment",
609                max_readings,
610                MAX_QUERY_READINGS_PER_SEGMENT,
611            )?;
612        }
613        Ok(self)
614    }
615}
616
617impl Default for ZhArtifactLicense {
618    fn default() -> Self {
619        Self {
620            selected_license: "CC BY-SA 4.0".to_string(),
621            references: vec![ZhArtifactLicenseReference {
622                label: "CC-CEDICT".to_string(),
623                path: "license/CC-CEDICT.md".to_string(),
624            }],
625        }
626    }
627}
628
629impl fmt::Display for CedictError {
630    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
631        match self {
632            Self::Io(err) => write!(f, "failed to read CC-CEDICT: {err}"),
633            Self::InvalidEntry { line, message } => {
634                write!(f, "invalid CC-CEDICT entry at line {line}: {message}")
635            }
636        }
637    }
638}
639
640impl Error for CedictError {
641    fn source(&self) -> Option<&(dyn Error + 'static)> {
642        match self {
643            Self::Io(err) => Some(err),
644            Self::InvalidEntry { .. } => None,
645        }
646    }
647}
648
649impl fmt::Display for ZhArtifactPayloadError {
650    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
651        match self {
652            Self::Io(err) => write!(f, "failed to read zh artifact payload: {err}"),
653            Self::Yaml(err) => write!(f, "invalid zh artifact payload YAML: {err}"),
654            Self::InvalidIndexedMagic { magic } => {
655                write!(f, "invalid zh indexed artifact magic {magic:?}")
656            }
657            Self::UnsupportedIndexedVersion { version } => {
658                write!(f, "unsupported zh indexed artifact version {version}")
659            }
660            Self::UnsupportedIndexedPinyinView { value } => {
661                write!(f, "unsupported zh indexed artifact pinyin view {value}")
662            }
663            Self::IndexedSectionTooLarge { field, len } => {
664                write!(f, "zh indexed artifact {field} length {len} exceeds usize::MAX")
665            }
666            Self::ArtifactLimitExceeded { field, len, max } => {
667                write!(f, "zh artifact {field} length/count {len} exceeds limit {max}")
668            }
669            Self::NonZeroIndexedReserved { value } => {
670                write!(f, "zh indexed artifact reserved header field is {value}")
671            }
672            Self::TruncatedIndexed { field } => {
673                write!(f, "truncated zh indexed artifact while reading {field}")
674            }
675            Self::InvalidIndexedFst { message } => {
676                write!(f, "invalid zh indexed artifact FST: {message}")
677            }
678            Self::IndexedEntryCountMismatch {
679                header_entries,
680                fst_entries,
681            } => write!(
682                f,
683                "zh indexed artifact header entry count {header_entries} does not match FST entry count {fst_entries}"
684            ),
685            Self::InvalidIndexedOffset { offset } => {
686                write!(f, "invalid zh indexed artifact readings offset {offset}")
687            }
688            Self::InvalidIndexedUtf8 { field, source } => {
689                write!(f, "invalid UTF-8 in zh indexed artifact {field}: {source}")
690            }
691            Self::UnsupportedSchemaVersion { version } => {
692                write!(f, "unsupported zh artifact payload schema version {version}")
693            }
694            Self::UnsupportedPayloadType { payload_type } => {
695                write!(f, "unsupported zh artifact payload type {payload_type:?}")
696            }
697            Self::UnsupportedPinyinView { pinyin_view } => {
698                write!(f, "unsupported zh artifact pinyin view {pinyin_view:?}")
699            }
700            Self::EmptySurface { entry_index } => {
701                write!(f, "zh artifact payload entry {entry_index} has an empty surface")
702            }
703            Self::DuplicateSurface { surface } => {
704                write!(f, "zh artifact payload has duplicate surface {surface:?}")
705            }
706            Self::EmptyReadings { surface } => {
707                write!(f, "zh artifact payload surface {surface:?} has no readings")
708            }
709            Self::EmptyReading {
710                surface,
711                reading_index,
712            } => write!(
713                f,
714                "zh artifact payload surface {surface:?} has an empty reading at index {reading_index}"
715            ),
716            Self::DuplicateReading { surface, reading } => write!(
717                f,
718                "zh artifact payload surface {surface:?} has duplicate reading {reading:?}"
719            ),
720            Self::ReadingNotNormalized {
721                surface,
722                reading,
723                normalized,
724            } => write!(
725                f,
726                "zh artifact payload surface {surface:?} has non-normalized reading {reading:?}; expected {normalized:?}"
727            ),
728        }
729    }
730}
731
732impl Error for ZhArtifactPayloadError {
733    fn source(&self) -> Option<&(dyn Error + 'static)> {
734        match self {
735            Self::Io(err) => Some(err),
736            Self::Yaml(err) => Some(err),
737            Self::InvalidIndexedUtf8 { source, .. } => Some(source),
738            _ => None,
739        }
740    }
741}
742
743impl From<std::io::Error> for CedictError {
744    fn from(err: std::io::Error) -> Self {
745        Self::Io(err)
746    }
747}
748
749impl From<std::io::Error> for ZhArtifactPayloadError {
750    fn from(err: std::io::Error) -> Self {
751        Self::Io(err)
752    }
753}
754
755impl From<serde_yaml::Error> for ZhArtifactPayloadError {
756    fn from(err: serde_yaml::Error) -> Self {
757        Self::Yaml(err)
758    }
759}
760
761impl fmt::Display for CnLatticeError {
762    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
763        match self {
764            Self::EmptyReadings => write!(f, "at least one pinyin reading is required"),
765            Self::UnsupportedDirectInput { surface } => {
766                write!(f, "unsupported direct pinyin input {surface:?}")
767            }
768            Self::ArtifactPayload(err) => write!(f, "{err}"),
769            Self::Distance(err) => write!(f, "{err}"),
770        }
771    }
772}
773
774impl Error for CnLatticeError {
775    fn source(&self) -> Option<&(dyn Error + 'static)> {
776        match self {
777            Self::Distance(err) => Some(err),
778            Self::EmptyReadings
779            | Self::UnsupportedDirectInput { .. }
780            | Self::ArtifactPayload(_) => None,
781        }
782    }
783}
784
785impl From<DistanceError> for CnLatticeError {
786    fn from(value: DistanceError) -> Self {
787        Self::Distance(value)
788    }
789}
790
791impl CedictReadingIndex {
792    /// Builds an index from a CC-CEDICT text file.
793    pub fn from_cedict_path(path: impl AsRef<Path>) -> Result<Self, CedictError> {
794        Self::from_cedict_path_with_options(path, CedictIndexOptions::default())
795    }
796
797    /// Builds an index from a CC-CEDICT text file with custom options.
798    pub fn from_cedict_path_with_options(
799        path: impl AsRef<Path>,
800        options: CedictIndexOptions,
801    ) -> Result<Self, CedictError> {
802        let file = File::open(path)?;
803        Self::from_cedict_reader_with_options(file, options)
804    }
805
806    /// Builds an index from a CC-CEDICT reader.
807    pub fn from_cedict_reader(reader: impl Read) -> Result<Self, CedictError> {
808        Self::from_cedict_reader_with_options(reader, CedictIndexOptions::default())
809    }
810
811    /// Builds an index from a CC-CEDICT reader with custom options.
812    pub fn from_cedict_reader_with_options(
813        reader: impl Read,
814        options: CedictIndexOptions,
815    ) -> Result<Self, CedictError> {
816        let mut by_surface = HashMap::<String, BTreeSet<String>>::new();
817        let reader = BufReader::new(reader);
818        for (line_index, line) in reader.lines().enumerate() {
819            let line_number = line_index + 1;
820            let line = line?;
821            let line = line.trim_end_matches('\r');
822            if line.is_empty() || line.starts_with('#') {
823                continue;
824            }
825
826            let entry = parse_cedict_entry(line, line_number)?;
827            let reading = normalize_pinyin(entry.pinyin, options.pinyin_view);
828            if reading.is_empty() {
829                continue;
830            }
831
832            by_surface
833                .entry(entry.traditional.to_string())
834                .or_default()
835                .insert(reading.clone());
836            by_surface
837                .entry(entry.simplified.to_string())
838                .or_default()
839                .insert(reading);
840        }
841
842        let readings_by_surface = by_surface
843            .into_iter()
844            .map(|(surface, readings)| {
845                let mut readings = readings.into_iter().collect::<Vec<_>>();
846                if let Some(max_readings) = options.max_readings_per_surface {
847                    readings.truncate(max_readings);
848                }
849                (surface, readings)
850            })
851            .filter(|(_, readings)| !readings.is_empty())
852            .collect();
853
854        Ok(Self {
855            storage: ZhReadingStorage::Eager(readings_by_surface),
856            pinyin_view: options.pinyin_view,
857        })
858    }
859
860    /// Loads a YAML artifact payload from a file path.
861    pub fn from_artifact_payload_path(
862        path: impl AsRef<Path>,
863    ) -> Result<Self, ZhArtifactPayloadError> {
864        let path = path.as_ref();
865        check_payload_file_size(path)?;
866        let file = File::open(path)?;
867        Self::from_artifact_payload_reader(file)
868    }
869
870    /// Loads a YAML artifact payload from a reader.
871    pub fn from_artifact_payload_reader(reader: impl Read) -> Result<Self, ZhArtifactPayloadError> {
872        let payload = serde_yaml::from_reader(reader)?;
873        Self::from_artifact_payload(payload)
874    }
875
876    /// Builds an index from a deserialized artifact payload.
877    pub fn from_artifact_payload(
878        payload: ZhReadingIndexPayload,
879    ) -> Result<Self, ZhArtifactPayloadError> {
880        validate_artifact_payload_header(&payload)?;
881        let pinyin_view = PinyinView::try_from(payload.pinyin_view.as_str()).map_err(|()| {
882            ZhArtifactPayloadError::UnsupportedPinyinView {
883                pinyin_view: payload.pinyin_view.clone(),
884            }
885        })?;
886        check_limit("entry_count", payload.entries.len(), MAX_ARTIFACT_ENTRIES)?;
887
888        let mut readings_by_surface = HashMap::new();
889        for (entry_index, entry) in payload.entries.into_iter().enumerate() {
890            check_limit(
891                "surface_bytes",
892                entry.surface.len(),
893                MAX_ARTIFACT_STRING_BYTES,
894            )?;
895            check_limit(
896                "reading_count",
897                entry.readings.len(),
898                MAX_ARTIFACT_READINGS_PER_ENTRY,
899            )?;
900            if entry.surface.is_empty() {
901                return Err(ZhArtifactPayloadError::EmptySurface { entry_index });
902            }
903            if entry.readings.is_empty() {
904                return Err(ZhArtifactPayloadError::EmptyReadings {
905                    surface: entry.surface,
906                });
907            }
908
909            let mut seen_readings = BTreeSet::new();
910            for (reading_index, reading) in entry.readings.iter().enumerate() {
911                check_limit("reading_bytes", reading.len(), MAX_ARTIFACT_STRING_BYTES)?;
912                if reading.is_empty() {
913                    return Err(ZhArtifactPayloadError::EmptyReading {
914                        surface: entry.surface,
915                        reading_index,
916                    });
917                }
918                let normalized = normalize_artifact_reading(reading, pinyin_view);
919                if normalized != *reading {
920                    return Err(ZhArtifactPayloadError::ReadingNotNormalized {
921                        surface: entry.surface,
922                        reading: reading.clone(),
923                        normalized,
924                    });
925                }
926                if !seen_readings.insert(reading) {
927                    return Err(ZhArtifactPayloadError::DuplicateReading {
928                        surface: entry.surface,
929                        reading: reading.clone(),
930                    });
931                }
932            }
933
934            if readings_by_surface
935                .insert(entry.surface.clone(), entry.readings)
936                .is_some()
937            {
938                return Err(ZhArtifactPayloadError::DuplicateSurface {
939                    surface: entry.surface,
940                });
941            }
942        }
943
944        Ok(Self {
945            storage: ZhReadingStorage::Eager(readings_by_surface),
946            pinyin_view,
947        })
948    }
949
950    /// Loads an indexed artifact payload from a file path.
951    ///
952    /// The payload is copied into owned immutable bytes before the index is
953    /// returned, so later changes to the source file cannot affect lookups.
954    /// Readings remain lazy and are decoded during lookup.
955    pub fn from_indexed_artifact_payload_path(
956        path: impl AsRef<Path>,
957    ) -> Result<Self, ZhArtifactPayloadError> {
958        let path = path.as_ref();
959        let bytes = read_payload_file_bounded(path)?;
960        Self::from_indexed_owned_bytes(bytes)
961    }
962
963    /// Loads an indexed artifact payload from bytes.
964    ///
965    /// The payload is copied into owned immutable bytes and decoded lazily
966    /// during lookup. This is intended for environments such as WebAssembly
967    /// where file-path loading is not available.
968    ///
969    /// # Errors
970    ///
971    /// Returns an error when the payload is too large, malformed, truncated,
972    /// has an invalid FST section, or fails canonical artifact validation.
973    pub fn from_indexed_artifact_payload_bytes(
974        bytes: &[u8],
975    ) -> Result<Self, ZhArtifactPayloadError> {
976        if bytes.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
977            return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
978                field: "payload_bytes",
979                len: bytes.len() as u64,
980                max: MAX_ARTIFACT_PAYLOAD_BYTES,
981            });
982        }
983        Self::from_indexed_owned_bytes(bytes.to_vec())
984    }
985
986    fn from_indexed_owned_bytes(bytes: Vec<u8>) -> Result<Self, ZhArtifactPayloadError> {
987        let bytes: Arc<[u8]> = Arc::from(bytes);
988        if bytes.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
989            return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
990                field: "payload_bytes",
991                len: bytes.len() as u64,
992                max: MAX_ARTIFACT_PAYLOAD_BYTES,
993            });
994        }
995        let header = read_indexed_artifact_payload_header_bytes(bytes.as_ref())?;
996        let fst_start = INDEXED_ARTIFACT_HEADER_LEN;
997        let fst_end = fst_start.checked_add(header.fst_len).ok_or(
998            ZhArtifactPayloadError::TruncatedIndexed {
999                field: "fst_section",
1000            },
1001        )?;
1002        let readings_end = fst_end.checked_add(header.readings_len).ok_or(
1003            ZhArtifactPayloadError::TruncatedIndexed {
1004                field: "readings_section",
1005            },
1006        )?;
1007        if bytes.len() < readings_end {
1008            return Err(ZhArtifactPayloadError::TruncatedIndexed {
1009                field: "indexed_payload",
1010            });
1011        }
1012
1013        let map = Map::new(bytes[fst_start..fst_end].to_vec()).map_err(|err| {
1014            ZhArtifactPayloadError::InvalidIndexedFst {
1015                message: err.to_string(),
1016            }
1017        })?;
1018        let fst_entries = map.len();
1019        if fst_entries != header.entries {
1020            return Err(ZhArtifactPayloadError::IndexedEntryCountMismatch {
1021                header_entries: header.entries,
1022                fst_entries,
1023            });
1024        }
1025        let indexed = IndexedZhPayload {
1026            bytes,
1027            map,
1028            readings_start: fst_end,
1029            entries: header.entries,
1030        };
1031        indexed.validate(header.pinyin_view)?;
1032        Ok(Self {
1033            storage: ZhReadingStorage::Indexed(indexed),
1034            pinyin_view: header.pinyin_view,
1035        })
1036    }
1037
1038    /// Returns the pinyin representation stored by this index.
1039    pub fn pinyin_view(&self) -> PinyinView {
1040        self.pinyin_view
1041    }
1042
1043    /// Returns pinyin readings for `surface`, if present.
1044    ///
1045    /// For indexed artifacts, decode errors are treated the same as a missing
1046    /// surface for backward compatibility. Use [`Self::try_readings`] at trust
1047    /// boundaries when artifact corruption must be reported distinctly.
1048    pub fn readings(&self, surface: &str) -> Option<Cow<'_, [String]>> {
1049        self.try_readings(surface).ok().flatten()
1050    }
1051
1052    /// Returns pinyin readings for `surface` and preserves indexed artifact
1053    /// decode errors.
1054    pub fn try_readings(
1055        &self,
1056        surface: &str,
1057    ) -> Result<Option<Cow<'_, [String]>>, ZhArtifactPayloadError> {
1058        match &self.storage {
1059            ZhReadingStorage::Eager(readings_by_surface) => Ok(readings_by_surface
1060                .get(surface)
1061                .map(|readings| Cow::Borrowed(readings.as_slice()))),
1062            ZhReadingStorage::Indexed(indexed) => indexed
1063                .readings(surface)
1064                .map(|readings| readings.map(Cow::Owned)),
1065        }
1066    }
1067
1068    /// Returns the number of indexed Chinese surface forms.
1069    pub fn len(&self) -> usize {
1070        match &self.storage {
1071            ZhReadingStorage::Eager(readings_by_surface) => readings_by_surface.len(),
1072            ZhReadingStorage::Indexed(indexed) => indexed.entries,
1073        }
1074    }
1075
1076    /// Returns `true` when the index contains no surface forms.
1077    pub fn is_empty(&self) -> bool {
1078        self.len() == 0
1079    }
1080
1081    /// Builds bundle metadata for the current index and caller-provided
1082    /// provenance.
1083    ///
1084    /// The returned metadata includes a canonical payload checksum computed
1085    /// from the normalized payload view.
1086    pub fn artifact_metadata(&self, options: ZhArtifactMetadataOptions) -> ZhArtifactMetadata {
1087        ZhArtifactMetadata {
1088            schema_version: 1,
1089            artifact_type: "moine.zh.reading-index".to_string(),
1090            artifact_name: options.artifact_name,
1091            generator: options.generator,
1092            payload: ZhArtifactPayload {
1093                path: options.payload_file_name,
1094                format: options.payload_format,
1095                file_digest_algorithm: None,
1096                file_digest: None,
1097                checksum_algorithm: ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM.to_string(),
1098                checksum: self.artifact_payload_checksum(),
1099            },
1100            source: ZhArtifactSource {
1101                name: options.source_name,
1102                version: options.source_version,
1103                cedict: options.source_cedict,
1104            },
1105            build: ZhArtifactBuild {
1106                pinyin_view: options.index_options.pinyin_view.as_str().to_string(),
1107                max_readings_per_surface: options.index_options.max_readings_per_surface,
1108                entries: self.len(),
1109            },
1110            query_defaults: ZhArtifactQueryDefaults {
1111                max_span_chars: options.query_defaults.max_span_chars,
1112                max_paths: options.query_defaults.max_paths,
1113                longest_match_only: options.query_defaults.longest_match_only,
1114                max_readings_per_segment: options.query_defaults.max_readings_per_segment,
1115            },
1116            license: options.license,
1117        }
1118    }
1119
1120    /// Returns the normalized YAML-compatible payload view for this index.
1121    ///
1122    /// Entries are sorted by surface form so serialization and checksums are
1123    /// deterministic regardless of the index storage backend.
1124    pub fn artifact_payload(&self) -> ZhReadingIndexPayload {
1125        let entries = match &self.storage {
1126            ZhReadingStorage::Eager(readings_by_surface) => {
1127                let mut entries = readings_by_surface
1128                    .iter()
1129                    .map(|(surface, readings)| ZhReadingIndexPayloadEntry {
1130                        surface: surface.clone(),
1131                        readings: readings.clone(),
1132                    })
1133                    .collect::<Vec<_>>();
1134                entries.sort_by(|left, right| left.surface.cmp(&right.surface));
1135                entries
1136            }
1137            ZhReadingStorage::Indexed(indexed) => indexed
1138                .entries()
1139                .expect("validated indexed artifact should decode"),
1140        };
1141
1142        ZhReadingIndexPayload {
1143            schema_version: ARTIFACT_PAYLOAD_SCHEMA_VERSION,
1144            payload_type: ARTIFACT_PAYLOAD_TYPE.to_string(),
1145            pinyin_view: self.pinyin_view.as_str().to_string(),
1146            entries,
1147        }
1148    }
1149
1150    /// Returns the canonical checksum for the normalized payload.
1151    pub fn artifact_payload_checksum(&self) -> String {
1152        self.artifact_payload_checksum_for_algorithm(ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
1153            .expect("default artifact checksum algorithm should be supported")
1154    }
1155
1156    /// Returns a canonical payload checksum for `algorithm`.
1157    ///
1158    /// Unknown algorithms return `None`.
1159    pub fn artifact_payload_checksum_for_algorithm(&self, algorithm: &str) -> Option<String> {
1160        let payload = self.artifact_payload();
1161        let bytes = canonical_payload_bytes(&payload);
1162        match algorithm {
1163            ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM => Some(sha256_hex(&bytes)),
1164            _ => None,
1165        }
1166    }
1167
1168    /// Writes the indexed FST-backed artifact payload format.
1169    ///
1170    /// The payload stores a finite-state transducer from surface form to an
1171    /// offset in a compact reading blob and can be loaded with
1172    /// [`Self::from_indexed_artifact_payload_path`].
1173    pub fn write_indexed_artifact_payload(
1174        &self,
1175        mut writer: impl Write,
1176    ) -> Result<(), ZhArtifactPayloadError> {
1177        let payload = self.artifact_payload();
1178        let mut fst_bytes = Vec::new();
1179        let mut readings_bytes = Vec::new();
1180        {
1181            let mut builder = MapBuilder::new(&mut fst_bytes).map_err(|err| {
1182                ZhArtifactPayloadError::InvalidIndexedFst {
1183                    message: err.to_string(),
1184                }
1185            })?;
1186            for entry in &payload.entries {
1187                let offset = readings_bytes.len() as u64;
1188                builder.insert(&entry.surface, offset).map_err(|err| {
1189                    ZhArtifactPayloadError::InvalidIndexedFst {
1190                        message: err.to_string(),
1191                    }
1192                })?;
1193                write_indexed_reading_block(&mut readings_bytes, &entry.readings)?;
1194            }
1195            builder
1196                .finish()
1197                .map_err(|err| ZhArtifactPayloadError::InvalidIndexedFst {
1198                    message: err.to_string(),
1199                })?;
1200        }
1201
1202        writer.write_all(INDEXED_ARTIFACT_MAGIC)?;
1203        writer.write_all(&INDEXED_ARTIFACT_VERSION.to_le_bytes())?;
1204        writer.write_all(&pinyin_view_header_value(self.pinyin_view).to_le_bytes())?;
1205        writer.write_all(&(payload.entries.len() as u64).to_le_bytes())?;
1206        writer.write_all(&(fst_bytes.len() as u64).to_le_bytes())?;
1207        writer.write_all(&(readings_bytes.len() as u64).to_le_bytes())?;
1208        writer.write_all(&fst_bytes)?;
1209        writer.write_all(&readings_bytes)?;
1210        Ok(())
1211    }
1212
1213    /// Expands `text` into joined pinyin reading strings.
1214    ///
1215    /// This is a compatibility helper over [`Self::reading_paths`]. It drops
1216    /// segment boundaries and treats indexed artifact decode errors or invalid
1217    /// expansion options as an empty expansion.
1218    pub fn reading_sequences(&self, text: &str, options: PinyinReadingOptions) -> Vec<String> {
1219        self.reading_paths(text, options)
1220            .into_iter()
1221            .map(|path| path.joined_reading)
1222            .collect()
1223    }
1224
1225    /// Expands `text` into dictionary-only pinyin reading paths.
1226    ///
1227    /// Every returned path contains surface/reading segment boundaries plus the
1228    /// joined pinyin reading. Use [`Self::try_reading_paths_with_stats`] when
1229    /// indexed artifact corruption or invalid expansion options must be
1230    /// reported.
1231    pub fn reading_paths(
1232        &self,
1233        text: &str,
1234        options: PinyinReadingOptions,
1235    ) -> Vec<PinyinReadingPath> {
1236        self.reading_paths_with_stats(text, options).paths
1237    }
1238
1239    /// Expands dictionary pinyin paths and treats artifact decode errors or
1240    /// invalid expansion options as an empty expansion for backward
1241    /// compatibility.
1242    ///
1243    /// Use [`Self::try_reading_paths_with_stats`] when loading indexed
1244    /// artifacts from outside the process trust boundary.
1245    pub fn reading_paths_with_stats(
1246        &self,
1247        text: &str,
1248        options: PinyinReadingOptions,
1249    ) -> PinyinReadingExpansion {
1250        self.try_reading_paths_with_stats(text, options)
1251            .unwrap_or_default()
1252    }
1253
1254    /// Expands dictionary pinyin paths and preserves indexed artifact decode
1255    /// and option validation errors.
1256    pub fn try_reading_paths_with_stats(
1257        &self,
1258        text: &str,
1259        options: PinyinReadingOptions,
1260    ) -> Result<PinyinReadingExpansion, ZhArtifactPayloadError> {
1261        self.reading_paths_with_stats_inner(text, options, false)
1262    }
1263
1264    /// Expands `text` into pinyin reading paths with direct fallback segments.
1265    ///
1266    /// Dictionary matches are preferred, but direct pinyin and punctuation
1267    /// spans can pass through directly so mixed dictionary/direct input can
1268    /// still form a full path.
1269    pub fn hybrid_reading_paths(
1270        &self,
1271        text: &str,
1272        options: PinyinReadingOptions,
1273    ) -> Vec<PinyinReadingPath> {
1274        self.hybrid_reading_paths_with_stats(text, options).paths
1275    }
1276
1277    /// Expands hybrid dictionary/direct pinyin paths and treats artifact decode
1278    /// errors or invalid expansion options as an empty expansion for backward
1279    /// compatibility.
1280    ///
1281    /// Use [`Self::try_hybrid_reading_paths_with_stats`] when loading indexed
1282    /// artifacts from outside the process trust boundary.
1283    pub fn hybrid_reading_paths_with_stats(
1284        &self,
1285        text: &str,
1286        options: PinyinReadingOptions,
1287    ) -> PinyinReadingExpansion {
1288        self.try_hybrid_reading_paths_with_stats(text, options)
1289            .unwrap_or_default()
1290    }
1291
1292    /// Expands hybrid dictionary/direct pinyin paths and preserves indexed
1293    /// artifact decode and option validation errors.
1294    pub fn try_hybrid_reading_paths_with_stats(
1295        &self,
1296        text: &str,
1297        options: PinyinReadingOptions,
1298    ) -> Result<PinyinReadingExpansion, ZhArtifactPayloadError> {
1299        self.reading_paths_with_stats_inner(text, options, true)
1300    }
1301
1302    /// Builds a pinyin lattice from dictionary-only readings of `text`.
1303    ///
1304    /// Returns `Ok(None)` when the dictionary cannot cover the entire input.
1305    /// Indexed artifact decode errors are reported as
1306    /// [`CnLatticeError::ArtifactPayload`].
1307    pub fn pinyin_lattice(
1308        &self,
1309        text: &str,
1310        options: PinyinReadingOptions,
1311    ) -> Result<Option<Lattice>, CnLatticeError> {
1312        let paths = self
1313            .try_reading_paths_with_stats(text, options)
1314            .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1315            .paths;
1316        if paths.is_empty() {
1317            return Ok(None);
1318        }
1319        pinyin_lattice_from_reading_paths(&paths).map(Some)
1320    }
1321
1322    /// Builds a pinyin lattice with dictionary readings and direct fallback.
1323    ///
1324    /// This is the preferred lattice builder for mixed Chinese text where
1325    /// direct pinyin or punctuation spans may appear beside
1326    /// CC-CEDICT-backed surfaces.
1327    pub fn hybrid_pinyin_lattice(
1328        &self,
1329        text: &str,
1330        options: PinyinReadingOptions,
1331    ) -> Result<Option<Lattice>, CnLatticeError> {
1332        let paths = self
1333            .try_hybrid_reading_paths_with_stats(text, options)
1334            .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1335            .paths;
1336        if paths.is_empty() {
1337            return Ok(None);
1338        }
1339        pinyin_lattice_from_reading_paths(&paths).map(Some)
1340    }
1341
1342    fn reading_paths_with_stats_inner(
1343        &self,
1344        text: &str,
1345        options: PinyinReadingOptions,
1346        allow_direct_fallback: bool,
1347    ) -> Result<PinyinReadingExpansion, ZhArtifactPayloadError> {
1348        let options = options.validate()?;
1349        if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1350            return Ok(PinyinReadingExpansion::default());
1351        }
1352
1353        let mut stats = PinyinReadingStats::default();
1354        let boundaries = char_boundaries(text);
1355        let char_len = boundaries.len() - 1;
1356        let mut suffix_paths = vec![Vec::<PinyinReadingPath>::new(); char_len + 1];
1357        suffix_paths[char_len].push(PinyinReadingPath {
1358            segments: Vec::new(),
1359            joined_reading: String::new(),
1360        });
1361
1362        for start in (0..char_len).rev() {
1363            let mut paths_by_reading = BTreeMap::new();
1364            let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1365            let mut matching_ends = Vec::new();
1366
1367            for end in start + 1..=end_limit {
1368                let surface = &text[boundaries[start]..boundaries[end]];
1369                if !suffix_paths[end].is_empty() {
1370                    if let Some(surface_readings) = self.try_readings(surface)? {
1371                        matching_ends.push((end, surface_readings));
1372                    }
1373                }
1374            }
1375            stats.matched_spans += matching_ends.len();
1376
1377            if options.longest_match_only {
1378                let pruned_spans = matching_ends.len().saturating_sub(1);
1379                if let Some(longest_match) = matching_ends.pop() {
1380                    stats.longest_match_pruned_spans += pruned_spans;
1381                    matching_ends.clear();
1382                    matching_ends.push(longest_match);
1383                }
1384            }
1385
1386            for (end, surface_readings) in matching_ends {
1387                let surface = &text[boundaries[start]..boundaries[end]];
1388
1389                stats.raw_segment_readings += surface_readings.len();
1390                let raw_surface_reading_count = surface_readings.len();
1391                let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1392                stats.used_segment_readings += surface_readings.len();
1393                stats.pruned_segment_readings += raw_surface_reading_count - surface_readings.len();
1394
1395                for surface_reading in surface_readings {
1396                    for suffix in &suffix_paths[end] {
1397                        stats.candidate_combinations += 1;
1398                        let mut reading = String::with_capacity(
1399                            surface_reading.len() + suffix.joined_reading.len(),
1400                        );
1401                        reading.push_str(surface_reading);
1402                        reading.push_str(&suffix.joined_reading);
1403
1404                        let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1405                        segments.push(PinyinReadingSegment {
1406                            surface: surface.to_string(),
1407                            reading: surface_reading.to_string(),
1408                            source: PinyinReadingSegmentSource::Dictionary,
1409                        });
1410                        segments.extend(suffix.segments.iter().cloned());
1411
1412                        match paths_by_reading.entry(reading.clone()) {
1413                            Entry::Vacant(entry) => {
1414                                entry.insert(PinyinReadingPath {
1415                                    segments,
1416                                    joined_reading: reading,
1417                                });
1418                                stats.unique_paths += 1;
1419                            }
1420                            Entry::Occupied(_) => {
1421                                stats.duplicate_joined_readings += 1;
1422                            }
1423                        }
1424
1425                        if paths_by_reading.len() >= options.max_paths {
1426                            stats.max_paths_hit_count += 1;
1427                            break;
1428                        }
1429                    }
1430
1431                    if paths_by_reading.len() >= options.max_paths {
1432                        break;
1433                    }
1434                }
1435
1436                if paths_by_reading.len() >= options.max_paths {
1437                    break;
1438                }
1439            }
1440
1441            if allow_direct_fallback && paths_by_reading.len() < options.max_paths {
1442                if let Some(end) = direct_fallback_end(text, &boundaries, start, char_len) {
1443                    if !suffix_paths[end].is_empty() {
1444                        stats.direct_fallback_spans += 1;
1445                        let surface = &text[boundaries[start]..boundaries[end]];
1446                        let reading = normalize_direct_pinyin_input(surface);
1447                        for suffix in &suffix_paths[end] {
1448                            stats.candidate_combinations += 1;
1449                            let mut joined =
1450                                String::with_capacity(reading.len() + suffix.joined_reading.len());
1451                            joined.push_str(&reading);
1452                            joined.push_str(&suffix.joined_reading);
1453
1454                            let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1455                            segments.push(PinyinReadingSegment {
1456                                surface: surface.to_string(),
1457                                reading: reading.clone(),
1458                                source: PinyinReadingSegmentSource::Direct,
1459                            });
1460                            segments.extend(suffix.segments.iter().cloned());
1461
1462                            match paths_by_reading.entry(joined.clone()) {
1463                                Entry::Vacant(entry) => {
1464                                    entry.insert(PinyinReadingPath {
1465                                        segments,
1466                                        joined_reading: joined,
1467                                    });
1468                                    stats.unique_paths += 1;
1469                                }
1470                                Entry::Occupied(_) => {
1471                                    stats.duplicate_joined_readings += 1;
1472                                }
1473                            }
1474
1475                            if paths_by_reading.len() >= options.max_paths {
1476                                stats.max_paths_hit_count += 1;
1477                                break;
1478                            }
1479                        }
1480                    }
1481                }
1482            }
1483
1484            suffix_paths[start] = paths_by_reading.into_values().collect();
1485        }
1486
1487        Ok(PinyinReadingExpansion {
1488            paths: suffix_paths.remove(0),
1489            stats,
1490        })
1491    }
1492}
1493
1494/// Compares two strings using direct pinyin handling and a CC-CEDICT index.
1495pub fn compare_with_cedict_index(
1496    left: &str,
1497    right: &str,
1498    index: &CedictReadingIndex,
1499    options: PinyinReadingOptions,
1500) -> Result<ChineseDistance, CnLatticeError> {
1501    compare_with_zh_index(left, right, index, options)
1502}
1503
1504/// Compares two strings using direct pinyin handling and a Chinese index.
1505pub fn compare_with_zh_index(
1506    left: &str,
1507    right: &str,
1508    index: &ZhReadingIndex,
1509    options: PinyinReadingOptions,
1510) -> Result<ChineseDistance, CnLatticeError> {
1511    let options = validate_pinyin_options(options)?;
1512    let left_lattice = cedict_or_direct_lattice(left, index, options)?;
1513    let right_lattice = cedict_or_direct_lattice(right, index, options)?;
1514    compare_lattices(left, right, &left_lattice, &right_lattice)
1515}
1516
1517/// Computes the best normalized similarity across Chinese pinyin readings.
1518pub fn normalized_similarity_with_zh_index(
1519    left: &str,
1520    right: &str,
1521    index: &ZhReadingIndex,
1522    options: PinyinReadingOptions,
1523) -> Result<f64, CnLatticeError> {
1524    let options = validate_pinyin_options(options)?;
1525    let left_paths = zh_or_direct_pinyin_paths(left, index, options)?;
1526    let right_paths = zh_or_direct_pinyin_paths(right, index, options)?;
1527    Ok(max_normalized_similarity(&left_paths, &right_paths))
1528}
1529
1530/// Builds a pinyin lattice from direct input, CC-CEDICT readings, or both.
1531pub fn cedict_or_direct_lattice(
1532    input: &str,
1533    index: &CedictReadingIndex,
1534    options: PinyinReadingOptions,
1535) -> Result<Lattice, CnLatticeError> {
1536    zh_or_direct_lattice(input, index, options)
1537}
1538
1539/// Builds a pinyin lattice from direct input, dictionary readings, or both.
1540pub fn zh_or_direct_lattice(
1541    input: &str,
1542    index: &ZhReadingIndex,
1543    options: PinyinReadingOptions,
1544) -> Result<Lattice, CnLatticeError> {
1545    let options = validate_pinyin_options(options)?;
1546    if let Some(lattice) = direct_pinyin_lattice(input) {
1547        return Ok(lattice);
1548    }
1549
1550    if let Some(lattice) = index.pinyin_lattice(input, options)? {
1551        return Ok(lattice);
1552    }
1553
1554    if let Some(lattice) = index.hybrid_pinyin_lattice(input, options)? {
1555        return Ok(lattice);
1556    }
1557
1558    direct_pinyin_lattice(input).ok_or_else(|| CnLatticeError::UnsupportedDirectInput {
1559        surface: input.to_string(),
1560    })
1561}
1562
1563/// Returns pinyin paths from direct input, dictionary readings, or both.
1564pub fn zh_or_direct_pinyin_paths(
1565    input: &str,
1566    index: &ZhReadingIndex,
1567    options: PinyinReadingOptions,
1568) -> Result<Vec<String>, CnLatticeError> {
1569    let options = validate_pinyin_options(options)?;
1570    if can_build_direct_pinyin_input(input) {
1571        return Ok(vec![normalize_direct_pinyin_input(input)]);
1572    }
1573
1574    let paths = index
1575        .try_reading_paths_with_stats(input, options)
1576        .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1577        .paths;
1578    if !paths.is_empty() {
1579        return Ok(paths.into_iter().map(|path| path.joined_reading).collect());
1580    }
1581
1582    let paths = index
1583        .try_hybrid_reading_paths_with_stats(input, options)
1584        .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1585        .paths;
1586    if !paths.is_empty() {
1587        return Ok(paths.into_iter().map(|path| path.joined_reading).collect());
1588    }
1589
1590    Err(CnLatticeError::UnsupportedDirectInput {
1591        surface: input.to_string(),
1592    })
1593}
1594
1595fn validate_pinyin_options(
1596    options: PinyinReadingOptions,
1597) -> Result<PinyinReadingOptions, CnLatticeError> {
1598    options
1599        .validate()
1600        .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))
1601}
1602
1603fn max_normalized_similarity(left_paths: &[String], right_paths: &[String]) -> f64 {
1604    let left_paths = char_paths(left_paths);
1605    let right_paths = char_paths(right_paths);
1606    let mut workspace = StringDistanceWorkspace::new();
1607    max_normalized_similarity_chars(&left_paths, &right_paths, &mut workspace)
1608}
1609
1610fn char_paths(paths: &[String]) -> Vec<Vec<char>> {
1611    paths.iter().map(|path| path.chars().collect()).collect()
1612}
1613
1614fn max_normalized_similarity_chars(
1615    left_paths: &[Vec<char>],
1616    right_paths: &[Vec<char>],
1617    workspace: &mut StringDistanceWorkspace,
1618) -> f64 {
1619    let mut best = 0.0;
1620    for left in left_paths {
1621        for right in right_paths {
1622            let distance = workspace.levenshtein(left, right);
1623            let similarity = normalized_similarity_from_distance(distance, left.len(), right.len());
1624            best = f64::max(best, similarity);
1625            if best == 1.0 {
1626                return best;
1627            }
1628        }
1629    }
1630    best
1631}
1632
1633fn compare_lattices(
1634    left: &str,
1635    right: &str,
1636    left_lattice: &Lattice,
1637    right_lattice: &Lattice,
1638) -> Result<ChineseDistance, CnLatticeError> {
1639    let lattice = try_distance(left_lattice, right_lattice)?;
1640    let lattice_damerau = try_damerau_distance(left_lattice, right_lattice)?;
1641    let surface_levenshtein = levenshtein_str(left, right);
1642    let surface_damerau = try_damerau_levenshtein_str(left, right)?;
1643
1644    Ok(ChineseDistance {
1645        surface_levenshtein,
1646        surface_damerau,
1647        lattice,
1648        lattice_damerau,
1649        combined: surface_damerau.min(lattice),
1650    })
1651}
1652
1653fn char_boundaries(text: &str) -> Vec<usize> {
1654    text.char_indices()
1655        .map(|(index, _)| index)
1656        .chain(std::iter::once(text.len()))
1657        .collect()
1658}
1659
1660fn limited_surface_readings(readings: &[String], options: PinyinReadingOptions) -> &[String] {
1661    if let Some(max_readings) = options.max_readings_per_segment {
1662        &readings[..readings.len().min(max_readings)]
1663    } else {
1664        readings
1665    }
1666}
1667
1668fn direct_fallback_end(
1669    text: &str,
1670    boundaries: &[usize],
1671    start: usize,
1672    char_len: usize,
1673) -> Option<usize> {
1674    let mut end = start;
1675    while end < char_len {
1676        let surface = &text[boundaries[start]..boundaries[end + 1]];
1677        if !can_build_direct_pinyin_input(surface) {
1678            break;
1679        }
1680        end += 1;
1681    }
1682
1683    (end > start).then_some(end)
1684}
1685
1686fn pinyin_view_header_value(view: PinyinView) -> u32 {
1687    match view {
1688        PinyinView::NoTone => 0,
1689        PinyinView::Tone3 => 1,
1690    }
1691}
1692
1693fn pinyin_view_from_header_value(value: u32) -> Result<PinyinView, ZhArtifactPayloadError> {
1694    match value {
1695        0 => Ok(PinyinView::NoTone),
1696        1 => Ok(PinyinView::Tone3),
1697        _ => Err(ZhArtifactPayloadError::UnsupportedIndexedPinyinView { value }),
1698    }
1699}
1700
1701fn write_binary_string(
1702    writer: &mut impl Write,
1703    field: &'static str,
1704    value: &str,
1705) -> Result<(), ZhArtifactPayloadError> {
1706    write_u32_len(writer, field, value.len())?;
1707    writer.write_all(value.as_bytes())?;
1708    Ok(())
1709}
1710
1711fn write_u32_len(
1712    writer: &mut impl Write,
1713    field: &'static str,
1714    len: usize,
1715) -> Result<(), ZhArtifactPayloadError> {
1716    let len = u32::try_from(len).map_err(|_| ZhArtifactPayloadError::IndexedSectionTooLarge {
1717        field,
1718        len: len as u64,
1719    })?;
1720    writer.write_all(&len.to_le_bytes())?;
1721    Ok(())
1722}
1723
1724fn read_indexed_artifact_payload_header_bytes(
1725    bytes: &[u8],
1726) -> Result<ZhIndexedArtifactPayloadHeader, ZhArtifactPayloadError> {
1727    if bytes.len() < INDEXED_ARTIFACT_HEADER_LEN {
1728        return Err(ZhArtifactPayloadError::TruncatedIndexed { field: "header" });
1729    }
1730    let mut magic = [0_u8; 8];
1731    magic.copy_from_slice(&bytes[..8]);
1732    if &magic != INDEXED_ARTIFACT_MAGIC {
1733        return Err(ZhArtifactPayloadError::InvalidIndexedMagic { magic });
1734    }
1735
1736    let version = read_u32_le_bytes(bytes, 8, "version")?;
1737    if version != INDEXED_ARTIFACT_VERSION {
1738        return Err(ZhArtifactPayloadError::UnsupportedIndexedVersion { version });
1739    }
1740    let pinyin_view = pinyin_view_from_header_value(read_u32_le_bytes(bytes, 12, "pinyin_view")?)?;
1741    let entry_count = read_u64_le_bytes(bytes, 16, "entry_count")?;
1742    let fst_len = read_u64_le_bytes(bytes, 24, "fst_len")?;
1743    let readings_len = read_u64_le_bytes(bytes, 32, "readings_len")?;
1744    let entries = checked_indexed_usize("entry_count", entry_count)?;
1745    check_limit("entry_count", entries, MAX_ARTIFACT_ENTRIES)?;
1746    Ok(ZhIndexedArtifactPayloadHeader {
1747        version,
1748        pinyin_view,
1749        entries,
1750        fst_len: checked_indexed_usize("fst_len", fst_len)?,
1751        readings_len: checked_indexed_usize("readings_len", readings_len)?,
1752    })
1753}
1754
1755fn read_u32_le_bytes(
1756    bytes: &[u8],
1757    offset: usize,
1758    field: &'static str,
1759) -> Result<u32, ZhArtifactPayloadError> {
1760    let end = offset
1761        .checked_add(4)
1762        .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1763    let chunk = bytes
1764        .get(offset..end)
1765        .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1766    Ok(u32::from_le_bytes(
1767        chunk.try_into().expect("slice length is 4"),
1768    ))
1769}
1770
1771fn read_u64_le_bytes(
1772    bytes: &[u8],
1773    offset: usize,
1774    field: &'static str,
1775) -> Result<u64, ZhArtifactPayloadError> {
1776    let end = offset
1777        .checked_add(8)
1778        .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1779    let chunk = bytes
1780        .get(offset..end)
1781        .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1782    Ok(u64::from_le_bytes(
1783        chunk.try_into().expect("slice length is 8"),
1784    ))
1785}
1786
1787fn checked_indexed_usize(field: &'static str, len: u64) -> Result<usize, ZhArtifactPayloadError> {
1788    usize::try_from(len).map_err(|_| ZhArtifactPayloadError::IndexedSectionTooLarge { field, len })
1789}
1790
1791fn check_payload_file_size(path: &Path) -> Result<(), ZhArtifactPayloadError> {
1792    let len = std::fs::metadata(path)?.len();
1793    if len > MAX_ARTIFACT_PAYLOAD_BYTES {
1794        return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1795            field: "payload_bytes",
1796            len,
1797            max: MAX_ARTIFACT_PAYLOAD_BYTES,
1798        });
1799    }
1800    Ok(())
1801}
1802
1803fn read_payload_file_bounded(path: &Path) -> Result<Vec<u8>, ZhArtifactPayloadError> {
1804    let metadata = std::fs::metadata(path)?;
1805    if !metadata.file_type().is_file() {
1806        return Err(std::io::Error::new(
1807            std::io::ErrorKind::InvalidInput,
1808            "artifact payload path is not a regular file",
1809        )
1810        .into());
1811    }
1812    if metadata.len() > MAX_ARTIFACT_PAYLOAD_BYTES {
1813        return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1814            field: "payload_bytes",
1815            len: metadata.len(),
1816            max: MAX_ARTIFACT_PAYLOAD_BYTES,
1817        });
1818    }
1819    let file = File::open(path)?;
1820    read_payload_reader_bounded(file, MAX_ARTIFACT_PAYLOAD_BYTES)
1821}
1822
1823fn read_payload_reader_bounded(
1824    reader: impl Read,
1825    max: u64,
1826) -> Result<Vec<u8>, ZhArtifactPayloadError> {
1827    let mut reader = reader.take(max.saturating_add(1));
1828    let mut bytes = Vec::new();
1829    let mut chunk = [0_u8; 8192];
1830    loop {
1831        let len = reader.read(&mut chunk)?;
1832        if len == 0 {
1833            return Ok(bytes);
1834        }
1835        let next_len = bytes.len() as u64 + len as u64;
1836        if next_len > max {
1837            return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1838                field: "payload_bytes",
1839                len: next_len,
1840                max,
1841            });
1842        }
1843        bytes.extend_from_slice(&chunk[..len]);
1844    }
1845}
1846
1847fn check_limit(field: &'static str, len: usize, max: usize) -> Result<(), ZhArtifactPayloadError> {
1848    if len > max {
1849        return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1850            field,
1851            len: len as u64,
1852            max: max as u64,
1853        });
1854    }
1855    Ok(())
1856}
1857
1858fn write_indexed_reading_block(
1859    writer: &mut Vec<u8>,
1860    readings: &[String],
1861) -> Result<(), ZhArtifactPayloadError> {
1862    write_u32_len(writer, "reading_count", readings.len())?;
1863    for reading in readings {
1864        write_binary_string(writer, "reading", reading)?;
1865    }
1866    Ok(())
1867}
1868
1869impl IndexedZhPayload {
1870    fn validate(&self, pinyin_view: PinyinView) -> Result<(), ZhArtifactPayloadError> {
1871        let mut stream = self.map.stream();
1872        while let Some((surface, offset)) = stream.next() {
1873            let surface = String::from_utf8(surface.to_vec()).map_err(|source| {
1874                ZhArtifactPayloadError::InvalidIndexedUtf8 {
1875                    field: "surface",
1876                    source,
1877                }
1878            })?;
1879            if surface.is_empty() {
1880                return Err(ZhArtifactPayloadError::EmptySurface { entry_index: 0 });
1881            }
1882            check_limit("surface_bytes", surface.len(), MAX_ARTIFACT_STRING_BYTES)?;
1883            let readings = self.readings_at(offset)?;
1884            if readings.is_empty() {
1885                return Err(ZhArtifactPayloadError::EmptyReadings { surface });
1886            }
1887            let mut seen = BTreeSet::new();
1888            for (reading_index, reading) in readings.iter().enumerate() {
1889                if reading.is_empty() {
1890                    return Err(ZhArtifactPayloadError::EmptyReading {
1891                        surface: surface.clone(),
1892                        reading_index,
1893                    });
1894                }
1895                let normalized = normalize_artifact_reading(reading, pinyin_view);
1896                if normalized != *reading {
1897                    return Err(ZhArtifactPayloadError::ReadingNotNormalized {
1898                        surface: surface.clone(),
1899                        reading: reading.clone(),
1900                        normalized,
1901                    });
1902                }
1903                if !seen.insert(reading) {
1904                    return Err(ZhArtifactPayloadError::DuplicateReading {
1905                        surface: surface.clone(),
1906                        reading: reading.clone(),
1907                    });
1908                }
1909            }
1910        }
1911        Ok(())
1912    }
1913
1914    fn readings(&self, surface: &str) -> Result<Option<Vec<String>>, ZhArtifactPayloadError> {
1915        self.map
1916            .get(surface)
1917            .map(|offset| self.readings_at(offset))
1918            .transpose()
1919    }
1920
1921    fn entries(&self) -> Result<Vec<ZhReadingIndexPayloadEntry>, ZhArtifactPayloadError> {
1922        let mut entries = Vec::with_capacity(self.entries);
1923        let mut stream = self.map.stream();
1924        while let Some((surface, offset)) = stream.next() {
1925            let surface = String::from_utf8(surface.to_vec()).map_err(|source| {
1926                ZhArtifactPayloadError::InvalidIndexedUtf8 {
1927                    field: "surface",
1928                    source,
1929                }
1930            })?;
1931            let readings = self.readings_at(offset)?;
1932            entries.push(ZhReadingIndexPayloadEntry { surface, readings });
1933        }
1934        Ok(entries)
1935    }
1936
1937    fn readings_at(&self, offset: u64) -> Result<Vec<String>, ZhArtifactPayloadError> {
1938        read_indexed_readings_at_bytes(self.bytes.as_ref(), self.readings_start, offset)
1939    }
1940}
1941
1942fn read_indexed_readings_at_bytes(
1943    bytes: &[u8],
1944    readings_start: usize,
1945    offset: u64,
1946) -> Result<Vec<String>, ZhArtifactPayloadError> {
1947    let offset = usize::try_from(offset)
1948        .map_err(|_| ZhArtifactPayloadError::InvalidIndexedOffset { offset })?;
1949    let start =
1950        readings_start
1951            .checked_add(offset)
1952            .ok_or(ZhArtifactPayloadError::InvalidIndexedOffset {
1953                offset: offset as u64,
1954            })?;
1955    if start >= bytes.len() {
1956        return Err(ZhArtifactPayloadError::InvalidIndexedOffset {
1957            offset: offset as u64,
1958        });
1959    }
1960    let mut cursor = start;
1961    let reading_count = read_u32_le_bytes(bytes, cursor, "reading_count")? as usize;
1962    check_limit(
1963        "reading_count",
1964        reading_count,
1965        MAX_ARTIFACT_READINGS_PER_ENTRY,
1966    )?;
1967    cursor += 4;
1968    let mut readings = Vec::with_capacity(reading_count);
1969    for _ in 0..reading_count {
1970        let len = read_u32_le_bytes(bytes, cursor, "reading_len")? as usize;
1971        check_limit("reading_bytes", len, MAX_ARTIFACT_STRING_BYTES)?;
1972        cursor += 4;
1973        let end = cursor
1974            .checked_add(len)
1975            .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
1976        let reading_bytes = bytes
1977            .get(cursor..end)
1978            .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
1979        let reading = String::from_utf8(reading_bytes.to_vec()).map_err(|source| {
1980            ZhArtifactPayloadError::InvalidIndexedUtf8 {
1981                field: "reading",
1982                source,
1983            }
1984        })?;
1985        readings.push(reading);
1986        cursor = end;
1987    }
1988    Ok(readings)
1989}
1990
1991/// Computes the SHA-256 file digest string for a Chinese artifact payload file.
1992pub fn artifact_file_digest_path(path: impl AsRef<Path>) -> Result<String, std::io::Error> {
1993    let file = File::open(path)?;
1994    artifact_file_digest_reader(file)
1995}
1996
1997/// Computes the SHA-256 file digest string from a reader.
1998pub fn artifact_file_digest_reader(mut reader: impl Read) -> Result<String, std::io::Error> {
1999    let mut hasher = Sha256::new();
2000    let mut buffer = [0_u8; 64 * 1024];
2001    loop {
2002        let read = reader.read(&mut buffer)?;
2003        if read == 0 {
2004            break;
2005        }
2006        hasher.update(&buffer[..read]);
2007    }
2008    Ok(sha256_digest_hex(hasher.finalize()))
2009}
2010
2011fn validate_artifact_payload_header(
2012    payload: &ZhReadingIndexPayload,
2013) -> Result<(), ZhArtifactPayloadError> {
2014    if payload.schema_version != ARTIFACT_PAYLOAD_SCHEMA_VERSION {
2015        return Err(ZhArtifactPayloadError::UnsupportedSchemaVersion {
2016            version: payload.schema_version,
2017        });
2018    }
2019    if payload.payload_type != ARTIFACT_PAYLOAD_TYPE {
2020        return Err(ZhArtifactPayloadError::UnsupportedPayloadType {
2021            payload_type: payload.payload_type.clone(),
2022        });
2023    }
2024    Ok(())
2025}
2026
2027fn canonical_payload_bytes(payload: &ZhReadingIndexPayload) -> Vec<u8> {
2028    let mut bytes = Vec::new();
2029    bytes.extend_from_slice(b"moine.zh.reading-index.surface-readings/v1\n");
2030    push_len_prefixed(&mut bytes, b"V", &payload.pinyin_view);
2031    for entry in &payload.entries {
2032        push_len_prefixed(&mut bytes, b"S", &entry.surface);
2033        bytes.extend_from_slice(format!("R{}\n", entry.readings.len()).as_bytes());
2034        for reading in &entry.readings {
2035            push_len_prefixed(&mut bytes, b"r", reading);
2036        }
2037    }
2038    bytes
2039}
2040
2041fn push_len_prefixed(bytes: &mut Vec<u8>, tag: &[u8], value: &str) {
2042    bytes.extend_from_slice(tag);
2043    bytes.extend_from_slice(value.len().to_string().as_bytes());
2044    bytes.push(b'\n');
2045    bytes.extend_from_slice(value.as_bytes());
2046    bytes.push(b'\n');
2047}
2048
2049fn sha256_hex(bytes: &[u8]) -> String {
2050    sha256_digest_hex(Sha256::digest(bytes))
2051}
2052
2053fn sha256_digest_hex(digest: impl IntoIterator<Item = u8>) -> String {
2054    let mut output = String::with_capacity(64);
2055    for byte in digest {
2056        write!(&mut output, "{byte:02x}").expect("writing to String should not fail");
2057    }
2058    output
2059}
2060
2061struct CedictEntry<'a> {
2062    traditional: &'a str,
2063    simplified: &'a str,
2064    pinyin: &'a str,
2065}
2066
2067fn parse_cedict_entry(line: &str, line_number: usize) -> Result<CedictEntry<'_>, CedictError> {
2068    let (traditional, rest) = take_token(line)
2069        .ok_or_else(|| invalid_entry(line_number, "missing traditional surface"))?;
2070    let (simplified, rest) = take_token(rest.trim_start())
2071        .ok_or_else(|| invalid_entry(line_number, "missing simplified surface"))?;
2072    let rest = rest.trim_start();
2073
2074    let (pinyin, rest) = if let Some(after_open) = rest.strip_prefix("[[") {
2075        let Some(end) = after_open.find("]]") else {
2076            return Err(invalid_entry(line_number, "missing closing ]] for pinyin"));
2077        };
2078        (&after_open[..end], &after_open[end + 2..])
2079    } else if let Some(after_open) = rest.strip_prefix('[') {
2080        let Some(end) = after_open.find(']') else {
2081            return Err(invalid_entry(line_number, "missing closing ] for pinyin"));
2082        };
2083        (&after_open[..end], &after_open[end + 1..])
2084    } else {
2085        return Err(invalid_entry(line_number, "missing pinyin bracket"));
2086    };
2087
2088    if pinyin.is_empty() {
2089        return Err(invalid_entry(line_number, "empty pinyin field"));
2090    }
2091    if !rest.trim_start().starts_with('/') {
2092        return Err(invalid_entry(line_number, "missing definition slash"));
2093    }
2094
2095    Ok(CedictEntry {
2096        traditional,
2097        simplified,
2098        pinyin,
2099    })
2100}
2101
2102fn invalid_entry(line: usize, message: impl Into<String>) -> CedictError {
2103    CedictError::InvalidEntry {
2104        line,
2105        message: message.into(),
2106    }
2107}
2108
2109fn take_token(input: &str) -> Option<(&str, &str)> {
2110    let input = input.trim_start();
2111    if input.is_empty() {
2112        return None;
2113    }
2114    for (index, ch) in input.char_indices() {
2115        if ch.is_whitespace() {
2116            return Some((&input[..index], &input[index..]));
2117        }
2118    }
2119    Some((input, ""))
2120}
2121
2122#[cfg(test)]
2123mod tests {
2124    use super::*;
2125
2126    fn indexed_payload_with_surface(
2127        surface: &str,
2128        readings: &[String],
2129    ) -> Result<Vec<u8>, ZhArtifactPayloadError> {
2130        let mut fst_bytes = Vec::new();
2131        let mut readings_bytes = Vec::new();
2132        {
2133            let mut builder = MapBuilder::new(&mut fst_bytes).map_err(|err| {
2134                ZhArtifactPayloadError::InvalidIndexedFst {
2135                    message: err.to_string(),
2136                }
2137            })?;
2138            builder.insert(surface, 0).map_err(|err| {
2139                ZhArtifactPayloadError::InvalidIndexedFst {
2140                    message: err.to_string(),
2141                }
2142            })?;
2143            builder
2144                .finish()
2145                .map_err(|err| ZhArtifactPayloadError::InvalidIndexedFst {
2146                    message: err.to_string(),
2147                })?;
2148        }
2149        write_indexed_reading_block(&mut readings_bytes, readings)?;
2150
2151        let mut bytes = Vec::new();
2152        bytes.extend_from_slice(INDEXED_ARTIFACT_MAGIC);
2153        bytes.extend_from_slice(&INDEXED_ARTIFACT_VERSION.to_le_bytes());
2154        bytes.extend_from_slice(&pinyin_view_header_value(PinyinView::NoTone).to_le_bytes());
2155        bytes.extend_from_slice(&1_u64.to_le_bytes());
2156        bytes.extend_from_slice(&(fst_bytes.len() as u64).to_le_bytes());
2157        bytes.extend_from_slice(&(readings_bytes.len() as u64).to_le_bytes());
2158        bytes.extend_from_slice(&fst_bytes);
2159        bytes.extend_from_slice(&readings_bytes);
2160        Ok(bytes)
2161    }
2162
2163    #[test]
2164    fn bounded_payload_reader_rejects_data_past_limit() {
2165        let err = read_payload_reader_bounded(std::io::Cursor::new([0_u8; 4]), 3).unwrap_err();
2166
2167        assert!(matches!(
2168            err,
2169            ZhArtifactPayloadError::ArtifactLimitExceeded {
2170                field: "payload_bytes",
2171                len: 4,
2172                max: 3
2173            }
2174        ));
2175    }
2176
2177    #[test]
2178    fn normalizes_pinyin_views() {
2179        assert_eq!(
2180            normalize_pinyin("Wei1 shi4 ji4", PinyinView::NoTone),
2181            "weishiji"
2182        );
2183        assert_eq!(
2184            normalize_pinyin("Wei1 shi4 ji4", PinyinView::Tone3),
2185            "wei1shi4ji4"
2186        );
2187        assert_eq!(normalize_pinyin("nu:3 er2", PinyinView::NoTone), "nver");
2188        assert_eq!(normalize_pinyin("nu:3 er2", PinyinView::Tone3), "nv3er2");
2189        assert_eq!(normalize_pinyin("hua1 r5", PinyinView::NoTone), "huar");
2190        assert_eq!(normalize_pinyin("11 Qu1", PinyinView::NoTone), "11qu");
2191        assert_eq!(normalize_pinyin("Shuang1 11", PinyinView::NoTone), "shuang");
2192        assert_eq!(
2193            normalize_pinyin("D N A jian4 ding4", PinyinView::NoTone),
2194            "dnajianding"
2195        );
2196    }
2197
2198    #[test]
2199    fn builds_no_tone_index_from_cedict() {
2200        let cedict = "\
2201# CC-CEDICT
2202威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2203布納哈本 布纳哈本 [Bu4 na4 ha1 ben3] /Bunnahabhain/
2204女兒 女儿 [nu:3 er2] /daughter/
2205";
2206        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2207
2208        assert_eq!(index.pinyin_view(), PinyinView::NoTone);
2209        assert_eq!(
2210            index.readings("威士忌").as_deref(),
2211            Some(&["weishiji".to_string()][..])
2212        );
2213        assert_eq!(
2214            index.readings("布纳哈本").as_deref(),
2215            Some(&["bunahaben".to_string()][..])
2216        );
2217        assert_eq!(
2218            index.readings("女儿").as_deref(),
2219            Some(&["nver".to_string()][..])
2220        );
2221    }
2222
2223    #[test]
2224    fn builds_tone3_index_when_requested() {
2225        let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2226        let index = CedictReadingIndex::from_cedict_reader_with_options(
2227            cedict.as_bytes(),
2228            CedictIndexOptions {
2229                pinyin_view: PinyinView::Tone3,
2230                ..CedictIndexOptions::default()
2231            },
2232        )
2233        .unwrap();
2234
2235        assert_eq!(index.pinyin_view(), PinyinView::Tone3);
2236        assert_eq!(
2237            index.readings("威士忌").as_deref(),
2238            Some(&["wei1shi4ji4".to_string()][..])
2239        );
2240    }
2241
2242    #[test]
2243    fn deduplicates_after_normalization() {
2244        let cedict = "\
2245樂 乐 [Le4] /surname Le/
2246樂 乐 [le4] /happy/
2247樂 乐 [Yue4] /surname Yue/
2248";
2249        let no_tone = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2250        let tone3 = CedictReadingIndex::from_cedict_reader_with_options(
2251            cedict.as_bytes(),
2252            CedictIndexOptions {
2253                pinyin_view: PinyinView::Tone3,
2254                ..CedictIndexOptions::default()
2255            },
2256        )
2257        .unwrap();
2258
2259        assert_eq!(
2260            no_tone.readings("乐").as_deref(),
2261            Some(&["le".to_string(), "yue".to_string()][..])
2262        );
2263        assert_eq!(
2264            tone3.readings("乐").as_deref(),
2265            Some(&["le4".to_string(), "yue4".to_string()][..])
2266        );
2267    }
2268
2269    #[test]
2270    fn rejects_malformed_entries() {
2271        let err = CedictReadingIndex::from_cedict_reader(
2272            "威士忌 威士忌 Wei1 shi4 ji4 /whisky/\n".as_bytes(),
2273        )
2274        .unwrap_err();
2275
2276        assert!(matches!(err, CedictError::InvalidEntry { line: 1, .. }));
2277    }
2278
2279    #[test]
2280    fn computes_dictionary_paths_and_stats() {
2281        let cedict = "\
2282威 威 [wei1] /power/
2283士忌 士忌 [shi4 ji4] /whisky transcription tail/
2284威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2285";
2286        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2287        let expansion = index.reading_paths_with_stats(
2288            "威士忌",
2289            PinyinReadingOptions {
2290                longest_match_only: true,
2291                ..PinyinReadingOptions::default()
2292            },
2293        );
2294
2295        assert_eq!(expansion.paths.len(), 1);
2296        assert_eq!(expansion.paths[0].joined_reading, "weishiji");
2297        assert_eq!(
2298            expansion.paths[0].segments,
2299            vec![PinyinReadingSegment {
2300                surface: "威士忌".to_string(),
2301                reading: "weishiji".to_string(),
2302                source: PinyinReadingSegmentSource::Dictionary,
2303            }]
2304        );
2305        assert_eq!(expansion.stats.longest_match_pruned_spans, 1);
2306    }
2307
2308    #[test]
2309    fn hybrid_paths_allow_ascii_prefix_and_dictionary_tail() {
2310        let cedict = "忌 忌 [ji4] /whisky transcription character/\n";
2311        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2312        let paths = index.hybrid_reading_paths("weishi忌", PinyinReadingOptions::default());
2313
2314        assert_eq!(paths.len(), 1);
2315        assert_eq!(paths[0].joined_reading, "weishiji");
2316    }
2317
2318    #[test]
2319    fn hybrid_paths_allow_dictionary_text_with_chinese_punctuation() {
2320        let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2321        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2322        let paths = index.hybrid_reading_paths("威士忌。", PinyinReadingOptions::default());
2323
2324        assert_eq!(paths.len(), 1);
2325        assert_eq!(paths[0].joined_reading, "weishiji。");
2326    }
2327
2328    #[test]
2329    fn compare_matches_pinyin_input_to_chinese_surface() {
2330        let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2331        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2332        let distances = compare_with_cedict_index(
2333            "weishiji",
2334            "威士忌",
2335            &index,
2336            PinyinReadingOptions::default(),
2337        )
2338        .unwrap();
2339
2340        assert_eq!(distances.lattice, 0);
2341        assert_eq!(distances.lattice_damerau, 0);
2342        assert!(distances.surface_damerau > distances.lattice);
2343    }
2344
2345    #[test]
2346    fn compare_handles_chinese_punctuation_between_pinyin_and_dictionary_spans() {
2347        let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2348        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2349        let distances = compare_with_cedict_index(
2350            "weishiji,威士忌。",
2351            "威士忌,weishiji。",
2352            &index,
2353            PinyinReadingOptions::default(),
2354        )
2355        .unwrap();
2356
2357        assert_eq!(distances.lattice, 0);
2358    }
2359
2360    #[test]
2361    fn direct_pinyin_normalizes_unicode_whitespace() {
2362        let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2363        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2364        for whitespace in [' ', '\u{00a0}', '\u{2003}', '\u{2009}', '\u{3000}'] {
2365            let input = format!("weishi{whitespace}ji");
2366            let distances = compare_with_cedict_index(
2367                &input,
2368                "威士忌",
2369                &index,
2370                PinyinReadingOptions::default(),
2371            )
2372            .unwrap();
2373
2374            assert_eq!(distances.lattice, 0);
2375        }
2376    }
2377
2378    #[test]
2379    fn lattice_damerau_counts_adjacent_pinyin_transposition() {
2380        let distances = compare_with_cedict_index(
2381            "weishiji",
2382            "wieshiji",
2383            &CedictReadingIndex::default(),
2384            PinyinReadingOptions::default(),
2385        )
2386        .unwrap();
2387
2388        assert_eq!(distances.lattice, 2);
2389        assert_eq!(distances.lattice_damerau, 1);
2390    }
2391
2392    #[test]
2393    fn normalized_similarity_matches_pinyin_input_to_chinese_surface() {
2394        let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2395        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2396        let similarity = normalized_similarity_with_zh_index(
2397            "weishiji",
2398            "威士忌",
2399            &index,
2400            PinyinReadingOptions::default(),
2401        )
2402        .unwrap();
2403
2404        assert_eq!(similarity, 1.0);
2405    }
2406
2407    #[test]
2408    fn emits_and_loads_artifact_payload() {
2409        let cedict = "\
2410威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2411布納哈本 布纳哈本 [Bu4 na4 ha1 ben3] /Bunnahabhain/
2412";
2413        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2414        let payload = index.artifact_payload();
2415        let loaded = ZhReadingIndex::from_artifact_payload(payload).unwrap();
2416
2417        assert_eq!(loaded.pinyin_view(), PinyinView::NoTone);
2418        assert_eq!(
2419            loaded.readings("威士忌").as_deref(),
2420            Some(&["weishiji".to_string()][..])
2421        );
2422        assert_eq!(
2423            loaded.readings("布纳哈本").as_deref(),
2424            Some(&["bunahaben".to_string()][..])
2425        );
2426        assert_eq!(
2427            loaded.artifact_payload_checksum(),
2428            index.artifact_payload_checksum()
2429        );
2430    }
2431
2432    #[test]
2433    fn indexed_artifact_payload_round_trips_and_supports_lookup() {
2434        let cedict = "\
2435威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2436布納哈本 布纳哈本 [Bu4 na4 ha1 ben3] /Bunnahabhain/
2437";
2438        let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2439        let mut bytes = Vec::new();
2440        index.write_indexed_artifact_payload(&mut bytes).unwrap();
2441        let path = std::env::temp_dir().join(format!(
2442            "moine-zh-indexed-test-{}-{}.moineidx",
2443            std::process::id(),
2444            std::time::SystemTime::now()
2445                .duration_since(std::time::UNIX_EPOCH)
2446                .unwrap()
2447                .as_nanos()
2448        ));
2449        std::fs::write(&path, &bytes).unwrap();
2450        let loaded = ZhReadingIndex::from_indexed_artifact_payload_path(&path).unwrap();
2451        std::fs::write(&path, b"truncated after load").unwrap();
2452        assert_eq!(
2453            loaded.readings("威士忌").as_deref(),
2454            Some(&["weishiji".to_string()][..])
2455        );
2456        std::fs::remove_file(&path).unwrap();
2457        let loaded_from_bytes = ZhReadingIndex::from_indexed_artifact_payload_bytes(&bytes)
2458            .expect("indexed payload bytes should load");
2459
2460        assert_eq!(loaded.pinyin_view(), PinyinView::NoTone);
2461        assert_eq!(
2462            loaded.readings("威士忌").as_deref(),
2463            Some(&["weishiji".to_string()][..])
2464        );
2465        assert_eq!(
2466            loaded_from_bytes.artifact_payload(),
2467            index.artifact_payload()
2468        );
2469        assert_eq!(
2470            loaded.readings("布纳哈本").as_deref(),
2471            Some(&["bunahaben".to_string()][..])
2472        );
2473        assert_eq!(
2474            loaded.artifact_payload_checksum(),
2475            index.artifact_payload_checksum()
2476        );
2477    }
2478
2479    #[test]
2480    fn artifact_metadata_records_build_and_license() {
2481        let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2482        let options = CedictIndexOptions {
2483            pinyin_view: PinyinView::Tone3,
2484            max_readings_per_surface: Some(4),
2485        };
2486        let index = CedictReadingIndex::from_cedict_reader_with_options(cedict.as_bytes(), options)
2487            .unwrap();
2488        let metadata = index.artifact_metadata(ZhArtifactMetadataOptions {
2489            artifact_name: "moine-cedict-test".to_string(),
2490            generator: "test".to_string(),
2491            payload_file_name: "payload.yaml".to_string(),
2492            payload_format: "yaml.surface-readings.v1".to_string(),
2493            source_name: "CC-CEDICT".to_string(),
2494            source_version: "2026-05-20".to_string(),
2495            source_cedict: "cedict.txt".to_string(),
2496            index_options: options,
2497            query_defaults: PinyinReadingOptions {
2498                longest_match_only: true,
2499                ..PinyinReadingOptions::default()
2500            },
2501            license: ZhArtifactLicense::default(),
2502        });
2503
2504        assert_eq!(metadata.artifact_type, "moine.zh.reading-index");
2505        assert_eq!(metadata.build.pinyin_view, "tone3");
2506        assert_eq!(metadata.build.max_readings_per_surface, Some(4));
2507        assert!(metadata.query_defaults.longest_match_only);
2508        assert_eq!(metadata.license.selected_license, "CC BY-SA 4.0");
2509    }
2510
2511    #[test]
2512    fn rejects_duplicate_artifact_surface() {
2513        let payload = ZhReadingIndexPayload {
2514            schema_version: 1,
2515            payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2516            pinyin_view: "no-tone".to_string(),
2517            entries: vec![
2518                ZhReadingIndexPayloadEntry {
2519                    surface: "威士忌".to_string(),
2520                    readings: vec!["weishiji".to_string()],
2521                },
2522                ZhReadingIndexPayloadEntry {
2523                    surface: "威士忌".to_string(),
2524                    readings: vec!["weishiji".to_string()],
2525                },
2526            ],
2527        };
2528        let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2529
2530        assert!(matches!(
2531            err,
2532            ZhArtifactPayloadError::DuplicateSurface { .. }
2533        ));
2534    }
2535
2536    #[test]
2537    fn rejects_artifact_payload_excessive_reading_count() {
2538        let payload = ZhReadingIndexPayload {
2539            schema_version: 1,
2540            payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2541            pinyin_view: "no-tone".to_string(),
2542            entries: vec![ZhReadingIndexPayloadEntry {
2543                surface: "威士忌".to_string(),
2544                readings: vec!["weishiji".to_string(); MAX_ARTIFACT_READINGS_PER_ENTRY + 1],
2545            }],
2546        };
2547        let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2548
2549        assert!(matches!(
2550            err,
2551            ZhArtifactPayloadError::ArtifactLimitExceeded {
2552                field: "reading_count",
2553                ..
2554            }
2555        ));
2556    }
2557
2558    #[test]
2559    fn rejects_indexed_artifact_payload_excessive_surface_bytes() {
2560        let bytes = indexed_payload_with_surface(
2561            &"威".repeat(MAX_ARTIFACT_STRING_BYTES + 1),
2562            &["wei".to_string()],
2563        )
2564        .unwrap();
2565
2566        let err = ZhReadingIndex::from_indexed_artifact_payload_bytes(&bytes).unwrap_err();
2567
2568        assert!(matches!(
2569            err,
2570            ZhArtifactPayloadError::ArtifactLimitExceeded {
2571                field: "surface_bytes",
2572                ..
2573            }
2574        ));
2575    }
2576
2577    #[test]
2578    fn rejects_excessive_pinyin_reading_options() {
2579        let index = ZhReadingIndex::default();
2580        let err = index
2581            .try_reading_paths_with_stats(
2582                "威士忌",
2583                PinyinReadingOptions {
2584                    max_span_chars: MAX_QUERY_SPAN_CHARS + 1,
2585                    ..PinyinReadingOptions::default()
2586                },
2587            )
2588            .unwrap_err();
2589
2590        assert!(matches!(
2591            err,
2592            ZhArtifactPayloadError::ArtifactLimitExceeded {
2593                field: "max_span_chars",
2594                ..
2595            }
2596        ));
2597
2598        let err = index
2599            .try_reading_paths_with_stats(
2600                "威士忌",
2601                PinyinReadingOptions {
2602                    max_paths: MAX_QUERY_PATHS + 1,
2603                    ..PinyinReadingOptions::default()
2604                },
2605            )
2606            .unwrap_err();
2607
2608        assert!(matches!(
2609            err,
2610            ZhArtifactPayloadError::ArtifactLimitExceeded {
2611                field: "max_paths",
2612                ..
2613            }
2614        ));
2615
2616        let err = index
2617            .try_reading_paths_with_stats(
2618                "威士忌",
2619                PinyinReadingOptions {
2620                    max_readings_per_segment: Some(MAX_QUERY_READINGS_PER_SEGMENT + 1),
2621                    ..PinyinReadingOptions::default()
2622                },
2623            )
2624            .unwrap_err();
2625
2626        assert!(matches!(
2627            err,
2628            ZhArtifactPayloadError::ArtifactLimitExceeded {
2629                field: "max_readings_per_segment",
2630                ..
2631            }
2632        ));
2633    }
2634
2635    #[test]
2636    fn rejects_non_normalized_artifact_reading() {
2637        let payload = ZhReadingIndexPayload {
2638            schema_version: 1,
2639            payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2640            pinyin_view: "no-tone".to_string(),
2641            entries: vec![ZhReadingIndexPayloadEntry {
2642                surface: "威士忌".to_string(),
2643                readings: vec!["Wei1shi4ji4".to_string()],
2644            }],
2645        };
2646        let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2647
2648        assert!(matches!(
2649            err,
2650            ZhArtifactPayloadError::ReadingNotNormalized { .. }
2651        ));
2652    }
2653
2654    #[test]
2655    fn no_tone_artifact_rejects_tone_digits_after_letters() {
2656        let payload = ZhReadingIndexPayload {
2657            schema_version: 1,
2658            payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2659            pinyin_view: "no-tone".to_string(),
2660            entries: vec![ZhReadingIndexPayloadEntry {
2661                surface: "威士忌".to_string(),
2662                readings: vec!["wei1shi4ji4".to_string()],
2663            }],
2664        };
2665        let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2666
2667        assert!(matches!(
2668            err,
2669            ZhArtifactPayloadError::ReadingNotNormalized { .. }
2670        ));
2671    }
2672
2673    #[test]
2674    fn artifact_validation_keeps_numeric_tokens_in_no_tone_view() {
2675        let payload = ZhReadingIndexPayload {
2676            schema_version: 1,
2677            payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2678            pinyin_view: "no-tone".to_string(),
2679            entries: vec![ZhReadingIndexPayloadEntry {
2680                surface: "11区".to_string(),
2681                readings: vec!["11qu".to_string()],
2682            }],
2683        };
2684        let index = ZhReadingIndex::from_artifact_payload(payload).unwrap();
2685
2686        assert_eq!(
2687            index.readings("11区").as_deref(),
2688            Some(&["11qu".to_string()][..])
2689        );
2690    }
2691
2692    #[test]
2693    fn tone3_view_preserves_tone_digits() {
2694        let cedict = "重 重 [chong2] /again/\n重 重 [zhong4] /heavy/\n";
2695        let index = CedictReadingIndex::from_cedict_reader_with_options(
2696            cedict.as_bytes(),
2697            CedictIndexOptions {
2698                pinyin_view: PinyinView::Tone3,
2699                ..CedictIndexOptions::default()
2700            },
2701        )
2702        .unwrap();
2703        let distances =
2704            compare_with_cedict_index("zhong4", "重", &index, PinyinReadingOptions::default())
2705                .unwrap();
2706
2707        assert_eq!(distances.lattice, 0);
2708    }
2709
2710    #[test]
2711    fn unknown_han_without_dictionary_path_is_rejected() {
2712        let index = CedictReadingIndex::default();
2713        let err =
2714            cedict_or_direct_lattice("印", &index, PinyinReadingOptions::default()).unwrap_err();
2715
2716        assert!(matches!(err, CnLatticeError::UnsupportedDirectInput { .. }));
2717    }
2718}