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::{DICTIONARY_FORMAT_VERSION, 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`, stamping the dictionary format version and
186    /// embedding the context-ID permutation when one was applied.
187    ///
188    /// The format version is taken from
189    /// [`DICTIONARY_FORMAT_VERSION`] rather than from the source metadata:
190    /// it describes the artifacts this builder just wrote, so the builder is
191    /// the only thing that can state it truthfully. A source `metadata.json`
192    /// carrying a stale (or invented) version must not be able to mislabel a
193    /// freshly built dictionary.
194    ///
195    /// Persisting the permutation is what lets a user dictionary compiled later be
196    /// relabeled into the same ID space (see
197    /// [`crate::dictionary::UserDictionary::remap_context_ids`]).
198    ///
199    /// # Arguments
200    ///
201    /// * `output_dir` - Directory to write `metadata.json` into.
202    /// * `remap` - The permutation applied by this build, if any.
203    pub fn build_metadata(
204        &self,
205        output_dir: &Path,
206        remap: Option<&Arc<ContextIdMap>>,
207    ) -> LinderaResult<()> {
208        let mut metadata = self.metadata.clone();
209        metadata.format_version = DICTIONARY_FORMAT_VERSION;
210        if let Some(map) = remap {
211            metadata.context_id_map = Some(ContextIdMap::clone(map));
212        }
213        MetadataBuilder::new().build(&metadata, output_dir)
214    }
215
216    pub fn build_character_definition(
217        &self,
218        input_dir: &Path,
219        output_dir: &Path,
220    ) -> LinderaResult<CharacterDefinition> {
221        CharacterDefinitionBuilderOptions::default()
222            .encoding(self.metadata.encoding.clone())
223            .builder()
224            .build(input_dir, output_dir)
225    }
226
227    pub fn build_unknown_dictionary(
228        &self,
229        input_dir: &Path,
230        output_dir: &Path,
231        chardef: &CharacterDefinition,
232        remap: Option<Arc<ContextIdMap>>,
233    ) -> LinderaResult<()> {
234        UnknownDictionaryBuilderOptions::default()
235            .encoding(self.metadata.encoding.clone())
236            .context_id_remap(remap)
237            .builder()
238            .build(input_dir, chardef, output_dir)
239    }
240
241    pub fn build_prefix_dictionary(
242        &self,
243        input_dir: &Path,
244        output_dir: &Path,
245        remap: Option<Arc<ContextIdMap>>,
246    ) -> LinderaResult<()> {
247        PrefixDictionaryBuilderOptions::default()
248            .flexible_csv(self.metadata.flexible_csv)
249            .encoding(self.metadata.encoding.clone())
250            .skip_invalid_cost_or_id(self.metadata.skip_invalid_cost_or_id)
251            .normalize_details(self.metadata.normalize_details)
252            .schema(self.metadata.dictionary_schema.clone())
253            .context_id_remap(remap)
254            .builder()
255            .build(input_dir, output_dir)
256    }
257
258    pub fn build_connection_cost_matrix(
259        &self,
260        input_dir: &Path,
261        output_dir: &Path,
262        remap: Option<Arc<ContextIdMap>>,
263    ) -> LinderaResult<()> {
264        ConnectionCostMatrixBuilderOptions::default()
265            .encoding(self.metadata.encoding.clone())
266            .context_id_remap(remap)
267            .builder()
268            .build(input_dir, output_dir)
269    }
270
271    pub fn build_user_dictionary(
272        &self,
273        input_file: &Path,
274        output_file: &Path,
275    ) -> LinderaResult<()> {
276        let user_dict = self.build_user_dict(input_file)?;
277        build_user_dictionary(user_dict, output_file)
278    }
279
280    pub fn build_user_dict(&self, input_file: &Path) -> LinderaResult<UserDictionary> {
281        let userdic_schema = self.metadata.user_dictionary_schema.clone();
282        let dict_schema = self.metadata.dictionary_schema.clone();
283        let default_field_value = self.metadata.default_field_value.clone();
284
285        UserDictionaryBuilderOptions::default()
286            .user_dictionary_fields_num(self.metadata.user_dictionary_schema.field_count())
287            .dictionary_fields_num(self.metadata.dictionary_schema.field_count())
288            .default_word_cost(self.metadata.default_word_cost)
289            .default_left_context_id(self.metadata.default_left_context_id)
290            .default_right_context_id(self.metadata.default_right_context_id)
291            .flexible_csv(self.metadata.flexible_csv)
292            .user_dictionary_handler(Some(Box::new(move |row: &StringRecord| {
293                // Map user dictionary fields to dictionary schema fields
294                let mut result = Vec::new();
295
296                // Skip the first 4 common fields (surface, left_id, right_id, cost)
297                for field_name in dict_schema.get_custom_fields() {
298                    if let Some(idx) = userdic_schema.get_field_index(field_name) {
299                        // If field exists in user dictionary schema, get value from CSV
300                        if idx < row.len() {
301                            result.push(row[idx].to_string());
302                        } else {
303                            result.push(default_field_value.clone());
304                        }
305                    } else {
306                        // Field not in user dictionary schema, use default value
307                        result.push(default_field_value.clone());
308                    }
309                }
310
311                Ok(result)
312            })))
313            .builder()
314            .build(input_file)
315    }
316}