# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-parent-agent-spans-from-double-counting-tokens-when-child-tool-call-spans-re-emit-model-invocation-events.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-parent-agent-spans-from-double-counting-tokens-when-child-tool-call-spans-re-emit-model-invocation-events.warrant.json
# Markdown SHA-256:   42406add6141c1bc6d6f4d468c24e309a92f9c66ea48b1fecb106bafd9f25a0e
# Code AST SHA-256:   5ab6fd846faeba7dd66aae5e582148eeadb257e2e73b8f71ce7a88ef7d4c9c4e
# Verified Classes:   AmbiguousTokenAccountingError
# Defensive Guards:   1 fail-closed invariants (zero external deps)

class AmbiguousTokenAccountingError(RuntimeError):
    """Raised when a span tree mixes inclusive and exclusive gen_ai.usage semantics."""


def aggregate_input_tokens(spans: list[dict]) -> int:
    """
    Sum gen_ai.usage.input_tokens across a span tree, refusing to guess
    when a span both carries usage and has usage-bearing descendants.
    """
    by_id = {s["span_id"]: s for s in spans}
    children: dict[str, list[str]] = {sid: [] for sid in by_id}
    for s in spans:
        parent = s.get("parent_span_id")
        if parent in children:
            children[parent].append(s["span_id"])

    def has_usage(span: dict) -> bool:
        return "gen_ai.usage.input_tokens" in span.get("attributes", {})

    def usage_bearing_descendants(sid: str) -> int:
        total = 0
        for cid in children[sid]:
            if has_usage(by_id[cid]):
                total += 1
            total += usage_bearing_descendants(cid)
        return total

    for s in spans:
        if not has_usage(s):
            continue
        if usage_bearing_descendants(s["span_id"]) > 0:
            raise AmbiguousTokenAccountingError(
                f"span {s['span_id']} (op={s.get('attributes', {}).get('gen_ai.operation.name')}) "
                f"carries gen_ai.usage.input_tokens but has usage-bearing descendants; "
                f"cannot determine inclusive vs exclusive semantics without a rollup marker. "
                f"Refusing to aggregate."
            )

    return sum(
        s["attributes"]["gen_ai.usage.input_tokens"]
        for s in spans
        if has_usage(s)
    )
