Skip to main content

polyc_agent/
identifiers.rs

1//! Normative identifier extraction for compaction retention (INV-C2).
2//!
3//! Anchored-iterative compaction replaces the conversation's anchor summary
4//! wholesale, so an identifier the summary drops is dropped forever. The
5//! retention invariant (INV-C2, conformance row CONF-2) is stated over
6//! *extractable identifiers* — and this module is the single, normative
7//! definition of that term. The compaction recall eval (#1134) and the
8//! mint-time retention gate (#1135) both call this extractor; neither may
9//! ship a private variant, or the eval would measure a different property
10//! than the gate enforces.
11//!
12//! # Token classes
13//!
14//! An extractable identifier is a token (or token run) in one of five
15//! classes, matching the conformance suite's ambiguity resolution:
16//!
17//! - [`IdentifierClass::Url`] — an `http://` or `https://` token, trailing
18//!   punctuation trimmed.
19//! - [`IdentifierClass::Amount`] — a numeral with a currency symbol
20//!   (`$`/`€`/`£`), a trailing `%`, a following unit word from
21//!   [`UNIT_WORDS`], or at least four digits (order numbers, PINs).
22//! - [`IdentifierClass::OpaqueId`] — a machine-shaped token: at least five
23//!   characters drawn from `[A-Za-z0-9._/-]` containing both a letter and a
24//!   digit (UUIDs, `ord_93k2f7x`, `ZK-4471-BQ`).
25//! - [`IdentifierClass::ProperNoun`] — a run of two or more consecutive
26//!   capitalized words, leading English function words stripped
27//!   (`Mirela Okafor`, not `The Mirela`).
28//! - [`IdentifierClass::Quoted`] — the content of a straight double-quoted
29//!   span of 3–120 bytes containing a letter. This is how an open question
30//!   stays trackable: prose that tags it (`open item: "night berthing at
31//!   dock 7"`) makes the tag extractable, while free interrogative prose is
32//!   semantic content the recall eval measures but the gate cannot extract.
33//!
34//! # Survival predicate
35//!
36//! An identifier *survives* into a candidate text when the candidate
37//! contains its text verbatim (case-sensitive substring —
38//! [`is_retained`]). A paraphrase (`Okafor, Mirela`) does not count: the
39//! gate's failure mode on over-strictness is rejecting a candidate summary
40//! and keeping the prior anchor, which is the safe direction (INV-C3).
41
42use std::collections::BTreeSet;
43
44/// The class of an extracted identifier. See the module docs for the
45/// normative grammar of each class.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum IdentifierClass {
48    /// An `http://`/`https://` URL.
49    Url,
50    /// A numeral with currency, percent, a unit word, or ≥ 4 digits.
51    Amount,
52    /// A machine-shaped token mixing letters and digits.
53    OpaqueId,
54    /// A run of two or more capitalized words.
55    ProperNoun,
56    /// The content of a straight double-quoted span.
57    Quoted,
58}
59
60/// One extracted identifier: its class and its verbatim text.
61#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
62pub struct Identifier {
63    /// Which token class matched.
64    pub class: IdentifierClass,
65    /// The verbatim text, exactly as it must reappear to survive.
66    pub text: String,
67}
68
69/// Unit words that promote a bare numeral to an [`IdentifierClass::Amount`].
70///
71/// `512.4 kg` extracts because `kg` is listed here. The list is
72/// deliberately small and closed: growing it loosens the gate for every
73/// deployment at once, so additions belong in a reviewed change, not
74/// configuration.
75pub 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
80/// English function words stripped from the head of a capitalized run so
81/// sentence-initial `The Fenwick Boathouse` extracts as `Fenwick Boathouse`.
82///
83/// No single-letter entries (`A`, `I`): [`is_capitalized_word`] requires
84/// `len() >= 2`, so a one-letter token never enters a run for this list to
85/// strip.
86const 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/// Extract every identifier in `text`, deduplicated and in a stable
94/// (class, text) order.
95///
96/// This is the normative extraction function behind INV-C2: the recall
97/// eval seeds transcripts with identifiers this function recognizes, and
98/// the retention gate compares `extract(prior anchor)` against a candidate
99/// summary. Determinism matters more than linguistic subtlety here — the
100/// grammar is a closed, reviewable token scan, not a model call.
101///
102/// JSON/bracket structure (`"`, `{`, `}`, `[`, `]`) is treated as token
103/// whitespace for the token-class scans, so an identifier embedded in an
104/// unspaced machine payload (`"node":"node_j4x9q2"`) extracts exactly as it
105/// would in prose. The quoted class reads the original text, where the
106/// quotes still exist.
107#[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/// The identifiers extracted from `prior` that do NOT survive (per
119/// [`is_retained`]) into `candidate`.
120///
121/// This is the retention gate's core comparison (CONF-3, #1135): a
122/// non-empty return means the candidate summary loses information the
123/// prior anchor carried, and the candidate must be rejected with the prior
124/// anchor kept authoritative.
125#[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/// The normative survival predicate: `candidate` retains `identifier` when
134/// it contains the identifier's text verbatim (case-sensitive substring).
135#[must_use]
136pub fn is_retained(candidate: &str, identifier: &str) -> bool {
137    candidate.contains(identifier)
138}
139
140/// Scan for `http://`/`https://` spans; capture to whitespace, trimming
141/// trailing punctuation that prose or JSON quoting attaches.
142fn 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
160/// Scan for straight double-quoted spans of 3–120 bytes containing a letter
161/// and no newline. Splitting on `"` puts quoted content at odd indices; an
162/// odd-indexed FINAL segment has no closing quote and is skipped.
163fn 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
179/// Trim prose punctuation from a whitespace token, keeping interior
180/// symbols. Leading: everything before the first `[A-Za-z0-9$€£]`.
181/// Trailing: everything after the last `[A-Za-z0-9%]`.
182fn 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
197/// True when `s` is `digits ( ',' digits )* ( '.' digits )?` — the numeral
198/// core of the amount grammar.
199fn 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
233/// Per-token classes: amounts and opaque ids, with one-token lookahead for
234/// unit words.
235fn 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        // Currency-prefixed numeral: `$12,845.03`, `€2,190`.
243        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        // Percent: `85%`.
257        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            // Unit-followed numeral: `512.4 kg`.
268            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            // Bare numeral with at least four digits: order numbers, PINs.
277            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        // Opaque id: ≥ 5 chars of [A-Za-z0-9._/-] mixing letters and digits.
286        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
301/// True for a capitalized word: `[A-Z]` then one or more `[a-z]`.
302fn 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
309/// Runs of ≥ 2 capitalized words, leading stopwords stripped. A run ends at
310/// a token whose raw form carries trailing punctuation (`Ilves.` closes the
311/// run), so names never join across a sentence or clause boundary.
312fn 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            // Trailing punctuation on the raw token closes the clause — and
337            // with it the run.
338            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        // A short bare numeral (three digits, no unit) is NOT an identifier —
399        // gating on every small count would reject nearly any summary.
400        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        // A run that is ONLY a stopword plus one word still extracts the tail
421        // pair only when two non-stopword words remain.
422        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        // An unspaced machine payload: JSON syntax is token whitespace for
460        // the token-class scans, so payload-only identifiers are extractable
461        // (the tool-noise-heavy golden family depends on this).
462        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}