Skip to main content

lindera_trainer/
lib.rs

1//! CRF-based dictionary training for Lindera.
2//!
3//! This crate implements the training pipeline (`lindera train`), producing
4//! a serialized model that `lindera export` turns into MeCab-format
5//! dictionary source files, which `lindera build` then compiles into a
6//! binary dictionary. It builds on the runtime types of
7//! [`lindera_dictionary`] and the CRF core of [`lindera_crf`].
8
9pub mod config;
10pub mod corpus;
11pub mod feature_extractor;
12pub mod feature_rewriter;
13pub mod model;
14
15use std::collections::HashMap;
16use std::num::NonZeroU32;
17
18use anyhow::Result;
19
20use self::feature_extractor::TemplateContext;
21
22/// Logging macros for training process
23macro_rules! log_info {
24    ($($arg:tt)*) => {
25        println!($($arg)*)
26    };
27}
28
29macro_rules! log_debug {
30    ($($arg:tt)*) => {
31        if cfg!(debug_assertions) {
32            println!("DEBUG: {}", format!($($arg)*))
33        }
34    };
35}
36
37macro_rules! log_progress {
38    ($($arg:tt)*) => {
39        println!($($arg)*)
40    };
41}
42
43pub use self::config::TrainerConfig;
44pub use self::corpus::{Corpus, Example, Word};
45pub use self::model::{Model, SerializableModel};
46
47/// CRF-based morphological analysis trainer for Japanese text
48///
49/// This trainer implements standard CRF-based morphological analysis training,
50/// adapted for Lindera's architecture. It supports:
51/// - Feature extraction from vocabulary and corpus
52/// - L-BFGS optimization for weight learning
53/// - Unknown word categorization for 6 character types
54/// - Connection cost matrix generation
55///
56/// # Training Process
57/// 1. Initialize feature provider with vocabulary entries
58/// 2. Build lattices from training corpus
59/// 3. Execute CRF training with L-BFGS optimization
60/// 4. Extract learned weights and create final model
61///
62/// # Example
63/// ```
64/// use lindera_trainer::{Trainer, TrainerConfig};
65/// use std::io::Cursor;
66///
67/// // Create minimal training data for demonstration
68/// let seed_csv = "これ,0,0,1000,連体詞,*,*,*,*,*,これ,コレ,コレ\n";
69/// let char_def = "DEFAULT 0 1 0\nHIRAGANA 1 1 0\n0x3042..0x3096 HIRAGANA\n";
70/// let unk_def = "DEFAULT,0,0,1500,名詞,一般,*,*,*,*,*,*,*\n";
71/// let feature_def = "UNIGRAM:%F[0]\nUNIGRAM:%F[1]\n";
72/// let rewrite_def = "*\tUNK\n";
73///
74/// let config = TrainerConfig::from_readers(
75///     Cursor::new(seed_csv),
76///     Cursor::new(char_def),
77///     Cursor::new(unk_def),
78///     Cursor::new(feature_def),
79///     Cursor::new(rewrite_def)
80/// ).unwrap();
81///
82/// let trainer = Trainer::new(config).unwrap()
83///     .regularization_cost(0.01)
84///     .max_iter(10); // Reduced for doc test
85///
86/// // Note: In practice, you would load an actual corpus file
87/// // let corpus = Corpus::from_reader(corpus_reader).unwrap();
88/// // let model = trainer.train(corpus).unwrap();
89/// ```
90pub struct Trainer {
91    config: TrainerConfig,
92
93    /// Maximum length (in characters) for tokens to be included in training.
94    /// Tokens longer than this value will be skipped during lattice construction
95    /// to improve training efficiency and avoid memory issues with extremely long tokens.
96    /// Default: Some(10) - tokens with more than 10 characters are skipped.
97    /// Set to None to include all tokens regardless of length.
98    max_grouping_len: Option<usize>,
99
100    provider: lindera_crf::FeatureProvider,
101
102    // Maps feature strings to label IDs
103    label_id_map: std::collections::HashMap<String, std::collections::HashMap<char, NonZeroU32>>,
104
105    /// Pre-allocated label IDs for unknown word categories.
106    /// Index corresponds to character type categories:
107    /// - 0: DEFAULT (fallback for unclassified characters)
108    /// - 1: HIRAGANA (Japanese hiragana characters)
109    /// - 2: KATAKANA (Japanese katakana characters)
110    /// - 3: KANJI (Chinese/Japanese ideographic characters)
111    /// - 4: ALPHA (ASCII alphabetic characters)
112    /// - 5: NUMERIC (ASCII numeric characters)
113    label_id_map_unk: Vec<NonZeroU32>,
114
115    regularization_cost: f64,
116    use_l2: bool,
117    elastic_net_l1_ratio: Option<f64>,
118    max_iter: u64,
119    num_threads: usize,
120}
121
122impl Trainer {
123    /// Creates a new [`Trainer`] using the specified configuration.
124    pub fn new(mut config: TrainerConfig) -> Result<Self> {
125        let mut provider = lindera_crf::FeatureProvider::default();
126        let mut label_id_map: HashMap<String, HashMap<char, NonZeroU32>> = HashMap::new();
127
128        // Build label mapping from surfaces and add feature sets to provider
129        // Generate default features based on dictionary schema
130        let default_features = if let Some(first_feature) = config.features.first() {
131            let field_count = first_feature.split(',').count();
132            vec!["*"; field_count].join(",")
133        } else {
134            "*".to_string()
135        };
136
137        for (i, surface) in config.surfaces.iter().enumerate() {
138            // Get feature string from config.features (parallel to surfaces)
139            let feature_str: &str = if i < config.features.len() {
140                &config.features[i]
141            } else {
142                &default_features
143            };
144
145            // Apply dictionary rewriter to get ufeature, lfeature, rfeature.
146            // Uncached on purpose: real feature strings are unique per row,
147            // so a cache keyed on them never hits (#975).
148            let (ufeature, lfeature, rfeature) = config.dictionary_rewriter.rewrite(feature_str);
149            let u_vec: Vec<&str> = ufeature.split(',').collect();
150            let l_vec: Vec<&str> = lfeature.split(',').collect();
151            let r_vec: Vec<&str> = rfeature.split(',').collect();
152
153            // Compute character category ID from the first character of the surface
154            let cate_id = if let Some(first_char) = surface.chars().next() {
155                let categories = config
156                    .dict
157                    .character_definition
158                    .lookup_categories(first_char);
159                if !categories.is_empty() {
160                    categories[0].0 as u32
161                } else {
162                    0 // DEFAULT category
163                }
164            } else {
165                0
166            };
167
168            // Create feature set for this vocabulary entry
169            let feature_extractor = &mut config.feature_extractor;
170            let ctx = TemplateContext {
171                surface: Some(surface),
172                ufeature: Some(&ufeature),
173                lfeature: Some(&lfeature),
174                rfeature: Some(&rfeature),
175            };
176
177            let unigram_ids =
178                feature_extractor.extract_unigram_feature_ids_with_ctx(&u_vec, cate_id, &ctx);
179            let left_ids = feature_extractor.extract_left_feature_ids_with_ctx(&l_vec, &ctx);
180            let right_ids = feature_extractor.extract_right_feature_ids_with_ctx(&r_vec, &ctx);
181
182            let feature_set = lindera_crf::FeatureSet::new(&unigram_ids, &right_ids, &left_ids);
183
184            // Add feature set to provider and get label ID
185            let label_id = provider.add_feature_set(feature_set)?;
186
187            // Map feature string to label ID using first character classification
188            let char_map = label_id_map.entry(feature_str.to_string()).or_default();
189            if let Some(first_char) = surface.chars().next() {
190                char_map.insert(first_char, label_id);
191            }
192        }
193
194        // Initialize unknown word labels from character definition categories
195        let mut label_id_map_unk = Vec::new();
196        let char_def = &config.dict.character_definition;
197        let unk_category_names = char_def.categories();
198
199        for (i, category) in unk_category_names.iter().enumerate() {
200            // Get unknown word feature string from unk_categories
201            let unk_feature: &str = config
202                .unk_categories
203                .get(category)
204                .map(|s| s.as_str())
205                .unwrap_or(&default_features);
206
207            // Apply dictionary rewriter to get ufeature, lfeature, rfeature.
208            // Uncached on purpose: see the seed loop above (#975).
209            let (ufeature, lfeature, rfeature) = config.dictionary_rewriter.rewrite(unk_feature);
210            let u_vec: Vec<&str> = ufeature.split(',').collect();
211            let l_vec: Vec<&str> = lfeature.split(',').collect();
212            let r_vec: Vec<&str> = rfeature.split(',').collect();
213
214            // Create feature set for unknown word category
215            let feature_extractor = &mut config.feature_extractor;
216            let ctx = TemplateContext {
217                surface: None,
218                ufeature: Some(&ufeature),
219                lfeature: Some(&lfeature),
220                rfeature: Some(&rfeature),
221            };
222
223            let unigram_ids =
224                feature_extractor.extract_unigram_feature_ids_with_ctx(&u_vec, i as u32, &ctx);
225            let left_ids = feature_extractor.extract_left_feature_ids_with_ctx(&l_vec, &ctx);
226            let right_ids = feature_extractor.extract_right_feature_ids_with_ctx(&r_vec, &ctx);
227
228            let feature_set = lindera_crf::FeatureSet::new(&unigram_ids, &right_ids, &left_ids);
229
230            // Add to provider
231            let unk_label_id = provider.add_feature_set(feature_set)?;
232            label_id_map_unk.push(unk_label_id);
233        }
234
235        Ok(Self {
236            config,
237            max_grouping_len: None, // Default: no length limit for feature grouping
238            provider,
239            label_id_map,
240            label_id_map_unk,
241            regularization_cost: 0.01,
242            use_l2: false,
243            elastic_net_l1_ratio: None,
244            max_iter: 100,
245            num_threads: 8,
246        })
247    }
248
249    /// Sets the regularization cost coefficient.
250    pub fn regularization_cost(mut self, cost: f64) -> Self {
251        self.regularization_cost = cost;
252        self
253    }
254
255    /// Sets whether to use L2 regularization (default: L1).
256    pub fn use_l2(mut self, l2: bool) -> Self {
257        self.use_l2 = l2;
258        self
259    }
260
261    /// Sets the Elastic Net L1 ratio (0.0 to 1.0).
262    /// When set, uses Elastic Net regularization instead of pure L1 or L2.
263    /// l1_ratio=1.0 is pure L1, l1_ratio=0.0 is pure L2.
264    pub fn elastic_net_l1_ratio(mut self, ratio: f64) -> Self {
265        self.elastic_net_l1_ratio = Some(ratio);
266        self
267    }
268
269    /// Sets the maximum number of iterations.
270    pub fn max_iter(mut self, iter: u64) -> Self {
271        self.max_iter = iter;
272        self
273    }
274
275    /// Sets the number of threads for training.
276    pub fn num_threads(mut self, threads: usize) -> Self {
277        self.num_threads = threads;
278        self
279    }
280
281    /// Sets the maximum grouping length for token sequences.
282    pub fn max_grouping_len(mut self, len: Option<usize>) -> Self {
283        self.max_grouping_len = len;
284        self
285    }
286
287    /// Get the regularization cost (lambda)
288    pub fn get_regularization_cost(&self) -> f64 {
289        self.regularization_cost
290    }
291
292    /// Get the maximum number of iterations
293    pub fn get_max_iter(&self) -> u64 {
294        self.max_iter
295    }
296
297    /// Get the number of threads
298    pub fn get_num_threads(&self) -> usize {
299        self.num_threads
300    }
301
302    /// Trains a model from the given corpus.
303    pub fn train(mut self, corpus: Corpus) -> Result<Model> {
304        let lattices = self.build_lattices_from_corpus(&corpus)?;
305        let labels = self.extract_labels();
306        let crf_model = self.train_crf_model(lattices)?;
307
308        self.create_final_model(crf_model, labels, corpus)
309    }
310
311    /// Build feature lattices from the training corpus
312    fn build_lattices_from_corpus(&mut self, corpus: &Corpus) -> Result<Vec<lindera_crf::Lattice>> {
313        log_info!("Building feature lattices...");
314
315        let mut lattices = Vec::new();
316        for (i, example) in corpus.examples.iter().enumerate() {
317            log_progress!("Processing example {}/{}", i + 1, corpus.examples.len());
318
319            // NOTE: Character-level processing is performed here during sentence compilation
320            // In Lindera, character property processing should be handled differently
321            // For now, we proceed with the existing approach
322
323            let lattice = self.build_lattice(example)?;
324            lattices.push(lattice);
325        }
326
327        Ok(lattices)
328    }
329
330    /// Configure and execute CRF training
331    fn train_crf_model(
332        &mut self,
333        lattices: Vec<lindera_crf::Lattice>,
334    ) -> Result<lindera_crf::RawModel> {
335        log_info!("Starting CRF training with {} lattices...", lattices.len());
336
337        // Determine regularization type
338        let reg_type = if let Some(l1_ratio) = self.elastic_net_l1_ratio {
339            log_info!(
340                "Training parameters: regularization={} (ElasticNet, l1_ratio={}), max_iter={}, threads={}",
341                self.regularization_cost,
342                l1_ratio,
343                self.max_iter,
344                self.num_threads
345            );
346            lindera_crf::Regularization::ElasticNet { l1_ratio }
347        } else if self.use_l2 {
348            log_info!(
349                "Training parameters: regularization={} (L2), max_iter={}, threads={}",
350                self.regularization_cost,
351                self.max_iter,
352                self.num_threads
353            );
354            lindera_crf::Regularization::L2
355        } else {
356            log_info!(
357                "Training parameters: regularization={} (L1), max_iter={}, threads={}",
358                self.regularization_cost,
359                self.max_iter,
360                self.num_threads
361            );
362            lindera_crf::Regularization::L1
363        };
364
365        // Configure the CRF trainer
366        let trainer = lindera_crf::Trainer::new()
367            .regularization(reg_type, self.regularization_cost)?
368            .max_iter(self.max_iter)?
369            .n_threads(self.num_threads)?;
370
371        self.execute_training(trainer, lattices)
372    }
373
374    /// Execute the actual CRF training with detailed logging
375    fn execute_training(
376        &mut self,
377        trainer: lindera_crf::Trainer,
378        lattices: Vec<lindera_crf::Lattice>,
379    ) -> Result<lindera_crf::RawModel> {
380        println!("L-BFGS optimization starting...");
381        println!(
382            "Note: This may take several minutes for large datasets. Progress will be shown by L-BFGS iterations above."
383        );
384        println!("Each 'iter:' line indicates training progress. Please wait...");
385
386        let start_time = std::time::Instant::now();
387
388        // Training with provider (consumes provider as per CRF training requirements)
389        let provider = std::mem::take(&mut self.provider);
390        let model = trainer.train(&lattices, provider);
391        let training_duration = start_time.elapsed();
392
393        self.log_training_results(&model);
394        println!(
395            "Training completed successfully in {:.2}s!",
396            training_duration.as_secs_f64()
397        );
398
399        Ok(model)
400    }
401
402    /// Log detailed training results for debugging
403    fn log_training_results(&self, model: &lindera_crf::RawModel) {
404        log_debug!("Training completed, checking raw model...");
405        log_debug!("Raw model weights count: {}", model.weights().len());
406        log_debug!(
407            "Raw model first 5 weights: {:?}",
408            &model.weights()[..std::cmp::min(5, model.weights().len())]
409        );
410
411        // Analyze weights for debugging
412        let weights = model.weights();
413        let nan_count = weights.iter().filter(|&&w| w.is_nan()).count();
414        let inf_count = weights.iter().filter(|&&w| w.is_infinite()).count();
415        let zero_count = weights.iter().filter(|&&w| w == 0.0).count();
416        log_debug!(
417            "Weight analysis - NaN: {}, Inf: {}, Zero: {}, Total: {}",
418            nan_count,
419            inf_count,
420            zero_count,
421            weights.len()
422        );
423
424        let weight_sum: f64 = weights.iter().sum();
425        log_debug!("Sum of all weights: {:.16}", weight_sum);
426
427        log_debug!(
428            "Model unigram_weight_indices len: {}",
429            model.unigram_weight_indices().len()
430        );
431        log_debug!(
432            "Model bigram_weight_indices len: {}",
433            model.bigram_weight_indices().len()
434        );
435    }
436
437    /// Create the final trained model from CRF results
438    fn create_final_model(
439        mut self,
440        crf_model: lindera_crf::RawModel,
441        labels: Vec<String>,
442        _corpus: Corpus,
443    ) -> Result<Model> {
444        // Remove unused features from feature extractor to optimize model size
445        self.remove_unused_features(&crf_model);
446
447        // Extract feature weights from the trained model
448        let feature_weights = self.extract_feature_weights(&crf_model);
449
450        // Create final model with metadata
451        Ok(Model::new_with_metadata(
452            crf_model,
453            self.config,
454            feature_weights,
455            labels,
456            self.regularization_cost,
457            self.max_iter,
458        ))
459    }
460
461    /// Extract feature weights from the trained CRF model
462    fn extract_feature_weights(&self, crf_model: &lindera_crf::RawModel) -> Vec<f64> {
463        log_info!("Extracting feature weights from trained model...");
464
465        let mut feature_weights = Vec::new();
466        match crf_model.merge() {
467            Ok(merged_model) => {
468                // Extract weights in the order of surfaces
469                for (i, _surface) in self.config.surfaces.iter().enumerate() {
470                    if i < merged_model.feature_sets.len() {
471                        feature_weights.push(merged_model.feature_sets[i].weight);
472                    } else {
473                        feature_weights.push(0.0);
474                    }
475                }
476
477                let non_zero_count = feature_weights.iter().filter(|&&w| w != 0.0).count();
478                log_info!(
479                    "Extracted {} weights ({} non-zero) from merged model",
480                    feature_weights.len(),
481                    non_zero_count
482                );
483            }
484            Err(e) => {
485                log_info!("Model merge failed: {e}, falling back to raw weights");
486                self.use_raw_weights(crf_model, &mut feature_weights);
487            }
488        }
489
490        feature_weights
491    }
492
493    /// Use raw CRF model weights as fallback
494    fn use_raw_weights(&self, crf_model: &lindera_crf::RawModel, feature_weights: &mut Vec<f64>) {
495        let raw_weights = crf_model.weights();
496
497        for i in 0..self.config.surfaces.len() {
498            if i < raw_weights.len() {
499                feature_weights.push(raw_weights[i]);
500            } else {
501                feature_weights.push(0.0);
502            }
503        }
504        log_info!("Used {} raw model weights", feature_weights.len());
505    }
506
507    /// Extracts labels from the configuration
508    fn extract_labels(&self) -> Vec<String> {
509        let mut labels = self.config.surfaces.clone();
510        // Add unknown word category labels from character definition
511        // This makes it work for any dictionary (IPADIC, UniDic, ko-dic, CC-CEDICT, etc.)
512        let char_def = &self.config.dict.character_definition;
513        for category_name in char_def.categories() {
514            labels.push(category_name.to_string());
515        }
516        labels
517    }
518
519    fn build_lattice(&mut self, example: &Example) -> Result<lindera_crf::Lattice> {
520        use lindera_crf::{Edge, Lattice};
521
522        let input_chars: Vec<char> = example.sentence.chars().collect();
523        let input_len = input_chars.len();
524
525        // Add positive edges from training data
526        let mut edges = vec![];
527        let mut pos = 0;
528        for token in &example.tokens {
529            let token_len = token.surface().chars().count();
530            let first_char = input_chars[pos];
531
532            // Try to find existing label ID, or create one
533            let label_id = self
534                .label_id_map
535                .get(token.feature())
536                .and_then(|hm| hm.get(&first_char))
537                .cloned()
538                .map(Ok)
539                .unwrap_or_else(|| {
540                    // Check for compatible unknown word first
541                    self.config
542                        .dict()
543                        .unknown_dictionary
544                        .compatible_unk_index(
545                            &example.sentence,
546                            pos,
547                            pos + token_len,
548                            token.feature(),
549                        )
550                        .map_or_else(
551                            || {
552                                self.provider.add_feature_set(lindera_crf::FeatureSet::new(
553                                    &[],
554                                    &[],
555                                    &[],
556                                ))
557                            },
558                            |unk_index| Ok(self.label_id_map_unk[unk_index.word_id as usize]),
559                        )
560                })?;
561
562            edges.push((pos, Edge::new(pos + token_len, label_id)));
563            pos += token_len;
564        }
565        assert_eq!(pos, input_len);
566
567        let mut lattice = Lattice::new(input_len)?;
568
569        // Add positive edges to lattice
570        for (pos, edge) in edges {
571            lattice.add_edge(pos, edge)?;
572        }
573
574        // Add negative edges using optimized unknown word generation
575        for start_word in 0..input_len {
576            let mut has_matched = false;
577
578            let suffix = &input_chars[start_word..];
579
580            // System lexicon matching with common_prefix_iterator
581            for m in self.config.system_lexicon().common_prefix_iterator(suffix) {
582                has_matched = true;
583                let label_id = NonZeroU32::new(m.word_idx.word_id + 1).unwrap(); // word_id is 0-based, NonZeroU32 needs +1
584                let pos = start_word;
585                let target = pos + m.end_char;
586                let edge = Edge::new(target, label_id);
587
588                // Skip adding if the edge is already added as a positive edge
589                if let Some(first_edge) = lattice.nodes()[pos].edges().first()
590                    && edge == *first_edge
591                {
592                    continue;
593                }
594                lattice.add_edge(pos, edge)?;
595            }
596
597            // Generate unknown words using callback system
598            let sentence: String = input_chars.iter().collect();
599            self.config.unk_handler().gen_unk_words(
600                &sentence,
601                start_word,
602                has_matched,
603                self.max_grouping_len,
604                |w| {
605                    let id_offset = self.config.surfaces.len() as u32;
606                    let label_id = NonZeroU32::new(id_offset + w.word_idx().word_id + 1).unwrap(); // Offset for unknown words
607                    let pos = start_word;
608                    let target = w.end_char();
609                    let edge = lindera_crf::Edge::new(target, label_id);
610
611                    // Skip adding if the edge is already added as a positive edge
612                    if let Some(first_edge) = lattice.nodes()[pos].edges().first()
613                        && edge == *first_edge
614                    {
615                        return;
616                    }
617                    lattice.add_edge(pos, edge).unwrap();
618                },
619            );
620        }
621
622        Ok(lattice)
623    }
624
625    /// Remove unused features from the feature extractor to optimize the model size and performance
626    fn remove_unused_features(&mut self, model: &lindera_crf::RawModel) {
627        println!("Removing unused features...");
628
629        use std::collections::HashSet;
630
631        let mut used_right_features = HashSet::new();
632
633        // Collect all feature keys to check
634        let unigram_feature_keys: Vec<_> = self
635            .config
636            .feature_extractor
637            .unigram_feature_ids
638            .keys()
639            .cloned()
640            .collect();
641        let left_feature_keys: Vec<_> = self
642            .config
643            .feature_extractor
644            .left_feature_ids
645            .keys()
646            .cloned()
647            .collect();
648        let right_feature_keys: Vec<_> = self
649            .config
650            .feature_extractor
651            .right_feature_ids
652            .keys()
653            .cloned()
654            .collect();
655
656        // Remove unused unigram features to reduce model complexity
657        for k in &unigram_feature_keys {
658            let id = self
659                .config
660                .feature_extractor
661                .unigram_feature_ids
662                .get(k)
663                .unwrap();
664            if model
665                .unigram_weight_indices()
666                .get((id.get() - 1) as usize)
667                .cloned()
668                .flatten()
669                .is_none()
670            {
671                self.config.feature_extractor.unigram_feature_ids.remove(k);
672            }
673        }
674
675        // Collect used right features from bigram weights
676        for feature_ids in model.bigram_weight_indices() {
677            for (feature_id, _) in feature_ids {
678                used_right_features.insert(*feature_id);
679            }
680        }
681
682        // Remove unused left features
683        for k in &left_feature_keys {
684            let id = self
685                .config
686                .feature_extractor
687                .left_feature_ids
688                .get(k)
689                .unwrap();
690            if let Some(x) = model.bigram_weight_indices().get(id.get() as usize)
691                && x.is_empty()
692            {
693                self.config.feature_extractor.left_feature_ids.remove(k);
694            }
695        }
696
697        // Remove unused right features
698        for k in &right_feature_keys {
699            let id = self
700                .config
701                .feature_extractor
702                .right_feature_ids
703                .get(k)
704                .unwrap();
705            if !used_right_features.contains(&id.get()) {
706                self.config.feature_extractor.right_feature_ids.remove(k);
707            }
708        }
709
710        println!("Feature cleanup completed");
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use std::io::Cursor;
718
719    #[test]
720    fn test_train_with_bigram_template() {
721        let seed_csv = "これ,0,0,0,名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは,0,0,0,助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト,0,0,0,名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\n";
722        let char_def = "DEFAULT 0 1 0\nHIRAGANA 1 1 0\nKATAKANA 1 1 0\nKANJI 0 0 2\nALPHA 1 1 0\nNUMERIC 1 1 0\n\n0x3041..0x3096 HIRAGANA\n0x30A1..0x30F6 KATAKANA\n0x4E00..0x9FAF KANJI\n0x0030..0x0039 NUMERIC\n0x0041..0x005A ALPHA\n0x0061..0x007A ALPHA\n";
723        let unk_def = "DEFAULT,0,0,0,名詞,一般,*,*,*,*,*,*,*\nHIRAGANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKATAKANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKANJI,0,0,0,名詞,一般,*,*,*,*,*,*,*\nALPHA,0,0,0,名詞,固有名詞,一般,*,*,*,*,*,*\nNUMERIC,0,0,0,名詞,数,*,*,*,*,*,*,*\n";
724        let feature_def = "UNIGRAM U00:%F[0]\nUNIGRAM U01:%F[0],%F?[1]\nBIGRAM B00:%L[0]/%R[0]\n";
725        let rewrite_def = "名詞,一般\tNOUN,GENERAL\n";
726        let corpus_text = "これ\t名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは\t助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト\t名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\nEOS\n";
727
728        let config = TrainerConfig::from_readers(
729            Cursor::new(seed_csv),
730            Cursor::new(char_def),
731            Cursor::new(unk_def),
732            Cursor::new(feature_def),
733            Cursor::new(rewrite_def),
734        )
735        .unwrap();
736
737        let trainer = Trainer::new(config)
738            .unwrap()
739            .regularization_cost(0.01)
740            .max_iter(5)
741            .num_threads(1);
742
743        let corpus = Corpus::from_reader(Cursor::new(corpus_text)).unwrap();
744
745        let model = trainer.train(corpus).unwrap();
746
747        // Verify model can be serialized and deserialized
748        let mut buf = Vec::new();
749        model.write_model(&mut buf).unwrap();
750        assert!(!buf.is_empty(), "Serialized model should not be empty");
751
752        let serializable: model::SerializableModel =
753            model::Model::read_model(Cursor::new(&buf)).unwrap();
754
755        // Verify labels match seed data (surfaces are encoded as labels)
756        assert!(
757            !serializable.labels.is_empty(),
758            "Labels should not be empty"
759        );
760
761        // Verify connection costs are generated (dense matrix)
762        assert!(
763            !serializable.connection_matrix.is_empty(),
764            "Connection matrix should not be empty"
765        );
766
767        // Verify left/right context ID mappings exist
768        assert!(
769            !serializable.left_id_map.is_empty(),
770            "Left ID map should not be empty"
771        );
772        assert!(
773            !serializable.right_id_map.is_empty(),
774            "Right ID map should not be empty"
775        );
776
777        // Verify unknown word categories are preserved
778        assert!(
779            !serializable.unk_categories.is_empty(),
780            "Unknown word categories should not be empty"
781        );
782
783        // Verify cost factor is set
784        assert!(
785            serializable.cost_factor > 0,
786            "Cost factor should be positive"
787        );
788
789        // === Verify all exported dictionary files ===
790
791        // 1. lex.csv: should contain all seed entries with costs
792        let mut lex_buf = Vec::new();
793        serializable.write_lexicon(&mut lex_buf).unwrap();
794        let lex_content = String::from_utf8(lex_buf).unwrap();
795        assert!(!lex_content.is_empty(), "lex.csv should not be empty");
796        for surface in &["これ", "は", "テスト"] {
797            assert!(
798                lex_content.contains(surface),
799                "lex.csv should contain {surface}"
800            );
801        }
802        // Each line should have CSV format: surface,left_id,right_id,cost,features...
803        for line in lex_content.lines() {
804            let fields: Vec<&str> = line.split(',').collect();
805            assert!(
806                fields.len() >= 4,
807                "lex.csv line should have at least 4 fields: {line}"
808            );
809            // left_id and right_id should be valid integers
810            fields[1]
811                .parse::<i32>()
812                .unwrap_or_else(|_| panic!("Invalid left_id in lex.csv: {line}"));
813            fields[2]
814                .parse::<i32>()
815                .unwrap_or_else(|_| panic!("Invalid right_id in lex.csv: {line}"));
816            // cost should be a valid integer
817            fields[3]
818                .parse::<i32>()
819                .unwrap_or_else(|_| panic!("Invalid cost in lex.csv: {line}"));
820        }
821
822        // 2. matrix.def: dense connection cost matrix
823        let mut matrix_buf = Vec::new();
824        serializable
825            .write_connection_costs(&mut matrix_buf)
826            .unwrap();
827        let matrix_content = String::from_utf8(matrix_buf).unwrap();
828        let mut lines = matrix_content.lines();
829        let header = lines.next().unwrap();
830        let dims: Vec<usize> = header
831            .split_whitespace()
832            .map(|s| s.parse().unwrap())
833            .collect();
834        assert_eq!(dims.len(), 2, "matrix.def header should have 2 dimensions");
835        let (rows, cols) = (dims[0], dims[1]);
836        assert!(rows > 0 && cols > 0, "Matrix dimensions should be positive");
837        // Each entry: right_id left_id cost
838        let mut entry_count = 0;
839        for line in lines {
840            let parts: Vec<&str> = line.split_whitespace().collect();
841            assert_eq!(
842                parts.len(),
843                3,
844                "matrix.def entry should have 3 fields: {line}"
845            );
846            parts[0]
847                .parse::<usize>()
848                .unwrap_or_else(|_| panic!("Invalid right_id: {line}"));
849            parts[1]
850                .parse::<usize>()
851                .unwrap_or_else(|_| panic!("Invalid left_id: {line}"));
852            parts[2]
853                .parse::<i32>()
854                .unwrap_or_else(|_| panic!("Invalid cost: {line}"));
855            entry_count += 1;
856        }
857        assert_eq!(
858            entry_count,
859            rows * cols,
860            "Dense matrix should have rows*cols entries"
861        );
862
863        // 3. unk.def: unknown word definitions
864        let mut unk_buf = Vec::new();
865        serializable.write_unknown_dictionary(&mut unk_buf).unwrap();
866        let unk_content = String::from_utf8(unk_buf).unwrap();
867        assert!(!unk_content.is_empty(), "unk.def should not be empty");
868        assert!(
869            unk_content.contains("DEFAULT"),
870            "unk.def should contain DEFAULT category"
871        );
872
873        // 4. char.def: character definition
874        let mut char_buf = Vec::new();
875        serializable.write_char_def(&mut char_buf).unwrap();
876        let char_content = String::from_utf8(char_buf).unwrap();
877        assert!(!char_content.is_empty(), "char.def should not be empty");
878
879        // 5. feature.def: feature template definition
880        let mut feat_buf = Vec::new();
881        serializable.write_feature_def(&mut feat_buf).unwrap();
882        let feat_content = String::from_utf8(feat_buf).unwrap();
883        assert!(!feat_content.is_empty(), "feature.def should not be empty");
884        assert!(
885            feat_content.contains("UNIGRAM"),
886            "feature.def should contain UNIGRAM"
887        );
888        assert!(
889            feat_content.contains("BIGRAM"),
890            "feature.def should contain BIGRAM"
891        );
892
893        // 6. rewrite.def: rewrite rule definition
894        let mut rewrite_buf = Vec::new();
895        serializable.write_rewrite_def(&mut rewrite_buf).unwrap();
896        let rewrite_content = String::from_utf8(rewrite_buf).unwrap();
897        assert!(
898            !rewrite_content.is_empty(),
899            "rewrite.def should not be empty"
900        );
901
902        // 7. left-id.def: left context ID mapping
903        let mut left_id_buf = Vec::new();
904        serializable.write_left_id_def(&mut left_id_buf).unwrap();
905        let left_id_content = String::from_utf8(left_id_buf).unwrap();
906        assert!(
907            !left_id_content.is_empty(),
908            "left-id.def should not be empty"
909        );
910        // Each line: id feature_string
911        for line in left_id_content.lines() {
912            let parts: Vec<&str> = line.splitn(2, ' ').collect();
913            assert_eq!(
914                parts.len(),
915                2,
916                "left-id.def line should have id and feature: {line}"
917            );
918            parts[0]
919                .parse::<u32>()
920                .unwrap_or_else(|_| panic!("Invalid id in left-id.def: {line}"));
921        }
922
923        // 8. right-id.def: right context ID mapping
924        let mut right_id_buf = Vec::new();
925        serializable.write_right_id_def(&mut right_id_buf).unwrap();
926        let right_id_content = String::from_utf8(right_id_buf).unwrap();
927        assert!(
928            !right_id_content.is_empty(),
929            "right-id.def should not be empty"
930        );
931        for line in right_id_content.lines() {
932            let parts: Vec<&str> = line.splitn(2, ' ').collect();
933            assert_eq!(
934                parts.len(),
935                2,
936                "right-id.def line should have id and feature: {line}"
937            );
938            parts[0]
939                .parse::<u32>()
940                .unwrap_or_else(|_| panic!("Invalid id in right-id.def: {line}"));
941        }
942
943        // Cross-check: left-id.def count should match matrix columns
944        let left_id_count = left_id_content.lines().count();
945        assert_eq!(
946            left_id_count, cols,
947            "left-id.def entries ({left_id_count}) should match matrix columns ({cols})"
948        );
949
950        // Cross-check: right-id.def count should match matrix rows
951        let right_id_count = right_id_content.lines().count();
952        assert_eq!(
953            right_id_count, rows,
954            "right-id.def entries ({right_id_count}) should match matrix rows ({rows})"
955        );
956    }
957}
958
959// Regression tests for #974: `lindera train` must produce a byte-reproducible
960// `model.dat`.
961//
962// These train for real, unlike the pure-serialization tests in `model.rs`, so
963// they cover the path the CLI actually takes -- including the fact that
964// `write_model` rebuilds the connection matrix from scratch on every call.
965#[cfg(test)]
966pub(crate) mod test_fixtures {
967    use crate::{Corpus, Trainer, TrainerConfig};
968    use std::io::Cursor;
969
970    /// Seed lexicon and corpus wide enough that the connection matrix holds
971    /// many entries.
972    ///
973    /// This matters for the determinism assertions built on it. With only a
974    /// handful of entries every key probes the same 16-slot window in rkyv's
975    /// archived `HashMap`, so two runs can land on the same layout by luck and
976    /// a reproducibility assertion passes for the wrong reason. Verified by
977    /// mutation: with three seed words such a test still passed after
978    /// reverting the #974 fix; with this fixture it fails.
979    pub(crate) fn wide_seed_and_corpus() -> (String, String) {
980        // Distinct part-of-speech pairs mint distinct connection ids, which is
981        // what widens the matrix.
982        let pos = [
983            ("名詞", "一般"),
984            ("名詞", "固有名詞"),
985            ("名詞", "サ変接続"),
986            ("名詞", "代名詞"),
987            ("動詞", "自立"),
988            ("動詞", "非自立"),
989            ("形容詞", "自立"),
990            ("副詞", "一般"),
991            ("助詞", "係助詞"),
992            ("助詞", "格助詞"),
993            ("助動詞", "*"),
994            ("接続詞", "*"),
995        ];
996        let mut seed = String::new();
997        let mut corpus = String::new();
998        for (i, (major, sub)) in pos.iter().enumerate() {
999            for variant in 0..4 {
1000                let surface = format!("語{i}{variant}");
1001                let feature = format!("{major},{sub},*,*,*,*,{surface},カナ,カナ");
1002                seed.push_str(&format!("{surface},0,0,0,{feature}\n"));
1003                corpus.push_str(&format!("{surface}\t{feature}\n"));
1004            }
1005            corpus.push_str("EOS\n");
1006        }
1007        (seed, corpus)
1008    }
1009
1010    /// The tiny in-line fixtures shared by these tests, mirroring
1011    /// `test_train_with_bigram_template`.
1012    pub(crate) fn fixtures() -> (
1013        &'static str,
1014        &'static str,
1015        &'static str,
1016        &'static str,
1017        &'static str,
1018        &'static str,
1019    ) {
1020        (
1021            "これ,0,0,0,名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは,0,0,0,助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト,0,0,0,名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\n",
1022            "DEFAULT 0 1 0\nHIRAGANA 1 1 0\nKATAKANA 1 1 0\nKANJI 0 0 2\nALPHA 1 1 0\nNUMERIC 1 1 0\n\n0x3041..0x3096 HIRAGANA\n0x30A1..0x30F6 KATAKANA\n0x4E00..0x9FAF KANJI\n0x0030..0x0039 NUMERIC\n0x0041..0x005A ALPHA\n0x0061..0x007A ALPHA\n",
1023            "DEFAULT,0,0,0,名詞,一般,*,*,*,*,*,*,*\nHIRAGANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKATAKANA,0,0,0,名詞,一般,*,*,*,*,*,*,*\nKANJI,0,0,0,名詞,一般,*,*,*,*,*,*,*\nALPHA,0,0,0,名詞,固有名詞,一般,*,*,*,*,*,*\nNUMERIC,0,0,0,名詞,数,*,*,*,*,*,*,*\n",
1024            "UNIGRAM U00:%F[0]\nUNIGRAM U01:%F[0],%F?[1]\nBIGRAM B00:%L[0]/%R[0]\n",
1025            "名詞,一般\tNOUN,GENERAL\n",
1026            "これ\t名詞,代名詞,一般,*,*,*,これ,コレ,コレ\nは\t助詞,係助詞,*,*,*,*,は,ハ,ワ\nテスト\t名詞,サ変接続,*,*,*,*,テスト,テスト,テスト\nEOS\n",
1027        )
1028    }
1029
1030    /// Trains the wide fixture with the given thread count and returns the
1031    /// model.
1032    pub(crate) fn train_wide_model(threads: usize) -> crate::model::Model {
1033        let (_, char_def, unk_def, feature_def, rewrite_def, _) = fixtures();
1034        let (seed, corpus_text) = wide_seed_and_corpus();
1035
1036        let config = match TrainerConfig::from_readers(
1037            Cursor::new(seed.as_bytes()),
1038            Cursor::new(char_def),
1039            Cursor::new(unk_def),
1040            Cursor::new(feature_def),
1041            Cursor::new(rewrite_def),
1042        ) {
1043            Ok(config) => config,
1044            Err(err) => panic!("failed to build config: {err}"),
1045        };
1046
1047        let trainer = match Trainer::new(config) {
1048            Ok(trainer) => trainer,
1049            Err(err) => panic!("failed to build trainer: {err}"),
1050        }
1051        .regularization_cost(0.01)
1052        .max_iter(5)
1053        .num_threads(threads);
1054
1055        let corpus = match Corpus::from_reader(Cursor::new(corpus_text.as_bytes())) {
1056            Ok(corpus) => corpus,
1057            Err(err) => panic!("failed to read corpus: {err}"),
1058        };
1059
1060        match trainer.train(corpus) {
1061            Ok(model) => model,
1062            Err(err) => panic!("training failed: {err}"),
1063        }
1064    }
1065}
1066
1067#[cfg(test)]
1068mod export_determinism_tests {
1069    use crate::test_fixtures::train_wide_model;
1070    use std::io::Cursor;
1071
1072    /// `read_user_lexicon` -> `write_dictionary` must emit the user entries,
1073    /// in input order, byte-identically across runs.
1074    ///
1075    /// Before #981 this wrote an empty file: the writer read
1076    /// `config.user_lexicon`, which nothing populates, instead of the
1077    /// `user_entries` that `read_user_lexicon` fills.
1078    #[test]
1079    fn user_lexicon_roundtrips_in_input_order() {
1080        // One `0,0,0` entry (parameters must be inferred) and one entry with
1081        // explicit parameters (must be re-emitted verbatim, per the contract
1082        // documented on `read_user_lexicon`).
1083        let user_csv = "外食,0,0,0,名詞,一般,*,*,*,*,外食,ガイショク,ガイショク\n\
1084                        ラー油,3,3,912,名詞,一般,*,*,*,*,ラー油,ラーユ,ラーユ\n";
1085
1086        let write_all = |model: &crate::model::Model| -> Vec<u8> {
1087            let (mut lex, mut conn, mut unk, mut user) =
1088                (Vec::new(), Vec::new(), Vec::new(), Vec::new());
1089            if let Err(err) = model.write_dictionary(&mut lex, &mut conn, &mut unk, &mut user) {
1090                panic!("write_dictionary failed: {err}");
1091            }
1092            user
1093        };
1094
1095        let mut model = train_wide_model(1);
1096        if let Err(err) = model.read_user_lexicon(Cursor::new(user_csv.as_bytes())) {
1097            panic!("read_user_lexicon failed: {err}");
1098        }
1099
1100        let first = write_all(&model);
1101        assert!(
1102            !first.is_empty(),
1103            "user lexicon output is empty: write_user_lexicon is not reading user_entries"
1104        );
1105
1106        let text = match String::from_utf8(first.clone()) {
1107            Ok(text) => text,
1108            Err(err) => panic!("user lexicon output is not UTF-8: {err}"),
1109        };
1110        let lines: Vec<&str> = text.lines().collect();
1111        assert_eq!(lines.len(), 2, "expected both user entries: {text}");
1112
1113        // Input order, not map order.
1114        assert!(
1115            lines[0].starts_with("外食,"),
1116            "first line must be the first input entry: {}",
1117            lines[0]
1118        );
1119        // The parameterized entry is re-emitted verbatim.
1120        assert_eq!(
1121            lines[1], "ラー油,3,3,912,名詞,一般,*,*,*,*,ラー油,ラーユ,ラーユ",
1122            "non-zero parameters must be preserved as-is"
1123        );
1124        // The inferred entry keeps its surface and feature string.
1125        assert!(
1126            lines[0].ends_with(",名詞,一般,*,*,*,*,外食,ガイショク,ガイショク"),
1127            "inferred line must keep the input features: {}",
1128            lines[0]
1129        );
1130
1131        // Byte-reproducible across repeated writes.
1132        let second = write_all(&model);
1133        assert_eq!(first, second, "user lexicon output must be reproducible");
1134    }
1135
1136    /// The three bigram-details files must be byte-identical across repeated
1137    /// writes, and must carry real feature names.
1138    ///
1139    /// Before #981 the cost file iterated an unsorted `hashbrown::HashMap`
1140    /// rebuilt per call (different seed each time), and its labels were
1141    /// fabricated `L{i}`/`R{j}` strings in the wrong id space, degenerating
1142    /// to `*`.
1143    #[test]
1144    fn bigram_details_are_reproducible_and_named() {
1145        let model = train_wide_model(1);
1146
1147        let dump = || -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1148            let (mut left, mut right, mut cost) = (Vec::new(), Vec::new(), Vec::new());
1149            if let Err(err) = model.write_bigram_details(&mut left, &mut right, &mut cost) {
1150                panic!("write_bigram_details failed: {err}");
1151            }
1152            (left, right, cost)
1153        };
1154
1155        let (left_a, right_a, cost_a) = dump();
1156        let (left_b, right_b, cost_b) = dump();
1157        assert_eq!(left_a, left_b, "left file must be reproducible");
1158        assert_eq!(right_a, right_b, "right file must be reproducible");
1159        assert_eq!(cost_a, cost_b, "cost file must be reproducible");
1160
1161        let cost_text = match String::from_utf8(cost_a) {
1162            Ok(text) => text,
1163            Err(err) => panic!("cost file is not UTF-8: {err}"),
1164        };
1165        assert!(
1166            cost_text.lines().any(|line| line.starts_with("BOS/")),
1167            "cost file must label bigram feature id 0 as BOS: {cost_text}"
1168        );
1169        assert!(
1170            cost_text.lines().any(|line| line.contains("/EOS\t")),
1171            "cost file must label right feature id 0 as EOS: {cost_text}"
1172        );
1173        assert!(
1174            cost_text.contains("名詞"),
1175            "cost file must carry real feature names, not fabricated ids: {cost_text}"
1176        );
1177
1178        let left_text = match String::from_utf8(left_a) {
1179            Ok(text) => text,
1180            Err(err) => panic!("left file is not UTF-8: {err}"),
1181        };
1182        assert!(
1183            left_text.contains("名詞"),
1184            "left file must carry real feature names: {left_text}"
1185        );
1186    }
1187}
1188
1189#[cfg(test)]
1190mod train_determinism_tests {
1191    use crate::test_fixtures::fixtures;
1192    use crate::{Corpus, Trainer, TrainerConfig};
1193    use std::io::Cursor;
1194
1195    /// Trains once with the given thread count and serializes the resulting
1196    /// model, returning the bytes.
1197    ///
1198    /// The thread count must not affect the bytes: the CRF loss and gradient
1199    /// sum a fixed partition of the lattices in a fixed order regardless of
1200    /// how many workers run (`lindera-crf`'s `LatticesLoss`), which
1201    /// `training_is_identical_across_thread_counts` pins end to end.
1202    fn train_and_serialize(threads: usize) -> Vec<u8> {
1203        let model = crate::test_fixtures::train_wide_model(threads);
1204        let mut buf = Vec::new();
1205        if let Err(err) = model.write_model(&mut buf) {
1206            panic!("failed to serialize model: {err}");
1207        }
1208        buf
1209    }
1210
1211    /// Two independent training runs over identical inputs must serialize to
1212    /// identical bytes. This is the issue's own repro, in-process.
1213    #[test]
1214    fn training_twice_produces_identical_bytes() {
1215        let first = train_and_serialize(1);
1216        let second = train_and_serialize(1);
1217
1218        assert!(!first.is_empty(), "serialized model must not be empty");
1219        assert_eq!(
1220            first.len(),
1221            second.len(),
1222            "two training runs produced different model sizes"
1223        );
1224        assert_eq!(
1225            first, second,
1226            "two training runs over identical inputs must produce identical bytes"
1227        );
1228    }
1229
1230    /// Training with one thread and with four threads must serialize to
1231    /// identical bytes: the CRF reduction is a fixed partition summed in a
1232    /// fixed order, so the thread count is a pure performance knob.
1233    #[test]
1234    fn training_is_identical_across_thread_counts() {
1235        let single = train_and_serialize(1);
1236        let parallel = train_and_serialize(4);
1237
1238        assert!(!single.is_empty(), "serialized model must not be empty");
1239        assert_eq!(
1240            single, parallel,
1241            "1-thread and 4-thread training must produce identical bytes"
1242        );
1243    }
1244
1245    /// Serializing one trained model twice must also be stable. `write_model`
1246    /// rebuilds the connection matrix on every call, so this exercises that
1247    /// rebuild rather than a cached buffer.
1248    #[test]
1249    fn writing_one_model_twice_produces_identical_bytes() {
1250        let (seed, char_def, unk_def, feature_def, rewrite_def, corpus_text) = fixtures();
1251
1252        let config = match TrainerConfig::from_readers(
1253            Cursor::new(seed),
1254            Cursor::new(char_def),
1255            Cursor::new(unk_def),
1256            Cursor::new(feature_def),
1257            Cursor::new(rewrite_def),
1258        ) {
1259            Ok(config) => config,
1260            Err(err) => panic!("failed to build config: {err}"),
1261        };
1262        let trainer = match Trainer::new(config) {
1263            Ok(trainer) => trainer,
1264            Err(err) => panic!("failed to build trainer: {err}"),
1265        }
1266        .regularization_cost(0.01)
1267        .max_iter(5)
1268        .num_threads(1);
1269        let corpus = match Corpus::from_reader(Cursor::new(corpus_text)) {
1270            Ok(corpus) => corpus,
1271            Err(err) => panic!("failed to read corpus: {err}"),
1272        };
1273        let model = match trainer.train(corpus) {
1274            Ok(model) => model,
1275            Err(err) => panic!("training failed: {err}"),
1276        };
1277
1278        let mut first = Vec::new();
1279        let mut second = Vec::new();
1280        if let Err(err) = model.write_model(&mut first) {
1281            panic!("first serialization failed: {err}");
1282        }
1283        if let Err(err) = model.write_model(&mut second) {
1284            panic!("second serialization failed: {err}");
1285        }
1286
1287        assert_eq!(first, second, "two writes of one model must match");
1288    }
1289}