1use std::collections::BTreeSet;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum IdentifierClass {
48 Url,
50 Amount,
52 OpaqueId,
54 ProperNoun,
56 Quoted,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
62pub struct Identifier {
63 pub class: IdentifierClass,
65 pub text: String,
67}
68
69pub const UNIT_WORDS: &[&str] = &[
76 "kg", "g", "t", "km", "m", "cm", "mm", "mi", "lb", "oz", "ms", "s", "min", "h", "GB", "MB",
77 "TB", "KiB", "MiB", "kWh", "EUR", "USD", "GBP", "PLN",
78];
79
80const LEADING_STOPWORDS: &[&str] = &[
87 "The", "An", "And", "But", "Or", "So", "If", "When", "While", "Then", "We", "It", "He", "She",
88 "They", "You", "On", "In", "At", "To", "For", "From", "By", "With", "As", "Is", "Are", "Was",
89 "Were", "Our", "My", "Your", "Their", "His", "Her", "No", "Not", "Yes", "Still", "Also",
90 "Both", "Each", "This", "That", "These", "Those",
91];
92
93#[must_use]
108pub fn extract_identifiers(text: &str) -> Vec<Identifier> {
109 let mut out: BTreeSet<Identifier> = BTreeSet::new();
110 let neutralized = text.replace(['"', '{', '}', '[', ']'], " ");
111 extract_urls(&neutralized, &mut out);
112 extract_quoted(text, &mut out);
113 extract_token_classes(&neutralized, &mut out);
114 extract_proper_nouns(&neutralized, &mut out);
115 out.into_iter().collect()
116}
117
118#[must_use]
126pub fn missing_identifiers(prior: &str, candidate: &str) -> Vec<Identifier> {
127 extract_identifiers(prior)
128 .into_iter()
129 .filter(|id| !is_retained(candidate, &id.text))
130 .collect()
131}
132
133#[must_use]
136pub fn is_retained(candidate: &str, identifier: &str) -> bool {
137 candidate.contains(identifier)
138}
139
140fn extract_urls(text: &str, out: &mut BTreeSet<Identifier>) {
143 for scheme in ["https://", "http://"] {
144 let mut rest = text;
145 while let Some(pos) = rest.find(scheme) {
146 let tail = &rest[pos..];
147 let end = tail.find(char::is_whitespace).unwrap_or(tail.len());
148 let url = tail[..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')', '"', '\'']);
149 if url.len() > scheme.len() {
150 out.insert(Identifier {
151 class: IdentifierClass::Url,
152 text: url.to_owned(),
153 });
154 }
155 rest = &tail[end.min(tail.len())..];
156 }
157 }
158}
159
160fn extract_quoted(text: &str, out: &mut BTreeSet<Identifier>) {
164 let segments: Vec<&str> = text.split('"').collect();
165 for (i, content) in segments.iter().enumerate().skip(1).step_by(2) {
166 if i + 1 < segments.len()
167 && (3..=120).contains(&content.len())
168 && !content.contains('\n')
169 && content.chars().any(|c| c.is_ascii_alphabetic())
170 {
171 out.insert(Identifier {
172 class: IdentifierClass::Quoted,
173 text: (*content).to_owned(),
174 });
175 }
176 }
177}
178
179fn trim_token(token: &str) -> &str {
183 let start = token
184 .char_indices()
185 .find(|(_, c)| c.is_ascii_alphanumeric() || matches!(c, '$' | '€' | '£'))
186 .map(|(i, _)| i);
187 let Some(start) = start else { return "" };
188 let end = token
189 .char_indices()
190 .rev()
191 .find(|(_, c)| c.is_ascii_alphanumeric() || *c == '%')
192 .map(|(i, c)| i + c.len_utf8());
193 let Some(end) = end else { return "" };
194 if end <= start { "" } else { &token[start..end] }
195}
196
197fn is_numeral(s: &str) -> bool {
200 if s.is_empty()
201 || !s
202 .chars()
203 .all(|c| c.is_ascii_digit() || c == ',' || c == '.')
204 {
205 return false;
206 }
207 let mut chars = s.chars().peekable();
208 if !chars.peek().is_some_and(char::is_ascii_digit) {
209 return false;
210 }
211 let mut prev_sep = false;
212 let mut seen_dot = false;
213 for c in s.chars() {
214 match c {
215 ',' | '.' => {
216 if prev_sep || (c == ',' && seen_dot) {
217 return false;
218 }
219 if c == '.' {
220 if seen_dot {
221 return false;
222 }
223 seen_dot = true;
224 }
225 prev_sep = true;
226 }
227 _ => prev_sep = false,
228 }
229 }
230 !prev_sep
231}
232
233fn extract_token_classes(text: &str, out: &mut BTreeSet<Identifier>) {
236 let tokens: Vec<&str> = text.split_whitespace().collect();
237 for (i, raw) in tokens.iter().enumerate() {
238 let tok = trim_token(raw);
239 if tok.is_empty() {
240 continue;
241 }
242 if let Some(rest) = tok
244 .strip_prefix('$')
245 .or_else(|| tok.strip_prefix('€'))
246 .or_else(|| tok.strip_prefix('£'))
247 {
248 if is_numeral(rest) {
249 out.insert(Identifier {
250 class: IdentifierClass::Amount,
251 text: tok.to_owned(),
252 });
253 }
254 continue;
255 }
256 if let Some(rest) = tok.strip_suffix('%') {
258 if is_numeral(rest) {
259 out.insert(Identifier {
260 class: IdentifierClass::Amount,
261 text: tok.to_owned(),
262 });
263 }
264 continue;
265 }
266 if is_numeral(tok) {
267 let unit = tokens.get(i + 1).map(|t| trim_token(t));
269 if let Some(unit) = unit.filter(|u| UNIT_WORDS.contains(u)) {
270 out.insert(Identifier {
271 class: IdentifierClass::Amount,
272 text: format!("{tok} {unit}"),
273 });
274 continue;
275 }
276 if tok.chars().filter(char::is_ascii_digit).count() >= 4 {
278 out.insert(Identifier {
279 class: IdentifierClass::Amount,
280 text: tok.to_owned(),
281 });
282 }
283 continue;
284 }
285 if tok.len() >= 5
287 && tok
288 .chars()
289 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
290 && tok.chars().any(|c| c.is_ascii_alphabetic())
291 && tok.chars().any(|c| c.is_ascii_digit())
292 {
293 out.insert(Identifier {
294 class: IdentifierClass::OpaqueId,
295 text: tok.to_owned(),
296 });
297 }
298 }
299}
300
301fn is_capitalized_word(s: &str) -> bool {
303 let mut chars = s.chars();
304 chars.next().is_some_and(|c| c.is_ascii_uppercase())
305 && s.len() >= 2
306 && chars.all(|c| c.is_ascii_lowercase())
307}
308
309fn extract_proper_nouns(text: &str, out: &mut BTreeSet<Identifier>) {
313 let raw_tokens: Vec<&str> = text.split_whitespace().collect();
314 let mut run: Vec<&str> = Vec::new();
315 let mut flush = |run: &mut Vec<&str>| {
316 let mut slice = run.as_slice();
317 while let Some((head, rest)) = slice.split_first() {
318 if LEADING_STOPWORDS.contains(head) {
319 slice = rest;
320 } else {
321 break;
322 }
323 }
324 if slice.len() >= 2 {
325 out.insert(Identifier {
326 class: IdentifierClass::ProperNoun,
327 text: slice.join(" "),
328 });
329 }
330 run.clear();
331 };
332 for raw in raw_tokens {
333 let tok = trim_token(raw);
334 if is_capitalized_word(tok) {
335 run.push(tok);
336 if raw.ends_with(['.', ',', ';', ':', '!', '?', ')', '"', '\'']) {
339 flush(&mut run);
340 }
341 } else {
342 flush(&mut run);
343 }
344 }
345 flush(&mut run);
346}
347
348#[cfg(test)]
349mod tests {
350 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
351
352 use super::*;
353
354 fn texts(ids: &[Identifier]) -> Vec<&str> {
355 ids.iter().map(|i| i.text.as_str()).collect()
356 }
357
358 fn class_of(ids: &[Identifier], text: &str) -> Option<IdentifierClass> {
359 ids.iter().find(|i| i.text == text).map(|i| i.class)
360 }
361
362 #[test]
363 fn extracts_every_class_from_mixed_prose() {
364 let text = "Our broker is Mirela Okafor; she filed entry ZK-4471-BQ for order ord_93k2f7x; \
365 duty came to $12,845.03 plus a 512.4 kg pallet. Manifest at \
366 https://port.example/manifests/BX-201. Open item: \"night berthing at dock 7\".";
367 let ids = extract_identifiers(text);
368 assert_eq!(
369 class_of(&ids, "Mirela Okafor"),
370 Some(IdentifierClass::ProperNoun)
371 );
372 assert_eq!(
373 class_of(&ids, "ZK-4471-BQ"),
374 Some(IdentifierClass::OpaqueId)
375 );
376 assert_eq!(
377 class_of(&ids, "ord_93k2f7x"),
378 Some(IdentifierClass::OpaqueId)
379 );
380 assert_eq!(class_of(&ids, "$12,845.03"), Some(IdentifierClass::Amount));
381 assert_eq!(class_of(&ids, "512.4 kg"), Some(IdentifierClass::Amount));
382 assert_eq!(
383 class_of(&ids, "https://port.example/manifests/BX-201"),
384 Some(IdentifierClass::Url)
385 );
386 assert_eq!(
387 class_of(&ids, "night berthing at dock 7"),
388 Some(IdentifierClass::Quoted)
389 );
390 }
391
392 #[test]
393 fn currency_and_percent_and_bare_numerals() {
394 let ids = extract_identifiers("€2,190 due; retries at 85%; PIN 88417 set; row 212 done");
395 assert_eq!(class_of(&ids, "€2,190"), Some(IdentifierClass::Amount));
396 assert_eq!(class_of(&ids, "85%"), Some(IdentifierClass::Amount));
397 assert_eq!(class_of(&ids, "88417"), Some(IdentifierClass::Amount));
398 assert!(!texts(&ids).contains(&"212"));
401 }
402
403 #[test]
404 fn dates_and_plain_words_are_not_opaque_ids() {
405 let ids = extract_identifiers("shipped 2026-07-16 with care by the harbor team");
406 assert!(
407 ids.is_empty(),
408 "no letters+digits token, no ≥2-cap run, nothing quoted: {ids:?}"
409 );
410 }
411
412 #[test]
413 fn leading_stopword_is_stripped_from_proper_noun_runs() {
414 let ids = extract_identifiers("The Fenwick Boathouse holds the booking.");
415 assert_eq!(
416 texts(&ids),
417 vec!["Fenwick Boathouse"],
418 "stopword stripped, run kept"
419 );
420 let ids = extract_identifiers("The Boathouse holds the booking.");
423 assert!(ids.is_empty(), "one non-stopword capitalized word is prose");
424 }
425
426 #[test]
427 fn sentence_boundary_closes_a_proper_noun_run() {
428 let ids = extract_identifiers("the coordinator is Tomas Ilves. Route it through him.");
429 assert_eq!(
430 texts(&ids),
431 vec!["Tomas Ilves"],
432 "trailing punctuation ends the run; the next sentence's opener is prose"
433 );
434 }
435
436 #[test]
437 fn quoted_spans_bound_length_and_need_a_letter() {
438 let ids = extract_identifiers(r#"tagged "parking for the string quartet" and "12" and """#);
439 assert_eq!(texts(&ids), vec!["parking for the string quartet"]);
440 }
441
442 #[test]
443 fn urls_trim_trailing_prose_punctuation() {
444 let ids = extract_identifiers("see https://tracker.example/c/7781, then reply");
445 assert!(texts(&ids).contains(&"https://tracker.example/c/7781"));
446 }
447
448 #[test]
449 fn uuids_extract_as_opaque_ids() {
450 let ids = extract_identifiers("container 7f3d9a12-58c4-4de1-9b02-aa1c40f6d2e9 pinged");
451 assert_eq!(
452 class_of(&ids, "7f3d9a12-58c4-4de1-9b02-aa1c40f6d2e9"),
453 Some(IdentifierClass::OpaqueId)
454 );
455 }
456
457 #[test]
458 fn json_embedded_identifiers_extract_like_prose() {
459 let ids = extract_identifiers(r#"{"node":"node_j4x9q2","cost":"$7,412.88"}"#);
463 assert_eq!(
464 class_of(&ids, "node_j4x9q2"),
465 Some(IdentifierClass::OpaqueId)
466 );
467 assert_eq!(class_of(&ids, "$7,412.88"), Some(IdentifierClass::Amount));
468 }
469
470 #[test]
471 fn missing_identifiers_flags_dropped_and_passes_retained() {
472 let prior = "Entry ZK-4471-BQ cleared for Mirela Okafor at $12,845.03.";
473 let keeps = "Customs entry ZK-4471-BQ (broker Mirela Okafor) settled: $12,845.03.";
474 assert!(missing_identifiers(prior, keeps).is_empty());
475 let drops = "Customs entry cleared for the broker; duty settled.";
476 let missing = missing_identifiers(prior, drops);
477 let missing_texts = texts(&missing);
478 assert!(missing_texts.contains(&"ZK-4471-BQ"));
479 assert!(missing_texts.contains(&"Mirela Okafor"));
480 assert!(missing_texts.contains(&"$12,845.03"));
481 }
482
483 #[test]
484 fn survival_is_verbatim_not_paraphrase() {
485 assert!(is_retained("broker Mirela Okafor signed", "Mirela Okafor"));
486 assert!(!is_retained(
487 "broker Okafor, Mirela signed",
488 "Mirela Okafor"
489 ));
490 }
491}