# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/what-happens-when-an-instance-attributes-token-cost-to-a-span-before-its-parent-trace-context-is-federated.md
# Remnant Warrant:    https://corpusfetch.com/c/what-happens-when-an-instance-attributes-token-cost-to-a-span-before-its-parent-trace-context-is-federated.warrant.json
# Markdown SHA-256:   0cb2f3333632227bbe2c5845f3d0e2f9bf3404d3b677dc0d664ae2ef0626abf3
# Code AST SHA-256:   b7c317aa116a1bfd5d5a4a6d99be3a0b91210c71f18e305b66a3f3b0a0a0ae76
# Verified Classes:   CostAttributionError
# Defensive Guards:   2 fail-closed invariants (zero external deps)

class CostAttributionError(RuntimeError):
    """Raised when trace cost cannot be attributed to a real parent node."""
    pass


def attribute_cost(span_graph: dict, span_local_cost: dict) -> dict:
    """
    span_graph: {span_id: {"parent_id": str | None, "orphaned": bool}}
    span_local_cost: {span_id: float}
    Returns {node_id: rolled_up_cost} or raises CostAttributionError.
    """
    synthetic_root = "__synthetic_root__"
    attributed: dict[str, float] = {}

    for span_id, meta in span_graph.items():
        cost = span_local_cost.get(span_id, 0.0)

        if meta["orphaned"]:
            # A span was re-parented to the synthetic root because its real
            # parent missed the federation grace window. Rolling this up
            # silently would mis-attribute cost to the synthetic root.
            raise CostAttributionError(
                f"span {span_id} was re-parented to {synthetic_root} "
                f"(parent missed federation window); refusing to roll up "
                f"{cost} tokens to synthetic root — defer to trace-close or "
                f"extend the federation grace window beyond max parent latency"
            )

        parent = meta["parent_id"]
        if parent is None:
            attributed[span_id] = attributed.get(span_id, 0.0) + cost
        else:
            if parent not in span_graph:
                raise CostAttributionError(
                    f"span {span_id} references parent {parent} not present "
                    f"in joined graph; rollup is unsound"
                )
            attributed[parent] = attributed.get(parent, 0.0) + cost

    return attributed
