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 pub feature_count: usize,
71 pub label_count: usize,
72 pub max_left_context_id: usize,
73 pub max_right_context_id: usize,
74 pub connection_matrix_size: String,
75 pub version: String,
76 pub training_iterations: u64,
77 pub regularization: f64,
78 pub updated_at: u64,
79}
80
81#[derive(Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
82
83pub struct Metadata {
84 /// On-disk layout version of the dictionary directory this metadata was
85 /// written into. See [`DICTIONARY_FORMAT_VERSION`].
86 ///
87 /// Absent from source `metadata.json` files, which describe build inputs
88 /// rather than a built dictionary, and absent from dictionaries built
89 /// before the field existed; both read back as
90 /// [`LEGACY_FORMAT_VERSION`].
91 #[serde(default = "legacy_format_version")]
92 pub format_version: u32,
93 pub name: String, // Name of the dictionary
94 pub encoding: String, // Character encoding
95 pub default_word_cost: i16, // Word cost for simple user dictionary
96 pub default_left_context_id: u16, // Context ID for simple user dictionary
97 pub default_right_context_id: u16, // Context ID for simple user dictionary
98 pub default_field_value: String, // Default value for fields in simple user dictionary
99 pub flexible_csv: bool, // Handle CSV columns flexibly
100 pub skip_invalid_cost_or_id: bool, // Skip invalid cost or ID
101 pub normalize_details: bool, // Normalize characters
102 /// Reorder connection-cost context IDs by frequency at build time so that
103 /// frequently-used connection-matrix cells cluster in cache. Optional and
104 /// defaults to `false`; when `false` the field is omitted from `metadata.json`
105 /// so existing files stay byte-identical, and the build output is unchanged.
106 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
107 pub connection_id_mapping: bool,
108 /// The context-ID permutation that was applied when this dictionary was built.
109 ///
110 /// Written into the *built* `metadata.json` when `connection_id_mapping` is on, so
111 /// that anything compiled later against this dictionary — most importantly a
112 /// detailed user dictionary — can be relabeled into the same ID space. Absent (and
113 /// omitted from the file) for an un-remapped dictionary, which keeps those builds
114 /// byte-identical. Source `metadata.json` files carry only the boolean flag.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub context_id_map: Option<ContextIdMap>,
117 pub dictionary_schema: Schema, // Schema for the dictionary
118 pub user_dictionary_schema: Schema, // Schema for user dictionary
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub model_info: Option<ModelInfo>, // Training model information (optional)
121}
122
123impl Default for Metadata {
124 fn default() -> Self {
125 // Default metadata values can be adjusted as needed
126 Metadata::new(
127 "default".to_string(),
128 "UTF-8".to_string(),
129 DEFAULT_WORD_COST,
130 DEFAULT_LEFT_CONTEXT_ID,
131 DEFAULT_RIGHT_CONTEXT_ID,
132 DEFAULT_FIELD_VALUE.to_string(),
133 false,
134 false,
135 false,
136 Schema::default(),
137 Schema::new(vec![
138 "surface".to_string(),
139 "reading".to_string(),
140 "pronunciation".to_string(),
141 ]),
142 )
143 }
144}
145
146impl Metadata {
147 #[allow(clippy::too_many_arguments)]
148 pub fn new(
149 name: String,
150 encoding: String,
151 simple_word_cost: i16,
152 default_left_context_id: u16,
153 default_right_context_id: u16,
154 default_field_value: String,
155 flexible_csv: bool,
156 skip_invalid_cost_or_id: bool,
157 normalize_details: bool,
158 schema: Schema,
159 userdic_schema: Schema,
160 ) -> Self {
161 Self {
162 format_version: DICTIONARY_FORMAT_VERSION,
163 encoding,
164 default_word_cost: simple_word_cost,
165 default_left_context_id,
166 default_right_context_id,
167 default_field_value,
168 dictionary_schema: schema,
169 name,
170 flexible_csv,
171 skip_invalid_cost_or_id,
172 normalize_details,
173 connection_id_mapping: false,
174 context_id_map: None,
175 user_dictionary_schema: userdic_schema,
176 model_info: None,
177 }
178 }
179
180 /// Load metadata from binary data (JSON format).
181 /// This provides a consistent interface with other dictionary components.
182 pub fn load(data: &[u8]) -> crate::LinderaResult<Self> {
183 // If data is empty, return an error since metadata is required
184 if data.is_empty() {
185 return Err(crate::error::LinderaErrorKind::Io
186 .with_error(anyhow::anyhow!("Empty metadata data")));
187 }
188
189 // Deserialize as JSON
190 serde_json::from_slice(data).map_err(|err| {
191 crate::error::LinderaErrorKind::Deserialize
192 .with_error(anyhow::anyhow!(err))
193 .add_context("Failed to deserialize metadata from JSON")
194 })
195 }
196
197 /// Rejects a built dictionary whose on-disk layout this build cannot read.
198 ///
199 /// Call this after loading the `metadata.json` of a *built dictionary
200 /// directory*. Do **not** call it on a source `metadata.json`: those
201 /// describe build inputs (schema, encoding, `flexible_csv` and friends),
202 /// carry no `format_version`, and stay valid across format changes.
203 ///
204 /// Without this check a stale dictionary is not merely unreadable but
205 /// silently wrong: `matrix.mtx`, `dict.vals` and `dict.words` are
206 /// headerless raw arrays, so an old file of a plausible length decodes
207 /// into garbage costs rather than failing.
208 ///
209 /// # Returns
210 ///
211 /// `Ok(())` when the dictionary was built with this crate's format
212 /// version, otherwise an error naming both versions and how to recover.
213 pub fn validate_format_version(&self) -> crate::LinderaResult<()> {
214 if self.format_version == DICTIONARY_FORMAT_VERSION {
215 return Ok(());
216 }
217
218 let hint = if self.format_version < DICTIONARY_FORMAT_VERSION {
219 "rebuild it with `lindera build`, or download a matching prebuilt dictionary with `lindera download`"
220 } else {
221 "upgrade Lindera to a version that understands this dictionary"
222 };
223
224 Err(crate::error::LinderaErrorKind::Deserialize.with_error(anyhow::anyhow!(
225 "Dictionary '{}' has format version {}, but this build of Lindera reads format version {}. To fix this, {hint}.",
226 self.name,
227 self.format_version,
228 DICTIONARY_FORMAT_VERSION,
229 )))
230 }
231
232 /// Load metadata with fallback to default values.
233 /// This is used when feature flags are disabled and data might be empty.
234 pub fn load_or_default(data: &[u8], default_fn: fn() -> Self) -> Self {
235 if data.is_empty() {
236 default_fn()
237 } else {
238 match Self::load(data) {
239 Ok(metadata) => metadata,
240 Err(_) => default_fn(),
241 }
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_metadata_default() {
252 let metadata = Metadata::default();
253 assert_eq!(metadata.name, "default");
254 // Schema no longer has name field
255 }
256
257 /// A source `metadata.json` -- the hand-written kind checked into each
258 /// dictionary crate -- carries no `format_version` and must keep parsing.
259 #[test]
260 fn metadata_without_format_version_reads_as_legacy() {
261 let json = serde_json::to_value(Metadata::default()).unwrap();
262 let mut object = json.as_object().unwrap().clone();
263 object.remove("format_version");
264 let without = serde_json::to_vec(&object).unwrap();
265
266 let metadata = Metadata::load(&without).unwrap();
267 assert_eq!(metadata.format_version, LEGACY_FORMAT_VERSION);
268 }
269
270 /// v5.x shipped the format that is now version 1, so a dictionary built
271 /// before the field existed must still load. If this fails after a bump of
272 /// [`DICTIONARY_FORMAT_VERSION`], that is correct and expected -- the test
273 /// documents the boundary rather than pinning it.
274 #[test]
275 fn legacy_version_is_the_first_format_version() {
276 assert_eq!(LEGACY_FORMAT_VERSION, 1);
277 }
278
279 #[test]
280 fn validate_format_version_accepts_the_current_version() {
281 let metadata = Metadata::default();
282 assert_eq!(metadata.format_version, DICTIONARY_FORMAT_VERSION);
283 assert!(metadata.validate_format_version().is_ok());
284 }
285
286 #[test]
287 fn validate_format_version_rejects_an_older_dictionary() {
288 let metadata = Metadata {
289 name: "ipadic".to_string(),
290 format_version: DICTIONARY_FORMAT_VERSION - 1,
291 ..Metadata::default()
292 };
293
294 let err = metadata.validate_format_version().unwrap_err().to_string();
295 assert!(err.contains("ipadic"), "{err}");
296 assert!(err.contains("lindera build"), "{err}");
297 }
298
299 #[test]
300 fn validate_format_version_rejects_a_newer_dictionary() {
301 let metadata = Metadata {
302 format_version: DICTIONARY_FORMAT_VERSION + 1,
303 ..Metadata::default()
304 };
305
306 let err = metadata.validate_format_version().unwrap_err().to_string();
307 assert!(err.contains("upgrade Lindera"), "{err}");
308 }
309
310 /// The version must survive a JSON round trip; a `skip_serializing_if`
311 /// added by accident would make every built dictionary claim to be legacy.
312 #[test]
313 fn format_version_round_trips_through_json() {
314 let metadata = Metadata {
315 format_version: 7,
316 ..Metadata::default()
317 };
318
319 let json = serde_json::to_vec(&metadata).unwrap();
320 assert_eq!(Metadata::load(&json).unwrap().format_version, 7);
321 }
322
323 #[test]
324 fn test_metadata_new() {
325 let schema = Schema::default();
326 let metadata = Metadata::new(
327 "TestDict".to_string(),
328 "UTF-8".to_string(),
329 -10000,
330 0,
331 0,
332 "*".to_string(),
333 false,
334 false,
335 false,
336 schema.clone(),
337 Schema::new(vec!["surface".to_string(), "reading".to_string()]),
338 );
339 assert_eq!(metadata.name, "TestDict");
340 // Schema no longer has name field
341 }
342
343 #[test]
344 fn test_metadata_serialization() {
345 let metadata = Metadata::default();
346
347 // Test serialization
348 let serialized = serde_json::to_string(&metadata).unwrap();
349 assert!(serialized.contains("default"));
350 assert!(serialized.contains("schema"));
351 assert!(serialized.contains("name"));
352
353 // Test deserialization
354 let deserialized: Metadata = serde_json::from_str(&serialized).unwrap();
355 assert_eq!(deserialized.name, "default");
356 // Schema no longer has name field
357 }
358}