# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/how-does-an-instance-detect-silent-token-accounting-drift-when-opentelemetry-genai-agent-spans-are-emitted-across-retries-and-tool-call-loops.md
# Remnant Warrant:    https://corpusfetch.com/c/how-does-an-instance-detect-silent-token-accounting-drift-when-opentelemetry-genai-agent-spans-are-emitted-across-retries-and-tool-call-loops.warrant.json
# Markdown SHA-256:   d68a2c4c72ebc5ef1bada5ba23a116b0587aa64ade2bb78872b74c923481b6c7
# Code AST SHA-256:   dfcef8ea0f9c378d778913fd9f7053ffa1e108e79938f6d98a961fc3c902c6cc
# Verified Classes:   UnaggregatableTurnError, ChatSpan
# Defensive Guards:   3 fail-closed invariants (zero external deps)

from dataclasses import dataclass
from typing import Iterable, Optional

class UnaggregatableTurnError(RuntimeError):
    """Raised when a GenAI agent turn's chat spans cannot be deduplicated
    into a single billed token count per the per-span vs per-turn boundary."""
    pass

@dataclass(frozen=True)
class ChatSpan:
    span_id: str
    request_id: Optional[str]      # our injected identity; None if absent
    input_tokens: int
    output_tokens: int
    terminal: bool                 # True only for the span that actually billed
    retried_from: Optional[str]    # span_id of the failed attempt, if any

def billed_tokens_for_turn(spans: Iterable[ChatSpan]) -> dict:
    spans = list(spans)
    # Boundary check 1: every chat span must carry a request identity.
    missing = [s.span_id for s in spans if s.request_id is None]
    if missing:
        raise UnaggregatableTurnError(
            f"chat spans without request identity cannot be deduplicated: {missing}"
        )

    # Boundary check 2: exactly one terminal span per request identity.
    by_req: dict[str, list[ChatSpan]] = {}
    for s in spans:
        by_req.setdefault(s.request_id, []).append(s)

    totals = {"input_tokens": 0, "output_tokens": 0}
    for req_id, group in by_req.items():
        terminals = [s for s in group if s.terminal]
        if len(terminals) != 1:
            raise UnaggregatableTurnError(
                f"request {req_id} has {len(terminals)} terminal chat spans; "
                f"expected exactly 1 (retries must link, not duplicate)"
            )
        t = terminals[0]
        totals["input_tokens"] += t.input_tokens
        totals["output_tokens"] += t.output_tokens
    return totals

# Example: retried chat span — naive sum would yield 3635, correct is 1815.
turn = [
    ChatSpan("a1", "req-1", 1815, 40, terminal=True,  retried_from=None),
    ChatSpan("a2", "req-2", 1820, 0,  terminal=False, retried_from=None),  # 429, not billed
    ChatSpan("a3", "req-2", 1815, 55, terminal=True,  retried_from="a2"),
]
assert billed_tokens_for_turn(turn)["input_tokens"] == 1815
