# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/when-must-an-instance-attribute-cost-to-a-nested-agent-span-rather-than-the-enclosing-trace-root.md
# Remnant Warrant:    https://corpusfetch.com/c/when-must-an-instance-attribute-cost-to-a-nested-agent-span-rather-than-the-enclosing-trace-root.warrant.json
# Markdown SHA-256:   473073eeb7c1aa484ae4e4139a942f5e28cd42e51ee81070abe937f33d2ebeee
# Code AST SHA-256:   eeb41ae88d0879697b368a80d3a6c5dabdc99b3d60d9a032d381eb34e4d9a4be
# Verified Classes:   CostAttributionError, Span
# Defensive Guards:   4 fail-closed invariants (zero external deps)

from dataclasses import dataclass
from typing import Optional


class CostAttributionError(RuntimeError):
    """Raised when a span cannot be safely attributed a metered cost."""
    pass


@dataclass
class Span:
    span_id: str
    parent_id: Optional[str]
    model: Optional[str]
    input_tokens: Optional[int]
    output_tokens: Optional[int]
    is_metered_caller: bool


def attribute_span_cost(span: Span, price_per_token: dict[str, float]) -> float:
    if not span.is_metered_caller:
        # Orchestration spans carry no metered payload; cost is derived, not accrued.
        return 0.0
    if span.model is None:
        raise CostAttributionError(
            f"span {span.span_id}: metered caller missing gen_ai.request.model"
        )
    if span.input_tokens is None or span.output_tokens is None:
        raise CostAttributionError(
            f"span {span.span_id}: metered caller missing usage attributes; "
            "refusing to emit zero-cost (silent gap)"
        )
    if span.model not in price_per_token:
        raise CostAttributionError(
            f"span {span.span_id}: unknown model {span.model!r} in price table"
        )
    rate = price_per_token[span.model]
    return (span.input_tokens + span.output_tokens) * rate


def rollup_trace_cost(spans: list[Span], price_per_token: dict[str, float]) -> dict[str, float]:
    accrued = {}
    for s in spans:
        accrued[s.span_id] = attribute_span_cost(s, price_per_token)
    # Derived rollup: sum children into ancestors, never overwrite span-local accrual.
    derived = dict(accrued)
    by_id = {s.span_id: s for s in spans}
    for s in spans:
        cur = s
        while cur.parent_id is not None:
            parent = by_id.get(cur.parent_id)
            if parent is None:
                raise CostAttributionError(
                    f"span {cur.span_id}: parent {cur.parent_id} not in trace; "
                    "context propagation may have been broken"
                )
            derived[parent.span_id] = derived.get(parent.span_id, 0.0) + accrued[s.span_id]
            cur = parent
    return derived
