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>
This commit is contained in:
2026-02-19 01:44:14 +01:00
parent 144a88c0c2
commit b20fe4db68
4 changed files with 882 additions and 0 deletions

View File

@@ -39,6 +39,23 @@ from .evaluation_io import (
write_entity_evaluation,
write_snapshot,
)
from .config import (
DisciplineBinding,
InfospaceConfig,
PipelineConfig,
PipelineStage,
SchemaRegistry,
TopicConfig,
ViabilityThreshold,
find_infospace_config,
load_infospace_config,
save_infospace_config,
)
from .state import (
InfospaceState,
ViabilityResult,
build_state,
)
__all__ = [
"EntityMeta",
@@ -72,4 +89,19 @@ __all__ = [
"read_snapshot",
"write_entity_evaluation",
"write_snapshot",
# Config
"DisciplineBinding",
"InfospaceConfig",
"PipelineConfig",
"PipelineStage",
"SchemaRegistry",
"TopicConfig",
"ViabilityThreshold",
"find_infospace_config",
"load_infospace_config",
"save_infospace_config",
# State
"InfospaceState",
"ViabilityResult",
"build_state",
]

View File

@@ -0,0 +1,309 @@
"""
Infospace configuration model and YAML loader.
An infospace is declared via an ``infospace.yaml`` file that specifies
its topic, disciplines, schemas, competency questions, and viability
thresholds. This module provides the data models and I/O for that
configuration.
Example ``infospace.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
competency_questions: schemas/competency-questions.md
viability:
coverage_ratio: { min: 0.60 }
per_entity_mean: { min: 3.5 }
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@dataclass
class TopicConfig:
"""The subject matter an infospace explains.
Attributes:
name: Human-readable topic name.
domain: Broader knowledge domain.
sources: Path (relative to infospace root) to source material.
"""
name: str
domain: str = ""
sources: str = ""
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"name": self.name}
if self.domain:
d["domain"] = self.domain
if self.sources:
d["sources"] = self.sources
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> TopicConfig:
return cls(
name=data["name"],
domain=data.get("domain", ""),
sources=data.get("sources", ""),
)
@dataclass
class DisciplineBinding:
"""An external infospace applied as an analytical lens.
Attributes:
name: Human-readable discipline name.
path: Path to the discipline infospace (relative to root).
"""
name: str
path: str = ""
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"name": self.name}
if self.path:
d["path"] = self.path
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> DisciplineBinding:
return cls(name=data["name"], path=data.get("path", ""))
@dataclass
class SchemaRegistry:
"""Schema paths governing entity and document structure.
All paths are relative to the infospace root directory.
"""
entity: str = ""
mapping: str = ""
analysis: str = ""
extra: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {}
if self.entity:
d["entity"] = self.entity
if self.mapping:
d["mapping"] = self.mapping
if self.analysis:
d["analysis"] = self.analysis
d.update(self.extra)
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> SchemaRegistry:
known = {"entity", "mapping", "analysis"}
extra = {k: v for k, v in data.items() if k not in known}
return cls(
entity=data.get("entity", ""),
mapping=data.get("mapping", ""),
analysis=data.get("analysis", ""),
extra=extra,
)
@dataclass
class ViabilityThreshold:
"""Threshold for a single viability metric.
At least one of *min* or *max* should be set.
"""
metric: str
min: Optional[float] = None
max: Optional[float] = None
def check(self, value: float) -> bool:
"""Return ``True`` if *value* is within the threshold."""
if self.min is not None and value < self.min:
return False
if self.max is not None and value > self.max:
return False
return True
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {}
if self.min is not None:
d["min"] = self.min
if self.max is not None:
d["max"] = self.max
return d
@dataclass
class PipelineStage:
"""A single stage in the processing pipeline."""
template: str
spaces: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"template": self.template}
if self.spaces:
d["spaces"] = self.spaces
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> PipelineStage:
return cls(
template=data["template"],
spaces=data.get("spaces", []),
)
@dataclass
class PipelineConfig:
"""Processing pipeline configuration."""
stages: List[PipelineStage] = field(default_factory=list)
post_batch: List[PipelineStage] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {}
if self.stages:
d["stages"] = [s.to_dict() for s in self.stages]
if self.post_batch:
d["post_batch"] = [s.to_dict() for s in self.post_batch]
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> PipelineConfig:
return cls(
stages=[PipelineStage.from_dict(s) for s in data.get("stages", [])],
post_batch=[PipelineStage.from_dict(s) for s in data.get("post_batch", [])],
)
@dataclass
class InfospaceConfig:
"""Complete infospace configuration, loaded from ``infospace.yaml``.
This is the declarative description of an infospace: what it
explains, through which lenses, governed by which schemas, and
what quality thresholds it must meet.
"""
topic: TopicConfig
disciplines: List[DisciplineBinding] = field(default_factory=list)
schemas: SchemaRegistry = field(default_factory=SchemaRegistry)
competency_questions: str = ""
viability: Dict[str, ViabilityThreshold] = field(default_factory=dict)
pipeline: Optional[PipelineConfig] = None
entities_dir: str = "output/entities"
evaluations_dir: str = "output/evaluations"
metrics_dir: str = "output/metrics"
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"topic": self.topic.to_dict()}
if self.disciplines:
d["disciplines"] = [db.to_dict() for db in self.disciplines]
schemas_dict = self.schemas.to_dict()
if schemas_dict:
d["schemas"] = schemas_dict
if self.competency_questions:
d["competency_questions"] = self.competency_questions
if self.viability:
d["viability"] = {
name: t.to_dict() for name, t in self.viability.items()
}
if self.pipeline:
d["pipeline"] = self.pipeline.to_dict()
if self.entities_dir != "output/entities":
d["entities_dir"] = self.entities_dir
if self.evaluations_dir != "output/evaluations":
d["evaluations_dir"] = self.evaluations_dir
if self.metrics_dir != "output/metrics":
d["metrics_dir"] = self.metrics_dir
return d
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> InfospaceConfig:
viability_raw = data.get("viability", {})
viability = {
name: ViabilityThreshold(metric=name, **bounds)
for name, bounds in viability_raw.items()
}
pipeline_raw = data.get("pipeline")
pipeline = PipelineConfig.from_dict(pipeline_raw) if pipeline_raw else None
return cls(
topic=TopicConfig.from_dict(data["topic"]),
disciplines=[
DisciplineBinding.from_dict(d)
for d in data.get("disciplines", [])
],
schemas=SchemaRegistry.from_dict(data.get("schemas", {})),
competency_questions=data.get("competency_questions", ""),
viability=viability,
pipeline=pipeline,
entities_dir=data.get("entities_dir", "output/entities"),
evaluations_dir=data.get("evaluations_dir", "output/evaluations"),
metrics_dir=data.get("metrics_dir", "output/metrics"),
)
def load_infospace_config(path: Path) -> InfospaceConfig:
"""Load an :class:`InfospaceConfig` from a YAML file.
Args:
path: Path to ``infospace.yaml``.
Raises:
FileNotFoundError: If *path* does not exist.
ValueError: If required fields are missing.
"""
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Expected a YAML mapping in {path}")
if "topic" not in data:
raise ValueError(f"Missing required 'topic' key in {path}")
return InfospaceConfig.from_dict(data)
def save_infospace_config(config: InfospaceConfig, path: Path) -> None:
"""Write an :class:`InfospaceConfig` to a YAML file."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml.safe_dump(
config.to_dict(),
default_flow_style=False,
sort_keys=False,
),
encoding="utf-8",
)
def find_infospace_config(start: Optional[Path] = None) -> Optional[Path]:
"""Walk up from *start* looking for ``infospace.yaml``.
Returns the path to the config file, or ``None``.
"""
current = (start or Path.cwd()).resolve()
for directory in [current, *current.parents]:
candidate = directory / "infospace.yaml"
if candidate.is_file():
return candidate
return None

