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>
This commit is contained in:
Crespin Alexandre
2026-07-13 18:23:38 +02:00
committed by GitHub
co-authored by kunail0804 Claude Opus 4.8
parent 50e7b0a8a4
commit 87d9525176
21 changed files with 749 additions and 4 deletions
+10
View File
@@ -7,6 +7,16 @@ All notable changes to Scene Compiler are documented here. The project follows
### Added
- **Semantic & embedding fallback (epic #34).** An **opt-in** nearest-neighbour
fallback for the Resolver, off by default (`semantic.enabled`). When enabled, a
concept that misses both deterministic lookup and the head-noun reduction falls
back to its nearest Knowledge Base entry in a committed embedding index
(`data/kb_embedding_index.json`), accepted only above `semantic.min_similarity`
and emitting `SC0020`. The default embedding backend is a deterministic, offline,
dependency-free character-n-gram model. Guarantees: deterministic lookup always
wins, the fallback can only return an existing KB entry (never invents a tag),
and identical input yields identical output. Config surfaced on the Configuration
node. Build the index with `scripts/build_embedding_index.py`.
- **Automatic Knowledge Base builder & validation tooling (epic #35).** A single,
fully-automated pipeline (`scripts/build_knowledge_base.py`, zero human input)
regenerates the generated KB deterministically from committed snapshots: generate
+2
View File
@@ -7,3 +7,5 @@ recursive-include prompts *.md
recursive-include config *.json
recursive-include examples *.json *.md
recursive-include docs *.md
# The embedding index is a runtime artifact for the optional semantic fallback.
include data/kb_embedding_index.json
+26
View File
@@ -126,6 +126,29 @@ class PromptBuilderConfig:
}
@dataclass(frozen=True)
class SemanticConfig:
"""Semantic/embedding fallback options (epic #34).
Off by default: when ``enabled`` is false the Resolver behaves exactly as
before. When enabled, an unknown concept that misses deterministic lookup may
fall back to the nearest Knowledge Base entry in the embedding index, accepted
only when similarity is at least ``min_similarity``. The default backend is a
deterministic, offline character-n-gram embedding, so results are reproducible.
"""
enabled: bool = False
min_similarity: float = 0.5
backend: str = "char_ngram"
def to_json(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"min_similarity": self.min_similarity,
"backend": self.backend,
}
@dataclass(frozen=True)
class DebugConfig:
"""Debug/logging options (§23.2)."""
@@ -146,6 +169,7 @@ class Config:
resolver: ResolverConfig = ResolverConfig()
validator: ValidatorConfig = ValidatorConfig()
prompt_builder: PromptBuilderConfig = PromptBuilderConfig()
semantic: SemanticConfig = SemanticConfig()
debug: DebugConfig = DebugConfig()
@classmethod
@@ -169,6 +193,7 @@ class Config:
resolver=_section_from_json(ResolverConfig, data.get("resolver")),
validator=_section_from_json(ValidatorConfig, data.get("validator")),
prompt_builder=_section_from_json(PromptBuilderConfig, data.get("prompt_builder")),
semantic=_section_from_json(SemanticConfig, data.get("semantic")),
debug=_section_from_json(DebugConfig, data.get("debug")),
)
@@ -179,6 +204,7 @@ class Config:
"resolver": self.resolver.to_json(),
"validator": self.validator.to_json(),
"prompt_builder": self.prompt_builder.to_json(),
"semantic": self.semantic.to_json(),
"debug": self.debug.to_json(),
}
+85
View File
@@ -0,0 +1,85 @@
"""Deterministic, offline embedding backend (MASTER_SPEC §30.2, epic #34).
The semantic fallback needs to compare an unknown concept against Knowledge Base
entries **without any network call, extra dependency, or nondeterminism**. This
module provides a small ``embed(texts) -> vectors`` abstraction and a default
character-n-gram implementation that satisfies those constraints:
- **Offline & dependency-free** — pure standard library; no model download.
- **Deterministic** — the same text always maps to the same vector (no random
seed, no hardware-dependent floats beyond IEEE arithmetic).
- **Sparse** — a vector is a mapping of character n-gram → weight, so the committed
Knowledge Base index (epic #34, #115) stays small.
Because the index embeds each entry's canonical text **and its aliases**, a query
matches an entry when it is lexically close to any of the entry's surface forms
(e.g. ``stockings`` matches the ``thighhighs`` entry via its ``stockings`` alias).
Retrieval therefore never invents a tag — it can only return an existing entry.
"""
from __future__ import annotations
import math
import unicodedata
from typing import Protocol
# A sparse embedding: character n-gram -> L2-normalized weight.
SparseVector = dict[str, float]
DEFAULT_BACKEND = "char_ngram"
_NGRAM_SIZE = 3
_PAD = "\x02" # a boundary marker so prefixes/suffixes get their own n-grams
class EmbeddingBackend(Protocol):
"""A text embedding backend: turn texts into comparable sparse vectors."""
def embed(self, texts: list[str]) -> list[SparseVector]: ...
def _normalize_text(text: str) -> str:
"""Lowercase, NFKC-normalize, and collapse whitespace/underscores."""
folded = unicodedata.normalize("NFKC", text).replace("_", " ").lower()
return " ".join(folded.split())
class CharNGramEmbeddingBackend:
"""Character-n-gram bag-of-features embedding (deterministic, offline)."""
def __init__(self, ngram_size: int = _NGRAM_SIZE) -> None:
self._n = ngram_size
def embed(self, texts: list[str]) -> list[SparseVector]:
return [self._embed_one(text) for text in texts]
def _embed_one(self, text: str) -> SparseVector:
normalized = _normalize_text(text)
if not normalized:
return {}
padded = _PAD + normalized + _PAD
counts: SparseVector = {}
for i in range(len(padded) - self._n + 1):
gram = padded[i : i + self._n]
counts[gram] = counts.get(gram, 0.0) + 1.0
norm = math.sqrt(sum(weight * weight for weight in counts.values()))
return {gram: weight / norm for gram, weight in counts.items()}
def get_backend(backend_id: str = DEFAULT_BACKEND) -> EmbeddingBackend:
"""Return the embedding backend for a config id.
Raises:
ValueError: If ``backend_id`` is not a known backend.
"""
if backend_id == DEFAULT_BACKEND:
return CharNGramEmbeddingBackend()
raise ValueError(f"Unknown embedding backend '{backend_id}'.")
def cosine_similarity(a: SparseVector, b: SparseVector) -> float:
"""Cosine similarity of two L2-normalized sparse vectors (their dot product)."""
if not a or not b:
return 0.0
# Iterate the smaller vector for speed; both are unit-normalized.
small, large = (a, b) if len(a) <= len(b) else (b, a)
return sum(weight * large.get(gram, 0.0) for gram, weight in small.items())
+133
View File
@@ -0,0 +1,133 @@
"""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
+1
View File
@@ -50,6 +50,7 @@ _EXTENSIONS: dict[str, CodeSpec] = {
"SC0017": CodeSpec(Severity.WARNING, "Interaction Dropped"),
"SC0018": CodeSpec(Severity.ERROR, "Analyzer Unexpected Response"),
"SC0019": CodeSpec(Severity.WARNING, "Concept Reduced"),
"SC0020": CodeSpec(Severity.WARNING, "Semantic Fallback"),
"SC0021": CodeSpec(Severity.WARNING, "List Under-Transcription"),
"SC0022": CodeSpec(Severity.WARNING, "Candidate Rejected"),
}
+48 -1
View File
@@ -17,6 +17,7 @@ from collections.abc import Iterator
from compiler.common.categories import is_valid_category
from compiler.common.config import Config
from compiler.common.embedding_index import EmbeddingIndex
from compiler.common.knowledge_base import KnowledgeBase, KnowledgeBaseEntry
from compiler.common.log import StructuredLogger
from compiler.common.message_codes import message
@@ -51,9 +52,15 @@ def resolve_scene(
knowledge_base: KnowledgeBase,
config: Config,
logger: StructuredLogger | None = None,
embedding_index: EmbeddingIndex | None = None,
) -> CompilerResult:
"""Resolve a validated Scene into an ordered tuple of Resolved Tags.
When ``config.semantic.enabled`` and an ``embedding_index`` is supplied, a
concept that misses deterministic lookup **and** the head-noun reduction may
fall back to its nearest Knowledge Base entry (epic #34). Deterministic lookup
always wins; with the feature disabled (or no index) behaviour is unchanged.
Returns:
A CompilerResult whose ``data`` is the tuple of :class:`ResolvedTag` on
success, or ``None`` with error messages when an error condition
@@ -61,7 +68,7 @@ def resolve_scene(
"""
include_nsfw = config.resolver.include_nsfw
index = _build_normalized_index(knowledge_base, include_nsfw)
resolver = _ConceptResolver(knowledge_base, index, config, logger)
resolver = _ConceptResolver(knowledge_base, index, config, logger, embedding_index)
tags: list[ResolvedTag] = []
warnings: list[Message] = []
@@ -154,6 +161,7 @@ class _ConceptResolver:
index: dict[str, KnowledgeBaseEntry],
config: Config,
logger: StructuredLogger | None,
embedding_index: EmbeddingIndex | None = None,
) -> None:
self._kb = knowledge_base
self._index = index
@@ -162,6 +170,8 @@ class _ConceptResolver:
self._max_depth = config.resolver.max_expansion_depth
self._include_nsfw = config.resolver.include_nsfw
self._logger = logger
self._min_similarity = config.semantic.min_similarity
self._embedding_index = embedding_index if config.semantic.enabled else None
def resolve(self, concept_name: str) -> _Resolution:
outcome = _Resolution()
@@ -174,6 +184,8 @@ class _ConceptResolver:
tokens = key.split()
entry, reduced_key, start = self._reduce(tokens)
if entry is None:
if self._semantic_fallback(concept_name, outcome):
return outcome
outcome.warnings.append(
message(
"SC0001",
@@ -202,6 +214,41 @@ class _ConceptResolver:
self._recover_modifiers(tokens, start, concept_name, outcome)
return outcome
def _semantic_fallback(self, concept_name: str, outcome: _Resolution) -> bool:
"""Resolve an otherwise-dropped concept via nearest-neighbour (epic #34, #116).
Runs only when the feature is enabled and an index is present, i.e. after
deterministic lookup and the head-noun reduction both miss — deterministic
resolution always wins. Accepts the neighbour only when its similarity is at
least ``min_similarity`` and it survives NSFW gating; the matched entry is a
real Knowledge Base entry, so nothing is invented. Returns True when the
concept was resolved (emitting ``SC0020``), False to keep the drop/warn path.
"""
if self._embedding_index is None:
return False
match = self._embedding_index.nearest(concept_name)
if match is None or match.score < self._min_similarity:
return False
entry = self._kb.get(match.entry_id)
if entry is None:
return False
if entry.rating == "explicit" and not self._include_nsfw:
return False # gated NSFW neighbour: keep the drop/warn path
outcome.warnings.append(
message(
"SC0020",
(
f"Concept '{concept_name}' resolved via semantic fallback to "
f"'{entry.id}' (similarity {match.score:.3f})."
),
concept=concept_name,
entry=entry.id,
score=match.score,
)
)
self._expand(entry, concept_name, depth=0, path=(), outcome=outcome)
return True
def _reduce(self, tokens: list[str]) -> tuple[KnowledgeBaseEntry | None, str | None, int]:
"""Fall back to the concept's head noun when the full key has no entry (§17.2).
+5
View File
@@ -24,6 +24,11 @@
"separator": ",",
"remove_duplicate_tags": true
},
"semantic": {
"enabled": false,
"min_similarity": 0.5,
"backend": "char_ngram"
},
"debug": {
"enabled": false,
"level": "basic"
File diff suppressed because one or more lines are too long
+1
View File
@@ -44,6 +44,7 @@ constant before running) and are occasional dev steps, not part of runtime or CI
## Related tools
- `python scripts/build_embedding_index.py` — rebuild the committed semantic-fallback embedding index (`data/kb_embedding_index.json`) from the current KB.
- `python scripts/detect_kb_conflicts.py knowledge_base/` — standalone conflict scan (CI-gateable).
- `python scripts/coverage_benchmark.py` — Knowledge Base coverage report.
- `python scripts/validate_knowledge_base.py knowledge_base/` — full KB validation.
+14
View File
@@ -53,6 +53,12 @@ class ConfigurationNode:
"optional": {
"analyzer_system_prompt": ("STRING", {"multiline": True, "default": ""}),
"resolver_knowledge_base_version": ("STRING", {"default": ""}),
"semantic_enabled": ("BOOLEAN", {"default": False}),
"semantic_min_similarity": (
"FLOAT",
{"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05},
),
"semantic_backend": ("STRING", {"default": "char_ngram"}),
},
}
@@ -76,6 +82,9 @@ class ConfigurationNode:
debug_level: str,
analyzer_system_prompt: str = "",
resolver_knowledge_base_version: str = "",
semantic_enabled: bool = False,
semantic_min_similarity: float = 0.5,
semantic_backend: str = "char_ngram",
) -> tuple[Any, str]:
analyzer: dict[str, Any] = {
"model": analyzer_model,
@@ -102,6 +111,11 @@ class ConfigurationNode:
),
},
"validator": {"allow_unknown_fields": validator_allow_unknown_fields},
"semantic": {
"enabled": semantic_enabled,
"min_similarity": semantic_min_similarity,
"backend": semantic_backend,
},
"prompt_builder": {
"target": prompt_target,
"separator": prompt_separator,
+5 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from typing import Any
from compiler.common.config import Config
from compiler.common.embedding_index import load_default_index
from compiler.resolver.illustrious_resolver import resolve_scene, tags_to_prompt
from .adapters import format_messages, to_raw_json, upstream_failure_message
@@ -44,7 +45,10 @@ class ResolverNode:
if scene is None or knowledge_base is None:
return ("", "", upstream_failure_message("scene or knowledge base"), "")
config = config or Config()
result = resolve_scene(scene, knowledge_base, config)
# The (optional) semantic fallback reuses the shipped embedding index,
# loaded once and cached; disabled configs never touch it.
embedding_index = load_default_index() if config.semantic.enabled else None
result = resolve_scene(scene, knowledge_base, config, embedding_index=embedding_index)
separator = config.prompt_builder.separator
prompt = "" if result.data is None else tags_to_prompt(result.data, separator)
return (
+9
View File
@@ -49,6 +49,15 @@
"remove_duplicate_tags": { "type": "boolean" }
}
},
"semantic": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"min_similarity": { "type": "number", "minimum": 0, "maximum": 1 },
"backend": { "type": "string" }
}
},
"debug": {
"type": "object",
"additionalProperties": false,
+46
View File
@@ -0,0 +1,46 @@
"""Build the committed Knowledge Base embedding index (issue #115, epic #34).
Embeds each Knowledge Base entry's surface forms (canonical id, tags, aliases)
with the deterministic offline backend and writes the index snapshot to
``data/kb_embedding_index.json``. The snapshot maps vectors back to existing entry
ids only, so the semantic fallback can never invent a tag. It is committed so CI
never recomputes embeddings.
Paths are hard-coded constants (no CLI/untrusted input) matching the style of
``scripts/build_vocab.py`` (SonarCloud S8707).
Usage:
python scripts/build_embedding_index.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from compiler.common.embedding_index import build_index_rows # noqa: E402
from compiler.common.knowledge_base import load_knowledge_base # noqa: E402
_REPO_ROOT = Path(__file__).resolve().parent.parent
_KB_DIR = _REPO_ROOT / "knowledge_base"
_OUTPUT = _REPO_ROOT / "data" / "kb_embedding_index.json"
def build() -> int:
kb = load_knowledge_base(_KB_DIR)
rows = build_index_rows(kb)
_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
_OUTPUT.write_text(
json.dumps(rows, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8"
)
print(
f"wrote {len(rows)} index rows for {len(kb)} entries to {_OUTPUT.relative_to(_REPO_ROOT)}"
)
return len(rows)
if __name__ == "__main__":
build()
+2 -2
View File
@@ -64,7 +64,7 @@ def test_resolves_to_flat_prompt_with_real_resolver() -> None:
def test_delegates_and_surfaces_messages(monkeypatch) -> None:
def fake_resolve(scene_arg, knowledge_base, config):
def fake_resolve(scene_arg, knowledge_base, config, embedding_index=None):
return CompilerResult(data=()).add_warning(message("SC0001", "unknown 'x'"))
monkeypatch.setattr(node_module, "resolve_scene", fake_resolve)
@@ -76,7 +76,7 @@ def test_delegates_and_surfaces_messages(monkeypatch) -> None:
def test_default_config_used_when_absent(monkeypatch) -> None:
captured = {}
def fake_resolve(scene_arg, knowledge_base, config):
def fake_resolve(scene_arg, knowledge_base, config, embedding_index=None):
captured["config"] = config
return CompilerResult(data=())
+125
View File
@@ -0,0 +1,125 @@
"""Tests for the semantic nearest-neighbour fallback (issue #116, epic #34)."""
from __future__ import annotations
from compiler.common.config import Config
from compiler.common.embedding_index import EmbeddingIndex, build_index_rows
from compiler.common.knowledge_base import KnowledgeBase, KnowledgeBaseEntry
from compiler.resolver.illustrious_resolver import resolve_scene
from schemas.models import Scene
def entry(id_, tags, category="clothing", aliases=(), rating="general"):
return KnowledgeBaseEntry(
id=id_, tags=tuple(tags), category=category, aliases=tuple(aliases), rating=rating
)
def kb() -> KnowledgeBase:
return KnowledgeBase(
[
entry("thighhighs", ["thighhighs"], aliases=["stockings"]),
entry("dress", ["dress"]),
entry("pussy", ["pussy"], category="body", rating="explicit"),
]
)
def index(knowledge: KnowledgeBase) -> EmbeddingIndex:
return EmbeddingIndex(build_index_rows(knowledge))
def scene_with(concept: str) -> Scene:
return Scene.from_json(
{
"characters": [
{
"id": 0,
"identity": [concept],
"appearance": [],
"clothing": [],
"accessories": [],
"pose": [],
"expression": [],
"actions": [],
}
],
"interactions": [],
"objects": [],
"environment": [],
"camera": [],
"lighting": [],
"metadata": {"schema_version": "1.0"},
}
)
def semantic_config(min_similarity: float = 0.5, include_nsfw: bool = False) -> Config:
return Config.from_json(
{
"semantic": {"enabled": True, "min_similarity": min_similarity},
"resolver": {"include_nsfw": include_nsfw},
}
)
def tags(result):
return [t.tag for t in result.data]
def codes(messages):
return [m.code for m in messages]
def test_near_concept_resolves_via_fallback_and_emits_sc0020() -> None:
knowledge = kb()
result = resolve_scene(
scene_with("thighighs"), knowledge, semantic_config(), embedding_index=index(knowledge)
)
assert tags(result) == ["thighhighs"]
assert "SC0020" in codes(result.warnings)
def test_far_concept_still_drops() -> None:
knowledge = kb()
result = resolve_scene(
scene_with("quantum flux inverter"),
knowledge,
semantic_config(min_similarity=0.6),
embedding_index=index(knowledge),
)
assert tags(result) == []
assert "SC0001" in codes(result.warnings)
assert "SC0020" not in codes(result.warnings)
def test_disabled_feature_is_byte_identical() -> None:
knowledge = kb()
disabled = resolve_scene(scene_with("thighighs"), knowledge, Config())
with_index_but_disabled = resolve_scene(
scene_with("thighighs"), knowledge, Config(), embedding_index=index(knowledge)
)
assert tags(disabled) == tags(with_index_but_disabled) == []
assert "SC0020" not in codes(with_index_but_disabled.warnings)
def test_deterministic_lookup_always_wins() -> None:
knowledge = kb()
result = resolve_scene(
scene_with("dress"), knowledge, semantic_config(), embedding_index=index(knowledge)
)
assert tags(result) == ["dress"]
assert "SC0020" not in codes(result.warnings) # exact hit, fallback not used
def test_gated_nsfw_neighbour_is_not_used() -> None:
knowledge = kb()
# "pusy" is closest to the explicit "pussy" entry, but NSFW is gated off.
result = resolve_scene(
scene_with("pusy"),
knowledge,
semantic_config(include_nsfw=False),
embedding_index=index(knowledge),
)
assert "SC0020" not in codes(result.warnings)
assert "SC0001" in codes(result.warnings)
+102
View File
@@ -0,0 +1,102 @@
"""Determinism & no-invention guarantees for the semantic fallback (issue #117)."""
from __future__ import annotations
from compiler.common.config import Config
from compiler.common.embedding_index import EmbeddingIndex, build_index_rows
from compiler.common.knowledge_base import KnowledgeBase, KnowledgeBaseEntry
from compiler.common.message_codes import CODES
from compiler.common.result import Severity
from compiler.resolver.illustrious_resolver import resolve_scene
from schemas.models import Scene
def entry(id_, tags, aliases=()):
return KnowledgeBaseEntry(id=id_, tags=tuple(tags), category="clothing", aliases=tuple(aliases))
def kb() -> KnowledgeBase:
return KnowledgeBase(
[
entry("thighhighs", ["thighhighs"], aliases=["stockings"]),
entry("dress", ["dress"]),
entry("jacket", ["jacket"]),
]
)
def scene_with(*concepts: str) -> Scene:
return Scene.from_json(
{
"characters": [
{
"id": 0,
"identity": list(concepts),
"appearance": [],
"clothing": [],
"accessories": [],
"pose": [],
"expression": [],
"actions": [],
}
],
"interactions": [],
"objects": [],
"environment": [],
"camera": [],
"lighting": [],
"metadata": {"schema_version": "1.0"},
}
)
def enabled_config() -> Config:
return Config.from_json({"semantic": {"enabled": True, "min_similarity": 0.3}})
class _ExplodingIndex(EmbeddingIndex):
"""An index that fails if queried — proves the fallback was never reached."""
def nearest(self, text: str): # type: ignore[override]
raise AssertionError(f"embedding fallback must not run for '{text}'")
def test_sc0020_is_registered() -> None:
assert "SC0020" in CODES
assert CODES["SC0020"].severity is Severity.WARNING
def test_exact_hit_never_routes_through_fallback() -> None:
knowledge = kb()
# Every concept is an exact id/alias hit, so nearest() must never be called
# even with the feature enabled — deterministic resolution always wins.
result = resolve_scene(
scene_with("dress", "jacket", "stockings"),
knowledge,
enabled_config(),
embedding_index=_ExplodingIndex(build_index_rows(knowledge)),
)
assert [t.tag for t in result.data] == ["dress", "jacket", "thighhighs"]
def test_fallback_only_emits_existing_kb_tags() -> None:
knowledge = kb()
index = EmbeddingIndex(build_index_rows(knowledge))
kb_tags = {tag for e in knowledge.by_id.values() for tag in e.tags}
# A spread of misspellings/near-misses; whatever resolves must be a real tag.
for query in ["thighighs", "jackt", "dres", "stockngs", "zzzz"]:
result = resolve_scene(
scene_with(query), knowledge, enabled_config(), embedding_index=index
)
for resolved in result.data:
assert resolved.tag in kb_tags # no invention
def test_same_scene_twice_is_identical() -> None:
knowledge = kb()
index = EmbeddingIndex(build_index_rows(knowledge))
scene = scene_with("thighighs", "dres", "jacket")
first = resolve_scene(scene, knowledge, enabled_config(), embedding_index=index)
second = resolve_scene(scene, knowledge, enabled_config(), embedding_index=index)
assert [t.tag for t in first.data] == [t.tag for t in second.data]
assert [m.code for m in first.warnings] == [m.code for m in second.warnings]
+15
View File
@@ -34,6 +34,7 @@ EXAMPLE_CONFIG = {
"separator": ",",
"remove_duplicate_tags": True,
},
"semantic": {"enabled": False, "min_similarity": 0.5, "backend": "char_ngram"},
"debug": {"enabled": False, "level": "basic"},
}
@@ -81,6 +82,20 @@ def test_partial_config_fills_defaults() -> None:
assert c.resolver.max_expansion_depth == 8
def test_semantic_defaults_off() -> None:
c = Config()
assert c.semantic.enabled is False
assert c.semantic.min_similarity == pytest.approx(0.5)
assert c.semantic.backend == "char_ngram"
def test_semantic_section_roundtrips() -> None:
c = Config.from_json({"semantic": {"enabled": True, "min_similarity": 0.8}})
assert c.semantic.enabled is True
assert c.semantic.min_similarity == pytest.approx(0.8)
assert c.to_json()["semantic"]["enabled"] is True
def test_knowledge_base_version_defaults_to_none_and_is_omitted() -> None:
c = Config()
assert c.resolver.knowledge_base_version is None
+3
View File
@@ -25,6 +25,9 @@ def default_inputs() -> dict:
"prompt_remove_duplicate_tags": True,
"debug_enabled": False,
"debug_level": "basic",
"semantic_enabled": False,
"semantic_min_similarity": 0.5,
"semantic_backend": "char_ngram",
}
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the deterministic offline embedding backend (issue #114, epic #34)."""
from __future__ import annotations
import pytest
from compiler.common.embedding import (
CharNGramEmbeddingBackend,
cosine_similarity,
get_backend,
)
def test_embedding_is_deterministic() -> None:
backend = CharNGramEmbeddingBackend()
assert backend.embed(["thighhighs"]) == backend.embed(["thighhighs"])
def test_vectors_are_l2_normalized() -> None:
(vector,) = CharNGramEmbeddingBackend().embed(["blue eyes"])
magnitude = sum(weight * weight for weight in vector.values())
assert magnitude == pytest.approx(1.0)
def test_identical_text_has_self_similarity_one() -> None:
(vector,) = CharNGramEmbeddingBackend().embed(["long hair"])
assert cosine_similarity(vector, vector) == pytest.approx(1.0)
def test_similar_text_scores_higher_than_dissimilar() -> None:
backend = CharNGramEmbeddingBackend()
(thighhighs,) = backend.embed(["thighhighs"])
(thigh_highs,) = backend.embed(["thigh highs"])
(unrelated,) = backend.embed(["castle"])
assert cosine_similarity(thighhighs, thigh_highs) > cosine_similarity(thighhighs, unrelated)
def test_normalization_ignores_case_and_underscores() -> None:
backend = CharNGramEmbeddingBackend()
assert backend.embed(["Long_Hair"]) == backend.embed(["long hair"])
def test_empty_text_yields_empty_vector() -> None:
assert CharNGramEmbeddingBackend().embed([""]) == [{}]
assert cosine_similarity({}, {"abc": 1.0}) == pytest.approx(0.0)
def test_get_backend_returns_default_and_rejects_unknown() -> None:
assert isinstance(get_backend("char_ngram"), CharNGramEmbeddingBackend)
with pytest.raises(ValueError):
get_backend("nope")
+65
View File
@@ -0,0 +1,65 @@
"""Tests for the Knowledge Base embedding index (issue #115, epic #34)."""
from __future__ import annotations
from pathlib import Path
from compiler.common.embedding_index import EmbeddingIndex, build_index_rows, load_index
from compiler.common.knowledge_base import KnowledgeBase, KnowledgeBaseEntry, load_knowledge_base
REPO_ROOT = Path(__file__).resolve().parent.parent
KB_DIR = REPO_ROOT / "knowledge_base"
INDEX_FILE = REPO_ROOT / "data" / "kb_embedding_index.json"
def _entry(id_, tags, aliases=()):
return KnowledgeBaseEntry(id=id_, tags=tuple(tags), category="clothing", aliases=tuple(aliases))
# --- committed reference index ---------------------------------------------
def test_committed_index_maps_known_concept_to_expected_entry() -> None:
index = load_index(INDEX_FILE)
match = index.nearest("stockings")
assert match.entry_id == "thighhighs" # via the ingested alias surface
assert match.score > 0.99 # near-exact (weights are rounded in the snapshot)
def test_index_only_references_real_kb_entries() -> None:
kb = load_knowledge_base(KB_DIR)
index = load_index(INDEX_FILE)
# Every row id is a real entry: a fallback can never invent a tag.
match = index.nearest("blue eyes")
assert kb.get(match.entry_id) is not None
# --- build determinism ------------------------------------------------------
def test_build_is_deterministic() -> None:
kb = KnowledgeBase(
[
_entry("thighhighs", ["thighhighs"], aliases=["thigh highs", "stockings"]),
_entry("pantyhose", ["pantyhose"], aliases=["tights"]),
]
)
assert build_index_rows(kb) == build_index_rows(kb)
def test_nearest_returns_expected_surface() -> None:
kb = KnowledgeBase(
[
_entry("thighhighs", ["thighhighs"], aliases=["stockings"]),
_entry("dress", ["dress"]),
]
)
index = EmbeddingIndex(build_index_rows(kb))
match = index.nearest("stockings")
assert match.entry_id == "thighhighs"
assert match.surface == "stockings"
def test_empty_query_has_no_neighbour() -> None:
index = EmbeddingIndex(build_index_rows(KnowledgeBase([_entry("dress", ["dress"])])))
assert index.nearest("") is None