Ground unknown tokens in ingest
This commit is contained in:
parent
2b78cd1179
commit
9ba6abfa3e
3 changed files with 304 additions and 1 deletions
197
ingest/gate.py
197
ingest/gate.py
|
|
@ -24,9 +24,204 @@ Contract:
|
|||
Output: FieldState with F satisfying versor_condition(F) < 1e-6
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from algebra.cl41 import geometric_product
|
||||
from algebra.versor import normalize_to_versor, versor_condition
|
||||
from algebra.holonomy import holonomy_encode
|
||||
from field.state import FieldState
|
||||
from language_packs.schema import MorphologyEntry
|
||||
from language_packs.compiler import _feature_rotor
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GroundedUnknown:
|
||||
token: str
|
||||
root_used: str
|
||||
versor: np.ndarray
|
||||
operators_applied: tuple[str, ...]
|
||||
condition: float
|
||||
|
||||
|
||||
def _compact_root(root: str) -> str:
|
||||
return root.replace("-", "")
|
||||
|
||||
|
||||
def _known_edges(morphology_entries: tuple[MorphologyEntry, ...]) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
prefixes = {
|
||||
prefix
|
||||
for morphology in morphology_entries
|
||||
for prefix in morphology.prefix_chain
|
||||
if prefix
|
||||
}
|
||||
suffixes = {
|
||||
suffix
|
||||
for morphology in morphology_entries
|
||||
for suffix in morphology.suffix_chain
|
||||
if suffix
|
||||
}
|
||||
return (
|
||||
tuple(sorted(prefixes, key=len, reverse=True)),
|
||||
tuple(sorted(suffixes, key=len, reverse=True)),
|
||||
)
|
||||
|
||||
|
||||
def _root_surfaces(vocab, morphology_entries: tuple[MorphologyEntry, ...]) -> dict[str, str]:
|
||||
roots: dict[str, str] = {}
|
||||
for morphology in morphology_entries:
|
||||
for candidate in (
|
||||
morphology.surface,
|
||||
morphology.lemma,
|
||||
morphology.stem,
|
||||
_compact_root(morphology.root) if morphology.root else None,
|
||||
):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
vocab.get_versor(candidate)
|
||||
except KeyError:
|
||||
continue
|
||||
roots.setdefault(candidate, candidate)
|
||||
return roots
|
||||
|
||||
|
||||
def _root_affinity(candidate: str, root: str) -> int:
|
||||
common_prefix = 0
|
||||
for left, right in zip(candidate, root):
|
||||
if left != right:
|
||||
break
|
||||
common_prefix += 1
|
||||
shared = len(set(candidate).intersection(root))
|
||||
length_penalty = abs(len(candidate) - len(root))
|
||||
return (common_prefix * 8) + (shared * 2) - length_penalty
|
||||
|
||||
|
||||
def _best_decomposition(
|
||||
token: str,
|
||||
vocab,
|
||||
morphology_entries: tuple[MorphologyEntry, ...],
|
||||
) -> tuple[str, tuple[str, ...], tuple[str, ...]]:
|
||||
prefixes, suffixes = _known_edges(morphology_entries)
|
||||
roots = _root_surfaces(vocab, morphology_entries)
|
||||
prefix_options = ("", *prefixes)
|
||||
suffix_options = ("", *suffixes)
|
||||
|
||||
best: tuple[int, str, tuple[str, ...], tuple[str, ...]] | None = None
|
||||
for prefix in prefix_options:
|
||||
if prefix and not token.startswith(prefix):
|
||||
continue
|
||||
after_prefix = token[len(prefix):] if prefix else token
|
||||
for suffix in suffix_options:
|
||||
if suffix and not after_prefix.endswith(suffix):
|
||||
continue
|
||||
root_candidate = after_prefix[: -len(suffix)] if suffix else after_prefix
|
||||
root_surface = roots.get(root_candidate)
|
||||
if root_surface is None:
|
||||
continue
|
||||
score = len(root_candidate) * 8 + len(prefix) + len(suffix)
|
||||
if prefix or suffix:
|
||||
score += 64
|
||||
if best is None or score > best[0]:
|
||||
best = (
|
||||
score,
|
||||
root_surface,
|
||||
(prefix,) if prefix else (),
|
||||
(suffix,) if suffix else (),
|
||||
)
|
||||
|
||||
if best is None:
|
||||
for prefix in prefix_options:
|
||||
if prefix and not token.startswith(prefix):
|
||||
continue
|
||||
after_prefix = token[len(prefix):] if prefix else token
|
||||
for suffix in suffix_options:
|
||||
if suffix and not after_prefix.endswith(suffix):
|
||||
continue
|
||||
root_candidate = after_prefix[: -len(suffix)] if suffix else after_prefix
|
||||
for known_root, root_surface in roots.items():
|
||||
affinity = _root_affinity(root_candidate, known_root)
|
||||
score = affinity + len(prefix) + len(suffix)
|
||||
if prefix or suffix:
|
||||
score += 32
|
||||
if best is None or score > best[0]:
|
||||
best = (
|
||||
score,
|
||||
root_surface,
|
||||
(prefix,) if prefix else (),
|
||||
(suffix,) if suffix else (),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _compose_delta(root_versor: np.ndarray, prefixes: tuple[str, ...], suffixes: tuple[str, ...]) -> tuple[np.ndarray, tuple[str, ...]]:
|
||||
versor = np.asarray(root_versor, dtype=np.float32).copy()
|
||||
operators: list[str] = []
|
||||
|
||||
for idx, prefix in enumerate(prefixes):
|
||||
versor = geometric_product(
|
||||
versor,
|
||||
_feature_rotor(f"{idx}:{prefix.lower()}", "morph:prefix", 0.03 / (idx + 1)),
|
||||
)
|
||||
operators.append(f"prefix:{prefix}")
|
||||
|
||||
for idx, suffix in enumerate(suffixes):
|
||||
versor = geometric_product(
|
||||
versor,
|
||||
_feature_rotor(f"{idx}:{suffix.lower()}", "morph:suffix", 0.02 / (idx + 1)),
|
||||
)
|
||||
operators.append(f"suffix:{suffix}")
|
||||
|
||||
return versor.astype(np.float32, copy=False), tuple(operators)
|
||||
|
||||
|
||||
def _ground_unknown_token(token: str, vocab) -> np.ndarray:
|
||||
morphology_entries = (
|
||||
vocab.morphology_entries()
|
||||
if hasattr(vocab, "morphology_entries")
|
||||
else ()
|
||||
)
|
||||
if not morphology_entries or not hasattr(vocab, "insert_transient"):
|
||||
raise KeyError(f"Word '{token}' not in vocabulary.")
|
||||
|
||||
root_used, prefixes, suffixes = _best_decomposition(token, vocab, morphology_entries)
|
||||
root_versor = vocab.get_versor(root_used)
|
||||
versor, operators_applied = _compose_delta(root_versor, prefixes, suffixes)
|
||||
condition = versor_condition(versor)
|
||||
if condition > 1e-6:
|
||||
raise RuntimeError(
|
||||
f"Unknown-token construction for '{token}' produced non-versor: "
|
||||
f"condition={condition:.2e}."
|
||||
)
|
||||
|
||||
grounded = _GroundedUnknown(
|
||||
token=token,
|
||||
root_used=root_used,
|
||||
versor=versor,
|
||||
operators_applied=operators_applied,
|
||||
condition=condition,
|
||||
)
|
||||
vocab.insert_transient(grounded.token, grounded.versor)
|
||||
if hasattr(vocab, "record_unknown_token"):
|
||||
vocab.record_unknown_token(
|
||||
grounded.token,
|
||||
grounded.root_used,
|
||||
grounded.operators_applied,
|
||||
grounded.condition,
|
||||
)
|
||||
return grounded.versor.copy()
|
||||
|
||||
|
||||
def _lookup_or_ground(token: str, vocab) -> np.ndarray:
|
||||
try:
|
||||
return vocab.get_versor(token)
|
||||
except KeyError:
|
||||
return _ground_unknown_token(token, vocab)
|
||||
|
||||
|
||||
def inject(tokens: list, vocab) -> FieldState:
|
||||
|
|
@ -39,7 +234,7 @@ def inject(tokens: list, vocab) -> FieldState:
|
|||
3. normalize_to_versor() — the single allowed gate normalization call
|
||||
4. Assert versor condition before returning
|
||||
"""
|
||||
word_versors = [vocab.get_versor(t) for t in tokens]
|
||||
word_versors = [_lookup_or_ground(t, vocab) for t in tokens]
|
||||
H = holonomy_encode(word_versors)
|
||||
F = normalize_to_versor(H)
|
||||
|
||||
|
|
|
|||
59
tests/test_unknown_token_ingest.py
Normal file
59
tests/test_unknown_token_ingest.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from algebra.backend import cga_inner
|
||||
from algebra.versor import unitize_versor, versor_condition
|
||||
from ingest.gate import inject
|
||||
from language_packs.compiler import load_mounted_packs
|
||||
from session.context import SessionContext
|
||||
|
||||
|
||||
def _random_versor(seed: int) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
vec = rng.standard_normal(32).astype(np.float32)
|
||||
return unitize_versor(vec)
|
||||
|
||||
|
||||
def test_unknown_token_is_grounded_as_valid_transient_versor() -> None:
|
||||
vocab = load_mounted_packs(("he_logos_micro_v1",))
|
||||
token = "דברית"
|
||||
|
||||
state = inject([token], vocab)
|
||||
constructed = vocab.get_versor(token)
|
||||
root = vocab.get_versor("דבר")
|
||||
random = _random_versor(41)
|
||||
|
||||
assert versor_condition(constructed) < 1e-6
|
||||
assert versor_condition(state.F) < 1e-6
|
||||
assert vocab.is_transient(token)
|
||||
token_idx = vocab.index_of(token)
|
||||
assert vocab.nearest(constructed, exclude_indices=set(range(token_idx)))[0] == token
|
||||
assert cga_inner(constructed, root) > cga_inner(constructed, random)
|
||||
|
||||
log = vocab.unknown_token_log
|
||||
assert log == (
|
||||
{
|
||||
"token": token,
|
||||
"root_used": "דבר",
|
||||
"operators_applied": ("suffix:ית",),
|
||||
"versor_condition_score": log[0]["versor_condition_score"],
|
||||
},
|
||||
)
|
||||
assert log[0]["versor_condition_score"] < 1e-6
|
||||
|
||||
|
||||
def test_unknown_token_session_turn_evolves_field() -> None:
|
||||
vocab = load_mounted_packs(("he_logos_micro_v1", "grc_logos_micro_v1"))
|
||||
session = SessionContext(vocab=vocab)
|
||||
token = "דברית"
|
||||
|
||||
first = session.ingest([token])
|
||||
response = session.respond(max_tokens=3)
|
||||
second = session.ingest([token])
|
||||
|
||||
assert vocab.is_transient(token)
|
||||
assert versor_condition(first.F) < 1e-6
|
||||
assert versor_condition(response.final_state.F) < 1e-6
|
||||
assert versor_condition(second.F) < 1e-6
|
||||
assert not np.array_equal(first.F, second.F)
|
||||
|
|
@ -38,6 +38,8 @@ class VocabManifold:
|
|||
self._words: list[str] = []
|
||||
self._versors: list[np.ndarray] = [] # each shape (32,), grade-normed to ±1
|
||||
self._morphology_by_word: dict[str, MorphologyEntry] = {}
|
||||
self._transient_words: set[str] = set()
|
||||
self._unknown_token_log: list[dict[str, object]] = []
|
||||
|
||||
def add(self, word: str, versor: np.ndarray, morphology: MorphologyEntry | None = None) -> None:
|
||||
"""
|
||||
|
|
@ -69,6 +71,53 @@ class VocabManifold:
|
|||
if morphology is not None:
|
||||
self._morphology_by_word[word] = morphology
|
||||
|
||||
def insert_transient(self, word: str, versor: np.ndarray) -> None:
|
||||
"""
|
||||
Register a session-local ad hoc word-versor pair.
|
||||
|
||||
Transient entries live only in this manifold instance. They use the
|
||||
same storage as compiled entries so nearest() and get_versor() remain
|
||||
exact manifold operations, but no language pack persistence path ever
|
||||
sees them.
|
||||
"""
|
||||
if word in self._words and word not in self._transient_words:
|
||||
raise ValueError(f"Word '{word}' already exists as a compiled manifold entry.")
|
||||
if word in self._transient_words:
|
||||
self.update(word, versor)
|
||||
return
|
||||
self.add(word, versor)
|
||||
self._transient_words.add(word)
|
||||
|
||||
def is_transient(self, word: str) -> bool:
|
||||
"""Return True when word was inserted as a session-local transient."""
|
||||
return word in self._transient_words
|
||||
|
||||
def morphology_entries(self) -> tuple[MorphologyEntry, ...]:
|
||||
"""Return morphology entries carried by compiled manifold surfaces."""
|
||||
return tuple(self._morphology_by_word.values())
|
||||
|
||||
def record_unknown_token(
|
||||
self,
|
||||
token: str,
|
||||
root_used: str,
|
||||
operators_applied: tuple[str, ...],
|
||||
versor_condition_score: float,
|
||||
) -> None:
|
||||
"""Append an audit record for gate-constructed unknown-token grounding."""
|
||||
self._unknown_token_log.append(
|
||||
{
|
||||
"token": token,
|
||||
"root_used": root_used,
|
||||
"operators_applied": operators_applied,
|
||||
"versor_condition_score": versor_condition_score,
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def unknown_token_log(self) -> tuple[dict[str, object], ...]:
|
||||
"""Session-local audit trail of ad hoc unknown-token constructions."""
|
||||
return tuple(dict(entry) for entry in self._unknown_token_log)
|
||||
|
||||
def update(self, word: str, versor: np.ndarray) -> None:
|
||||
"""
|
||||
Replace the versor for an existing word in-place.
|
||||
|
|
|
|||
Loading…
Reference in a new issue