use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum IdentifierClass {
Url,
Amount,
OpaqueId,
ProperNoun,
Quoted,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Identifier {
pub class: IdentifierClass,
pub text: String,
}
pub const UNIT_WORDS: &[&str] = &[
"kg", "g", "t", "km", "m", "cm", "mm", "mi", "lb", "oz", "ms", "s", "min", "h", "GB", "MB",
"TB", "KiB", "MiB", "kWh", "EUR", "USD", "GBP", "PLN",
];
const LEADING_STOPWORDS: &[&str] = &[
"The", "An", "And", "But", "Or", "So", "If", "When", "While", "Then", "We", "It", "He", "She",
"They", "You", "On", "In", "At", "To", "For", "From", "By", "With", "As", "Is", "Are", "Was",
"Were", "Our", "My", "Your", "Their", "His", "Her", "No", "Not", "Yes", "Still", "Also",
"Both", "Each", "This", "That", "These", "Those",
];
#[must_use]
pub fn extract_identifiers(text: &str) -> Vec<Identifier> {
let mut out: BTreeSet<Identifier> = BTreeSet::new();
let neutralized = text.replace(['"', '{', '}', '[', ']'], " ");
extract_urls(&neutralized, &mut out);
extract_quoted(text, &mut out);
extract_token_classes(&neutralized, &mut out);
extract_proper_nouns(&neutralized, &mut out);
out.into_iter().collect()
}
#[must_use]
pub fn missing_identifiers(prior: &str, candidate: &str) -> Vec<Identifier> {
extract_identifiers(prior)
.into_iter()
.filter(|id| !is_retained(candidate, &id.text))
.collect()
}
#[must_use]
pub fn is_retained(candidate: &str, identifier: &str) -> bool {
candidate.contains(identifier)
}
fn extract_urls(text: &str, out: &mut BTreeSet<Identifier>) {
for scheme in ["https://", "http://"] {
let mut rest = text;
while let Some(pos) = rest.find(scheme) {
let tail = &rest[pos..];
let end = tail.find(char::is_whitespace).unwrap_or(tail.len());
let url = tail[..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')', '"', '\'']);
if url.len() > scheme.len() {
out.insert(Identifier {
class: IdentifierClass::Url,
text: url.to_owned(),
});
}
rest = &tail[end.min(tail.len())..];
}
}
}
fn extract_quoted(text: &str, out: &mut BTreeSet<Identifier>) {
let segments: Vec<&str> = text.split('"').collect();
for (i, content) in segments.iter().enumerate().skip(1).step_by(2) {
if i + 1 < segments.len()
&& (3..=120).contains(&content.len())
&& !content.contains('\n')
&& content.chars().any(|c| c.is_ascii_alphabetic())
{
out.insert(Identifier {
class: IdentifierClass::Quoted,
text: (*content).to_owned(),
});
}
}
}
fn trim_token(token: &str) -> &str {
let start = token
.char_indices()
.find(|(_, c)| c.is_ascii_alphanumeric() || matches!(c, '$' | '€' | '£'))
.map(|(i, _)| i);
let Some(start) = start else { return "" };
let end = token
.char_indices()
.rev()
.find(|(_, c)| c.is_ascii_alphanumeric() || *c == '%')
.map(|(i, c)| i + c.len_utf8());
let Some(end) = end else { return "" };
if end <= start { "" } else { &token[start..end] }
}
fn is_numeral(s: &str) -> bool {
if s.is_empty()
|| !s
.chars()
.all(|c| c.is_ascii_digit() || c == ',' || c == '.')
{
return false;
}
let mut chars = s.chars().peekable();
if !chars.peek().is_some_and(char::is_ascii_digit) {
return false;
}
let mut prev_sep = false;
let mut seen_dot = false;
for c in s.chars() {
match c {
',' | '.' => {
if prev_sep || (c == ',' && seen_dot) {
return false;
}
if c == '.' {
if seen_dot {
return false;
}
seen_dot = true;
}
prev_sep = true;
}
_ => prev_sep = false,
}
}
!prev_sep
}
fn extract_token_classes(text: &str, out: &mut BTreeSet<Identifier>) {
let tokens: Vec<&str> = text.split_whitespace().collect();
for (i, raw) in tokens.iter().enumerate() {
let tok = trim_token(raw);
if tok.is_empty() {
continue;
}
if let Some(rest) = tok
.strip_prefix('$')
.or_else(|| tok.strip_prefix('€'))
.or_else(|| tok.strip_prefix('£'))
{
if is_numeral(rest) {
out.insert(Identifier {
class: IdentifierClass::Amount,
text: tok.to_owned(),
});
}
continue;
}
if let Some(rest) = tok.strip_suffix('%') {
if is_numeral(rest) {
out.insert(Identifier {
class: IdentifierClass::Amount,
text: tok.to_owned(),
});
}
continue;
}
if is_numeral(tok) {
let unit = tokens.get(i + 1).map(|t| trim_token(t));
if let Some(unit) = unit.filter(|u| UNIT_WORDS.contains(u)) {
out.insert(Identifier {
class: IdentifierClass::Amount,
text: format!("{tok} {unit}"),
});
continue;
}
if tok.chars().filter(char::is_ascii_digit).count() >= 4 {
out.insert(Identifier {
class: IdentifierClass::Amount,
text: tok.to_owned(),
});
}
continue;
}
if tok.len() >= 5
&& tok
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
&& tok.chars().any(|c| c.is_ascii_alphabetic())
&& tok.chars().any(|c| c.is_ascii_digit())
{
out.insert(Identifier {
class: IdentifierClass::OpaqueId,
text: tok.to_owned(),
});
}
}
}
fn is_capitalized_word(s: &str) -> bool {
let mut chars = s.chars();
chars.next().is_some_and(|c| c.is_ascii_uppercase())
&& s.len() >= 2
&& chars.all(|c| c.is_ascii_lowercase())
}
fn extract_proper_nouns(text: &str, out: &mut BTreeSet<Identifier>) {
let raw_tokens: Vec<&str> = text.split_whitespace().collect();
let mut run: Vec<&str> = Vec::new();
let mut flush = |run: &mut Vec<&str>| {
let mut slice = run.as_slice();
while let Some((head, rest)) = slice.split_first() {
if LEADING_STOPWORDS.contains(head) {
slice = rest;
} else {
break;
}
}
if slice.len() >= 2 {
out.insert(Identifier {
class: IdentifierClass::ProperNoun,
text: slice.join(" "),
});
}
run.clear();
};
for raw in raw_tokens {
let tok = trim_token(raw);
if is_capitalized_word(tok) {
run.push(tok);
if raw.ends_with(['.', ',', ';', ':', '!', '?', ')', '"', '\'']) {
flush(&mut run);
}
} else {
flush(&mut run);
}
}
flush(&mut run);
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn texts(ids: &[Identifier]) -> Vec<&str> {
ids.iter().map(|i| i.text.as_str()).collect()
}
fn class_of(ids: &[Identifier], text: &str) -> Option<IdentifierClass> {
ids.iter().find(|i| i.text == text).map(|i| i.class)
}
#[test]
fn extracts_every_class_from_mixed_prose() {
let text = "Our broker is Mirela Okafor; she filed entry ZK-4471-BQ for order ord_93k2f7x; \
duty came to $12,845.03 plus a 512.4 kg pallet. Manifest at \
https://port.example/manifests/BX-201. Open item: \"night berthing at dock 7\".";
let ids = extract_identifiers(text);
assert_eq!(
class_of(&ids, "Mirela Okafor"),
Some(IdentifierClass::ProperNoun)
);
assert_eq!(
class_of(&ids, "ZK-4471-BQ"),
Some(IdentifierClass::OpaqueId)
);
assert_eq!(
class_of(&ids, "ord_93k2f7x"),
Some(IdentifierClass::OpaqueId)
);
assert_eq!(class_of(&ids, "$12,845.03"), Some(IdentifierClass::Amount));
assert_eq!(class_of(&ids, "512.4 kg"), Some(IdentifierClass::Amount));
assert_eq!(
class_of(&ids, "https://port.example/manifests/BX-201"),
Some(IdentifierClass::Url)
);
assert_eq!(
class_of(&ids, "night berthing at dock 7"),
Some(IdentifierClass::Quoted)
);
}
#[test]
fn currency_and_percent_and_bare_numerals() {
let ids = extract_identifiers("€2,190 due; retries at 85%; PIN 88417 set; row 212 done");
assert_eq!(class_of(&ids, "€2,190"), Some(IdentifierClass::Amount));
assert_eq!(class_of(&ids, "85%"), Some(IdentifierClass::Amount));
assert_eq!(class_of(&ids, "88417"), Some(IdentifierClass::Amount));
assert!(!texts(&ids).contains(&"212"));
}
#[test]
fn dates_and_plain_words_are_not_opaque_ids() {
let ids = extract_identifiers("shipped 2026-07-16 with care by the harbor team");
assert!(
ids.is_empty(),
"no letters+digits token, no ≥2-cap run, nothing quoted: {ids:?}"
);
}
#[test]
fn leading_stopword_is_stripped_from_proper_noun_runs() {
let ids = extract_identifiers("The Fenwick Boathouse holds the booking.");
assert_eq!(
texts(&ids),
vec!["Fenwick Boathouse"],
"stopword stripped, run kept"
);
let ids = extract_identifiers("The Boathouse holds the booking.");
assert!(ids.is_empty(), "one non-stopword capitalized word is prose");
}
#[test]
fn sentence_boundary_closes_a_proper_noun_run() {
let ids = extract_identifiers("the coordinator is Tomas Ilves. Route it through him.");
assert_eq!(
texts(&ids),
vec!["Tomas Ilves"],
"trailing punctuation ends the run; the next sentence's opener is prose"
);
}
#[test]
fn quoted_spans_bound_length_and_need_a_letter() {
let ids = extract_identifiers(r#"tagged "parking for the string quartet" and "12" and """#);
assert_eq!(texts(&ids), vec!["parking for the string quartet"]);
}
#[test]
fn urls_trim_trailing_prose_punctuation() {
let ids = extract_identifiers("see https://tracker.example/c/7781, then reply");
assert!(texts(&ids).contains(&"https://tracker.example/c/7781"));
}
#[test]
fn uuids_extract_as_opaque_ids() {
let ids = extract_identifiers("container 7f3d9a12-58c4-4de1-9b02-aa1c40f6d2e9 pinged");
assert_eq!(
class_of(&ids, "7f3d9a12-58c4-4de1-9b02-aa1c40f6d2e9"),
Some(IdentifierClass::OpaqueId)
);
}
#[test]
fn json_embedded_identifiers_extract_like_prose() {
let ids = extract_identifiers(r#"{"node":"node_j4x9q2","cost":"$7,412.88"}"#);
assert_eq!(
class_of(&ids, "node_j4x9q2"),
Some(IdentifierClass::OpaqueId)
);
assert_eq!(class_of(&ids, "$7,412.88"), Some(IdentifierClass::Amount));
}
#[test]
fn missing_identifiers_flags_dropped_and_passes_retained() {
let prior = "Entry ZK-4471-BQ cleared for Mirela Okafor at $12,845.03.";
let keeps = "Customs entry ZK-4471-BQ (broker Mirela Okafor) settled: $12,845.03.";
assert!(missing_identifiers(prior, keeps).is_empty());
let drops = "Customs entry cleared for the broker; duty settled.";
let missing = missing_identifiers(prior, drops);
let missing_texts = texts(&missing);
assert!(missing_texts.contains(&"ZK-4471-BQ"));
assert!(missing_texts.contains(&"Mirela Okafor"));
assert!(missing_texts.contains(&"$12,845.03"));
}
#[test]
fn survival_is_verbatim_not_paraphrase() {
assert!(is_retained("broker Mirela Okafor signed", "Mirela Okafor"));
assert!(!is_retained(
"broker Okafor, Mirela signed",
"Mirela Okafor"
));
}
}