# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-self-editing-context-from-destroying-the-evidence-it-depends-on.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-self-editing-context-from-destroying-the-evidence-it-depends-on.warrant.json
# Markdown SHA-256:   fc0c26620c339bb92238a25bd3ef9e50ab87395dcd003381e20e6513628c9629
# Code AST SHA-256:   47d8eda6043f54c873e42c4138ce35ce4151ea3eef22d8686679e66f531c2d31
# Verified Classes:   ContextEditInvariantError, Span, DecisionLog
# Defensive Guards:   1 fail-closed invariants (zero external deps)

from dataclasses import dataclass, field
from typing import Optional


class ContextEditInvariantError(RuntimeError):
    """Raised when a self-edit would destroy the evidence justifying itself."""


@dataclass
class Span:
    span_id: str
    text: str
    contains_rationale_for: set[str] = field(default_factory=set)


@dataclass
class DecisionLog:
    """Append-only, never a pruning target."""
    entries: list[dict] = field(default_factory=list)

    def record(self, edit_id: str, rationale: str, evidence_span: str) -> None:
        self.entries.append(
            {"edit_id": edit_id, "rationale": rationale, "evidence_span": evidence_span}
        )

    def has_rationale(self, edit_id: str) -> bool:
        return any(e["edit_id"] == edit_id for e in self.entries)


def prune_span(span: Span, log: DecisionLog, edit_id: str) -> None:
    """Prune a span only if its rationale is externalized and no surviving
    span depends on it for justification."""
    if span.contains_rationale_for and not log.has_rationale(edit_id):
        raise ContextEditInvariantError(
            f"Refusing to prune span {span.span_id!r}: it carries rationale for "
            f"{sorted(span.contains_rationale_for)} and no external decision-log "
            f"entry for edit {edit_id!r} exists. Externalize rationale first."
        )
    # Safe: rationale is in the append-only log, span is now removable.
    span.text = "[pruned]"


def compact(context: list[Span], log: DecisionLog, edit_id: str) -> None:
    # Rationale extraction MUST precede data removal.
    for span in context:
        if span.contains_rationale_for:
            log.record(
                edit_id=edit_id,
                rationale=f"span {span.span_id} justifies pruning of "
                          f"{sorted(span.contains_rationale_for)}",
                evidence_span=span.span_id,
            )
    for span in context:
        prune_span(span, log, edit_id)
