Skip to main content

lindera_dictionary/
builder.rs

1pub mod character_definition;
2pub mod connection_cost_matrix;
3pub mod context_id_remap;
4pub mod metadata;
5pub mod prefix_dictionary;
6pub mod unknown_dictionary;
7pub mod user_dictionary;
8
9use std::fs;
10use std::path::Path;
11use std::sync::Arc;
12
13use csv::StringRecord;
14
15use self::character_definition::CharacterDefinitionBuilderOptions;
16use self::connection_cost_matrix::ConnectionCostMatrixBuilderOptions;
17use self::context_id_remap::compute_context_id_remap;
18use self::metadata::MetadataBuilder;
19use self::prefix_dictionary::PrefixDictionaryBuilderOptions;
20use self::unknown_dictionary::UnknownDictionaryBuilderOptions;
21use self::user_dictionary::{UserDictionaryBuilderOptions, build_user_dictionary};
22use crate::LinderaResult;
23use crate::dictionary::UserDictionary;
24use crate::dictionary::character_definition::CharacterDefinition;
25use crate::dictionary::context_id_map::ContextIdMap;
26use crate::dictionary::metadata::Metadata;
27use crate::error::LinderaErrorKind;
28
29#[derive(Clone)]
30pub struct DictionaryBuilder {
31    metadata: Metadata,
32    /// Optional path to a bundled context-ID access-frequency histogram, used to
33    /// rank context IDs when `metadata.connection_id_mapping` is enabled. Shipped
34    /// alongside `metadata.json` in the dictionary crate; see
35    /// [`context_id_remap::compute_context_id_remap`] for the precedence rules.
36    context_id_freq: Option<std::path::PathBuf>,
37}
38
39impl DictionaryBuilder {
40    pub fn new(metadata: Metadata) -> Self {
41        Self {
42            metadata,
43            context_id_freq: None,
44        }
45    }
46
47    /// Attach a bundled context-ID frequency histogram used for the connection-cost
48    /// remap ranking.
49    ///
50    /// # Arguments
51    ///
52    /// * `path` - Histogram file (as produced by the `ctxfreq` instrumentation).
53    ///
54    /// # Returns
55    ///
56    /// The builder with the frequency source attached.
57    pub fn with_context_id_freq(mut self, path: impl Into<std::path::PathBuf>) -> Self {
58        self.context_id_freq = Some(path.into());
59        self
60    }
61
62    /// Build all dictionary artifacts from `input_dir` into `output_dir`.
63    ///
64    /// The independent stages run concurrently on non-wasm targets and
65    /// sequentially on wasm (which has no OS threads). The output files are
66    /// identical regardless of the path taken.
67    ///
68    /// # Arguments
69    ///
70    /// * `input_dir` - Directory containing the source dictionary files.
71    /// * `output_dir` - Directory to write the built artifacts into.
72    ///
73    /// # Returns
74    ///
75    /// `Ok(())` on success, or the first stage error in stage order.
76    pub fn build_dictionary(&self, input_dir: &Path, output_dir: &Path) -> LinderaResult<()> {
77        fs::create_dir_all(output_dir)
78            .map_err(|err| LinderaErrorKind::Io.with_error(anyhow::anyhow!(err)))?;
79
80        // Compute the connection-cost context-ID remap ONCE, serially, before the
81        // independent stages start, so the prefix / unknown / matrix stages all apply
82        // the same permutations. `None` (flag off) keeps every stage byte-identical.
83        let remap: Option<Arc<ContextIdMap>> = if self.metadata.connection_id_mapping {
84            Some(Arc::new(compute_context_id_remap(
85                input_dir,
86                &self.metadata,
87                self.context_id_freq.as_deref(),
88            )?))
89        } else {
90            None
91        };
92
93        #[cfg(not(target_family = "wasm"))]
94        {
95            self.build_dictionary_parallel(input_dir, output_dir, remap.as_ref())
96        }
97        #[cfg(target_family = "wasm")]
98        {
99            self.build_dictionary_sequential(input_dir, output_dir, remap.as_ref())
100        }
101    }
102
103    /// Build every stage sequentially.
104    ///
105    /// Used on wasm targets, and as the reference ordering: metadata,
106    /// character definition, unknown dictionary, prefix dictionary, then
107    /// connection cost matrix.
108    ///
109    /// # Arguments
110    ///
111    /// * `input_dir` - Directory containing the source dictionary files.
112    /// * `output_dir` - Directory to write the built artifacts into.
113    #[cfg(target_family = "wasm")]
114    fn build_dictionary_sequential(
115        &self,
116        input_dir: &Path,
117        output_dir: &Path,
118        remap: Option<&Arc<ContextIdMap>>,
119    ) -> LinderaResult<()> {
120        self.build_metadata(output_dir, remap)?;
121        let chardef = self.build_character_definition(input_dir, output_dir)?;
122        self.build_unknown_dictionary(input_dir, output_dir, &chardef, remap.cloned())?;
123        self.build_prefix_dictionary(input_dir, output_dir, remap.cloned())?;
124        self.build_connection_cost_matrix(input_dir, output_dir, remap.cloned())?;
125
126        Ok(())
127    }
128
129    /// Build the four independent stage chains concurrently.
130    ///
131    /// The only data dependency is `character definition -> unknown
132    /// dictionary`; the metadata, prefix dictionary, and connection cost matrix
133    /// stages are independent and write disjoint files, so each chain runs on
134    /// its own scoped thread. All threads are joined before results are
135    /// inspected, and the earliest failure in stage order is returned so the
136    /// result matches the sequential fail-fast order; a panicked stage is
137    /// re-raised rather than swallowed.
138    ///
139    /// Peak memory is higher than the sequential path, since the working sets
140    /// of the concurrent stages (most notably the prefix dictionary and
141    /// connection cost matrix) are held at the same time.
142    ///
143    /// # Arguments
144    ///
145    /// * `input_dir` - Directory containing the source dictionary files.
146    /// * `output_dir` - Directory to write the built artifacts into.
147    #[cfg(not(target_family = "wasm"))]
148    fn build_dictionary_parallel(
149        &self,
150        input_dir: &Path,
151        output_dir: &Path,
152        remap: Option<&Arc<ContextIdMap>>,
153    ) -> LinderaResult<()> {
154        std::thread::scope(|scope| {
155            let metadata = scope.spawn(move || self.build_metadata(output_dir, remap));
156            let unknown = scope.spawn(move || {
157                let chardef = self.build_character_definition(input_dir, output_dir)?;
158                self.build_unknown_dictionary(input_dir, output_dir, &chardef, remap.cloned())
159            });
160            let prefix = scope
161                .spawn(move || self.build_prefix_dictionary(input_dir, output_dir, remap.cloned()));
162            let matrix = scope.spawn(move || {
163                self.build_connection_cost_matrix(input_dir, output_dir, remap.cloned())
164            });
165
166            // Join all stages, then report the earliest failure in stage order.
167            let results = [
168                metadata.join(),
169                unknown.join(),
170                prefix.join(),
171                matrix.join(),
172            ];
173            for result in results {
174                match result {
175                    Ok(Ok(())) => {}
176                    Ok(Err(err)) => return Err(err),
177                    Err(panic) => std::panic::resume_unwind(panic),
178                }
179            }
180
181            Ok(())
182        })
183    }
184
185    /// Write `metadata.json`, embedding the context-ID permutation when one was applied.
186    ///
187    /// Persisting the permutation is what lets a user dictionary compiled later be
188    /// relabeled into the same ID space (see
189    /// [`crate::dictionary::UserDictionary::remap_context_ids`]). With no remap the
190    /// metadata is written unchanged, so the artifact stays byte-identical.
191    ///
192    /// # Arguments
193    ///
194    /// * `output_dir` - Directory to write `metadata.json` into.
195    /// * `remap` - The permutation applied by this build, if any.
196    pub fn build_metadata(
197        &self,
198        output_dir: &Path,
199        remap: Option<&Arc<ContextIdMap>>,
200    ) -> LinderaResult<()> {
201        match remap {
202            Some(map) => {
203                let mut metadata = self.metadata.clone();
204                metadata.context_id_map = Some(ContextIdMap::clone(map));
205                MetadataBuilder::new().build(&metadata, output_dir)
206            }
207            None => MetadataBuilder::new().build(&self.metadata, output_dir),
208        }
209    }
210
211    pub fn build_character_definition(
212        &self,
213        input_dir: &Path,
214        output_dir: &Path,
215    ) -> LinderaResult<CharacterDefinition> {
216        CharacterDefinitionBuilderOptions::default()
217            .encoding(self.metadata.encoding.clone())
218            .builder()
219            .build(input_dir, output_dir)
220    }
221
222    pub fn build_unknown_dictionary(
223        &self,
224        input_dir: &Path,
225        output_dir: &Path,
226        chardef: &CharacterDefinition,
227        remap: Option<Arc<ContextIdMap>>,
228    ) -> LinderaResult<()> {
229        UnknownDictionaryBuilderOptions::default()
230            .encoding(self.metadata.encoding.clone())
231            .context_id_remap(remap)
232            .builder()
233            .build(input_dir, chardef, output_dir)
234    }
235
236    pub fn build_prefix_dictionary(
237        &self,
238        input_dir: &Path,
239        output_dir: &Path,
240        remap: Option<Arc<ContextIdMap>>,
241    ) -> LinderaResult<()> {
242        PrefixDictionaryBuilderOptions::default()
243            .flexible_csv(self.metadata.flexible_csv)
244            .encoding(self.metadata.encoding.clone())
245            .skip_invalid_cost_or_id(self.metadata.skip_invalid_cost_or_id)
246            .normalize_details(self.metadata.normalize_details)
247            .schema(self.metadata.dictionary_schema.clone())
248            .context_id_remap(remap)
249            .builder()
250            .build(input_dir, output_dir)
251    }
252
253    pub fn build_connection_cost_matrix(
254        &self,
255        input_dir: &Path,
256        output_dir: &Path,
257        remap: Option<Arc<ContextIdMap>>,
258    ) -> LinderaResult<()> {
259        ConnectionCostMatrixBuilderOptions::default()
260            .encoding(self.metadata.encoding.clone())
261            .context_id_remap(remap)
262            .builder()
263            .build(input_dir, output_dir)
264    }
265
266    pub fn build_user_dictionary(
267        &self,
268        input_file: &Path,
269        output_file: &Path,
270    ) -> LinderaResult<()> {
271        let user_dict = self.build_user_dict(input_file)?;
272        build_user_dictionary(user_dict, output_file)
273    }
274
275    pub fn build_user_dict(&self, input_file: &Path) -> LinderaResult<UserDictionary> {
276        let userdic_schema = self.metadata.user_dictionary_schema.clone();
277        let dict_schema = self.metadata.dictionary_schema.clone();
278        let default_field_value = self.metadata.default_field_value.clone();
279
280        UserDictionaryBuilderOptions::default()
281            .user_dictionary_fields_num(self.metadata.user_dictionary_schema.field_count())
282            .dictionary_fields_num(self.metadata.dictionary_schema.field_count())
283            .default_word_cost(self.metadata.default_word_cost)
284            .default_left_context_id(self.metadata.default_left_context_id)
285            .default_right_context_id(self.metadata.default_right_context_id)
286            .flexible_csv(self.metadata.flexible_csv)
287            .user_dictionary_handler(Some(Box::new(move |row: &StringRecord| {
288                // Map user dictionary fields to dictionary schema fields
289                let mut result = Vec::new();
290
291                // Skip the first 4 common fields (surface, left_id, right_id, cost)
292                for field_name in dict_schema.get_custom_fields() {
293                    if let Some(idx) = userdic_schema.get_field_index(field_name) {
294                        // If field exists in user dictionary schema, get value from CSV
295                        if idx < row.len() {
296                            result.push(row[idx].to_string());
297                        } else {
298                            result.push(default_field_value.clone());
299                        }
300                    } else {
301                        // Field not in user dictionary schema, use default value
302                        result.push(default_field_value.clone());
303                    }
304                }
305
306                Ok(result)
307            })))
308            .builder()
309            .build(input_file)
310    }
311}