diff --git a/rigour/reset.py b/rigour/reset.py index 75712e7a..626795b1 100644 --- a/rigour/reset.py +++ b/rigour/reset.py @@ -15,10 +15,9 @@ from rigour.names.split_phrases import _split_phrase_regex from rigour.addresses.format import _load_formats, _load_template from rigour.addresses.normalize import _address_replacer -# Tagger caches live Rust-side, keyed on (TaggerKind, Normalize, -# Cleanup) in a process-lifetime RwLock. There's no -# Python-side handle to reset; the built automata stay until process -# exit. Same shape as the org_types Replacer cache. +# Tagger automata are two process-lifetime LazyLock statics +# in Rust (rust/src/names/tagger.rs). There's no Python-side handle +# to reset; the built automata stay until process exit. def reset_caches() -> None: diff --git a/rust/src/names/analyze.rs b/rust/src/names/analyze.rs index 32290266..e97dc92a 100644 --- a/rust/src/names/analyze.rs +++ b/rust/src/names/analyze.rs @@ -45,21 +45,6 @@ use crate::names::tagger::{TaggerKind, get_tagger}; use crate::text::normalize::{Cleanup, Normalize, casefold, normalize}; use crate::text::stopwords::stopwords_list; -/// Normalise-flag combination for the tagger's alias set. -/// -/// Must match the shape of `Name.norm_form` on the haystack side -/// so the AC automaton's needles line up with the text it's -/// searching. `NAME` runs `tokenize_name + ' '.join` as the final -/// pipeline step — this subsumes `SQUASH_SPACES` and the -/// Unicode-category handling + skip-char deletion the pre-port -/// tagger used to do in a hardcoded post-pass. -/// -/// No `Cleanup` accepted: `tokenize_name` already handles Unicode -/// categories, and `Cleanup::Strong` would drop Lm/Mc characters -/// (CJK / combining marks) the haystack keeps, breaking matches -/// on non-Latin scripts. -const TAGGER_FLAGS: Normalize = Normalize::CASEFOLD.union(Normalize::NAME); - /// Minimum total `form_str` char count of an `ORG_CLASS` span before /// it can promote an `ENT` Name to `ORG`. /// @@ -243,7 +228,7 @@ fn apply_initial_preamble(py: Python<'_>, name: &Py, infer_initials: bool) /// to `name` via `apply_phrase`. fn apply_tagger(py: Python<'_>, name: &Py, kind: TaggerKind) -> PyResult<()> { let norm_form: String = name.bind(py).borrow().norm_form.bind(py).extract()?; - let tagger = get_tagger(kind, TAGGER_FLAGS); + let tagger = get_tagger(kind); let matches = tagger.tag(&norm_form); for (phrase, symbol) in matches { let sym_py = Py::new(py, symbol)?; diff --git a/rust/src/names/person_names.rs b/rust/src/names/person_names.rs index 7e8d073f..707454fd 100644 --- a/rust/src/names/person_names.rs +++ b/rust/src/names/person_names.rs @@ -13,8 +13,8 @@ // authoritative emission. // // No static `LazyLock` cache — the only consumer is the tagger -// builder (`names::tagger`), which runs once per `(TaggerKind, flags, -// cleanup)` combination and drops the decompressed buffer as soon as +// builder (`names::tagger`), which runs once per tagger static +// build and drops the decompressed buffer as soon as // its AC automaton is assembled. Caching ~8.5 MB of decompressed text // for process life when nobody reads it after tagger build would just // be dead retention on top of the 2.7 MB compressed copy that already diff --git a/rust/src/names/tagger.rs b/rust/src/names/tagger.rs index 935fc2cf..3f4ebe3a 100644 --- a/rust/src/names/tagger.rs +++ b/rust/src/names/tagger.rs @@ -13,16 +13,19 @@ // // Each source contributes to a `HashMap>` that // seeds `Needles>`. Aliases are normalised with the -// caller's `Normalize` flags before insertion — callers must -// normalise runtime input with the same flags. Overlapping match +// production flags (`CASEFOLD | NAME`) before insertion — callers +// must normalise runtime input with the same flags. Overlapping match // iteration emits every recognised phrase as an independent // `(matched_phrase, Symbol)` pair. // -// Flag-keyed cache: one compiled Tagger per `(TaggerKind, Normalize)` -// combination, same shape as the org_types Replacer cache. +// Two `LazyLock` statics (Org, Person) replace the old +// per-flag cache: the tagger has no Python surface and is only +// driven by `analyze_names` with a constant flag combination, so +// a `RwLock>` was guarding against a use-case that +// never materialised. use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, LazyLock, RwLock}; +use std::sync::LazyLock; use serde::Deserialize; @@ -35,6 +38,10 @@ use crate::territories; use crate::text::normalize::{Cleanup, Normalize, normalize}; use crate::text::ordinals; +/// Production-normalisation flags the tagger builds its needles with +/// and that `analyze_names` uses to normalise the haystack. +pub(crate) const TAGGER_FLAGS: Normalize = Normalize::CASEFOLD.union(Normalize::NAME); + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum TaggerKind { Org, @@ -301,34 +308,23 @@ fn build_person_tagger(flags: Normalize) -> Tagger { b.finish() } -type TaggerCache = RwLock>>; - -static TAGGER_CACHE: LazyLock = LazyLock::new(|| RwLock::new(HashMap::new())); +static ORG_TAGGER: LazyLock = LazyLock::new(|| build_org_tagger(TAGGER_FLAGS)); +static PERSON_TAGGER: LazyLock = LazyLock::new(|| build_person_tagger(TAGGER_FLAGS)); -pub fn get_tagger(kind: TaggerKind, flags: Normalize) -> Arc { - let key = (kind, flags); - if let Some(existing) = TAGGER_CACHE.read().unwrap().get(&key) { - return existing.clone(); +pub fn get_tagger(kind: TaggerKind) -> &'static Tagger { + match kind { + TaggerKind::Org => &ORG_TAGGER, + TaggerKind::Person => &PERSON_TAGGER, } - let built = Arc::new(match kind { - TaggerKind::Org => build_org_tagger(flags), - TaggerKind::Person => build_person_tagger(flags), - }); - let mut writer = TAGGER_CACHE.write().unwrap(); - Arc::clone(writer.entry(key).or_insert(built)) } #[cfg(test)] mod tests { use super::*; - // Test-local flag combination; production uses - // `analyze::TAGGER_FLAGS` (CASEFOLD | NAME). - const FLAGS: Normalize = Normalize::CASEFOLD.union(Normalize::SQUASH_SPACES); - #[test] fn org_tagger_matches_ordinals() { - let tagger = get_tagger(TaggerKind::Org, FLAGS); + let tagger = get_tagger(TaggerKind::Org); let matches = tagger.tag("acme number one limited"); assert!( matches @@ -341,7 +337,7 @@ mod tests { #[test] fn org_tagger_matches_org_class() { - let tagger = get_tagger(TaggerKind::Org, FLAGS); + let tagger = get_tagger(TaggerKind::Org); // "aktiengesellschaft" should map to ORG_CLASS (via generic=JSC). let matches = tagger.tag("siemens aktiengesellschaft"); assert!( @@ -360,7 +356,7 @@ mod tests { // must carry the ORG_CLASS evidence — the tagger's needle // set is data-driven, not dependent on the compare-rewrite // having canonicalised the alias to "fze" first. - let tagger = get_tagger(TaggerKind::Org, FLAGS); + let tagger = get_tagger(TaggerKind::Org); let matches = tagger.tag("acme free zone establishment"); assert!( matches @@ -387,7 +383,7 @@ mod tests { // but `generic: SOE`: removal-from-comparison and class // evidence are orthogonal, so the alias still tags when it // appears in text. - let tagger = get_tagger(TaggerKind::Org, FLAGS); + let tagger = get_tagger(TaggerKind::Org); let matches = tagger.tag("federal state budgetary institution rosgeolfond"); assert!( matches @@ -400,7 +396,7 @@ mod tests { #[test] fn person_tagger_matches_corpus_name() { - let tagger = get_tagger(TaggerKind::Person, FLAGS); + let tagger = get_tagger(TaggerKind::Person); // Any name from the corpus should produce at least one NAME // symbol. Pick "john" — extremely common, should resolve to // at least one Wikidata-keyed Symbol. @@ -415,17 +411,17 @@ mod tests { } #[test] - fn cache_returns_same_arc() { - let a = get_tagger(TaggerKind::Org, FLAGS); - let b = get_tagger(TaggerKind::Org, FLAGS); - assert!(Arc::ptr_eq(&a, &b)); + fn repeated_calls_return_same_static() { + let a = get_tagger(TaggerKind::Org); + let b = get_tagger(TaggerKind::Org); + assert!(std::ptr::eq(a, b)); } #[test] - fn cache_distinguishes_kind() { - let org = get_tagger(TaggerKind::Org, FLAGS); - let person = get_tagger(TaggerKind::Person, FLAGS); - assert!(!Arc::ptr_eq(&org, &person)); + fn org_and_person_taggers_are_distinct() { + let org = get_tagger(TaggerKind::Org); + let person = get_tagger(TaggerKind::Person); + assert!(!std::ptr::eq(org, person)); } #[test] @@ -434,7 +430,7 @@ mod tests { // plus several NAME:Qxxx entries from the person-names corpus). // Without the dedupe in `tag`, the AC iteration would emit // 2 × K identical `(phrase, symbol)` pairs for it. - let tagger = get_tagger(TaggerKind::Person, FLAGS); + let tagger = get_tagger(TaggerKind::Person); let matches = tagger.tag("isa bin tarif al bin ali"); let distinct: HashSet<_> = matches.iter().cloned().collect(); assert_eq!( diff --git a/rust/src/territories.rs b/rust/src/territories.rs index 1440bb8e..3815c3ed 100644 --- a/rust/src/territories.rs +++ b/rust/src/territories.rs @@ -14,7 +14,7 @@ // returns a fresh owned `String`. Both consumers are one-shot: // Python's `rigour.territories.*` reads via `@cache`-decorated index // builders (so exactly one FFI hop per process), and the Rust tagger -// walks the lines once per `(TaggerKind, flags, cleanup)` cache miss. +// walks the lines once when the Org tagger static is initialised. // A persistent Rust-side copy would just duplicate what's already in // Python's cached PyString / the tagger's AC automaton.