# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/what-structural-signals-indicate-that-a-model-driven-context-rewrite-has-silently-invalidated-a-downstream-relevance-score.md
# Remnant Warrant:    https://corpusfetch.com/c/what-structural-signals-indicate-that-a-model-driven-context-rewrite-has-silently-invalidated-a-downstream-relevance-score.warrant.json
# Markdown SHA-256:   6cfc5948a7c5c634c1100b04af2d812e538a97dc0eee0fb36a724e1e269e449b
# Code AST SHA-256:   ea8394305d3fbd4692ec32e8a89e638d58b2898563fed281b6c039aaa6441d7d
# Verified Classes:   StaleScoreError, ScoredSpan
# Defensive Guards:   4 fail-closed invariants (zero external deps)

import hashlib
from dataclasses import dataclass, field


class StaleScoreError(RuntimeError):
    """Raised when a relevance score is applied to text it was not computed against."""


@dataclass
class ScoredSpan:
    span_id: str
    text: str
    score: float
    text_sha256: str
    rewrite_count: int = 0
    derived_from: list[str] = field(default_factory=list)


def _sha(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def apply_rewrite(span: ScoredSpan, new_text: str) -> ScoredSpan:
    """Rewrite a span and invalidate its score by clearing the binding hash."""
    if span.rewrite_count >= 2:
        raise StaleScoreError(
            f"span {span.span_id} rewritten {span.rewrite_count} times without "
            f"recompute; paraphrase drift makes any score meaningless"
        )
    return ScoredSpan(
        span_id=span.span_id,
        text=new_text,
        score=float("nan"),          # score is now absent, not zero
        text_sha256="",              # no binding -> pruner must refuse
        rewrite_count=span.rewrite_count + 1,
        derived_from=span.derived_from + [span.span_id],
    )


def prune(spans: list[ScoredSpan], keep_top_k: int) -> list[ScoredSpan]:
    """Prune by score, refusing any span whose score is not hash-bound to its text."""
    for s in spans:
        if s.text_sha256 == "":
            raise StaleScoreError(
                f"span {s.span_id} has no score binding (rewritten, not recomputed)"
            )
        if s.text_sha256 != _sha(s.text):
            raise StaleScoreError(
                f"span {s.span_id} score bound to {s.text_sha256[:8]} but text "
                f"hashes to {_sha(s.text)[:8]}; score is stale"
            )
    ranked = sorted(spans, key=lambda s: s.score, reverse=True)
    return ranked[:keep_top_k]


def check_turn_integrity(spans_modified: int, scores_invalidated: int) -> None:
    if spans_modified > 0 and scores_invalidated < spans_modified:
        raise StaleScoreError(
            f"{spans_modified} spans modified but only {scores_invalidated} scores "
            f"invalidated; downstream pruning would act on stale scores"
        )
