Files
87d9525176 feat(resolver): opt-in semantic nearest-neighbour fallback (#34) (#140)
Closes #114, #115, #116, #117.

- Deterministic, offline, dependency-free char-ngram embedding backend + config
  section (semantic.enabled default false, min_similarity, backend).
- Committed embedding index (data/kb_embedding_index.json) mapping vectors to real
  KB entry ids only; built by scripts/build_embedding_index.py, loaded once/cached.
- Resolver falls back to the nearest entry on a deterministic miss when enabled,
  accepting >= min_similarity and emitting SC0020; NSFW-gated neighbours are
  skipped. Disabled => byte-identical to before.
- Guarantee tests: deterministic lookup always wins (exact hit never queries the
  index), fallback emits only KB tags (no invention), identical input => identical
  output. SC0020 registered.

Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:23:38 +02:00

134 lines
4.9 KiB
Python

"""Knowledge Base embedding index (MASTER_SPEC §30.2, epic #34, #115).
A committed snapshot that maps embedding vectors back to **existing** Knowledge
Base entry ids, so the semantic fallback can only ever return a real entry — it
can never invent a tag. Each entry contributes one row per surface form (its
canonical id-as-words, its tags, and its aliases), so a query matches an entry
when it is close to any of that entry's surfaces (``stockings`` → the
``thighhighs`` entry via its ``stockings`` alias).
The index is built offline by ``scripts/build_embedding_index.py`` and committed
under ``data/`` so CI never recomputes embeddings. :class:`EmbeddingIndex` reads
it once and reuses it for nearest-neighbour search.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from compiler.common.embedding import (
DEFAULT_BACKEND,
EmbeddingBackend,
SparseVector,
cosine_similarity,
get_backend,
)
from compiler.common.knowledge_base import KnowledgeBase
# Weights are rounded when serialized to keep the committed artifact compact and
# its regeneration byte-stable.
_WEIGHT_PRECISION = 6
def _entry_surfaces(entry) -> list[str]:
"""The distinct surface texts of an entry: id-as-words, tags, and aliases."""
surfaces = [entry.id.replace("_", " "), *entry.tags, *entry.aliases]
seen: set[str] = set()
ordered: list[str] = []
for surface in surfaces:
if surface not in seen:
seen.add(surface)
ordered.append(surface)
return ordered
def build_index_rows(
knowledge_base: KnowledgeBase, backend: EmbeddingBackend | None = None
) -> list[dict]:
"""Build the index rows ``[{"id", "surface", "vector"}, ...]`` deterministically.
Entries are visited in sorted id order and surfaces in their listed order, so
the artifact is byte-stable for a given Knowledge Base.
"""
backend = backend or get_backend(DEFAULT_BACKEND)
rows: list[dict] = []
for entry_id in sorted(knowledge_base.by_id):
entry = knowledge_base.by_id[entry_id]
surfaces = _entry_surfaces(entry)
vectors = backend.embed(surfaces)
for surface, vector in zip(surfaces, vectors, strict=True):
if not vector:
continue
rows.append(
{
"id": entry_id,
"surface": surface,
"vector": {
gram: round(weight, _WEIGHT_PRECISION) for gram, weight in vector.items()
},
}
)
return rows
@dataclass(frozen=True)
class NeighbourMatch:
"""The nearest Knowledge Base entry to a query, with its similarity score."""
entry_id: str
surface: str
score: float
class EmbeddingIndex:
"""An in-memory embedding index over Knowledge Base entry surfaces."""
def __init__(self, rows: list[dict], backend: EmbeddingBackend | None = None) -> None:
self._rows: list[tuple[str, str, SparseVector]] = [
(row["id"], row["surface"], row["vector"]) for row in rows
]
self._backend = backend or get_backend(DEFAULT_BACKEND)
@property
def backend(self) -> EmbeddingBackend:
return self._backend
def nearest(self, text: str) -> NeighbourMatch | None:
"""Return the nearest entry surface to ``text`` (highest cosine), or None."""
(query,) = self._backend.embed([text])
if not query:
return None
best: NeighbourMatch | None = None
for entry_id, surface, vector in self._rows:
score = cosine_similarity(query, vector)
# Strict '>' keeps the first (sorted-id, listed-surface) winner on ties,
# so results are deterministic regardless of scan order.
if best is None or score > best.score:
best = NeighbourMatch(entry_id=entry_id, surface=surface, score=score)
return best
def __len__(self) -> int:
return len(self._rows)
def load_index(path: str | Path, backend: EmbeddingBackend | None = None) -> EmbeddingIndex:
"""Load a committed embedding-index artifact once into an :class:`EmbeddingIndex`."""
rows = json.loads(Path(path).read_text(encoding="utf-8"))
return EmbeddingIndex(rows, backend)
# The shipped index lives next to the package data; resolved relative to the repo
# root so it works regardless of the process working directory.
DEFAULT_INDEX_PATH = Path(__file__).resolve().parents[2] / "data" / "kb_embedding_index.json"
_default_index_cache: EmbeddingIndex | None = None
def load_default_index() -> EmbeddingIndex | None:
"""Load and cache the shipped embedding index once, or None when it is absent."""
global _default_index_cache
if _default_index_cache is None and DEFAULT_INDEX_PATH.is_file():
_default_index_cache = load_index(DEFAULT_INDEX_PATH)
return _default_index_cache