feat(compiler): add typed, injectable configuration system (#5) (#49)

Implement the compiler configuration per MASTER_SPEC §23 in
compiler/common/config.py, with a strict configuration schema.

- Frozen dataclasses per section (analyzer, resolver, validator, prompt_builder,
  debug) plus a top-level `schema` version; every default matches §23.2.
- Config.from_json validates against schemas/json/configuration.schema.json and
  raises ConfigError carrying a Fatal SC0014 message on invalid input; every
  section/field is optional and falls back to defaults.
- load_config(path) reads and validates a JSON file.
- Deterministic by construction: additionalProperties:false rejects unknown keys
  (including any random seed); temperature has minimum 0.
- Injected into stages; no global/singleton config state.
- Ships config/default_config.json (the §23.2 example) as the canonical default.

Tests: defaults match §23.2, full/empty/partial load, file load, and
invalid-config rejection (wrong type, unknown field, forbidden seed, negative
temperature) producing SC0014.

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-07 19:11:44 +02:00
committed by GitHub
co-authored by kunail0804 Claude Opus 4.8
parent 60eac7f1d4
commit d1bebdb571
4 changed files with 396 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
"""Typed, injectable compiler configuration (MASTER_SPEC §23).
Configuration changes behaviour, never architecture, and MUST stay deterministic:
there is no random-seed option and unknown keys are rejected. Every section and
field is optional; omitted values fall back to the §23.2 defaults.
Configuration is loaded once and injected into stages — this module intentionally
exposes no global/singleton config. Invalid configuration is a Fatal condition
(the compiler cannot run) and raises :class:`ConfigError` carrying an ``SC0014``
message.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Any
from compiler.common.result import Message, Severity
from schemas.validation import validate_document
_CONFIG_SCHEMA = "configuration"
class ConfigError(Exception):
"""Raised when configuration is invalid; carries the Fatal SC0014 message."""
def __init__(self, message: Message) -> None:
super().__init__(message.description)
self.message = message
def _section_from_json(section_cls: type, data: Mapping[str, Any] | None) -> Any:
"""Build a config section, filling missing fields with the section's defaults."""
values = data or {}
known = {f.name for f in fields(section_cls)}
return section_cls(**{name: values[name] for name in known if name in values})
@dataclass(frozen=True)
class AnalyzerConfig:
"""Scene Analyzer options (§23.2). temperature 0 maximizes repeatability."""
backend: str = "ollama"
model: str = "llama3"
temperature: float = 0.0
max_retries: int = 3
timeout: int = 60
def to_json(self) -> dict[str, Any]:
return {
"backend": self.backend,
"model": self.model,
"temperature": self.temperature,
"max_retries": self.max_retries,
"timeout": self.timeout,
}
@dataclass(frozen=True)
class ResolverConfig:
"""Resolver options (§23.2)."""
knowledge_base: str = "knowledge_base/"
strict_mode: bool = True
allow_aliases: bool = True
expansion_enabled: bool = True
max_expansion_depth: int = 8
def to_json(self) -> dict[str, Any]:
return {
"knowledge_base": self.knowledge_base,
"strict_mode": self.strict_mode,
"allow_aliases": self.allow_aliases,
"expansion_enabled": self.expansion_enabled,
"max_expansion_depth": self.max_expansion_depth,
}
@dataclass(frozen=True)
class ValidatorConfig:
"""Scene Validator options (§23.2)."""
allow_unknown_fields: bool = False
def to_json(self) -> dict[str, Any]:
return {"allow_unknown_fields": self.allow_unknown_fields}
@dataclass(frozen=True)
class PromptBuilderConfig:
"""Prompt Builder options (§23.2)."""
target: str = "easy_illustrious"
separator: str = ","
trim_empty_outputs: bool = True
remove_duplicate_tags: bool = True
def to_json(self) -> dict[str, Any]:
return {
"target": self.target,
"separator": self.separator,
"trim_empty_outputs": self.trim_empty_outputs,
"remove_duplicate_tags": self.remove_duplicate_tags,
}
@dataclass(frozen=True)
class DebugConfig:
"""Debug/logging options (§23.2)."""
enabled: bool = False
level: str = "basic"
def to_json(self) -> dict[str, Any]:
return {"enabled": self.enabled, "level": self.level}
@dataclass(frozen=True)
class Config:
"""The complete compiler configuration (§23.2), immutable once produced."""
schema: str = "1.0"
analyzer: AnalyzerConfig = AnalyzerConfig()
resolver: ResolverConfig = ResolverConfig()
validator: ValidatorConfig = ValidatorConfig()
prompt_builder: PromptBuilderConfig = PromptBuilderConfig()
debug: DebugConfig = DebugConfig()
@classmethod
def from_json(cls, data: Mapping[str, Any]) -> Config:
"""Validate and build a Config; raise ConfigError (SC0014) if invalid."""
issues = validate_document(data, _CONFIG_SCHEMA)
if issues:
details = "; ".join(f"{i.path or '<root>'}: {i.message}" for i in issues)
raise ConfigError(
Message(
code="SC0014",
severity=Severity.FATAL,
title="Invalid configuration",
description=f"Configuration failed schema validation: {details}",
context={"issues": [i.message for i in issues]},
)
)
return cls(
schema=data.get("schema", "1.0"),
analyzer=_section_from_json(AnalyzerConfig, data.get("analyzer")),
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")),
debug=_section_from_json(DebugConfig, data.get("debug")),
)
def to_json(self) -> dict[str, Any]:
return {
"schema": self.schema,
"analyzer": self.analyzer.to_json(),
"resolver": self.resolver.to_json(),
"validator": self.validator.to_json(),
"prompt_builder": self.prompt_builder.to_json(),
"debug": self.debug.to_json(),
}
def load_config(path: str | Path) -> Config:
"""Load and validate configuration from a JSON file.
Raises:
ConfigError: If the file contents fail schema validation (SC0014).
"""
data = json.loads(Path(path).read_text(encoding="utf-8"))
return Config.from_json(data)
+30
View File
@@ -0,0 +1,30 @@
{
"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
},
"validator": {
"allow_unknown_fields": false
},
"prompt_builder": {
"target": "easy_illustrious",
"separator": ",",
"trim_empty_outputs": true,
"remove_duplicate_tags": true
},
"debug": {
"enabled": false,
"level": "basic"
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "configuration",
"version": "1.0",
"title": "Configuration",
"description": "Global compiler configuration. MASTER_SPEC §23. Strict: unknown keys (including any random seed) are rejected. Every section and field is optional; omitted values fall back to defaults.",
"type": "object",
"additionalProperties": false,
"properties": {
"schema": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
"analyzer": {
"type": "object",
"additionalProperties": false,
"properties": {
"backend": { "type": "string" },
"model": { "type": "string" },
"temperature": { "type": "number", "minimum": 0 },
"max_retries": { "type": "integer", "minimum": 0 },
"timeout": { "type": "number", "exclusiveMinimum": 0 }
}
},
"resolver": {
"type": "object",
"additionalProperties": false,
"properties": {
"knowledge_base": { "type": "string" },
"strict_mode": { "type": "boolean" },
"allow_aliases": { "type": "boolean" },
"expansion_enabled": { "type": "boolean" },
"max_expansion_depth": { "type": "integer", "minimum": 1 }
}
},
"validator": {
"type": "object",
"additionalProperties": false,
"properties": {
"allow_unknown_fields": { "type": "boolean" }
}
},
"prompt_builder": {
"type": "object",
"additionalProperties": false,
"properties": {
"target": { "type": "string" },
"separator": { "type": "string" },
"trim_empty_outputs": { "type": "boolean" },
"remove_duplicate_tags": { "type": "boolean" }
}
},
"debug": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"level": { "type": "string" }
}
}
}
}
+133
View File
@@ -0,0 +1,133 @@
"""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,
},
"validator": {"allow_unknown_fields": False},
"prompt_builder": {
"target": "easy_illustrious",
"separator": ",",
"trim_empty_outputs": True,
"remove_duplicate_tags": True,
},
"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_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()