1use std::collections::BTreeMap;
2use std::fs;
3use std::io::{BufRead, BufReader};
4use std::path::Path;
5
6use gaze_types::PiiClass;
7use serde::Deserialize;
8use sha2::{Digest, Sha256};
9
10use super::decode::split_bio;
11use super::detector::NerDetector;
12use super::error::NerLoadError;
13use super::types::{
14 LabelMap, NerBackendKind, VerifiedArtifacts, CHECKSUMS_FILE, CONFIG_FILE, LABELS_FILE,
15 MODEL_FILE, TOKENIZER_FILE,
16};
17
18impl NerDetector {
19 pub fn verify_artifacts(model_dir: &Path) -> Result<VerifiedArtifacts, NerLoadError> {
22 if !model_dir.is_dir() {
23 return Err(NerLoadError::ModelDirMissing {
24 path: model_dir.to_path_buf(),
25 });
26 }
27
28 let sums_path = model_dir.join(CHECKSUMS_FILE);
29 if !sums_path.exists() {
30 return Err(NerLoadError::ChecksumsMissing { path: sums_path });
31 }
32
33 let entries = parse_checksums(&sums_path)?;
34 for required in [MODEL_FILE, TOKENIZER_FILE, CONFIG_FILE, LABELS_FILE] {
35 if !entries.iter().any(|(name, _)| name == required) {
36 return Err(NerLoadError::MissingArtifact {
37 path: model_dir.join(required),
38 });
39 }
40 }
41
42 for (rel, expected) in &entries {
43 let path = model_dir.join(rel);
44 if !path.exists() {
45 return Err(NerLoadError::MissingArtifact { path });
46 }
47 let got = hash_file(&path)?;
48 if !got.eq_ignore_ascii_case(expected) {
49 return Err(NerLoadError::ChecksumMismatch {
50 path,
51 expected: expected.clone(),
52 got,
53 });
54 }
55 }
56
57 let labels = parse_labels(&model_dir.join(LABELS_FILE))?;
58 let config = parse_config(&model_dir.join(CONFIG_FILE))?;
59 let backend_kind = NerBackendKind::parse(config.backend.as_deref())?;
60 let recognizer_model_id = config
61 .recognizer_model_id()
62 .map(sanitize_recognizer_segment)
63 .filter(|value| !value.is_empty())
64 .unwrap_or_else(|| "unknown".to_string());
65 let recognizer_model_version = config
66 .recognizer_model_version()
67 .map(sanitize_version_segment)
68 .filter(|value| !value.is_empty())
69 .unwrap_or_else(|| "v0".to_string());
70 let id2label = config_to_id2label(config.id2label)?;
71
72 Ok(VerifiedArtifacts {
73 model_dir: model_dir.to_path_buf(),
74 backend_kind,
75 recognizer_model_id,
76 recognizer_model_version,
77 labels,
78 id2label,
79 })
80 }
81}
82
83pub(crate) fn warn_on_label_vocab_mismatch(
84 labels: &LabelMap,
85 id2label: &[String],
86 model_dir: &Path,
87) {
88 let mut usable = 0usize;
89 for tag in id2label {
90 let (_, entity) = split_bio(tag);
91 if entity.is_empty() {
92 continue;
93 }
94 if labels.resolve(tag, entity).is_some() {
95 usable += 1;
96 }
97 }
98 if usable == 0 {
99 let sample_label: String = labels.keys().take(5).collect::<Vec<_>>().join(",");
100 let sample_id: String = id2label
101 .iter()
102 .take(5)
103 .cloned()
104 .collect::<Vec<_>>()
105 .join(",");
106 tracing::warn!(
107 model_dir = %model_dir.display(),
108 label_keys = %sample_label,
109 id2label_sample = %sample_id,
110 "ner: labels.json has zero overlap with model id2label — detector will emit zero detections. Expected keys like 'PER'/'LOC' or 'B-PER'/'I-PER'."
111 );
112 }
113}
114
115fn parse_checksums(path: &Path) -> Result<Vec<(String, String)>, NerLoadError> {
116 let file = fs::File::open(path).map_err(|source| NerLoadError::Io {
117 path: path.to_path_buf(),
118 source,
119 })?;
120 let reader = BufReader::new(file);
121 let mut entries = Vec::new();
122 for (idx, line) in reader.lines().enumerate() {
123 let line_no = idx + 1;
124 let line = line.map_err(|source| NerLoadError::Io {
125 path: path.to_path_buf(),
126 source,
127 })?;
128 let trimmed = line.trim();
129 if trimmed.is_empty() || trimmed.starts_with('#') {
130 continue;
131 }
132 let mut parts = trimmed.splitn(2, char::is_whitespace);
133 let Some(hash) = parts.next() else {
134 return Err(NerLoadError::ChecksumsMalformed {
135 line: line_no,
136 reason: "missing hash".into(),
137 });
138 };
139 let Some(rest) = parts.next() else {
140 return Err(NerLoadError::ChecksumsMalformed {
141 line: line_no,
142 reason: "missing path".into(),
143 });
144 };
145 if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
146 return Err(NerLoadError::ChecksumsMalformed {
147 line: line_no,
148 reason: format!("invalid sha256 hex: {hash}"),
149 });
150 }
151 let file = rest.trim_start().trim_start_matches('*').trim().to_string();
152 if file.is_empty() {
153 return Err(NerLoadError::ChecksumsMalformed {
154 line: line_no,
155 reason: "empty path".into(),
156 });
157 }
158 entries.push((file, hash.to_ascii_lowercase()));
159 }
160 Ok(entries)
161}
162
163fn hash_file(path: &Path) -> Result<String, NerLoadError> {
164 let bytes = fs::read(path).map_err(|source| NerLoadError::Io {
165 path: path.to_path_buf(),
166 source,
167 })?;
168 let mut hasher = Sha256::new();
169 hasher.update(&bytes);
170 Ok(hex::encode(hasher.finalize()))
171}
172
173fn parse_labels(path: &Path) -> Result<LabelMap, NerLoadError> {
174 let bytes = fs::read(path).map_err(|source| NerLoadError::Io {
175 path: path.to_path_buf(),
176 source,
177 })?;
178 let raw: BTreeMap<String, String> =
179 serde_json::from_slice(&bytes).map_err(|err| NerLoadError::LabelsParse(err.to_string()))?;
180 let mut map = BTreeMap::new();
181 for (key, value) in raw {
182 let class = match value.to_ascii_lowercase().as_str() {
183 "name" | "per" | "person" => PiiClass::Name,
184 "location" | "loc" => PiiClass::Location,
185 "organization" | "org" => PiiClass::Organization,
186 "email" => PiiClass::Email,
187 "drop" | "ignore" | "" => continue,
188 other => PiiClass::custom(other),
189 };
190 map.insert(key, class);
191 }
192 if map.is_empty() {
193 return Err(NerLoadError::LabelsParse(
194 "labels.json produced no usable mappings".into(),
195 ));
196 }
197 Ok(LabelMap(map))
198}
199
200#[derive(Deserialize)]
201struct ConfigFile {
202 backend: Option<String>,
203 model_id: Option<String>,
204 model_name: Option<String>,
205 model: Option<String>,
206 version: Option<String>,
207 model_version: Option<String>,
208 recognizer_version: Option<String>,
209 id2label: Option<BTreeMap<String, String>>,
210}
211
212impl ConfigFile {
213 fn recognizer_model_id(&self) -> Option<&str> {
214 self.model_id
215 .as_deref()
216 .or(self.model_name.as_deref())
217 .or(self.model.as_deref())
218 }
219
220 fn recognizer_model_version(&self) -> Option<&str> {
221 self.recognizer_version
222 .as_deref()
223 .or(self.model_version.as_deref())
224 .or(self.version.as_deref())
225 }
226}
227
228fn parse_config(path: &Path) -> Result<ConfigFile, NerLoadError> {
229 let bytes = fs::read(path).map_err(|source| NerLoadError::Io {
230 path: path.to_path_buf(),
231 source,
232 })?;
233 serde_json::from_slice(&bytes).map_err(|err| NerLoadError::ConfigParse(err.to_string()))
234}
235
236fn config_to_id2label(
237 id2label: Option<BTreeMap<String, String>>,
238) -> Result<Vec<String>, NerLoadError> {
239 let map = id2label
240 .ok_or_else(|| NerLoadError::ConfigParse("config.json missing id2label".to_string()))?;
241 let mut pairs: Vec<(usize, String)> = map
242 .into_iter()
243 .map(|(key, value)| {
244 key.parse::<usize>()
245 .map(|index| (index, value))
246 .map_err(|err| NerLoadError::ConfigParse(format!("id2label key {key}: {err}")))
247 })
248 .collect::<Result<_, _>>()?;
249 pairs.sort_by_key(|(index, _)| *index);
250 let max_idx = pairs.last().map(|(index, _)| *index).unwrap_or(0);
251 let mut out = vec!["O".to_string(); max_idx + 1];
252 for (index, label) in pairs {
253 out[index] = label;
254 }
255 Ok(out)
256}
257
258fn sanitize_recognizer_segment(raw: &str) -> String {
259 raw.trim()
260 .to_ascii_lowercase()
261 .chars()
262 .map(|ch| match ch {
263 'a'..='z' | '0'..='9' => ch,
264 _ => '-',
265 })
266 .collect::<String>()
267 .split('-')
268 .filter(|part| !part.is_empty())
269 .collect::<Vec<_>>()
270 .join("-")
271}
272
273fn sanitize_version_segment(raw: &str) -> String {
274 let sanitized = sanitize_recognizer_segment(raw);
275 if sanitized.is_empty() || sanitized.starts_with('v') {
276 sanitized
277 } else {
278 format!("v{sanitized}")
279 }
280}