feat(kb): versioned Knowledge Base datasets with manifest + migration (#36) (#138)

Closes #127, #128, #129.

- manifest.json (new knowledge_base_manifest schema) records a dataset version,
  deterministic content_hash, source_vocab digest, optional entry_schema_version.
  Loader reads/skips it; regeneration stamps it deterministically.
- resolver.knowledge_base_version config option (Configuration + KB Loader nodes)
  pins a version; unavailable version -> clear SC0004.
- In-memory entry migration adapts older datasets to the current entry schema at
  load time without rewriting pinned files; too-old versions error clearly.

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 16:11:23 +02:00
committed by GitHub
co-authored by kunail0804 Claude Opus 4.8
parent af775d48c0
commit bb846c8aa0
15 changed files with 616 additions and 15 deletions
+10
View File
@@ -7,6 +7,16 @@ All notable changes to Scene Compiler are documented here. The project follows
### Added
- **Knowledge Base versioning (epic #36).** A Knowledge Base directory can now
ship a `manifest.json` (new `knowledge_base_manifest` schema) recording a
dataset `version`, a deterministic `content_hash`, the `source_vocab` digest,
and an optional `entry_schema_version`. Workflows can pin a version via the new
`resolver.knowledge_base_version` config option (Configuration node + Knowledge
Base Loader node `version` input); an unavailable version fails with a clear
error. Older datasets are adapted to the current entry schema **in memory** at
load time (pinned files are never rewritten); versions too old to adapt error
clearly. A missing manifest defaults to an implicit `v1`, so existing installs
load unchanged.
- **Concept fidelity (epic #86).** The compiler no longer silently drops
meaningful parts of the input:
- The Resolver now recovers leading modifiers the head-noun reduction drops:
+11 -2
View File
@@ -65,7 +65,12 @@ class AnalyzerConfig:
@dataclass(frozen=True)
class ResolverConfig:
"""Resolver options (§23.2)."""
"""Resolver options (§23.2).
``knowledge_base_version`` pins the Knowledge Base dataset version to load
(matched against the dataset manifest, epic #36). Unset (``None``) means "use
whatever is on the configured path", preserving the pre-versioning behaviour.
"""
knowledge_base: str = "knowledge_base/"
strict_mode: bool = True
@@ -73,9 +78,10 @@ class ResolverConfig:
expansion_enabled: bool = True
max_expansion_depth: int = 8
include_nsfw: bool = False
knowledge_base_version: str | None = None
def to_json(self) -> dict[str, Any]:
return {
result: dict[str, Any] = {
"knowledge_base": self.knowledge_base,
"strict_mode": self.strict_mode,
"allow_aliases": self.allow_aliases,
@@ -83,6 +89,9 @@ class ResolverConfig:
"max_expansion_depth": self.max_expansion_depth,
"include_nsfw": self.include_nsfw,
}
if self.knowledge_base_version is not None:
result["knowledge_base_version"] = self.knowledge_base_version
return result
@dataclass(frozen=True)
+132
View File
@@ -0,0 +1,132 @@
"""Knowledge Base dataset manifest (MASTER_SPEC §30.2, epic #36).
A Knowledge Base directory may ship a ``manifest.json`` that pins the dataset's
``version`` so a workflow can select and reproduce a specific Knowledge Base
build. The manifest is *data about the dataset*, not an entry file, so the loader
skips it when reading entries.
The manifest is deterministic: its ``content_hash`` is a stable digest of every
entry file (sorted by name), and ``source_vocab`` is the digest of the committed
Danbooru vocabulary snapshot the generated entries came from. Regenerating an
unchanged Knowledge Base therefore produces a byte-identical manifest — there is
no wall-clock build date. A directory without a manifest defaults to the implicit
``v1`` version so existing installs keep loading unchanged.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
MANIFEST_FILENAME = "manifest.json"
# The manifest document's own schema version (bumped if the manifest shape
# changes), distinct from the dataset ``version`` it records.
MANIFEST_SCHEMA_VERSION = "1.0"
# The version assumed when a Knowledge Base ships no manifest (backward compat).
IMPLICIT_VERSION = "1.0.0"
@dataclass(frozen=True)
class KnowledgeBaseManifest:
"""The parsed manifest of a Knowledge Base dataset."""
version: str
content_hash: str
source_vocab: str | None = None
entry_schema_version: str | None = None
manifest_schema_version: str = MANIFEST_SCHEMA_VERSION
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {
"manifest_schema_version": self.manifest_schema_version,
"version": self.version,
"content_hash": self.content_hash,
}
if self.source_vocab is not None:
result["source_vocab"] = self.source_vocab
if self.entry_schema_version is not None:
result["entry_schema_version"] = self.entry_schema_version
return result
def _entry_files(directory: Path) -> list[Path]:
"""Every Knowledge Base entry file (``*.json`` except the manifest), sorted."""
return [p for p in sorted(directory.glob("*.json")) if p.name != MANIFEST_FILENAME]
def compute_content_hash(directory: str | Path) -> str:
"""Return a deterministic ``sha256:`` digest of all entry files in a directory.
The digest covers each entry file's name and raw bytes in sorted order, so it
is stable across runs and changes only when an entry file's content changes.
"""
directory = Path(directory)
digest = hashlib.sha256()
for path in _entry_files(directory):
digest.update(path.name.encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return f"sha256:{digest.hexdigest()}"
def _hash_file(path: Path) -> str | None:
if not path.is_file():
return None
return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}"
def build_manifest(
directory: str | Path, version: str, source_vocab_path: str | Path
) -> KnowledgeBaseManifest:
"""Build a manifest for a directory, hashing its entries and the source vocab."""
directory = Path(directory)
return KnowledgeBaseManifest(
version=version,
content_hash=compute_content_hash(directory),
source_vocab=_hash_file(Path(source_vocab_path)),
)
def write_manifest(
directory: str | Path, version: str, source_vocab_path: str | Path
) -> KnowledgeBaseManifest:
"""Stamp ``manifest.json`` into ``directory`` deterministically and return it."""
directory = Path(directory)
manifest = build_manifest(directory, version, source_vocab_path)
path = directory / MANIFEST_FILENAME
path.write_text(
json.dumps(manifest.to_json(), indent=2, ensure_ascii=False, sort_keys=True) + "\n",
encoding="utf-8",
)
return manifest
def load_manifest(directory: str | Path) -> KnowledgeBaseManifest:
"""Load the manifest of a Knowledge Base directory.
A missing manifest yields the implicit ``v1`` version (with the content hash
computed on the fly), so a directory without a manifest still reports a
coherent version and loads unchanged.
"""
directory = Path(directory)
path = directory / MANIFEST_FILENAME
if not path.is_file():
return KnowledgeBaseManifest(
version=IMPLICIT_VERSION,
content_hash=compute_content_hash(directory),
source_vocab=None,
)
data = json.loads(path.read_text(encoding="utf-8"))
return KnowledgeBaseManifest(
version=data["version"],
content_hash=data["content_hash"],
source_vocab=data.get("source_vocab"),
entry_schema_version=data.get("entry_schema_version"),
manifest_schema_version=data.get("manifest_schema_version", MANIFEST_SCHEMA_VERSION),
)
+105
View File
@@ -0,0 +1,105 @@
"""Cross-version Knowledge Base entry migration (MASTER_SPEC §30.2, epic #36).
A pinned Knowledge Base may have been produced against an older entry-schema
version than the running code. To keep pinned datasets loadable as the format
evolves, entries are *adapted in memory at load time* — the on-disk (pinned)
files are never rewritten.
Compatibility policy
--------------------
- The current entry-schema version is :data:`CURRENT_ENTRY_SCHEMA_VERSION` (it
matches ``schemas/json/knowledge_base_entry.schema.json``'s ``version``).
- A dataset declares its entry-schema version via the manifest's
``entry_schema_version`` field. A dataset with **no** manifest, or a manifest
without that field, is assumed to already be at the current version — so the
shipped reference Knowledge Base loads with no adaptation.
- Each supported older version has a forward adapter that upgrades a raw entry
dict to the next version. Adapters are applied in sequence up to the current
version, then the entry is validated and materialised as usual.
- Versions older than :data:`MIN_SUPPORTED_ENTRY_SCHEMA_VERSION` cannot be adapted
and raise a clear error rather than loading a misinterpreted dataset.
Adapters must be pure and deterministic: the same pinned dataset always adapts to
the same in-memory entries.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
# Keep in sync with schemas/json/knowledge_base_entry.schema.json "version".
CURRENT_ENTRY_SCHEMA_VERSION = "1.1"
MIN_SUPPORTED_ENTRY_SCHEMA_VERSION = "1.0"
class UnsupportedKnowledgeBaseVersion(Exception):
"""Raised when a dataset's entry-schema version is too old to adapt."""
def _version_tuple(version: str) -> tuple[int, ...]:
return tuple(int(part) for part in version.split("."))
def _adapt_1_0_to_1_1(entry: dict[str, Any]) -> dict[str, Any]:
"""Entry schema 1.0 → 1.1.
In 1.0 the explicit/general distinction was carried by a boolean ``nsfw``
field; 1.1 replaced it with the ``rating`` enum (``general``/``explicit``).
Translate the old field so a 1.0 dataset keeps its NSFW gating.
"""
if "nsfw" in entry:
entry.setdefault("rating", "explicit" if entry["nsfw"] else "general")
del entry["nsfw"]
return entry
# Forward adapters, keyed by the version they upgrade FROM. Each returns the
# entry at the next version. Extend this chain as the entry schema evolves.
_ADAPTERS: dict[str, tuple[str, Callable[[dict[str, Any]], dict[str, Any]]]] = {
"1.0": ("1.1", _adapt_1_0_to_1_1),
}
def adapt_entries(
entries: list[Mapping[str, Any]], source_version: str | None
) -> list[dict[str, Any]]:
"""Adapt raw entry dicts from ``source_version`` to the current entry schema.
Args:
entries: The raw (parsed JSON) entries as loaded from disk.
source_version: The dataset's entry-schema version, or ``None`` to assume
the current version (no adaptation).
Returns:
New entry dicts at the current entry-schema version. Input dicts are not
mutated.
Raises:
UnsupportedKnowledgeBaseVersion: If ``source_version`` is older than the
minimum supported version, or references an unknown version with no
adapter path to the current one.
"""
version = source_version or CURRENT_ENTRY_SCHEMA_VERSION
adapted = [dict(entry) for entry in entries]
if version == CURRENT_ENTRY_SCHEMA_VERSION:
return adapted
if _version_tuple(version) < _version_tuple(MIN_SUPPORTED_ENTRY_SCHEMA_VERSION):
raise UnsupportedKnowledgeBaseVersion(
f"Knowledge Base entry-schema version '{version}' is older than the "
f"minimum supported '{MIN_SUPPORTED_ENTRY_SCHEMA_VERSION}' and cannot be adapted."
)
# Walk the adapter chain forward to the current version.
while version != CURRENT_ENTRY_SCHEMA_VERSION:
step = _ADAPTERS.get(version)
if step is None:
raise UnsupportedKnowledgeBaseVersion(
f"No migration path from Knowledge Base entry-schema version '{version}' "
f"to '{CURRENT_ENTRY_SCHEMA_VERSION}'."
)
next_version, adapter = step
adapted = [adapter(entry) for entry in adapted]
version = next_version
return adapted
+75 -8
View File
@@ -21,6 +21,8 @@ from pathlib import Path
from types import MappingProxyType
from typing import Any
from compiler.common.kb_manifest import IMPLICIT_VERSION, MANIFEST_FILENAME, load_manifest
from compiler.common.kb_migration import UnsupportedKnowledgeBaseVersion, adapt_entries
from compiler.common.kb_validation import validate_knowledge_base
from compiler.common.log import StructuredLogger
from compiler.common.result import Message, Severity
@@ -65,7 +67,7 @@ class KnowledgeBaseEntry:
class KnowledgeBase:
"""An immutable, validated Knowledge Base with id and alias lookups."""
def __init__(self, entries: list[KnowledgeBaseEntry]) -> None:
def __init__(self, entries: list[KnowledgeBaseEntry], version: str = IMPLICIT_VERSION) -> None:
by_id: dict[str, KnowledgeBaseEntry] = {}
alias_to_id: dict[str, str] = {}
for entry in entries:
@@ -74,6 +76,12 @@ class KnowledgeBase:
alias_to_id[alias] = entry.id
self._by_id: Mapping[str, KnowledgeBaseEntry] = MappingProxyType(by_id)
self._alias_to_id: Mapping[str, str] = MappingProxyType(alias_to_id)
self._version = version
@property
def version(self) -> str:
"""The dataset version from the manifest (implicit ``v1`` when absent)."""
return self._version
@property
def by_id(self) -> Mapping[str, KnowledgeBaseEntry]:
@@ -107,6 +115,8 @@ def _read_entries(directory: Path) -> tuple[list[dict], list[Message]]:
entries: list[dict] = []
problems: list[Message] = []
for path in sorted(directory.glob("*.json")):
if path.name == MANIFEST_FILENAME:
continue # the manifest is dataset metadata, not an entry array
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
@@ -134,15 +144,59 @@ def _load_problem(description: str, source: str) -> Message:
def load_knowledge_base(
directory: str | Path,
logger: StructuredLogger | None = None,
requested_version: str | None = None,
) -> KnowledgeBase:
"""Load and validate a Knowledge Base directory into an in-memory index.
Args:
directory: The Knowledge Base directory to load.
logger: Optional structured logger.
requested_version: If set, the dataset manifest ``version`` must match it
exactly; otherwise a Fatal ``SC0004`` is raised (the requested version
is unavailable at this path). ``None`` loads whatever is on the path.
Raises:
KnowledgeBaseError: If any file cannot be read or the Knowledge Base
fails validation. Nothing is loaded in that case.
KnowledgeBaseError: If any file cannot be read, the Knowledge Base fails
validation, or the requested version is unavailable. Nothing is loaded
in that case.
"""
directory = Path(directory)
manifest = load_manifest(directory)
if requested_version is not None and manifest.version != requested_version:
raise KnowledgeBaseError(
Message(
code="SC0004",
severity=Severity.FATAL,
title="Knowledge Base Load Failure",
description=(
f"Requested Knowledge Base version '{requested_version}' is not "
f"available at '{directory}' (found version '{manifest.version}')."
),
context={
"id": str(directory),
"requested_version": requested_version,
"available_version": manifest.version,
},
),
findings=[],
)
raw_entries, problems = _read_entries(directory)
try:
raw_entries = adapt_entries(raw_entries, manifest.entry_schema_version)
except UnsupportedKnowledgeBaseVersion as exc:
raise KnowledgeBaseError(
Message(
code="SC0004",
severity=Severity.FATAL,
title="Knowledge Base Load Failure",
description=str(exc),
context={
"id": str(directory),
"entry_schema_version": manifest.entry_schema_version,
},
),
findings=[],
) from exc
findings = problems + validate_knowledge_base(raw_entries)
if findings:
raise KnowledgeBaseError(
@@ -160,24 +214,37 @@ def load_knowledge_base(
)
entries = [KnowledgeBaseEntry.from_json(entry) for entry in raw_entries]
kb = KnowledgeBase(entries)
kb = KnowledgeBase(entries, version=manifest.version)
if logger is not None:
logger.basic("knowledge_base_loaded", directory=str(directory), entries=len(kb))
logger.basic(
"knowledge_base_loaded",
directory=str(directory),
entries=len(kb),
version=kb.version,
)
return kb
class KnowledgeBaseLoader:
"""Loads a Knowledge Base once and caches it; reload is explicit and safe."""
def __init__(self, directory: str | Path, logger: StructuredLogger | None = None) -> None:
def __init__(
self,
directory: str | Path,
logger: StructuredLogger | None = None,
requested_version: str | None = None,
) -> None:
self._directory = Path(directory)
self._logger = logger
self._requested_version = requested_version
self._cache: KnowledgeBase | None = None
def get(self) -> KnowledgeBase:
"""Return the cached Knowledge Base, loading it on first use."""
if self._cache is None:
self._cache = load_knowledge_base(self._directory, self._logger)
self._cache = load_knowledge_base(
self._directory, self._logger, self._requested_version
)
return self._cache
def reload(self) -> KnowledgeBase:
@@ -186,6 +253,6 @@ class KnowledgeBaseLoader:
On failure the previously cached Knowledge Base is left untouched (the
load raises before the cache is replaced).
"""
kb = load_knowledge_base(self._directory, self._logger)
kb = load_knowledge_base(self._directory, self._logger, self._requested_version)
self._cache = kb
return kb
+2 -1
View File
@@ -13,7 +13,8 @@
"allow_aliases": true,
"expansion_enabled": true,
"max_expansion_depth": 8,
"include_nsfw": false
"include_nsfw": false,
"knowledge_base_version": null
},
"validator": {
"allow_unknown_fields": false
+6
View File
@@ -0,0 +1,6 @@
{
"content_hash": "sha256:a9f132307076670a20999a5be768537c2a6b93f7a0afdd41ce46a3da08175b68",
"manifest_schema_version": "1.0",
"source_vocab": "sha256:d9238cecb70736aa81e1297b174d2ed0c4555c0b214651d33c955d9e5ce0e88b",
"version": "1.0.0"
}
+9
View File
@@ -48,8 +48,11 @@ class ConfigurationNode:
"debug_enabled": ("BOOLEAN", {"default": False}),
"debug_level": (_DEBUG_LEVELS, {"default": "basic"}),
},
# Appended at the END so saved workflows keep their positional widget
# values (ComfyUI stores widget values positionally).
"optional": {
"analyzer_system_prompt": ("STRING", {"multiline": True, "default": ""}),
"resolver_knowledge_base_version": ("STRING", {"default": ""}),
},
}
@@ -72,6 +75,7 @@ class ConfigurationNode:
debug_enabled: bool,
debug_level: str,
analyzer_system_prompt: str = "",
resolver_knowledge_base_version: str = "",
) -> tuple[Any, str]:
analyzer: dict[str, Any] = {
"model": analyzer_model,
@@ -91,6 +95,11 @@ class ConfigurationNode:
"expansion_enabled": resolver_expansion_enabled,
"max_expansion_depth": resolver_max_expansion_depth,
"include_nsfw": resolver_include_nsfw,
**(
{"knowledge_base_version": resolver_knowledge_base_version}
if resolver_knowledge_base_version
else {}
),
},
"validator": {"allow_unknown_fields": validator_allow_unknown_fields},
"prompt_builder": {
+9 -3
View File
@@ -40,13 +40,19 @@ class KnowledgeBaseLoaderNode:
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {"path": ("STRING", {"default": "knowledge_base/"})},
"optional": {"reload": ("INT", {"default": 0, "min": 0})},
# New widgets are appended at the END so saved workflows keep their
# positional widget values (ComfyUI stores widgets positionally).
"optional": {
"reload": ("INT", {"default": 0, "min": 0}),
"version": ("STRING", {"default": ""}),
},
}
def run(self, path: str, reload: int = 0) -> tuple[Any, str, str, str]:
def run(self, path: str, reload: int = 0, version: str = "") -> tuple[Any, str, str, str]:
resolved = _resolve_path(path)
requested_version = version or None
try:
knowledge_base = load_knowledge_base(resolved)
knowledge_base = load_knowledge_base(resolved, requested_version=requested_version)
except KnowledgeBaseError as error:
errors = format_messages([error.message, *error.findings])
return (None, "", errors, f"Failed to load Knowledge Base from: {resolved}")
+2 -1
View File
@@ -29,7 +29,8 @@
"allow_aliases": { "type": "boolean" },
"expansion_enabled": { "type": "boolean" },
"max_expansion_depth": { "type": "integer", "minimum": 1 },
"include_nsfw": { "type": "boolean" }
"include_nsfw": { "type": "boolean" },
"knowledge_base_version": { "type": ["string", "null"] }
}
},
"validator": {
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "knowledge_base_manifest",
"version": "1.0",
"title": "Knowledge Base Manifest",
"description": "Metadata for a versioned Knowledge Base dataset. MASTER_SPEC §30.2 (epic #36). Deterministic: content_hash and source_vocab are content digests, so regenerating an unchanged dataset yields a byte-identical manifest.",
"type": "object",
"required": ["manifest_schema_version", "version", "content_hash"],
"additionalProperties": false,
"properties": {
"manifest_schema_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
"version": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)*$" },
"content_hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
"source_vocab": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
"entry_schema_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" }
}
}
+12
View File
@@ -20,13 +20,22 @@ Usage:
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.kb_manifest import write_manifest # noqa: E402
_REPO_ROOT = Path(__file__).resolve().parent.parent
_VOCAB = _REPO_ROOT / "data" / "danbooru_vocab.txt"
_KB_DIR = _REPO_ROOT / "knowledge_base"
_GEN_PREFIX = "gen_"
# The dataset version stamped into the manifest on each rebuild. Bump this when a
# regeneration changes the dataset in a way workflows should be able to pin.
_DATASET_VERSION = "1.0.0"
# Raffle category -> one of the 19 canonical SceneCompiler categories.
_CATEGORY_MAP = {
"clothes_and_accessories": "clothing",
@@ -138,8 +147,11 @@ def generate() -> dict[str, int]:
out.write_text(json.dumps(entries, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
counts[sc_category] = len(entries)
manifest = write_manifest(_KB_DIR, _DATASET_VERSION, _VOCAB)
total = sum(counts.values())
print(f"generated {total} entries across {len(counts)} category files")
print(f" stamped manifest version {manifest.version} ({manifest.content_hash[:19]}...)")
print(f" skipped {skipped_reserved} (already hand-curated), {skipped_unmapped} (unmapped)")
for cat, n in sorted(counts.items(), key=lambda x: -x[1]):
print(f" {n:6} gen_{cat}.json")
+12
View File
@@ -81,6 +81,18 @@ def test_partial_config_fills_defaults() -> None:
assert c.resolver.max_expansion_depth == 8
def test_knowledge_base_version_defaults_to_none_and_is_omitted() -> None:
c = Config()
assert c.resolver.knowledge_base_version is None
assert "knowledge_base_version" not in c.resolver.to_json() # omitted when unset
def test_knowledge_base_version_roundtrips_when_set() -> None:
c = Config.from_json({"resolver": {"knowledge_base_version": "2.0.0"}})
assert c.resolver.knowledge_base_version == "2.0.0"
assert c.to_json()["resolver"]["knowledge_base_version"] == "2.0.0"
def test_load_config_reads_file(tmp_path) -> None:
path = tmp_path / "config.json"
path.write_text(json.dumps(EXAMPLE_CONFIG), encoding="utf-8")
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the Knowledge Base dataset manifest (issue #127, epic #36)."""
from __future__ import annotations
import json
from pathlib import Path
from compiler.common.kb_manifest import (
IMPLICIT_VERSION,
MANIFEST_FILENAME,
compute_content_hash,
load_manifest,
write_manifest,
)
from compiler.common.knowledge_base import load_knowledge_base
from schemas.validation import validate_document
REPO_ROOT = Path(__file__).resolve().parent.parent
KB_DIR = REPO_ROOT / "knowledge_base"
# --- shipped reference manifest --------------------------------------------
def test_reference_manifest_exists_and_validates() -> None:
data = json.loads((KB_DIR / MANIFEST_FILENAME).read_text(encoding="utf-8"))
assert validate_document(data, "knowledge_base_manifest") == []
assert data["version"] == "1.0.0"
def test_loader_reads_manifest_version() -> None:
kb = load_knowledge_base(KB_DIR)
assert kb.version == "1.0.0"
def test_manifest_content_hash_matches_current_entries() -> None:
# The committed manifest is a faithful, deterministic stamp of the dataset.
manifest = load_manifest(KB_DIR)
assert manifest.content_hash == compute_content_hash(KB_DIR)
# --- backward compatibility -------------------------------------------------
def test_missing_manifest_defaults_to_implicit_v1(tmp_path) -> None:
(tmp_path / "hair.json").write_text(
'[{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]',
encoding="utf-8",
)
assert load_manifest(tmp_path).version == IMPLICIT_VERSION
assert load_knowledge_base(tmp_path).version == IMPLICIT_VERSION
# --- determinism ------------------------------------------------------------
def test_write_manifest_is_deterministic(tmp_path) -> None:
vocab = tmp_path / "vocab.txt"
vocab.write_text("clothing\tshirt\n", encoding="utf-8")
(tmp_path / "clothing.json").write_text(
'[{"id": "shirt", "tags": ["shirt"], "category": "clothing"}]',
encoding="utf-8",
)
first = write_manifest(tmp_path, "2.0.0", vocab)
first_bytes = (tmp_path / MANIFEST_FILENAME).read_bytes()
second = write_manifest(tmp_path, "2.0.0", vocab)
second_bytes = (tmp_path / MANIFEST_FILENAME).read_bytes()
assert first == second
assert first_bytes == second_bytes # byte-identical regeneration
def test_requesting_available_version_loads(tmp_path) -> None:
(tmp_path / "hair.json").write_text(
'[{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]',
encoding="utf-8",
)
write_manifest(tmp_path, "3.1.0", tmp_path / "missing_vocab.txt")
kb = load_knowledge_base(tmp_path, requested_version="3.1.0")
assert kb.version == "3.1.0"
assert kb.get("long_hair") is not None
def test_requesting_unavailable_version_errors_clearly(tmp_path) -> None:
from compiler.common.knowledge_base import KnowledgeBaseError
(tmp_path / "hair.json").write_text(
'[{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]',
encoding="utf-8",
)
write_manifest(tmp_path, "3.1.0", tmp_path / "missing_vocab.txt")
try:
load_knowledge_base(tmp_path, requested_version="9.9.9")
except KnowledgeBaseError as error:
assert error.message.code == "SC0004"
assert "9.9.9" in error.message.description
assert error.message.context["available_version"] == "3.1.0"
else:
raise AssertionError("expected KnowledgeBaseError for unavailable version")
def test_manifest_is_not_read_as_an_entry_file(tmp_path) -> None:
# A manifest.json in the KB dir must not break loading (it is not an entry array).
(tmp_path / "hair.json").write_text(
'[{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]',
encoding="utf-8",
)
write_manifest(tmp_path, "1.0.0", tmp_path / "missing_vocab.txt")
kb = load_knowledge_base(tmp_path)
assert len(kb) == 1 # the manifest was skipped, only the one entry loaded
+105
View File
@@ -0,0 +1,105 @@
"""Tests for cross-version Knowledge Base loading (issue #129, epic #36)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from compiler.common.kb_manifest import (
MANIFEST_FILENAME,
compute_content_hash,
load_manifest,
)
from compiler.common.kb_migration import (
CURRENT_ENTRY_SCHEMA_VERSION,
UnsupportedKnowledgeBaseVersion,
adapt_entries,
)
from compiler.common.knowledge_base import KnowledgeBaseError, load_knowledge_base
REPO_ROOT = Path(__file__).resolve().parent.parent
KB_DIR = REPO_ROOT / "knowledge_base"
def _write_manifest(directory: Path, entry_schema_version: str) -> None:
(directory / MANIFEST_FILENAME).write_text(
json.dumps(
{
"manifest_schema_version": "1.0",
"version": "1.0.0",
"content_hash": compute_content_hash(directory),
"entry_schema_version": entry_schema_version,
}
),
encoding="utf-8",
)
# --- adapter unit level -----------------------------------------------------
def test_no_source_version_is_treated_as_current() -> None:
entries = [{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]
assert adapt_entries(entries, None) == entries
def test_adapter_1_0_translates_nsfw_to_rating() -> None:
entries = [
{"id": "a", "tags": ["a"], "category": "body", "nsfw": True},
{"id": "b", "tags": ["b"], "category": "body", "nsfw": False},
]
adapted = adapt_entries(entries, "1.0")
assert adapted[0]["rating"] == "explicit" and "nsfw" not in adapted[0]
assert adapted[1]["rating"] == "general" and "nsfw" not in adapted[1]
assert entries[0]["nsfw"] is True # inputs not mutated
def test_too_old_version_raises() -> None:
with pytest.raises(UnsupportedKnowledgeBaseVersion):
adapt_entries([], "0.9")
# --- loader level -----------------------------------------------------------
def test_current_reference_kb_loads_without_adaptation() -> None:
assert load_manifest(KB_DIR).entry_schema_version is None
kb = load_knowledge_base(KB_DIR) # loads clean, no migration applied
assert len(kb) > 100
def test_older_dataset_adapts_and_resolves(tmp_path) -> None:
(tmp_path / "body.json").write_text(
json.dumps(
[{"id": "bare_shoulders", "tags": ["bare shoulders"], "category": "body", "nsfw": True}]
),
encoding="utf-8",
)
_write_manifest(tmp_path, "1.0")
kb = load_knowledge_base(tmp_path)
entry = kb.get("bare_shoulders")
assert entry is not None
assert entry.rating == "explicit" # adapted from nsfw=true
def test_unsupported_old_dataset_errors_clearly(tmp_path) -> None:
(tmp_path / "hair.json").write_text(
json.dumps([{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]),
encoding="utf-8",
)
_write_manifest(tmp_path, "0.9")
with pytest.raises(KnowledgeBaseError) as exc:
load_knowledge_base(tmp_path)
assert exc.value.message.code == "SC0004"
assert "0.9" in exc.value.message.description
def test_current_version_string_matches_entry_schema_file() -> None:
schema = json.loads(
(REPO_ROOT / "schemas" / "json" / "knowledge_base_entry.schema.json").read_text(
encoding="utf-8"
)
)
assert schema["version"] == CURRENT_ENTRY_SCHEMA_VERSION