From cc46dca87a9ba1c49df2a4a68d2c73729dc6f0cf Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 15 May 2026 11:53:46 -0700 Subject: [PATCH] Cache OOV morphology grounding structures - cache morphology index per vocab identity for OOV grounding - cache decomposition results per vocab/token with bounded storage - preserve OOV semantics, audit records, final closure checks, and transient isolation - add focused tests for determinism, audit preservation, transient isolation, closure, and cache reuse --- ingest/gate.py | 46 +++++++++++++++++-- tests/test_oov_grounding_cache.py | 76 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/test_oov_grounding_cache.py diff --git a/ingest/gate.py b/ingest/gate.py index cc473831..6dbff04a 100644 --- a/ingest/gate.py +++ b/ingest/gate.py @@ -47,6 +47,18 @@ class _GroundedUnknown: condition: float +@dataclass(frozen=True, slots=True) +class _MorphologyIndex: + prefixes: tuple[str, ...] + suffixes: tuple[str, ...] + roots: dict[str, str] + + +_MORPH_INDEX_CACHE: dict[int, _MorphologyIndex] = {} +_DECOMPOSITION_CACHE: dict[tuple[int, str], tuple[str, tuple[str, ...], tuple[str, ...]]] = {} +_DECOMPOSITION_CACHE_MAX = 4096 + + def _compact_root(root: str) -> str: return root.replace("-", "") @@ -89,6 +101,22 @@ def _root_surfaces(vocab, morphology_entries: tuple[MorphologyEntry, ...]) -> di return roots +def _build_morphology_index(vocab, morphology_entries: tuple[MorphologyEntry, ...]) -> _MorphologyIndex: + prefixes, suffixes = _known_edges(morphology_entries) + roots = _root_surfaces(vocab, morphology_entries) + return _MorphologyIndex(prefixes=prefixes, suffixes=suffixes, roots=roots) + + +def _morphology_index_for(vocab, morphology_entries: tuple[MorphologyEntry, ...]) -> _MorphologyIndex: + key = id(vocab) + cached = _MORPH_INDEX_CACHE.get(key) + if cached is not None: + return cached + index = _build_morphology_index(vocab, morphology_entries) + _MORPH_INDEX_CACHE[key] = index + return index + + def _root_affinity(candidate: str, root: str) -> int: common_prefix = 0 for left, right in zip(candidate, root): @@ -105,8 +133,16 @@ def _best_decomposition( vocab, morphology_entries: tuple[MorphologyEntry, ...], ) -> tuple[str, tuple[str, ...], tuple[str, ...]]: - prefixes, suffixes = _known_edges(morphology_entries) - roots = _root_surfaces(vocab, morphology_entries) + vocab_key = id(vocab) + cache_key = (vocab_key, token) + cached = _DECOMPOSITION_CACHE.get(cache_key) + if cached is not None: + return cached + + index = _morphology_index_for(vocab, morphology_entries) + prefixes = index.prefixes + suffixes = index.suffixes + roots = index.roots prefix_options = ("", *prefixes) suffix_options = ("", *suffixes) @@ -158,7 +194,11 @@ def _best_decomposition( if best is None: raise KeyError(f"Token '{token}' cannot be decomposed against mounted morphology.") _, root_surface, applied_prefixes, applied_suffixes = best - return root_surface, applied_prefixes, applied_suffixes + result = (root_surface, applied_prefixes, applied_suffixes) + if len(_DECOMPOSITION_CACHE) >= _DECOMPOSITION_CACHE_MAX: + _DECOMPOSITION_CACHE.clear() + _DECOMPOSITION_CACHE[cache_key] = result + return result def _compose_delta(root_versor: np.ndarray, prefixes: tuple[str, ...], suffixes: tuple[str, ...]) -> tuple[np.ndarray, tuple[str, ...]]: diff --git a/tests/test_oov_grounding_cache.py b/tests/test_oov_grounding_cache.py new file mode 100644 index 00000000..69b98ec9 --- /dev/null +++ b/tests/test_oov_grounding_cache.py @@ -0,0 +1,76 @@ +"""Tests for OOV grounding morphology cache behavior.""" +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.versor import versor_condition +from ingest import gate +from ingest.gate import inject +from language_packs.compiler import load_mounted_packs + + +def test_oov_grounding_repeated_token_is_deterministic() -> None: + vocab_a = load_mounted_packs(("he_logos_micro_v1",)) + vocab_b = load_mounted_packs(("he_logos_micro_v1",)) + + state_a = inject(["דברית"], vocab_a) + state_b = inject(["דברית"], vocab_b) + + np.testing.assert_allclose( + vocab_a.get_versor("דברית"), + vocab_b.get_versor("דברית"), + atol=1e-6, + ) + np.testing.assert_allclose(state_a.F, state_b.F, atol=1e-6) + + +def test_oov_cache_does_not_skip_unknown_token_audit() -> None: + vocab = load_mounted_packs(("he_logos_micro_v1",)) + + inject(["דברית"], vocab) + + log = vocab.unknown_token_log + assert len(log) >= 1 + assert log[0]["token"] == "דברית" + + +def test_oov_cache_does_not_share_transients_between_vocab_instances() -> None: + vocab_a = load_mounted_packs(("he_logos_micro_v1",)) + vocab_b = load_mounted_packs(("he_logos_micro_v1",)) + + inject(["דברית"], vocab_a) + + assert vocab_a.is_transient("דברית") + assert not vocab_b.is_transient("דברית") + + +def test_oov_cache_preserves_versor_condition() -> None: + vocab = load_mounted_packs(("he_logos_micro_v1",)) + + state = inject(["דברית"], vocab) + + assert versor_condition(state.F) < 1e-6 + assert versor_condition(vocab.get_versor("דברית")) < 1e-6 + + +def test_oov_cache_reuses_morphology_index(monkeypatch: pytest.MonkeyPatch) -> None: + vocab = load_mounted_packs(("he_logos_micro_v1",)) + + gate._MORPH_INDEX_CACHE.pop(id(vocab), None) + + calls = 0 + original = gate._build_morphology_index + + def counted(v, entries): + nonlocal calls + calls += 1 + return original(v, entries) + + monkeypatch.setattr(gate, "_build_morphology_index", counted) + + inject(["דברית"], vocab) + vocab_b = load_mounted_packs(("he_logos_micro_v1",)) + inject(["דברית"], vocab_b) + + assert calls <= 2