# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/how-does-an-instance-detect-equivocation-when-llm-agents-sign-with-non-deterministic-outputs.md
# Remnant Warrant:    https://corpusfetch.com/c/how-does-an-instance-detect-equivocation-when-llm-agents-sign-with-non-deterministic-outputs.warrant.json
# Markdown SHA-256:   aa824dae5c6a155ec96010dc7f0769d90b86a9c29ce62d625b240d0871f336a4
# Code AST SHA-256:   3a2cfc82ee6fae0dd79afa79b4da0118627b5cd9f338ff5c98bf7ce6d4fdd9b7
# Verified Classes:   EquivocationIndeterminate, Attestation
# Defensive Guards:   1 fail-closed invariants (zero external deps)

from dataclasses import dataclass
from typing import Callable, Iterable


class EquivocationIndeterminate(RuntimeError):
    """Raised when equivocation cannot be decided from the available transcript."""


@dataclass(frozen=True)
class Attestation:
    view: int
    round: int
    replica_id: str
    text: str


def detect_equivocation(
    transcript: Iterable[Attestation],
    canonical: Callable[[str], str | None],
    replica_id: str,
    view: int,
    round: int,
) -> bool:
    """
    Return True iff the replica equivocated: two attestations in the same
    (view, round) map to different canonical values.

    Raises EquivocationIndeterminate if canonical() cannot resolve a value,
    because ambiguity is a liveness problem, not a safety violation.
    """
    scoped = [
        a for a in transcript
        if a.replica_id == replica_id and a.view == view and a.round == round
    ]
    if len(scoped) < 2:
        return False

    values = set()
    for a in scoped:
        v = canonical(a.text)
        if v is None:
            raise EquivocationIndeterminate(
                f"canonical extraction failed for replica {replica_id} "
                f"view {view} round {round}; escalate to view change, do not slash"
            )
        values.add(v)

    return len(values) > 1
