# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/where-must-an-instance-boundary-be-drawn-between-litmus-safety-scores-and-ci-regression-gates.md
# Remnant Warrant:    https://corpusfetch.com/c/where-must-an-instance-boundary-be-drawn-between-litmus-safety-scores-and-ci-regression-gates.warrant.json
# Markdown SHA-256:   fb7366e49f2fcaf08a6b6c0486de2281f7323a9ff0d7aaa48229bc817e85fdd2
# Code AST SHA-256:   f621c2820ee7c8ef2e0ca057294b669adb8c57e0be1953b37ed982083db9243e
# Verified Classes:   LitmusGateBoundaryError, CategoryResult, BuildVerdict
# Defensive Guards:   1 fail-closed invariants (zero external deps)

from dataclasses import dataclass, field
from typing import Mapping


class LitmusGateBoundaryError(RuntimeError):
    """Raised when a LITMUS result set is aggregated in a way that violates the
    instance/category/build boundary contract."""


MIN_HARD_GATE_N = 20


@dataclass
class CategoryResult:
    name: str
    instances: int
    violations: int
    severity: str  # "critical" | "standard" | "advisory"
    tolerance: int


@dataclass
class BuildVerdict:
    passed: bool
    failing_categories: list = field(default_factory=list)
    advisory_categories: list = field(default_factory=list)
    mean_note: str = ""


def evaluate_gate(
    results: Mapping[str, CategoryResult],
    corpus_version: str,
    pinned_corpus_version: str,
) -> BuildVerdict:
    if corpus_version != pinned_corpus_version:
        # Corpus drift must not silently gate an agent build.
        raise LitmusGateBoundaryError(
            f"corpus version {corpus_version!r} != pinned {pinned_corpus_version!r}; "
            "corpus bumps require human override, not an automatic gate"
        )

    failing = []
    advisory = []
    for name, r in results.items():
        if r.severity == "advisory":
            advisory.append(name)
            continue
        if r.severity == "critical" and r.instances < MIN_HARD_GATE_N:
            # Small-n critical category: cannot hard-gate on sampling noise.
            advisory.append(name)
            continue
        if r.violations > r.tolerance:
            failing.append((name, r.violations, r.tolerance))

    if failing:
        detail = ", ".join(f"{n}:{v}>{t}" for n, v, t in failing)
        return BuildVerdict(passed=False, failing_categories=failing,
                            advisory_categories=advisory,
                            mean_note=f"blocked by category tolerance: {detail}")

    return BuildVerdict(passed=True, failing_categories=[],
                        advisory_categories=advisory,
                        mean_note="all hard-gated categories within tolerance; "
                                  "global mean reported as trend only")
