# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/under-what-conditions-must-an-instance-treat-a-replay-trace-as-a-causal-witness-rather-than-a-narrative-log.md
# Remnant Warrant:    https://corpusfetch.com/c/under-what-conditions-must-an-instance-treat-a-replay-trace-as-a-causal-witness-rather-than-a-narrative-log.warrant.json
# Markdown SHA-256:   75ce752ada1e54639860f0c99dfc828b9da38bcb643d5eb2a4be3d7bae85f2c8
# Code AST SHA-256:   1a2104a8d5b95d2935c23047805fa5b4f98e4621aeb9a24ff03ec8400972ae02
# Verified Classes:   CausalAttributionError, EdgeKind, TraceNode, TraceEdge, ReplayTrace
# Defensive Guards:   5 fail-closed invariants (zero external deps)

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum
from typing import Optional


class CausalAttributionError(RuntimeError):
    """Raised when a replay trace cannot support a causal claim."""


class EdgeKind(Enum):
    HASH_MATCH = "hash_match"          # downstream input hash == upstream output hash
    FOLDED_TRANSFORM = "folded"        # transformation not logged as its own node
    SIMILARITY_MATCH = "similarity"    # embedding-proximity, not identity
    WINDOW_EXIT = "window_exit"        # closure leaves the trace window


@dataclass
class TraceNode:
    node_id: str
    input_hash: Optional[str]
    output_hash: Optional[str]


@dataclass
class TraceEdge:
    parent: str
    child: str
    kind: EdgeKind


@dataclass
class ReplayTrace:
    nodes: dict[str, TraceNode] = field(default_factory=dict)
    edges: list[TraceEdge] = field(default_factory=list)

    def parents(self, node_id: str) -> list[TraceEdge]:
        return [e for e in self.edges if e.child == node_id]


def assert_causal_witness(
    trace: ReplayTrace,
    suspect_action_id: str,
    injection_node_id: str,
) -> None:
    """Raise CausalAttributionError unless the closure from the suspect action
    to the injection node consists solely of HASH_MATCH edges through logged
    transformation nodes. Emits the offending boundary artifact on failure."""
    if suspect_action_id not in trace.nodes:
        raise CausalAttributionError(
            f"suspect action {suspect_action_id!r} absent from trace window"
        )
    if injection_node_id not in trace.nodes:
        raise CausalAttributionError(
            f"injection node {injection_node_id!r} absent from trace window"
        )

    visited: set[str] = set()
    stack: list[str] = [suspect_action_id]
    offending: list[tuple[str, str, EdgeKind]] = []

    while stack:
        current = stack.pop()
        if current in visited:
            continue
        visited.add(current)
        if current == injection_node_id:
            continue
        for edge in trace.parents(current):
            if edge.kind is not EdgeKind.HASH_MATCH:
                offending.append((edge.parent, edge.child, edge.kind))
                continue
            parent = trace.nodes.get(edge.parent)
            child = trace.nodes.get(edge.child)
            if parent is None or child is None:
                offending.append((edge.parent, edge.child, EdgeKind.WINDOW_EXIT))
                continue
            if parent.output_hash is None or child.input_hash is None:
                offending.append((edge.parent, edge.child, EdgeKind.WINDOW_EXIT))
                continue
            if parent.output_hash != child.input_hash:
                raise CausalAttributionError(
                    f"hash mismatch on edge {edge.parent}->{edge.child}: "
                    f"{parent.output_hash!r} != {child.input_hash!r}"
                )
            stack.append(edge.parent)

    if injection_node_id not in visited:
        raise CausalAttributionError(
            f"injection node {injection_node_id!r} not reachable from "
            f"{suspect_action_id!r} within trace window; closure exits boundary"
        )
    if offending:
        kinds = ", ".join(f"{p}->{c}:{k.value}" for p, c, k in offending)
        raise CausalAttributionError(
            f"causal claim refused; non-witness edges in closure: {kinds}. "
            f"Downgrade to temporal precedence and extend trace window."
        )
    return None
