# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/where-must-an-instance-reconcile-genai-agent-spans-against-token-level-telemetry-the-tool-call-boundary-invariant.md
# Remnant Warrant:    https://corpusfetch.com/c/where-must-an-instance-reconcile-genai-agent-spans-against-token-level-telemetry-the-tool-call-boundary-invariant.warrant.json
# Markdown SHA-256:   23ef60bd3089ad72d72b86bddb891e289a6ac60a6450b3bdf19bc38b0a5da518
# Code AST SHA-256:   cc40071fc8dfe22e03b0de5cb5b0dbae1691b17745f960815d2fe248a397fd61
# Verified Classes:   ToolCallCorrelationError, ChatSpan, ExecuteToolSpan
# Defensive Guards:   2 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass, field


class ToolCallCorrelationError(RuntimeError):
    """Raised when execute_tool spans cannot be reconciled to chat-span tool-call IDs."""


@dataclass
class ChatSpan:
    span_id: str
    output_tool_call_ids: set[str] = field(default_factory=set)
    output_tokens: int = 0


@dataclass
class ExecuteToolSpan:
    span_id: str
    parent_chat_id: str
    tool_call_id: str


def reconcile_trace(
    chat_spans: list[ChatSpan],
    tool_spans: list[ExecuteToolSpan],
) -> dict[str, list[str]]:
    """Reconcile execute_tool spans against chat-span output tool-call IDs.

    Returns a dict of soft warnings keyed by kind. Raises
    ToolCallCorrelationError on hard violations (orphaned tool spans).
    """
    all_chat_ids: set[str] = set()
    for cs in chat_spans:
        all_chat_ids |= cs.output_tool_call_ids

    orphaned = [ts for ts in tool_spans if ts.tool_call_id not in all_chat_ids]
    if orphaned:
        # A tool executed that no recorded chat turn requested: hard failure.
        ids = sorted(ts.tool_call_id for ts in orphaned)
        raise ToolCallCorrelationError(
            f"orphaned execute_tool spans with no matching chat output: {ids}"
        )

    executed_ids = {ts.tool_call_id for ts in tool_spans}
    unexecuted = sorted(all_chat_ids - executed_ids)
    warnings: dict[str, list[str]] = {}
    if unexecuted:
        warnings["genai.tool_call.unexecuted"] = unexecuted

    # Count-only equality is necessary but not sufficient; record divergence
    # as a diagnostic without failing, since parallel calls make it expected.
    if len(tool_spans) != sum(len(cs.output_tool_call_ids) for cs in chat_spans):
        warnings["genai.tool_call.count_divergence"] = [
            f"spans={len(tool_spans)} ids={sum(len(cs.output_tool_call_ids) for cs in chat_spans)}"
        ]
    return warnings


def process_trace(chat_spans: list[ChatSpan], tool_spans: list[ExecuteToolSpan]) -> None:
    try:
        warnings = reconcile_trace(chat_spans, tool_spans)
    except ToolCallCorrelationError:
        # Re-raise with trace context attached for the span processor.
        raise
    for kind, entries in warnings.items():
        # Emit as metric/log; do not fail the trace.
        print(f"[warn] {kind}: {entries}")
