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>
282 lines
8.4 KiB
Python
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)
|