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>
161 lines
5.1 KiB
Python
161 lines
5.1 KiB
Python
"""Tests for the configuration system (issue #5, MASTER_SPEC §23)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from compiler.common.config import Config, ConfigError, load_config
|
|
from compiler.common.result import Severity
|
|
|
|
# The canonical example from MASTER_SPEC §23.2.
|
|
EXAMPLE_CONFIG = {
|
|
"schema": "1.0",
|
|
"analyzer": {
|
|
"backend": "ollama",
|
|
"model": "llama3",
|
|
"temperature": 0.0,
|
|
"max_retries": 3,
|
|
"timeout": 60,
|
|
},
|
|
"resolver": {
|
|
"knowledge_base": "knowledge_base/",
|
|
"strict_mode": True,
|
|
"allow_aliases": True,
|
|
"expansion_enabled": True,
|
|
"max_expansion_depth": 8,
|
|
"include_nsfw": False,
|
|
},
|
|
"validator": {"allow_unknown_fields": False},
|
|
"prompt_builder": {
|
|
"target": "easy_illustrious",
|
|
"separator": ",",
|
|
"remove_duplicate_tags": True,
|
|
},
|
|
"semantic": {"enabled": False, "min_similarity": 0.5, "backend": "char_ngram"},
|
|
"debug": {"enabled": False, "level": "basic"},
|
|
}
|
|
|
|
|
|
# --- defaults --------------------------------------------------------------
|
|
|
|
|
|
def test_defaults_match_section_23_2() -> None:
|
|
c = Config()
|
|
assert c.schema == "1.0"
|
|
assert c.analyzer.backend == "ollama"
|
|
assert c.analyzer.temperature == pytest.approx(0.0)
|
|
assert c.analyzer.max_retries == 3
|
|
assert c.analyzer.timeout == 60
|
|
assert c.resolver.knowledge_base == "knowledge_base/"
|
|
assert c.resolver.max_expansion_depth == 8
|
|
assert c.resolver.strict_mode is True
|
|
assert c.validator.allow_unknown_fields is False
|
|
assert c.prompt_builder.separator == ","
|
|
assert c.prompt_builder.target == "easy_illustrious"
|
|
assert c.debug.enabled is False
|
|
assert c.debug.level == "basic"
|
|
|
|
|
|
def test_defaults_equal_full_example() -> None:
|
|
assert Config() == Config.from_json(EXAMPLE_CONFIG)
|
|
|
|
|
|
# --- load ------------------------------------------------------------------
|
|
|
|
|
|
def test_full_example_loads_and_roundtrips() -> None:
|
|
c = Config.from_json(EXAMPLE_CONFIG)
|
|
assert c.to_json() == EXAMPLE_CONFIG
|
|
|
|
|
|
def test_empty_config_uses_all_defaults() -> None:
|
|
assert Config.from_json({}) == Config()
|
|
|
|
|
|
def test_partial_config_fills_defaults() -> None:
|
|
c = Config.from_json({"analyzer": {"model": "mistral"}})
|
|
assert c.analyzer.model == "mistral"
|
|
assert c.analyzer.temperature == pytest.approx(0.0) # default preserved
|
|
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
|
|
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")
|
|
assert load_config(path) == Config.from_json(EXAMPLE_CONFIG)
|
|
|
|
|
|
# --- invalid config -> SC0014 ----------------------------------------------
|
|
|
|
|
|
def test_wrong_type_rejected_with_sc0014() -> None:
|
|
with pytest.raises(ConfigError) as exc:
|
|
Config.from_json({"analyzer": {"temperature": "hot"}})
|
|
assert exc.value.message.code == "SC0014"
|
|
assert exc.value.message.severity is Severity.FATAL
|
|
|
|
|
|
def test_unknown_field_rejected() -> None:
|
|
with pytest.raises(ConfigError):
|
|
Config.from_json({"analyzer": {"unknown_option": 1}})
|
|
|
|
|
|
def test_random_seed_option_is_forbidden() -> None:
|
|
with pytest.raises(ConfigError) as exc:
|
|
Config.from_json({"analyzer": {"seed": 42}})
|
|
assert exc.value.message.code == "SC0014"
|
|
|
|
|
|
def test_negative_temperature_rejected() -> None:
|
|
with pytest.raises(ConfigError):
|
|
Config.from_json({"analyzer": {"temperature": -1.0}})
|
|
|
|
|
|
# --- immutability ----------------------------------------------------------
|
|
|
|
|
|
def test_config_is_immutable() -> None:
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
Config().analyzer.temperature = 1.0 # type: ignore[misc]
|
|
|
|
|
|
# --- shipped default file --------------------------------------------------
|
|
|
|
|
|
def test_shipped_default_config_file_matches_defaults() -> None:
|
|
from pathlib import Path
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
default_file = repo_root / "config" / "default_config.json"
|
|
data = json.loads(default_file.read_text(encoding="utf-8"))
|
|
assert Config.from_json(data) == Config()
|