# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/in-what-scenarios-must-an-instance-treat-span-level-safety-scores-as-non-aggregable-across-an-agent-trace-boundary.md
# Remnant Warrant:    https://corpusfetch.com/c/in-what-scenarios-must-an-instance-treat-span-level-safety-scores-as-non-aggregable-across-an-agent-trace-boundary.warrant.json
# Markdown SHA-256:   dcdebb3286f661343e881e05798b236b934cae22e3404ea233a94112c4546ad9
# Code AST SHA-256:   38ec224af6d69b631d645d19467eb46bc6eef812c3088d10b9f42a48085a2154
# Verified Classes:   NonAggregableTraceError, Span, TraceVerdict
# Defensive Guards:   4 fail-closed invariants (zero external deps)

"""Guard: refuse to emit an aggregable trace-level safety scalar when the
trace's safety policy is relational or span identity is not unique."""

from dataclasses import dataclass
from typing import Sequence


class NonAggregableTraceError(RuntimeError):
    """Raised when a trace cannot be reduced to a single safety scalar."""


@dataclass(frozen=True)
class Span:
    span_id: str
    role: str          # "planner" | "retrieval" | "tool_call" | "completion"
    safety_score: float


@dataclass(frozen=True)
class TraceVerdict:
    max_score: float
    argmax_span_id: str
    span_count: int
    policy_class: str


RELATIONAL_POLICIES = {"compositional_exfiltration", "multi_step_jailbreak", "tool_chaining"}


def reduce_trace(
    spans: Sequence[Span],
    policy_class: str,
    *,
    allow_scalar: bool,
) -> TraceVerdict:
    if not spans:
        raise NonAggregableTraceError("empty trace: no spans to score")

    ids = [s.span_id for s in spans]
    if len(ids) != len(set(ids)):
        # Forensics join would cross-link sub-agent spans. Refuse.
        raise NonAggregableTraceError(
            f"duplicate span_id within trace scope: {sorted(ids)}"
        )

    if policy_class in RELATIONAL_POLICIES and allow_scalar:
        raise NonAggregableTraceError(
            f"policy {policy_class!r} is relational; a scalar mean would "
            "dilute the argmax span and must not be emitted"
        )

    for s in spans:
        if not (0.0 <= s.safety_score <= 1.0):
            raise NonAggregableTraceError(
                f"span {s.span_id} score out of range: {s.safety_score}"
            )

    top = max(spans, key=lambda s: s.safety_score)
    return TraceVerdict(
        max_score=top.safety_score,
        argmax_span_id=top.span_id,
        span_count=len(spans),
        policy_class=policy_class,
    )
