Files
markitect-main/markitect/infospace/composition.py
tegwick b76d6d38c1 feat(infospace): add composition model for discipline binding (S2.6)
Discipline resolution, viability checking, entity access, stale
mapping detection, and binding management. CLI commands: bind-discipline,
disciplines, stale-mappings.

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

282 lines
8.4 KiB
Python

"""
Infospace composition model.
Allows one infospace to use another as a discipline — a reusable
framework of concepts applied as an analytical lens.
Key operations:
- Resolve and validate discipline bindings
- Check discipline viability (must meet its own thresholds)
- List discipline entities as mapping targets
- Detect stale mappings when discipline content changes
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from markitect.infospace.config import (
DisciplineBinding,
InfospaceConfig,
load_infospace_config,
)
from markitect.infospace.entity_parser import parse_entity_directory
from markitect.infospace.history import get_latest_snapshot, read_metrics_file
from markitect.infospace.models import EntityMeta
from markitect.infospace.state import InfospaceState, ViabilityResult, build_state
@dataclass
class DisciplineStatus:
"""Status of a bound discipline infospace."""
name: str
path: str
resolved_path: Optional[Path] = None
exists: bool = False
has_config: bool = False
entity_count: int = 0
is_viable: bool = False
viability_results: List[ViabilityResult] = field(default_factory=list)
error: str = ""
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"name": self.name,
"path": self.path,
"exists": self.exists,
"has_config": self.has_config,
"entity_count": self.entity_count,
"is_viable": self.is_viable,
}
if self.viability_results:
d["viability"] = [r.to_dict() for r in self.viability_results]
if self.error:
d["error"] = self.error
return d
@dataclass
class StaleMappingInfo:
"""Information about a mapping that may be stale."""
entity_slug: str
discipline_entity: str
reason: str
def to_dict(self) -> Dict[str, Any]:
return {
"entity_slug": self.entity_slug,
"discipline_entity": self.discipline_entity,
"reason": self.reason,
}
# ── Resolution ───────────────────────────────────────────────────────
def resolve_discipline_path(
binding: DisciplineBinding, root: Path
) -> Optional[Path]:
"""Resolve a discipline binding to an absolute path.
Tries the binding's path relative to *root*, then as an absolute path.
Returns ``None`` if the directory doesn't exist.
"""
if not binding.path:
return None
# Try relative to root first
candidate = root / binding.path
if candidate.is_dir():
return candidate.resolve()
# Try as absolute
candidate = Path(binding.path)
if candidate.is_dir():
return candidate.resolve()
return None
def load_discipline_config(
binding: DisciplineBinding, root: Path
) -> Optional[InfospaceConfig]:
"""Load the infospace config for a bound discipline.
Returns ``None`` if the discipline path cannot be resolved or
has no ``infospace.yaml``.
"""
disc_path = resolve_discipline_path(binding, root)
if disc_path is None:
return None
config_file = disc_path / "infospace.yaml"
if not config_file.is_file():
return None
return load_infospace_config(config_file)
# ── Viability checking ───────────────────────────────────────────────
def check_discipline_status(
binding: DisciplineBinding, root: Path
) -> DisciplineStatus:
"""Check the full status of a bound discipline.
Resolves the path, loads config, counts entities, and checks
viability against the discipline's own thresholds.
"""
status = DisciplineStatus(name=binding.name, path=binding.path)
disc_path = resolve_discipline_path(binding, root)
if disc_path is None:
status.error = f"Path not found: {binding.path}"
return status
status.resolved_path = disc_path
status.exists = True
# Load config
config_file = disc_path / "infospace.yaml"
if not config_file.is_file():
status.error = "No infospace.yaml found"
return status
disc_config = load_infospace_config(config_file)
status.has_config = True
# Count entities
entities_dir = disc_path / disc_config.entities_dir
if entities_dir.is_dir():
entities = parse_entity_directory(entities_dir)
status.entity_count = len(entities)
# Check viability
if disc_config.viability:
metrics = read_metrics_file(disc_path / disc_config.metrics_dir / "metrics.yaml")
if metrics:
state = build_state(disc_config, metrics=metrics)
status.viability_results = state.viability_results
status.is_viable = state.is_viable
return status
def get_discipline_entities(
binding: DisciplineBinding, root: Path
) -> List[EntityMeta]:
"""Get all entities from a bound discipline infospace."""
disc_path = resolve_discipline_path(binding, root)
if disc_path is None:
return []
disc_config = load_discipline_config(binding, root)
if disc_config is None:
return []
entities_dir = disc_path / disc_config.entities_dir
if not entities_dir.is_dir():
return []
return parse_entity_directory(entities_dir)
# ── Stale mapping detection ─────────────────────────────────────────
def _content_digest(entity: EntityMeta) -> str:
"""Compute a short content digest for an entity."""
content = f"{entity.slug}|{entity.definition}|{entity.domain}"
return hashlib.sha256(content.encode()).hexdigest()[:12]
def compute_discipline_digests(
binding: DisciplineBinding, root: Path
) -> Dict[str, str]:
"""Compute content digests for all entities in a discipline.
Returns ``{slug: digest}`` mapping.
"""
entities = get_discipline_entities(binding, root)
return {e.slug: _content_digest(e) for e in entities}
def find_stale_mappings(
config: InfospaceConfig,
root: Path,
mapping_references: Optional[Dict[str, List[str]]] = None,
) -> List[StaleMappingInfo]:
"""Find mappings that may be stale due to discipline changes.
Args:
config: The infospace configuration.
root: Project root directory.
mapping_references: ``{entity_slug: [discipline_entity_slugs]}``
mapping of local entities to the discipline entities they
reference. If ``None``, returns an empty list (no mapping
data available).
Returns:
List of stale mapping info objects.
"""
if not mapping_references:
return []
stale: List[StaleMappingInfo] = []
for binding in config.disciplines:
disc_entities = get_discipline_entities(binding, root)
disc_slugs = {e.slug for e in disc_entities}
for entity_slug, refs in mapping_references.items():
for ref_slug in refs:
if ref_slug not in disc_slugs:
stale.append(StaleMappingInfo(
entity_slug=entity_slug,
discipline_entity=ref_slug,
reason=f"Discipline entity '{ref_slug}' no longer exists in '{binding.name}'",
))
return stale
# ── Binding management ───────────────────────────────────────────────
def bind_discipline(
config: InfospaceConfig,
name: str,
path: str,
root: Path,
) -> DisciplineStatus:
"""Add a discipline binding to the config and validate it.
Does NOT persist the config — the caller should save it.
Args:
config: The infospace configuration to update.
name: Discipline name.
path: Path to the discipline infospace.
root: Project root for path resolution.
Returns:
Status of the newly bound discipline.
"""
# Check for duplicates
existing = {d.name for d in config.disciplines}
if name in existing:
return DisciplineStatus(
name=name, path=path, error=f"Discipline '{name}' already bound"
)
binding = DisciplineBinding(name=name, path=path)
config.disciplines.append(binding)
return check_discipline_status(binding, root)