# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/in-what-scenarios-must-an-instance-treat-model-driven-context-pruning-as-a-lossy-non-invertible-projection-rather-than-a-reversible-edit.md
# Remnant Warrant:    https://corpusfetch.com/c/in-what-scenarios-must-an-instance-treat-model-driven-context-pruning-as-a-lossy-non-invertible-projection-rather-than-a-reversible-edit.warrant.json
# Markdown SHA-256:   c9f42516357b14cae748260833a161b1c3f8713fbdfe1c89026bc881ca17e4c1
# Code AST SHA-256:   0a8623ef2d8ea481300ae1fbf9bc18a89947c0e9f5069268d1f1596baf6e2566
# Verified Classes:   ContextProjectionError, Span, ContextWindow
# Defensive Guards:   4 fail-closed invariants (zero external deps)

import hashlib
from dataclasses import dataclass, field
from typing import Optional


class ContextProjectionError(RuntimeError):
    """Raised when a pruning operation would destroy provenance or re-summarize a derived span."""


@dataclass
class Span:
    span_id: str
    content: str
    is_derived: bool = False          # True if this span is itself a summary
    derivation_chain: tuple = ()      # span_ids this span was derived from
    tombstone_ref: Optional[str] = None


@dataclass
class ContextWindow:
    spans: dict = field(default_factory=dict)
    tombstone_store: dict = field(default_factory=dict)  # external, keyed by span_id

    def prune(self, span_id: str, policy_version: str, score: float) -> None:
        span = self.spans.get(span_id)
        if span is None:
            raise ContextProjectionError(f"unknown span {span_id}")

        # Invariant 1: a derived span must not be silently re-summarized.
        if span.is_derived and not span.derivation_chain:
            raise ContextProjectionError(
                f"span {span_id} is derived but has no derivation chain; "
                "re-summarizing would launder provenance"
            )

        # Invariant 2: eviction must leave an external tombstone, never an in-window one.
        digest = hashlib.sha256(span.content.encode()).hexdigest()
        if span_id in self.tombstone_store and self.tombstone_store[span_id]["digest"] != digest:
            raise ContextProjectionError(
                f"tombstone digest mismatch for {span_id}: content mutated since last eviction"
            )
        self.tombstone_store[span_id] = {
            "digest": digest,
            "policy_version": policy_version,
            "score": score,
            "derivation_chain": span.derivation_chain,
        }
        del self.spans[span_id]

    def assert_complete_for(self, claim: str, required_span_ids: set) -> None:
        """Call before any side-effecting action that depends on window completeness."""
        missing = {s for s in required_span_ids if s not in self.spans}
        unrecoverable = {s for s in missing if s not in self.tombstone_store}
        if unrecoverable:
            raise ContextProjectionError(
                f"cannot substantiate claim {claim!r}: spans {sorted(unrecoverable)} "
                "were pruned without tombstones; window is a lossy projection"
            )
