Skip to main content

lindera_trainer/
config.rs

1use std::collections::{BTreeMap, HashMap};
2use std::io::{BufReader, Read};
3use std::path::Path;
4use std::sync::Arc;
5
6use anyhow::Result;
7
8use super::feature_extractor::FeatureExtractor;
9use super::feature_rewriter::DictionaryRewriter;
10use lindera_dictionary::dictionary::Dictionary;
11use lindera_dictionary::dictionary::character_definition::CharacterDefinition;
12use lindera_dictionary::dictionary::connection_cost_matrix::ConnectionCostMatrix;
13use lindera_dictionary::dictionary::metadata::Metadata;
14use lindera_dictionary::dictionary::prefix_dictionary::PrefixDictionary;
15use lindera_dictionary::dictionary::unknown_dictionary::UnknownDictionary;
16
17/// Configuration for training.
18pub struct TrainerConfig {
19    pub(crate) dict: Dictionary,
20    pub(crate) surfaces: Vec<String>,
21    /// Feature strings for each entry (parallel to surfaces)
22    pub(crate) features: Vec<String>,
23    /// Maps surface forms to their original feature strings from the lexicon
24    pub(crate) surface_features: HashMap<String, String>,
25    /// User lexicon entries for additional vocabulary
26    pub(crate) user_lexicon: HashMap<String, String>,
27    pub(crate) feature_extractor: FeatureExtractor,
28    pub(crate) dictionary_rewriter: DictionaryRewriter,
29    /// Cost factor for converting CRF weights to i16 costs (MeCab's cost-factor)
30    pub(crate) cost_factor: i32,
31    /// Metadata from which encoding and schema information is derived
32    pub(crate) metadata: Metadata,
33    /// Maps unknown word category names to their feature strings from unk.def
34    /// Format: category -> "pos,feature1,feature2,..."
35    ///
36    /// Ordered rather than hashed: this map is cloned verbatim into
37    /// `SerializableModel::unk_categories`, whose serialized byte layout must
38    /// not depend on iteration order (#974).
39    pub(crate) unk_categories: BTreeMap<String, String>,
40    /// Maps unknown word category names to their costs from unk.def
41    /// Format: category -> cost
42    pub(crate) unk_costs: HashMap<String, i32>,
43    /// Raw content of the character definition file (char.def)
44    /// Preserved from training input for export
45    pub(crate) char_def_content: String,
46    /// Raw content of the feature definition file (feature.def)
47    /// Preserved from training input for export
48    pub(crate) feature_def_content: String,
49    /// Raw content of the rewrite rule definition file (rewrite.def)
50    /// Preserved from training input for export
51    pub(crate) rewrite_def_content: String,
52}
53
54impl TrainerConfig {
55    /// Access system lexicon for morphological analysis
56    pub fn system_lexicon(&self) -> &PrefixDictionary {
57        self.dict.prefix_dictionary.as_ref()
58    }
59
60    /// Access dictionary (for compatibility)
61    pub fn dict(&self) -> &Dictionary {
62        &self.dict
63    }
64
65    /// Access unknown word handler for out-of-vocabulary processing
66    pub fn unk_handler(
67        &self,
68    ) -> &lindera_dictionary::dictionary::unknown_dictionary::UnknownDictionary {
69        &self.dict.unknown_dictionary
70    }
71}
72
73impl TrainerConfig {
74    /// Creates a new trainer configuration from readers.
75    ///
76    /// # Arguments
77    ///
78    /// * `lexicon_rdr` - Reader for the seed lexicon file (lex.csv)
79    /// * `char_prop_rdr` - Reader for the character property file (char.def)
80    /// * `unk_handler_rdr` - Reader for the unknown word file (unk.def)
81    /// * `feature_templates_rdr` - Reader for the feature templates file (feature.def)
82    /// * `rewrite_rules_rdr` - Reader for the rewrite rules file (rewrite.def)
83    pub fn from_readers<R1, R2, R3, R4, R5>(
84        lexicon_rdr: R1,
85        char_prop_rdr: R2,
86        unk_handler_rdr: R3,
87        feature_templates_rdr: R4,
88        rewrite_rules_rdr: R5,
89    ) -> Result<Self>
90    where
91        R1: Read,
92        R2: Read,
93        R3: Read,
94        R4: Read,
95        R5: Read,
96    {
97        // Parse lexicon to extract surfaces and features
98        let mut surfaces = Vec::new();
99        let mut features = Vec::new();
100        let mut surface_features = HashMap::new();
101        let mut lexicon_content = String::new();
102        {
103            let mut lexicon_reader = BufReader::new(lexicon_rdr);
104            std::io::Read::read_to_string(&mut lexicon_reader, &mut lexicon_content)?;
105        }
106
107        for line in lexicon_content.lines() {
108            if line.trim().is_empty() || line.starts_with('#') {
109                continue;
110            }
111            let parts: Vec<&str> = line.split(',').collect();
112
113            // Accept any dictionary format with at least 5 columns
114            // Format: surface,left_id,right_id,cost,feature1,feature2,...
115            // - IPADIC:    13 columns (pos + 8 feature fields)
116            // - UniDic:    21+ columns (pos + 16+ feature fields)
117            // - ko-dic:    8 columns (pos + 3 feature fields)
118            // - CC-CEDICT: 8 columns (pos + 3 feature fields)
119            if parts.len() >= 5 {
120                let surface = parts[0].to_string();
121                // Extract features from columns 4 onwards (skip surface,left_id,right_id,cost)
122                // This works for any dictionary format
123                let feature_str = parts[4..].join(",");
124                surfaces.push(surface.clone());
125                features.push(feature_str.clone());
126                surface_features.insert(surface, feature_str);
127            }
128        }
129
130        // Create feature extractor from templates
131        let mut feature_content = String::new();
132        {
133            let mut template_reader = BufReader::new(feature_templates_rdr);
134            std::io::Read::read_to_string(&mut template_reader, &mut feature_content)?;
135        }
136
137        // Parse templates into unigram and bigram categories
138        let mut unigram_templates = Vec::new();
139        let mut bigram_templates = Vec::new();
140
141        for line in feature_content.lines() {
142            if line.trim().is_empty() || line.starts_with('#') {
143                continue;
144            }
145            // Parse template format: MeCab-compatible feature.def
146            // UNIGRAM U00:%F[0]  or  UNIGRAM:%F[0]
147            // BIGRAM B00:%L[0]/%R[0]  or  BIGRAM:%L[0]/%R[0]
148            if let Some(rest) = line.strip_prefix("UNIGRAM") {
149                // Extract the template part after optional label (e.g., "U00:")
150                let rest = rest.trim_start().trim_start_matches(':').trim_start();
151                let template = if let Some(idx) = rest.find('%') {
152                    &rest[idx..]
153                } else {
154                    rest
155                };
156                unigram_templates.push(template.to_string());
157            } else if let Some(rest) = line.strip_prefix("BIGRAM") {
158                // Extract the template part after optional label (e.g., "B00:")
159                let rest = rest.trim_start().trim_start_matches(':').trim_start();
160                let template = if let Some(idx) = rest.find('%') {
161                    &rest[idx..]
162                } else {
163                    rest
164                };
165                if let Some((left, right)) = template.split_once('/') {
166                    bigram_templates.push((left.to_string(), right.to_string()));
167                }
168            } else {
169                // Default unigram template (bare template without prefix)
170                unigram_templates.push(line.to_string());
171            }
172        }
173
174        // Create feature extractor with parsed templates
175        let feature_extractor =
176            FeatureExtractor::from_templates(&unigram_templates, &bigram_templates);
177
178        // Read rewrite rules content
179        let mut rewrite_def_content = String::new();
180        {
181            let mut rewrite_reader = BufReader::new(rewrite_rules_rdr);
182            std::io::Read::read_to_string(&mut rewrite_reader, &mut rewrite_def_content)?;
183        }
184
185        // Create dictionary rewriter with 3-section support
186        let dictionary_rewriter =
187            DictionaryRewriter::from_reader(std::io::Cursor::new(rewrite_def_content.as_bytes()))?;
188
189        // Parse unk.def to extract category-to-features mapping
190        let mut unk_content = String::new();
191        {
192            let mut unk_reader = BufReader::new(unk_handler_rdr);
193            std::io::Read::read_to_string(&mut unk_reader, &mut unk_content)?;
194        }
195
196        let mut unk_categories = BTreeMap::new();
197        let mut unk_costs = HashMap::new();
198        for line in unk_content.lines() {
199            if line.trim().is_empty() || line.starts_with('#') {
200                continue;
201            }
202            let parts: Vec<&str> = line.split(',').collect();
203            // Format: category,left_id,right_id,cost,feature1,feature2,...
204            if parts.len() >= 5 {
205                let category = parts[0].to_string();
206                let features = parts[4..].join(",");
207                unk_categories.insert(category.clone(), features);
208
209                // Parse cost (4th column)
210                if let Ok(cost) = parts[3].parse::<i32>() {
211                    unk_costs.insert(category, cost);
212                }
213            }
214        }
215
216        // Read character properties content
217        let mut char_def_content = String::new();
218        {
219            let mut char_prop_reader = BufReader::new(char_prop_rdr);
220            std::io::Read::read_to_string(&mut char_prop_reader, &mut char_def_content)?;
221        }
222
223        // Build dictionary from readers (need to re-create readers from content strings)
224        use std::io::Cursor;
225        let dict = Self::build_dictionary_from_readers(
226            &lexicon_content,
227            Cursor::new(char_def_content.as_bytes()),
228            Cursor::new(unk_content.as_bytes()),
229        )?;
230
231        Ok(Self {
232            dict,
233            surfaces,
234            features,
235            surface_features,
236            user_lexicon: HashMap::new(), // Initialize empty user lexicon
237            feature_extractor,
238            dictionary_rewriter,
239            cost_factor: 700,              // MeCab default cost-factor
240            metadata: Metadata::default(), // Use default metadata for backward compatibility
241            unk_categories,
242            unk_costs,
243            char_def_content,
244            feature_def_content: feature_content,
245            rewrite_def_content,
246        })
247    }
248
249    /// Get the surfaces extracted from the lexicon
250    pub fn surfaces(&self) -> &[String] {
251        &self.surfaces
252    }
253
254    /// Get the surface features mapping
255    pub fn surface_features(&self) -> &HashMap<String, String> {
256        &self.surface_features
257    }
258
259    /// Get the user lexicon mapping.
260    ///
261    /// This map only feeds [`TrainerConfig::get_features`]; dictionary export
262    /// reads the user entries loaded via `Model::read_user_lexicon` instead
263    /// (#981).
264    ///
265    /// # 戻り値
266    ///
267    /// The surface-to-features map.
268    pub fn user_lexicon(&self) -> &HashMap<String, String> {
269        &self.user_lexicon
270    }
271
272    /// Add user lexicon entry (user dictionary support).
273    ///
274    /// # 引数
275    ///
276    /// * `surface` - Surface form of the entry.
277    /// * `features` - Comma-joined feature string of the entry.
278    #[deprecated(
279        since = "5.3.0",
280        note = "this map only feeds TrainerConfig::get_features; dictionary \
281                export reads user entries loaded via Model::read_user_lexicon"
282    )]
283    pub fn add_user_lexicon_entry(&mut self, surface: String, features: String) {
284        self.user_lexicon.insert(surface, features);
285    }
286
287    /// Get features for a given surface form
288    pub fn get_features(&self, surface: &str) -> Option<String> {
289        // First check user lexicon, then surface features
290        self.user_lexicon
291            .get(surface)
292            .or_else(|| self.surface_features.get(surface))
293            .cloned()
294    }
295
296    /// Load user lexicon from CSV content.
297    ///
298    /// # 引数
299    ///
300    /// * `content` - User lexicon CSV content.
301    ///
302    /// # 戻り値
303    ///
304    /// `Ok(())`; malformed lines are skipped.
305    #[deprecated(
306        since = "5.3.0",
307        note = "this map only feeds TrainerConfig::get_features; dictionary \
308                export reads user entries loaded via Model::read_user_lexicon"
309    )]
310    pub fn load_user_lexicon_from_content(&mut self, content: &str) -> Result<()> {
311        for line in content.lines() {
312            if line.trim().is_empty() || line.starts_with('#') {
313                continue;
314            }
315
316            let parts: Vec<&str> = line.split(',').collect();
317            if parts.len() >= 5 {
318                let surface = parts[0].to_string();
319                // Extract features from columns 4 onwards (skip surface,left_id,right_id,cost)
320                let features = parts[4..].join(",");
321                self.user_lexicon.insert(surface, features);
322            }
323        }
324        Ok(())
325    }
326
327    /// Creates a new trainer configuration from file paths.
328    pub fn from_paths(
329        lexicon_path: &Path,
330        char_prop_path: &Path,
331        unk_handler_path: &Path,
332        feature_templates_path: &Path,
333        rewrite_rules_path: &Path,
334    ) -> Result<Self> {
335        use std::fs::File;
336
337        Self::from_readers(
338            File::open(lexicon_path)?,
339            File::open(char_prop_path)?,
340            File::open(unk_handler_path)?,
341            File::open(feature_templates_path)?,
342            File::open(rewrite_rules_path)?,
343        )
344    }
345
346    /// Get the metadata
347    pub fn metadata(&self) -> &Metadata {
348        &self.metadata
349    }
350
351    /// Builds a dictionary from raw file contents
352    fn build_dictionary_from_readers<R2, R3>(
353        lexicon_content: &str,
354        char_prop_rdr: R2,
355        unk_handler_rdr: R3,
356    ) -> Result<Dictionary>
357    where
358        R2: Read,
359        R3: Read,
360    {
361        // Read character properties
362        let mut char_prop_content = String::new();
363        let mut char_prop_reader = BufReader::new(char_prop_rdr);
364        std::io::Read::read_to_string(&mut char_prop_reader, &mut char_prop_content)?;
365
366        // Read unknown word definitions
367        let mut unk_content = String::new();
368        let mut unk_reader = BufReader::new(unk_handler_rdr);
369        std::io::Read::read_to_string(&mut unk_reader, &mut unk_content)?;
370
371        // Build character definition
372        let char_def = Self::build_char_def_from_content(&char_prop_content)?;
373
374        // Build unknown dictionary
375        let unknown_dict = Self::build_unknown_dict_from_content(&unk_content, &char_def)?;
376
377        // Build prefix dictionary (lexicon)
378        let prefix_dict = Self::build_prefix_dict_from_content(lexicon_content)?;
379
380        // Create minimal connection cost matrix
381        let conn_matrix = Self::create_minimal_connection_matrix()?;
382
383        Ok(Dictionary {
384            prefix_dictionary: Arc::new(prefix_dict),
385            connection_cost_matrix: Arc::new(conn_matrix),
386            character_definition: Arc::new(char_def),
387            unknown_dictionary: Arc::new(unknown_dict),
388            metadata: Arc::new(Metadata::default()),
389        })
390    }
391
392    fn build_char_def_from_content(content: &str) -> Result<CharacterDefinition> {
393        use lindera_dictionary::dictionary::character_definition::{
394            CategoryData, CategoryId, LookupTable,
395        };
396        use std::collections::HashMap;
397
398        let mut category_definitions = Vec::new();
399        let mut category_names = Vec::new();
400        let mut category_map = HashMap::new(); // Name -> Index
401        let mut char_ranges = Vec::new();
402
403        // Always add DEFAULT as category 0
404        category_names.push("DEFAULT".to_string());
405        category_map.insert("DEFAULT".to_string(), 0);
406        category_definitions.push(CategoryData {
407            invoke: false,
408            group: true,
409            length: 0,
410        });
411
412        // Parse the char.def file
413        for line in content.lines() {
414            let line = line.trim();
415
416            // Skip comments and empty lines
417            if line.is_empty() || line.starts_with('#') {
418                continue;
419            }
420
421            // Parse character range mappings (e.g., "0x3041..0x3096 HIRAGANA")
422            if line.starts_with("0x") {
423                let parts: Vec<&str> = line.split_whitespace().collect();
424                if parts.len() >= 2 {
425                    let range_str = parts[0];
426                    let category = parts[1];
427
428                    // Parse range (e.g., "0x3041..0x3096")
429                    if let Some(range_parts) = range_str.split_once("..") {
430                        let start = u32::from_str_radix(&range_parts.0[2..], 16)?;
431                        let end = u32::from_str_radix(&range_parts.1[2..], 16)?;
432
433                        // Get or create category index
434                        let cat_idx =
435                            *category_map.entry(category.to_string()).or_insert_with(|| {
436                                let idx = category_names.len();
437                                category_names.push(category.to_string());
438                                // Default category data - will be overridden if defined
439                                category_definitions.push(CategoryData {
440                                    invoke: true,
441                                    group: true,
442                                    length: 0,
443                                });
444                                idx
445                            });
446
447                        char_ranges.push((start, end, cat_idx));
448                    }
449                }
450            } else {
451                // Parse category definitions (e.g., "HIRAGANA 1 1 0")
452                let parts: Vec<&str> = line.split_whitespace().collect();
453                if parts.len() >= 4 {
454                    let name = parts[0];
455                    let invoke = parts[1] != "0";
456                    let group = parts[2] != "0";
457                    let length = parts[3].parse::<u8>().unwrap_or(0);
458
459                    // Get or create category index
460                    let cat_idx = *category_map.entry(name.to_string()).or_insert_with(|| {
461                        let idx = category_names.len();
462                        category_names.push(name.to_string());
463                        category_definitions.push(CategoryData {
464                            invoke,
465                            group,
466                            length: length.into(),
467                        });
468                        idx
469                    });
470
471                    // Update category definition if it already exists
472                    if cat_idx < category_definitions.len() {
473                        category_definitions[cat_idx] = CategoryData {
474                            invoke,
475                            group,
476                            length: length.into(),
477                        };
478                    }
479                }
480            }
481        }
482
483        // Sort char ranges by start position
484        char_ranges.sort_by_key(|&(start, _, _)| start);
485
486        // Build boundaries and mapping function
487        let mut boundaries = vec![0u32];
488        for &(start, end, _) in &char_ranges {
489            if start > boundaries[boundaries.len() - 1] {
490                boundaries.push(start);
491            }
492            boundaries.push(end + 1);
493        }
494        if boundaries[boundaries.len() - 1] < 0x10FFFF {
495            boundaries.push(0x10FFFF);
496        }
497
498        // Create lookup table with proper category mappings
499        let ranges_clone = char_ranges.clone();
500        let mapping = LookupTable::from_fn(boundaries, &|c, buff| {
501            let code = c;
502
503            // Find which category this character belongs to
504            for &(start, end, cat_idx) in &ranges_clone {
505                if code >= start && code <= end {
506                    buff.push(CategoryId(cat_idx));
507                    return;
508                }
509            }
510
511            // Default to category 0 (DEFAULT)
512            buff.push(CategoryId(0));
513        });
514
515        Ok(CharacterDefinition::new(
516            category_definitions,
517            category_names,
518            mapping,
519        ))
520    }
521
522    fn build_unknown_dict_from_content(
523        _content: &str,
524        _char_def: &CharacterDefinition,
525    ) -> Result<UnknownDictionary> {
526        // Create minimal unknown dictionary for training
527        Ok(UnknownDictionary {
528            category_references: vec![vec![0]; 6], // One for each category
529            costs: vec![],                         // Will be filled during training
530            words_idx_data: vec![],
531            words_data: vec![],
532        })
533    }
534
535    fn build_prefix_dict_from_content(_content: &str) -> Result<PrefixDictionary> {
536        // Create minimal (empty) prefix dictionary structure for training.
537        // In production, this would parse the lexicon CSV format.
538        PrefixDictionary::from_word_entry_map(&std::collections::BTreeMap::new())
539            .map_err(|err| anyhow::anyhow!("failed to build empty prefix dictionary: {err}"))
540    }
541
542    fn create_minimal_connection_matrix() -> Result<ConnectionCostMatrix> {
543        // Create minimal 6x6 connection matrix for the basic categories
544        let matrix_size = 6u16;
545        let mut matrix_data = vec![0u8; 4]; // Header: forward_size(2) + backward_size(2)
546
547        // Write matrix dimensions
548        matrix_data[0..2].copy_from_slice(&matrix_size.to_le_bytes());
549        matrix_data[2..4].copy_from_slice(&matrix_size.to_le_bytes());
550
551        // Add connection costs (all zero for simplicity)
552        let cost_data_size = (matrix_size as usize) * (matrix_size as usize) * 2; // 2 bytes per cost
553        matrix_data.extend(vec![0u8; cost_data_size]);
554
555        Ok(ConnectionCostMatrix::load(matrix_data)?)
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use std::io::Cursor;
563
564    #[test]
565    fn test_ipadic_format_13_columns() {
566        // IPADIC format: 13 columns
567        let seed_csv = "東京,0,0,5000,名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー\n\
568                        行く,1,1,4000,動詞,自立,*,*,五段・カ行促音便,基本形,行く,イク,イク\n";
569        let char_def = "DEFAULT 0 1 0\nHIRAGANA 1 1 0\n0x3042..0x3096 HIRAGANA\n";
570        let unk_def = "DEFAULT,0,0,1500,名詞,一般,*,*,*,*,*,*,*\n";
571        let feature_def = "UNIGRAM:%F[0]\nUNIGRAM:%F[1]\n";
572        let rewrite_def = "*\tUNK\n";
573
574        let config = TrainerConfig::from_readers(
575            Cursor::new(seed_csv),
576            Cursor::new(char_def),
577            Cursor::new(unk_def),
578            Cursor::new(feature_def),
579            Cursor::new(rewrite_def),
580        )
581        .unwrap();
582
583        assert_eq!(config.surfaces().len(), 2);
584        assert!(config.surfaces().contains(&"東京".to_string()));
585        assert!(config.surfaces().contains(&"行く".to_string()));
586
587        // Verify features are correctly extracted (9 fields after surface,left_id,right_id,cost)
588        let tokyo_features = config.surface_features().get("東京").unwrap();
589        assert_eq!(
590            tokyo_features,
591            "名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー"
592        );
593    }
594
595    #[test]
596    fn test_ko_dic_format_8_columns() {
597        // ko-dic format: 8 columns
598        let seed_csv = "한국,0,0,5000,NNG,Korea,F,han-guk\n\
599                        안녕,1,1,4000,NNG,hello,F,an-nyeong\n";
600        let char_def = "DEFAULT 0 1 0\nHANGUL 1 1 0\n0xAC00..0xD7A3 HANGUL\n";
601        let unk_def = "DEFAULT,0,0,1500,NNG,unknown,F,*\n";
602        let feature_def = "UNIGRAM:%F[0]\n";
603        let rewrite_def = "*\tUNK\n";
604
605        let config = TrainerConfig::from_readers(
606            Cursor::new(seed_csv),
607            Cursor::new(char_def),
608            Cursor::new(unk_def),
609            Cursor::new(feature_def),
610            Cursor::new(rewrite_def),
611        )
612        .unwrap();
613
614        assert_eq!(config.surfaces().len(), 2);
615        assert!(config.surfaces().contains(&"한국".to_string()));
616        assert!(config.surfaces().contains(&"안녕".to_string()));
617
618        // Verify features (4 fields after surface,left_id,right_id,cost)
619        let korea_features = config.surface_features().get("한국").unwrap();
620        assert_eq!(korea_features, "NNG,Korea,F,han-guk");
621    }
622
623    #[test]
624    fn test_cc_cedict_format_8_columns() {
625        // CC-CEDICT format: 8 columns
626        let seed_csv = "中国,0,0,5000,n,China,*,zhong1guo2\n\
627                        你好,1,1,4000,x,hello,*,ni3hao3\n";
628        let char_def = "DEFAULT 0 1 0\nHANZI 1 1 0\n0x4E00..0x9FFF HANZI\n";
629        let unk_def = "DEFAULT,0,0,1500,n,unknown,*,*\n";
630        let feature_def = "UNIGRAM:%F[0]\n";
631        let rewrite_def = "*\tUNK\n";
632
633        let config = TrainerConfig::from_readers(
634            Cursor::new(seed_csv),
635            Cursor::new(char_def),
636            Cursor::new(unk_def),
637            Cursor::new(feature_def),
638            Cursor::new(rewrite_def),
639        )
640        .unwrap();
641
642        assert_eq!(config.surfaces().len(), 2);
643        assert!(config.surfaces().contains(&"中国".to_string()));
644        assert!(config.surfaces().contains(&"你好".to_string()));
645
646        // Verify features (4 fields after surface,left_id,right_id,cost)
647        let china_features = config.surface_features().get("中国").unwrap();
648        assert_eq!(china_features, "n,China,*,zhong1guo2");
649    }
650
651    #[test]
652    fn test_unidic_format_21_columns() {
653        // UniDic format: 21 columns (simplified example)
654        let seed_csv = "東京,0,0,5000,名詞,固有名詞,地名,一般,*,*,トウキョウ,東京,東京,東京,東京,東京,トウキョウ,トーキョー,東京,東京,1\n";
655        let char_def = "DEFAULT 0 1 0\nKANJI 0 0 2\n0x4E00..0x9FFF KANJI\n";
656        let unk_def = "DEFAULT,0,0,1500,名詞,普通名詞,一般,*,*,*,*,*,*,*,*,*,*,*,*,*,*\n";
657        let feature_def = "UNIGRAM:%F[0]\nUNIGRAM:%F[1]\n";
658        let rewrite_def = "*\tUNK\n";
659
660        let config = TrainerConfig::from_readers(
661            Cursor::new(seed_csv),
662            Cursor::new(char_def),
663            Cursor::new(unk_def),
664            Cursor::new(feature_def),
665            Cursor::new(rewrite_def),
666        )
667        .unwrap();
668
669        assert_eq!(config.surfaces().len(), 1);
670        assert!(config.surfaces().contains(&"東京".to_string()));
671
672        // Verify features (17 fields after surface,left_id,right_id,cost)
673        let tokyo_features = config.surface_features().get("東京").unwrap();
674        assert_eq!(
675            tokyo_features,
676            "名詞,固有名詞,地名,一般,*,*,トウキョウ,東京,東京,東京,東京,東京,トウキョウ,トーキョー,東京,東京,1"
677        );
678    }
679
680    #[test]
681    fn test_mixed_column_counts() {
682        // Test that we can handle files with varying column counts
683        let seed_csv = "東京,0,0,5000,名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー\n\
684                        한국,1,1,4000,NNG,Korea,F,han-guk\n\
685                        中国,2,2,3000,n,China,*,zhong1guo2\n";
686        let char_def = "DEFAULT 0 1 0\n";
687        let unk_def = "DEFAULT,0,0,1500,*,*,*,*\n";
688        let feature_def = "UNIGRAM:%F[0]\n";
689        let rewrite_def = "*\tUNK\n";
690
691        let config = TrainerConfig::from_readers(
692            Cursor::new(seed_csv),
693            Cursor::new(char_def),
694            Cursor::new(unk_def),
695            Cursor::new(feature_def),
696            Cursor::new(rewrite_def),
697        )
698        .unwrap();
699
700        assert_eq!(config.surfaces().len(), 3);
701
702        // Each row has different number of feature fields, all should be accepted
703        assert_eq!(
704            config.surface_features().get("東京").unwrap(),
705            "名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー"
706        );
707        assert_eq!(
708            config.surface_features().get("한국").unwrap(),
709            "NNG,Korea,F,han-guk"
710        );
711        assert_eq!(
712            config.surface_features().get("中国").unwrap(),
713            "n,China,*,zhong1guo2"
714        );
715    }
716
717    #[test]
718    fn test_trainer_config_creation() {
719        // Test that TrainerConfig can be created with minimal valid data
720        let lexicon_data = "外国,0,0,5000,名詞,一般,*,*,*,*,外国,ガイコク,ガイコク\n人,1,1,5000,名詞,接尾,一般,*,*,*,人,ジン,ジン\n";
721        let char_data = "# char.def placeholder\n";
722        let unk_data = "# unk.def placeholder\n";
723        let feature_data = "UNIGRAM:%F[0]\nLEFT:%L[0]\nRIGHT:%R[0]\n";
724        let rewrite_data = "# rewrite.def placeholder\n";
725
726        let result = TrainerConfig::from_readers(
727            Cursor::new(lexicon_data.as_bytes()),
728            Cursor::new(char_data.as_bytes()),
729            Cursor::new(unk_data.as_bytes()),
730            Cursor::new(feature_data.as_bytes()),
731            Cursor::new(rewrite_data.as_bytes()),
732        );
733
734        // Config creation should now succeed with the fixed implementation
735        assert!(result.is_ok());
736        let config = result.unwrap();
737        // Verify that surfaces were extracted correctly using the getter
738        assert_eq!(config.surfaces().len(), 2);
739        assert!(config.surfaces().contains(&"外国".to_string()));
740        assert!(config.surfaces().contains(&"人".to_string()));
741    }
742
743    #[test]
744    fn test_unk_categories_ipadic() {
745        // Test that unk_categories are correctly extracted for IPADIC format
746        let lexicon_data = "東京,0,0,5000,名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー\n";
747        let char_data = "DEFAULT 0 1 0\nHIRAGANA 1 1 0\n";
748        let unk_data = "DEFAULT,0,0,1500,名詞,一般,*,*,*,*,*,*,*\nHIRAGANA,1,1,2000,名詞,代名詞,一般,*,*,*,*,*,*\n";
749        let feature_data = "UNIGRAM:%F[0]\n";
750        let rewrite_data = "*\tUNK\n";
751
752        let config = TrainerConfig::from_readers(
753            Cursor::new(lexicon_data),
754            Cursor::new(char_data),
755            Cursor::new(unk_data),
756            Cursor::new(feature_data),
757            Cursor::new(rewrite_data),
758        )
759        .unwrap();
760
761        // Verify unk_categories extracted correctly
762        assert_eq!(config.unk_categories.len(), 2);
763        assert_eq!(
764            config.unk_categories.get("DEFAULT").unwrap(),
765            "名詞,一般,*,*,*,*,*,*,*"
766        );
767        assert_eq!(
768            config.unk_categories.get("HIRAGANA").unwrap(),
769            "名詞,代名詞,一般,*,*,*,*,*,*"
770        );
771    }
772
773    #[test]
774    fn test_unk_categories_ko_dic() {
775        // Test that unk_categories work for Korean dictionary format
776        let lexicon_data = "한국,0,0,5000,NNG,Korea,F,han-guk\n";
777        let char_data = "DEFAULT 0 1 0\n";
778        let unk_data = "DEFAULT,0,0,1500,NNG,unknown,F,*\n";
779        let feature_data = "UNIGRAM:%F[0]\n";
780        let rewrite_data = "*\tUNK\n";
781
782        let config = TrainerConfig::from_readers(
783            Cursor::new(lexicon_data),
784            Cursor::new(char_data),
785            Cursor::new(unk_data),
786            Cursor::new(feature_data),
787            Cursor::new(rewrite_data),
788        )
789        .unwrap();
790
791        assert_eq!(config.unk_categories.len(), 1);
792        assert_eq!(
793            config.unk_categories.get("DEFAULT").unwrap(),
794            "NNG,unknown,F,*"
795        );
796    }
797
798    #[test]
799    fn test_unk_categories_cc_cedict() {
800        // Test that unk_categories work for Chinese dictionary format
801        let lexicon_data = "中国,0,0,5000,n,China,*,zhong1guo2\n";
802        let char_data = "DEFAULT 0 1 0\n";
803        let unk_data = "DEFAULT,0,0,1500,n,unknown,*,*\n";
804        let feature_data = "UNIGRAM:%F[0]\n";
805        let rewrite_data = "*\tUNK\n";
806
807        let config = TrainerConfig::from_readers(
808            Cursor::new(lexicon_data),
809            Cursor::new(char_data),
810            Cursor::new(unk_data),
811            Cursor::new(feature_data),
812            Cursor::new(rewrite_data),
813        )
814        .unwrap();
815
816        assert_eq!(config.unk_categories.len(), 1);
817        assert_eq!(
818            config.unk_categories.get("DEFAULT").unwrap(),
819            "n,unknown,*,*"
820        );
821    }
822}