""" 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)