Skip to main content

lindera_dictionary/dictionary/
metadata.rs

1use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
2use serde::{Deserialize, Serialize};
3
4use crate::dictionary::context_id_map::ContextIdMap;
5use crate::dictionary::schema::Schema;
6
7const DEFAULT_WORD_COST: i16 = -10000;
8const DEFAULT_LEFT_CONTEXT_ID: u16 = 1288;
9const DEFAULT_RIGHT_CONTEXT_ID: u16 = 1288;
10const DEFAULT_FIELD_VALUE: &str = "*";
11
12/// On-disk layout version of a *built* dictionary directory.
13///
14/// Written into the built `metadata.json` by
15/// [`crate::builder::DictionaryBuilder::build_metadata`] and checked by
16/// [`Metadata::validate_format_version`] when a dictionary directory is
17/// loaded, so that a dictionary built by an incompatible version of this crate
18/// fails with a clear message instead of being silently misread. `matrix.mtx`,
19/// `dict.vals` and `dict.words` are headerless raw arrays; nothing about them
20/// makes a stale file detectable on its own.
21///
22/// # When to bump this
23///
24/// Bump on **any** change to the bytes of a built artifact, including:
25///
26/// - adding, removing or renaming a file in the dictionary directory,
27/// - changing a record layout or a value encoding,
28/// - upgrading a dependency whose serialized form is written verbatim
29///   (`crawdad` for `dict.trie`, `rkyv` for `char_def.bin` and `unk.bin`,
30///   `daachorse` inside user dictionary `.bin` files).
31///
32/// That last case is easy to miss: such a bump changes the artifact bytes
33/// without a single line of this crate changing, and the build cache keyed
34/// on this constant is what stops a stale automaton from being served to a
35/// binary that walks a different layout.
36///
37/// # History
38///
39/// * `1` - the layout shipped through v5.x.
40/// * `2` - v6.0.0: the system prefix dictionary's daachorse automaton
41///   (`dict.da`, value-packed as `offset << 8 | count`) was replaced by a
42///   crawdad char-wise trie walked in place (`dict.trie`, keyed by ordinal)
43///   plus a `u32` prefix-sum index (`dict.valsidx`). `dict.vals`,
44///   `dict.words`, `dict.wordsidx`, `matrix.mtx`, `char_def.bin` and
45///   `unk.bin` are unchanged, as are user dictionary `.bin` files.
46pub const DICTIONARY_FORMAT_VERSION: u32 = 2;
47
48/// The format version assumed for a built dictionary whose `metadata.json`
49/// predates the `format_version` field.
50///
51/// Dictionaries built before the field existed are exactly the v5.x layout,
52/// which is version 1. Source `metadata.json` files also omit the field, but
53/// they describe build *inputs* and are never format-checked -- see
54/// [`Metadata::validate_format_version`].
55const LEGACY_FORMAT_VERSION: u32 = 1;
56
57/// Returns the format version to assume when `metadata.json` does not carry
58/// one.
59///
60/// # Returns
61///
62/// [`LEGACY_FORMAT_VERSION`].
63fn legacy_format_version() -> u32 {
64    LEGACY_FORMAT_VERSION
65}
66
67#[derive(Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
68
69pub struct ModelInfo {
70    /// Number of trained feature weights.
71    pub feature_count: usize,
72    /// Number of labels (vocabulary entries) in the trained model.
73    pub label_count: usize,
74    /// Highest left context ID used by the model.
75    pub max_left_context_id: usize,
76    /// Highest right context ID used by the model.
77    pub max_right_context_id: usize,
78    /// Connection matrix dimensions, formatted as `{rows}x{cols}`.
79    pub connection_matrix_size: String,
80    /// Version of the model format.
81    pub version: String,
82    /// Number of training iterations that were run.
83    pub training_iterations: u64,
84    /// Regularization coefficient used during training.
85    pub regularization: f64,
86}
87
88#[derive(Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
89
90pub struct Metadata {
91    /// On-disk layout version of the dictionary directory this metadata was
92    /// written into. See [`DICTIONARY_FORMAT_VERSION`].
93    ///
94    /// Absent from source `metadata.json` files, which describe build inputs
95    /// rather than a built dictionary, and absent from dictionaries built
96    /// before the field existed; both read back as
97    /// [`LEGACY_FORMAT_VERSION`].
98    #[serde(default = "legacy_format_version")]
99    pub format_version: u32,
100    pub name: String,                  // Name of the dictionary
101    pub encoding: String,              // Character encoding
102    pub default_word_cost: i16,        // Word cost for simple user dictionary
103    pub default_left_context_id: u16,  // Context ID for simple user dictionary
104    pub default_right_context_id: u16, // Context ID for simple user dictionary
105    pub default_field_value: String,   // Default value for fields in simple user dictionary
106    pub flexible_csv: bool,            // Handle CSV columns flexibly
107    pub skip_invalid_cost_or_id: bool, // Skip invalid cost or ID
108    pub normalize_details: bool,       // Normalize characters
109    /// Reorder connection-cost context IDs by frequency at build time so that
110    /// frequently-used connection-matrix cells cluster in cache. Optional and
111    /// defaults to `false`; when `false` the field is omitted from `metadata.json`
112    /// so existing files stay byte-identical, and the build output is unchanged.
113    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
114    pub connection_id_mapping: bool,
115    /// The context-ID permutation that was applied when this dictionary was built.
116    ///
117    /// Written into the *built* `metadata.json` when `connection_id_mapping` is on, so
118    /// that anything compiled later against this dictionary — most importantly a
119    /// detailed user dictionary — can be relabeled into the same ID space. Absent (and
120    /// omitted from the file) for an un-remapped dictionary, which keeps those builds
121    /// byte-identical. Source `metadata.json` files carry only the boolean flag.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub context_id_map: Option<ContextIdMap>,
124    pub dictionary_schema: Schema,      // Schema for the dictionary
125    pub user_dictionary_schema: Schema, // Schema for user dictionary
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub model_info: Option<ModelInfo>, // Training model information (optional)
128}
129
130impl Default for Metadata {
131    fn default() -> Self {
132        // Default metadata values can be adjusted as needed
133        Metadata::new(
134            "default".to_string(),
135            "UTF-8".to_string(),
136            DEFAULT_WORD_COST,
137            DEFAULT_LEFT_CONTEXT_ID,
138            DEFAULT_RIGHT_CONTEXT_ID,
139            DEFAULT_FIELD_VALUE.to_string(),
140            false,
141            false,
142            false,
143            Schema::default(),
144            Schema::new(vec![
145                "surface".to_string(),
146                "reading".to_string(),
147                "pronunciation".to_string(),
148            ]),
149        )
150    }
151}
152
153impl Metadata {
154    #[allow(clippy::too_many_arguments)]
155    pub fn new(
156        name: String,
157        encoding: String,
158        simple_word_cost: i16,
159        default_left_context_id: u16,
160        default_right_context_id: u16,
161        default_field_value: String,
162        flexible_csv: bool,
163        skip_invalid_cost_or_id: bool,
164        normalize_details: bool,
165        schema: Schema,
166        userdic_schema: Schema,
167    ) -> Self {
168        Self {
169            format_version: DICTIONARY_FORMAT_VERSION,
170            encoding,
171            default_word_cost: simple_word_cost,
172            default_left_context_id,
173            default_right_context_id,
174            default_field_value,
175            dictionary_schema: schema,
176            name,
177            flexible_csv,
178            skip_invalid_cost_or_id,
179            normalize_details,
180            connection_id_mapping: false,
181            context_id_map: None,
182            user_dictionary_schema: userdic_schema,
183            model_info: None,
184        }
185    }
186
187    /// Load metadata from binary data (JSON format).
188    /// This provides a consistent interface with other dictionary components.
189    pub fn load(data: &[u8]) -> crate::LinderaResult<Self> {
190        // If data is empty, return an error since metadata is required
191        if data.is_empty() {
192            return Err(crate::error::LinderaErrorKind::Io
193                .with_error(anyhow::anyhow!("Empty metadata data")));
194        }
195
196        // Deserialize as JSON
197        serde_json::from_slice(data).map_err(|err| {
198            crate::error::LinderaErrorKind::Deserialize
199                .with_error(anyhow::anyhow!(err))
200                .add_context("Failed to deserialize metadata from JSON")
201        })
202    }
203
204    /// Rejects a built dictionary whose on-disk layout this build cannot read.
205    ///
206    /// Call this after loading the `metadata.json` of a *built dictionary
207    /// directory*. Do **not** call it on a source `metadata.json`: those
208    /// describe build inputs (schema, encoding, `flexible_csv` and friends),
209    /// carry no `format_version`, and stay valid across format changes.
210    ///
211    /// Without this check a stale dictionary is not merely unreadable but
212    /// silently wrong: `matrix.mtx`, `dict.vals` and `dict.words` are
213    /// headerless raw arrays, so an old file of a plausible length decodes
214    /// into garbage costs rather than failing.
215    ///
216    /// # Returns
217    ///
218    /// `Ok(())` when the dictionary was built with this crate's format
219    /// version, otherwise an error naming both versions and how to recover.
220    pub fn validate_format_version(&self) -> crate::LinderaResult<()> {
221        if self.format_version == DICTIONARY_FORMAT_VERSION {
222            return Ok(());
223        }
224
225        let hint = if self.format_version < DICTIONARY_FORMAT_VERSION {
226            "rebuild it with `lindera build`, or download a matching prebuilt dictionary with `lindera download`"
227        } else {
228            "upgrade Lindera to a version that understands this dictionary"
229        };
230
231        Err(crate::error::LinderaErrorKind::Deserialize.with_error(anyhow::anyhow!(
232            "Dictionary '{}' has format version {}, but this build of Lindera reads format version {}. To fix this, {hint}.",
233            self.name,
234            self.format_version,
235            DICTIONARY_FORMAT_VERSION,
236        )))
237    }
238
239    /// Load metadata with fallback to default values.
240    /// This is used when feature flags are disabled and data might be empty.
241    pub fn load_or_default(data: &[u8], default_fn: fn() -> Self) -> Self {
242        if data.is_empty() {
243            default_fn()
244        } else {
245            match Self::load(data) {
246                Ok(metadata) => metadata,
247                Err(_) => default_fn(),
248            }
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_metadata_default() {
259        let metadata = Metadata::default();
260        assert_eq!(metadata.name, "default");
261        // Schema no longer has name field
262    }
263
264    /// A source `metadata.json` -- the hand-written kind checked into each
265    /// dictionary crate -- carries no `format_version` and must keep parsing.
266    #[test]
267    fn metadata_without_format_version_reads_as_legacy() {
268        let json = serde_json::to_value(Metadata::default()).unwrap();
269        let mut object = json.as_object().unwrap().clone();
270        object.remove("format_version");
271        let without = serde_json::to_vec(&object).unwrap();
272
273        let metadata = Metadata::load(&without).unwrap();
274        assert_eq!(metadata.format_version, LEGACY_FORMAT_VERSION);
275    }
276
277    /// v5.x shipped the format that is now version 1, so a dictionary built
278    /// before the field existed must still load. If this fails after a bump of
279    /// [`DICTIONARY_FORMAT_VERSION`], that is correct and expected -- the test
280    /// documents the boundary rather than pinning it.
281    #[test]
282    fn legacy_version_is_the_first_format_version() {
283        assert_eq!(LEGACY_FORMAT_VERSION, 1);
284    }
285
286    #[test]
287    fn validate_format_version_accepts_the_current_version() {
288        let metadata = Metadata::default();
289        assert_eq!(metadata.format_version, DICTIONARY_FORMAT_VERSION);
290        assert!(metadata.validate_format_version().is_ok());
291    }
292
293    #[test]
294    fn validate_format_version_rejects_an_older_dictionary() {
295        let metadata = Metadata {
296            name: "ipadic".to_string(),
297            format_version: DICTIONARY_FORMAT_VERSION - 1,
298            ..Metadata::default()
299        };
300
301        let err = metadata.validate_format_version().unwrap_err().to_string();
302        assert!(err.contains("ipadic"), "{err}");
303        assert!(err.contains("lindera build"), "{err}");
304    }
305
306    #[test]
307    fn validate_format_version_rejects_a_newer_dictionary() {
308        let metadata = Metadata {
309            format_version: DICTIONARY_FORMAT_VERSION + 1,
310            ..Metadata::default()
311        };
312
313        let err = metadata.validate_format_version().unwrap_err().to_string();
314        assert!(err.contains("upgrade Lindera"), "{err}");
315    }
316
317    /// A `model_info` object emitted by the current exporter carries no
318    /// `updated_at`; the struct must accept it (#981).
319    ///
320    /// Before #981 `updated_at` was a required field, so removing it from the
321    /// writer alone would have broken loading of every newly exported
322    /// `metadata.json`.
323    #[test]
324    fn model_info_without_updated_at_parses() {
325        let json = serde_json::json!({
326            "feature_count": 4,
327            "label_count": 1,
328            "max_left_context_id": 63,
329            "max_right_context_id": 63,
330            "connection_matrix_size": "64x64",
331            "version": "1.0.0",
332            "training_iterations": 10,
333            "regularization": 0.01,
334        });
335        let info: ModelInfo = serde_json::from_value(json).unwrap();
336        assert_eq!(info.feature_count, 4);
337        assert_eq!(info.version, "1.0.0");
338    }
339
340    /// A legacy `model_info` that still carries `updated_at` must keep
341    /// parsing: serde tolerates unknown fields here, which is what protects
342    /// dictionaries built before #981.
343    #[test]
344    fn model_info_with_legacy_updated_at_still_parses() {
345        let json = serde_json::json!({
346            "feature_count": 4,
347            "label_count": 1,
348            "max_left_context_id": 63,
349            "max_right_context_id": 63,
350            "connection_matrix_size": "64x64",
351            "version": "1.0.0",
352            "training_iterations": 10,
353            "regularization": 0.01,
354            "updated_at": 1773881523u64,
355        });
356        let info: ModelInfo = serde_json::from_value(json).unwrap();
357        assert_eq!(info.training_iterations, 10);
358    }
359
360    /// The version must survive a JSON round trip; a `skip_serializing_if`
361    /// added by accident would make every built dictionary claim to be legacy.
362    #[test]
363    fn format_version_round_trips_through_json() {
364        let metadata = Metadata {
365            format_version: 7,
366            ..Metadata::default()
367        };
368
369        let json = serde_json::to_vec(&metadata).unwrap();
370        assert_eq!(Metadata::load(&json).unwrap().format_version, 7);
371    }
372
373    #[test]
374    fn test_metadata_new() {
375        let schema = Schema::default();
376        let metadata = Metadata::new(
377            "TestDict".to_string(),
378            "UTF-8".to_string(),
379            -10000,
380            0,
381            0,
382            "*".to_string(),
383            false,
384            false,
385            false,
386            schema.clone(),
387            Schema::new(vec!["surface".to_string(), "reading".to_string()]),
388        );
389        assert_eq!(metadata.name, "TestDict");
390        // Schema no longer has name field
391    }
392
393    #[test]
394    fn test_metadata_serialization() {
395        let metadata = Metadata::default();
396
397        // Test serialization
398        let serialized = serde_json::to_string(&metadata).unwrap();
399        assert!(serialized.contains("default"));
400        assert!(serialized.contains("schema"));
401        assert!(serialized.contains("name"));
402
403        // Test deserialization
404        let deserialized: Metadata = serde_json::from_str(&serialized).unwrap();
405        assert_eq!(deserialized.name, "default");
406        // Schema no longer has name field
407    }
408}