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:
co-authored by
kunail0804
Claude Opus 4.8
parent
60eac7f1d4
commit
d1bebdb571
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user