Files
markitect-main/tests/unit/infospace/test_config.py
tegwick b20fe4db68 feat(infospace): add infospace configuration model and state (S2.1)
InfospaceConfig (topic, disciplines, schemas, competency questions,
viability thresholds, pipeline) with YAML load/save and directory
discovery. InfospaceState aggregates entities, evaluations, and
viability checks for status reporting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 01:44:14 +01:00

401 lines
13 KiB
Python

"""Tests for markitect.infospace.config and state."""
from datetime import datetime
from pathlib import Path
import pytest
from markitect.infospace.config import (
DisciplineBinding,
InfospaceConfig,
PipelineConfig,
PipelineStage,
SchemaRegistry,
TopicConfig,
ViabilityThreshold,
find_infospace_config,
load_infospace_config,
save_infospace_config,
)
from markitect.infospace.state import (
InfospaceState,
ViabilityResult,
build_state,
)
from markitect.infospace.models import EntityMeta
from markitect.infospace.evaluation import (
EntityEvaluation,
EvaluationSnapshot,
ScoreEntry,
)
# ── Helpers ──────────────────────────────────────────────────────────
_SAMPLE_YAML = """\
topic:
name: "The Wealth of Nations"
domain: "Classical Economics"
sources: artifacts/sources/
disciplines:
- name: "Viable System Model"
path: artifacts/vsm-reference/
schemas:
entity: schemas/economic-entity-schema-v1.0.md
mapping: schemas/vsm-mapping-schema-v1.0.md
competency_questions: schemas/competency-questions.md
viability:
coverage_ratio:
min: 0.60
per_entity_mean:
min: 3.5
redundancy_ratio:
max: 0.05
pipeline:
stages:
- template: extract-entities
spaces: [sources, guidelines]
- template: map-to-vsm
spaces: [entities, vsm-reference]
post_batch:
- template: assess-metrics
"""
def _sample_config() -> InfospaceConfig:
return InfospaceConfig(
topic=TopicConfig(name="Test Topic", domain="Testing"),
disciplines=[DisciplineBinding(name="VSM", path="vsm/")],
schemas=SchemaRegistry(entity="schemas/entity.md"),
competency_questions="schemas/cq.md",
viability={
"coverage_ratio": ViabilityThreshold("coverage_ratio", min=0.6),
"redundancy_ratio": ViabilityThreshold("redundancy_ratio", max=0.05),
},
)
def _sample_entities(n=5) -> list:
return [
EntityMeta(
slug=f"entity-{i}",
title=f"Entity {i}",
h1_raw=f"Entity {i}",
domain="Production" if i % 2 == 0 else "Distribution",
)
for i in range(n)
]
# ── TopicConfig ──────────────────────────────────────────────────────
class TestTopicConfig:
def test_round_trip(self):
tc = TopicConfig("WoN", "Economics", "sources/")
d = tc.to_dict()
restored = TopicConfig.from_dict(d)
assert restored.name == "WoN"
assert restored.domain == "Economics"
assert restored.sources == "sources/"
def test_minimal(self):
tc = TopicConfig.from_dict({"name": "Minimal"})
assert tc.domain == ""
assert tc.sources == ""
def test_to_dict_omits_empty(self):
tc = TopicConfig("X")
d = tc.to_dict()
assert "domain" not in d
assert "sources" not in d
# ── DisciplineBinding ────────────────────────────────────────────────
class TestDisciplineBinding:
def test_round_trip(self):
db = DisciplineBinding("VSM", "path/to/vsm")
d = db.to_dict()
restored = DisciplineBinding.from_dict(d)
assert restored.name == "VSM"
assert restored.path == "path/to/vsm"
# ── SchemaRegistry ───────────────────────────────────────────────────
class TestSchemaRegistry:
def test_round_trip(self):
sr = SchemaRegistry(entity="e.md", mapping="m.md", analysis="a.md")
d = sr.to_dict()
restored = SchemaRegistry.from_dict(d)
assert restored.entity == "e.md"
assert restored.mapping == "m.md"
def test_extra_schemas(self):
sr = SchemaRegistry.from_dict({"entity": "e.md", "custom": "c.md"})
assert sr.entity == "e.md"
assert sr.extra == {"custom": "c.md"}
# ── ViabilityThreshold ──────────────────────────────────────────────
class TestViabilityThreshold:
def test_min_check(self):
t = ViabilityThreshold("x", min=0.5)
assert t.check(0.6) is True
assert t.check(0.5) is True
assert t.check(0.4) is False
def test_max_check(self):
t = ViabilityThreshold("x", max=0.1)
assert t.check(0.05) is True
assert t.check(0.1) is True
assert t.check(0.2) is False
def test_min_and_max(self):
t = ViabilityThreshold("x", min=0.3, max=0.7)
assert t.check(0.5) is True
assert t.check(0.2) is False
assert t.check(0.8) is False
def test_no_bounds_always_passes(self):
t = ViabilityThreshold("x")
assert t.check(999.0) is True
# ── PipelineConfig ──────────────────────────────────────────────────
class TestPipelineConfig:
def test_round_trip(self):
pc = PipelineConfig(
stages=[PipelineStage("extract", ["s1", "s2"])],
post_batch=[PipelineStage("assess")],
)
d = pc.to_dict()
restored = PipelineConfig.from_dict(d)
assert len(restored.stages) == 1
assert restored.stages[0].template == "extract"
assert restored.stages[0].spaces == ["s1", "s2"]
assert len(restored.post_batch) == 1
# ── InfospaceConfig ─────────────────────────────────────────────────
class TestInfospaceConfig:
def test_to_dict_from_dict_round_trip(self):
cfg = _sample_config()
d = cfg.to_dict()
restored = InfospaceConfig.from_dict(d)
assert restored.topic.name == "Test Topic"
assert len(restored.disciplines) == 1
assert restored.schemas.entity == "schemas/entity.md"
assert restored.competency_questions == "schemas/cq.md"
assert len(restored.viability) == 2
def test_viability_thresholds_preserved(self):
cfg = _sample_config()
d = cfg.to_dict()
restored = InfospaceConfig.from_dict(d)
assert restored.viability["coverage_ratio"].min == 0.6
assert restored.viability["redundancy_ratio"].max == 0.05
def test_default_dirs(self):
cfg = InfospaceConfig(topic=TopicConfig("X"))
assert cfg.entities_dir == "output/entities"
assert cfg.evaluations_dir == "output/evaluations"
assert cfg.metrics_dir == "output/metrics"
def test_custom_dirs(self):
cfg = InfospaceConfig.from_dict({
"topic": {"name": "X"},
"entities_dir": "custom/entities",
})
assert cfg.entities_dir == "custom/entities"
# ── YAML I/O ────────────────────────────────────────────────────────
class TestYAMLIO:
def test_save_load_round_trip(self, tmp_path):
cfg = _sample_config()
p = tmp_path / "infospace.yaml"
save_infospace_config(cfg, p)
loaded = load_infospace_config(p)
assert loaded.topic.name == cfg.topic.name
assert len(loaded.viability) == len(cfg.viability)
def test_load_full_example(self, tmp_path):
p = tmp_path / "infospace.yaml"
p.write_text(_SAMPLE_YAML, encoding="utf-8")
cfg = load_infospace_config(p)
assert cfg.topic.name == "The Wealth of Nations"
assert cfg.topic.domain == "Classical Economics"
assert len(cfg.disciplines) == 1
assert cfg.disciplines[0].name == "Viable System Model"
assert cfg.schemas.entity == "schemas/economic-entity-schema-v1.0.md"
assert cfg.competency_questions == "schemas/competency-questions.md"
assert len(cfg.viability) == 3
assert cfg.viability["coverage_ratio"].min == 0.60
assert cfg.viability["redundancy_ratio"].max == 0.05
assert cfg.pipeline is not None
assert len(cfg.pipeline.stages) == 2
assert len(cfg.pipeline.post_batch) == 1
def test_load_missing_file(self, tmp_path):
with pytest.raises(FileNotFoundError):
load_infospace_config(tmp_path / "nonexistent.yaml")
def test_load_missing_topic(self, tmp_path):
p = tmp_path / "bad.yaml"
p.write_text("schemas:\n entity: x.md\n")
with pytest.raises(ValueError, match="topic"):
load_infospace_config(p)
def test_save_creates_parent_dirs(self, tmp_path):
cfg = InfospaceConfig(topic=TopicConfig("X"))
p = tmp_path / "deep" / "nested" / "infospace.yaml"
save_infospace_config(cfg, p)
assert p.exists()
class TestFindConfig:
def test_finds_config_in_current_dir(self, tmp_path):
(tmp_path / "infospace.yaml").write_text("topic:\n name: X\n")
found = find_infospace_config(tmp_path)
assert found is not None
assert found.name == "infospace.yaml"
def test_finds_config_in_parent(self, tmp_path):
(tmp_path / "infospace.yaml").write_text("topic:\n name: X\n")
child = tmp_path / "sub" / "dir"
child.mkdir(parents=True)
found = find_infospace_config(child)
assert found is not None
def test_returns_none_if_not_found(self, tmp_path):
assert find_infospace_config(tmp_path) is None
# ── InfospaceState ──────────────────────────────────────────────────
class TestInfospaceState:
def test_entity_count(self):
cfg = _sample_config()
state = InfospaceState(config=cfg, entities=_sample_entities(5))
assert state.entity_count == 5
def test_topic_name(self):
cfg = _sample_config()
state = InfospaceState(config=cfg)
assert state.topic_name == "Test Topic"
def test_domains(self):
cfg = _sample_config()
state = InfospaceState(config=cfg, entities=_sample_entities(4))
assert "Production" in state.domains
assert "Distribution" in state.domains
def test_has_evaluations(self):
cfg = _sample_config()
state = InfospaceState(config=cfg)
assert state.has_evaluations is False
snap = EvaluationSnapshot(
snapshot_id="s1",
created_at=datetime(2026, 1, 1),
schema_name="Test",
entity_count=0,
)
state.latest_snapshot = snap
assert state.has_evaluations is True
class TestViabilityCheck:
def test_all_pass(self):
cfg = _sample_config()
state = InfospaceState(config=cfg)
metrics = {"coverage_ratio": 0.8, "redundancy_ratio": 0.02}
results = state.check_viability(metrics)
assert all(r.passed for r in results)
assert state.is_viable is True
def test_one_fails(self):
cfg = _sample_config()
state = InfospaceState(config=cfg)
metrics = {"coverage_ratio": 0.4, "redundancy_ratio": 0.02}
results = state.check_viability(metrics)
assert not all(r.passed for r in results)
assert state.is_viable is False
def test_missing_metric_defaults_to_zero(self):
cfg = _sample_config()
state = InfospaceState(config=cfg)
# coverage_ratio min=0.6, missing → 0.0 → fails
results = state.check_viability({})
coverage = next(r for r in results if r.metric == "coverage_ratio")
assert coverage.passed is False
assert coverage.value == 0.0
def test_viability_counts(self):
cfg = _sample_config()
state = InfospaceState(config=cfg)
metrics = {"coverage_ratio": 0.8, "redundancy_ratio": 0.2}
state.check_viability(metrics)
assert state.viability_pass_count == 1 # coverage passes
assert state.viability_total_count == 2
def test_no_thresholds_not_viable(self):
cfg = InfospaceConfig(topic=TopicConfig("X"))
state = InfospaceState(config=cfg)
assert state.is_viable is False
class TestBuildState:
def test_builds_with_entities(self):
cfg = _sample_config()
entities = _sample_entities(3)
state = build_state(cfg, entities=entities)
assert state.entity_count == 3
def test_builds_with_metrics(self):
cfg = _sample_config()
metrics = {"coverage_ratio": 0.9, "redundancy_ratio": 0.01}
state = build_state(cfg, metrics=metrics)
assert state.is_viable is True
def test_summary(self):
cfg = _sample_config()
entities = _sample_entities(3)
metrics = {"coverage_ratio": 0.9, "redundancy_ratio": 0.01}
state = build_state(cfg, entities=entities, metrics=metrics)
s = state.summary()
assert s["topic"] == "Test Topic"
assert s["entity_count"] == 3
assert s["viable"] is True
class TestViabilityResult:
def test_to_dict(self):
t = ViabilityThreshold("x", min=0.5)
r = ViabilityResult(metric="x", value=0.7, threshold=t, passed=True)
d = r.to_dict()
assert d["metric"] == "x"
assert d["value"] == 0.7
assert d["passed"] is True
assert d["min"] == 0.5
assert "max" not in d