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:
@@ -39,6 +39,23 @@ from .evaluation_io import (
|
|||||||
write_entity_evaluation,
|
write_entity_evaluation,
|
||||||
write_snapshot,
|
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__ = [
|
__all__ = [
|
||||||
"EntityMeta",
|
"EntityMeta",
|
||||||
@@ -72,4 +89,19 @@ __all__ = [
|
|||||||
"read_snapshot",
|
"read_snapshot",
|
||||||
"write_entity_evaluation",
|
"write_entity_evaluation",
|
||||||
"write_snapshot",
|
"write_snapshot",
|
||||||
|
# Config
|
||||||
|
"DisciplineBinding",
|
||||||
|
"InfospaceConfig",
|
||||||
|
"PipelineConfig",
|
||||||
|
"PipelineStage",
|
||||||
|
"SchemaRegistry",
|
||||||
|
"TopicConfig",
|
||||||
|
"ViabilityThreshold",
|
||||||
|
"find_infospace_config",
|
||||||
|
"load_infospace_config",
|
||||||
|
"save_infospace_config",
|
||||||
|
# State
|
||||||
|
"InfospaceState",
|
||||||
|
"ViabilityResult",
|
||||||
|
"build_state",
|
||||||
]
|
]
|
||||||
|
|||||||
309
markitect/infospace/config.py
Normal file
309
markitect/infospace/config.py
Normal 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
|
||||||
141
markitect/infospace/state.py
Normal file
141
markitect/infospace/state.py
Normal 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
|
||||||
400
tests/unit/infospace/test_config.py
Normal file
400
tests/unit/infospace/test_config.py
Normal 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
|
||||||
Reference in New Issue
Block a user