Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions rigour/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap>. 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<Tagger> 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:
Expand Down
17 changes: 1 addition & 16 deletions rust/src/names/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down Expand Up @@ -243,7 +228,7 @@ fn apply_initial_preamble(py: Python<'_>, name: &Py<Name>, infer_initials: bool)
/// to `name` via `apply_phrase`.
fn apply_tagger(py: Python<'_>, name: &Py<Name>, 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)?;
Expand Down
4 changes: 2 additions & 2 deletions rust/src/names/person_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
// authoritative emission.
//
// No static `LazyLock<String>` 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
Expand Down
68 changes: 32 additions & 36 deletions rust/src/names/tagger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@
//
// Each source contributes to a `HashMap<String, Vec<Symbol>>` that
// seeds `Needles<Vec<Symbol>>`. 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<Tagger>` 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<HashMap<...>>` 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;

Expand All @@ -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,
Expand Down Expand Up @@ -301,34 +308,23 @@ fn build_person_tagger(flags: Normalize) -> Tagger {
b.finish()
}

type TaggerCache = RwLock<HashMap<(TaggerKind, Normalize), Arc<Tagger>>>;

static TAGGER_CACHE: LazyLock<TaggerCache> = LazyLock::new(|| RwLock::new(HashMap::new()));
static ORG_TAGGER: LazyLock<Tagger> = LazyLock::new(|| build_org_tagger(TAGGER_FLAGS));
static PERSON_TAGGER: LazyLock<Tagger> = LazyLock::new(|| build_person_tagger(TAGGER_FLAGS));

pub fn get_tagger(kind: TaggerKind, flags: Normalize) -> Arc<Tagger> {
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
Expand All @@ -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!(
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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]
Expand All @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion rust/src/territories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down