# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/when-must-an-instance-reject-a-tool-use-plan-as-hallucinated-before-executing-it.md
# Remnant Warrant:    https://corpusfetch.com/c/when-must-an-instance-reject-a-tool-use-plan-as-hallucinated-before-executing-it.warrant.json
# Markdown SHA-256:   13aff15f028393c7d60fee03bfea2ba5d55cfb1a1345053da3127a69340cda51
# Code AST SHA-256:   b79c50d6bb5209d305baa16cc879352c3b5163372029bcb8a5555512f79bbeae
# Verified Classes:   HallucinationGateError, ToolSchema
# Defensive Guards:   3 fail-closed invariants (zero external deps)

import hashlib
import json
from dataclasses import dataclass
from typing import Any


class HallucinationGateError(RuntimeError):
    """Raised when a plan cannot be validly classified by the gate."""


@dataclass(frozen=True)
class ToolSchema:
    name: str
    deterministic: bool
    verified: bool  # determinism empirically probed this epoch


def _canon_args(args: dict[str, Any]) -> str:
    return json.dumps(args, sort_keys=True, separators=(",", ":"))


def _canon_result(result: Any) -> str:
    return hashlib.sha256(
        json.dumps(result, sort_keys=True, separators=(",", ":")).encode()
    ).hexdigest()


def classify_plan(
    tool: ToolSchema,
    samples: list[tuple[dict[str, Any], Any]],
    *,
    arg_accept: float = 0.80,
    arg_reject: float = 0.20,
    result_accept: float = 0.80,
    min_samples: int = 5,
    fixed_epoch: bool = True,
) -> str:
    """Return 'accept' | 'reject' | 'defer'.

    Agreement is computed on arguments and results separately. Rejection is
    only legal on argument-level disagreement for a verified-deterministic
    tool in a fixed epoch.
    """
    if len(samples) < min_samples:
        raise HallucinationGateError(
            f"insufficient samples: {len(samples)} < {min_samples}"
        )
    if not fixed_epoch:
        raise HallucinationGateError(
            "epoch not fixed; tool nondeterminism would leak into arg signal"
        )

    arg_keys = [_canon_args(a) for a, _ in samples]
    res_keys = [_canon_result(r) for _, r in samples]
    n = len(samples)

    arg_agreement = max(arg_keys.count(k) for k in set(arg_keys)) / n
    res_agreement = max(res_keys.count(k) for k in set(res_keys)) / n

    if arg_agreement < arg_reject:
        if not tool.deterministic or not tool.verified:
            # Cannot reject: tool determinism unverified, so result
            # divergence may be masking a correct plan.
            return "defer"
        return "reject"

    if arg_agreement >= arg_accept and res_agreement < result_accept:
        # Plan structure is stable; tool output is not. Quarantine tool.
        return "defer"

    if arg_agreement >= arg_accept and res_agreement >= result_accept:
        return "accept"

    return "defer"


def gate_or_raise(tool: ToolSchema, samples: list[tuple[dict[str, Any], Any]]) -> str:
    if tool.deterministic and not tool.verified:
        raise HallucinationGateError(
            f"tool {tool.name!r} declared deterministic but not probed this epoch; "
            "refusing to score"
        )
    return classify_plan(tool, samples)
