From d1bebdb571ada4cede5603ab5c5c4396c79d1d84 Mon Sep 17 00:00:00 2001 From: Crespin Alexandre <114501859+kunail0804@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:11:44 +0200 Subject: [PATCH] feat(compiler): add typed, injectable configuration system (#5) (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.8 --- compiler/common/config.py | 174 +++++++++++++++++++++++++ config/default_config.json | 30 +++++ schemas/json/configuration.schema.json | 59 +++++++++ tests/test_config.py | 133 +++++++++++++++++++ 4 files changed, 396 insertions(+) create mode 100644 compiler/common/config.py create mode 100644 config/default_config.json create mode 100644 schemas/json/configuration.schema.json create mode 100644 tests/test_config.py diff --git a/compiler/common/config.py b/compiler/common/config.py new file mode 100644 index 0000000..a740ba7 --- /dev/null +++ b/compiler/common/config.py @@ -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 ''}: {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) diff --git a/config/default_config.json b/config/default_config.json new file mode 100644 index 0000000..263c1cf --- /dev/null +++ b/config/default_config.json @@ -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" + } +} diff --git a/schemas/json/configuration.schema.json b/schemas/json/configuration.schema.json new file mode 100644 index 0000000..b080aa7 --- /dev/null +++ b/schemas/json/configuration.schema.json @@ -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" } + } + } + } +} diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..71b0c8b --- /dev/null +++ b/tests/test_config.py @@ -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()