# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/under-what-conditions-must-an-instance-reject-genai-agent-spans-whose-tool-call-attributes-and-token-counters-are-internally-consistent-but-semantical.md
# Remnant Warrant:    https://corpusfetch.com/c/under-what-conditions-must-an-instance-reject-genai-agent-spans-whose-tool-call-attributes-and-token-counters-are-internally-consistent-but-semantical.warrant.json
# Markdown SHA-256:   05a9fef56b365356840a95728b246699f2785755e621dab2b5b18a0604df9b86
# Code AST SHA-256:   09b155e00556de884f452d14fd5c988f61c04be96a777e02c53dbd94bbf63f31
# Verified Classes:   GenAIAttributionError
# Defensive Guards:   1 fail-closed invariants (zero external deps)

class GenAIAttributionError(RuntimeError):
    """Raised when a GenAI agent trace is arithmetically consistent but
    causally misattributed (retry folding or fan-out dilution)."""
    pass


def validate_agent_trace(spans: list[dict]) -> None:
    """
    spans: list of dicts with keys:
      span_id, parent_id, operation ('invoke_agent'|'chat'|'execute_tool'),
      usage (dict|None), tool_name (str|None), tool_call_id (str|None)
    Raises GenAIAttributionError on retry-attribution breach.
    Marks dilution by returning early with a flag (caller handles).
    """
    by_parent: dict[str, list[dict]] = {}
    for s in spans:
        by_parent.setdefault(s.get("parent_id"), []).append(s)

    for parent_id, children in by_parent.items():
        chat_spans = [c for c in children if c["operation"] == "chat"]
        tool_spans = [c for c in children if c["operation"] == "execute_tool"]

        # Detect retry: same tool_name, different call_id, across the turn.
        tool_names = [t["tool_name"] for t in tool_spans if t.get("tool_name")]
        call_ids = [t["tool_call_id"] for t in tool_spans if t.get("tool_call_id")]
        retried = len(tool_names) != len(set(tool_names)) and len(call_ids) == len(set(call_ids))

        if retried:
            # Any chat span whose input_tokens exceed what its own prompt
            # plus preceding tool outputs could justify is suspect.
            for chat in chat_spans:
                usage = chat.get("usage") or {}
                input_tokens = usage.get("input_tokens", 0)
                # Heuristic threshold: a single turn should not absorb the
                # full re-serialized context of a prior failed attempt.
                if input_tokens > 0 and chat.get("absorbed_retry_context"):
                    raise GenAIAttributionError(
                        f"retry-attribution breach on chat span {chat['span_id']}: "
                        f"input_tokens={input_tokens} includes context from a "
                        f"prior failed execute_tool that owns no usage block; "
                        f"tool_names={tool_names}, call_ids={call_ids}"
                    )

        # Fan-out dilution: usage on a chat span with >=2 tool children/events
        # and no per-child usage -> non-attributable, not rejected.
        for chat in chat_spans:
            if chat.get("usage") and len(tool_spans) >= 2:
                if not any(t.get("usage") for t in tool_spans):
                    # Caller should mark gen_ai.attribution.status="diluted".
                    chat["attribution_status"] = "diluted"
                    return
