# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/how-does-an-instance-detect-the-token-accounting-double-count-boundary-in-opentelemetry-genai-agent-traces.md
# Remnant Warrant:    https://corpusfetch.com/c/how-does-an-instance-detect-the-token-accounting-double-count-boundary-in-opentelemetry-genai-agent-traces.warrant.json
# Markdown SHA-256:   9fcd8131d7db05672f95ddcf35651d2ec729dcbbf3b86970cd25ce3869241afb
# Code AST SHA-256:   21822baf03d2d6a7a48bb45a83f6c8986c2bc82cb096cf5ecd4ae289be9eae7e
# Verified Classes:   UsageAccountingBoundaryError, Span
# Defensive Guards:   2 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass


class UsageAccountingBoundaryError(RuntimeError):
    """Raised when trace-level GenAI token accounting cannot be resolved safely."""


@dataclass(frozen=True)
class Span:
    span_id: str
    parent_id: str | None
    name: str
    has_usage: bool
    total_tokens: int | None = None


def _ancestors(span: Span, by_id: dict[str, Span]) -> list[Span]:
    chain: list[Span] = []
    cur = span
    while cur.parent_id is not None:
        parent = by_id.get(cur.parent_id)
        if parent is None:
            break
        chain.append(parent)
        cur = parent
    return chain


def compute_trace_usage(spans: list[Span]) -> int:
    """Sum leaf-only usage; raise if the structural invariant is violated.

    Invariant: for every span carrying usage, no ancestor and no descendant
    may also carry usage. Violation means the trace is non-summable and the
    caller must not trust a single number.
    """
    by_id = {s.span_id: s for s in spans}
    usage_spans = [s for s in spans if s.has_usage]

    for span in usage_spans:
        for anc in _ancestors(span, by_id):
            if anc.has_usage:
                raise UsageAccountingBoundaryError(
                    f"usage collision: span {span.span_id!r} ({span.name}) and "
                    f"ancestor {anc.span_id!r} ({anc.name}) both carry gen_ai.usage.*; "
                    "trace total is double-counted and non-summable"
                )

    # Leaf-only: a usage span with a usage-bearing descendant is already excluded
    # by the ancestor check above, so remaining usage spans are leaves w.r.t. usage.
    total = 0
    for span in usage_spans:
        if span.total_tokens is None:
            raise UsageAccountingBoundaryError(
                f"span {span.span_id!r} ({span.name}) has usage attributes but no "
                "gen_ai.usage.total_tokens; cannot account"
            )
        total += span.total_tokens
    return total