View File

@@ -0,0 +1,141 @@
"""
Infospace runtime state.
Computed from the current entities, evaluations, and metrics on disk.
Provides the data behind ``markitect infospace status`` and
``markitect infospace viability``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from markitect.infospace.config import InfospaceConfig, ViabilityThreshold
from markitect.infospace.models import EntityMeta
from markitect.infospace.evaluation import EvaluationSnapshot
@dataclass
class ViabilityResult:
"""Result of checking a single viability threshold."""
metric: str
value: float
threshold: ViabilityThreshold
passed: bool
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"metric": self.metric,
"value": self.value,
"passed": self.passed,
}
if self.threshold.min is not None:
d["min"] = self.threshold.min
if self.threshold.max is not None:
d["max"] = self.threshold.max
return d
@dataclass
class InfospaceState:
"""Current runtime state of an infospace.
Aggregates entity metadata, evaluation results, and viability
checks into a single queryable object.
"""
config: InfospaceConfig
entities: List[EntityMeta] = field(default_factory=list)
latest_snapshot: Optional[EvaluationSnapshot] = None
viability_results: List[ViabilityResult] = field(default_factory=list)
computed_at: datetime = field(default_factory=datetime.utcnow)
@property
def entity_count(self) -> int:
return len(self.entities)
@property
def topic_name(self) -> str:
return self.config.topic.name
@property
def is_viable(self) -> bool:
"""``True`` if all viability thresholds are met."""
if not self.viability_results:
return False
return all(r.passed for r in self.viability_results)
@property
def viability_pass_count(self) -> int:
return sum(1 for r in self.viability_results if r.passed)
@property
def viability_total_count(self) -> int:
return len(self.viability_results)
@property
def domains(self) -> List[str]:
"""Distinct domain values across all entities."""
return sorted({e.domain for e in self.entities if e.domain})
@property
def has_evaluations(self) -> bool:
return self.latest_snapshot is not None
def check_viability(self, metrics: Dict[str, float]) -> List[ViabilityResult]:
"""Check *metrics* against the configured viability thresholds.
Updates :attr:`viability_results` and returns the results.
"""
results: List[ViabilityResult] = []
for name, threshold in self.config.viability.items():
value = metrics.get(name, 0.0)
results.append(ViabilityResult(
metric=name,
value=value,
threshold=threshold,
passed=threshold.check(value),
))
self.viability_results = results
return results
def summary(self) -> Dict[str, Any]:
"""Return a summary dict suitable for display or serialisation."""
d: Dict[str, Any] = {
"topic": self.topic_name,
"entity_count": self.entity_count,
"domains": self.domains,
"has_evaluations": self.has_evaluations,
}
if self.viability_results:
d["viable"] = self.is_viable
d["viability_pass"] = self.viability_pass_count
d["viability_total"] = self.viability_total_count
if self.latest_snapshot:
d["last_evaluated"] = self.latest_snapshot.created_at.isoformat()
return d
def build_state(
config: InfospaceConfig,
entities: Optional[List[EntityMeta]] = None,
snapshot: Optional[EvaluationSnapshot] = None,
metrics: Optional[Dict[str, float]] = None,
) -> InfospaceState:
"""Build an :class:`InfospaceState` from available data.
This is a convenience function that assembles the state object
and optionally runs viability checks if *metrics* are provided.
"""
state = InfospaceState(
config=config,
entities=entities or [],
latest_snapshot=snapshot,
)
if metrics is not None:
state.check_viability(metrics)
return state

View File

@@ -0,0 +1,400 @@
"""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