# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/what-structural-signals-indicate-that-hierarchical-context-compaction-has-crossed-the-salience-inversion-boundary.md
# Remnant Warrant:    https://corpusfetch.com/c/what-structural-signals-indicate-that-hierarchical-context-compaction-has-crossed-the-salience-inversion-boundary.warrant.json
# Markdown SHA-256:   fb40704aa7dfdfba83f7a0bb03cf89ce2b173cddcfbf9b0778187de822e498f0
# Code AST SHA-256:   a59c9adfb8d347777ddc337a99535a3ef300c7c361ee2d21fc1c4cbe87da60cc
# Verified Classes:   SalienceInversionError, CompactionState
# Defensive Guards:   3 fail-closed invariants (zero external deps)

from dataclasses import dataclass, field


class SalienceInversionError(RuntimeError):
    """Raised when compaction crosses the salience inversion boundary.

    Inversion is defined structurally, not by answer quality:
      - raw_ratio falls below the grounding floor, AND
      - distinct_source_spans is falling while retained salience is rising.
    A rising salience score alone is not an error; it is an error only
    when provenance reachability degrades concurrently.
    """


@dataclass
class CompactionState:
    raw_tokens: int
    summary_tokens: int
    distinct_source_spans: int
    query_cosine_retained: float
    summary_depth_max: int
    rounds_at_max_depth: int = 0

    @property
    def raw_ratio(self) -> float:
        total = self.raw_tokens + self.summary_tokens
        return 0.0 if total == 0 else self.raw_tokens / total


RAW_RATIO_FLOOR = 0.25
SPAN_FLOOR_FRACTION = 0.20  # of session-start span count
MAX_DEPTH_PIN_ROUNDS = 5


def check_inversion(
    prev: CompactionState,
    curr: CompactionState,
    session_start_spans: int,
) -> None:
    """Guard the compaction boundary. Call after every eviction pass."""
    if curr.summary_depth_max < 2:
        return  # inversion requires re-summarization depth >= 2

    span_floor = max(1, int(session_start_spans * SPAN_FLOOR_FRACTION))
    spans_falling = curr.distinct_source_spans < prev.distinct_source_spans
    salience_rising = curr.query_cosine_retained > prev.query_cosine_retained

    if curr.raw_ratio < RAW_RATIO_FLOOR and spans_falling and salience_rising:
        raise SalienceInversionError(
            f"salience inversion: raw_ratio={curr.raw_ratio:.2f} "
            f"(floor {RAW_RATIO_FLOOR}), spans {prev.distinct_source_spans}"
            f"->{curr.distinct_source_spans}, cosine {prev.query_cosine_retained:.2f}"
            f"->{curr.query_cosine_retained:.2f}. Halt and rehydrate."
        )

    if curr.distinct_source_spans < span_floor:
        raise SalienceInversionError(
            f"provenance floor breached: {curr.distinct_source_spans} < {span_floor} "
            f"(session start {session_start_spans}). Rehydrate lowest-depth summary."
        )

    if curr.rounds_at_max_depth > MAX_DEPTH_PIN_ROUNDS:
        raise SalienceInversionError(
            f"hierarchy pinned at depth {curr.summary_depth_max} for "
            f"{curr.rounds_at_max_depth} rounds; cap depth at 2 or accept token cost."
        )
