# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/when-must-an-mcts-planner-refuse-to-expand-a-memory-grounded-action-node.md
# Remnant Warrant:    https://corpusfetch.com/c/when-must-an-mcts-planner-refuse-to-expand-a-memory-grounded-action-node.warrant.json
# Markdown SHA-256:   b0adcde1a1a91fd5c2e9e01edbc628538abb7a2e5d70049878aee8417a329e0d
# Code AST SHA-256:   bfbb84b0f847dab0100eae96806dbd284221ccf6ede759112f2f1cbbc25d4481
# Verified Classes:   NonStationarySupportError, GroundedNode
# Defensive Guards:   2 fail-closed invariants (zero external deps)

class NonStationarySupportError(RuntimeError):
    """Raised when a memory-grounded node's action support changes between
    expansions while statistics from the prior support are still live."""
    pass


class GroundedNode:
    def __init__(self, node_id, action_set, priors):
        # Snapshot of the support at first expansion.
        self.node_id = node_id
        self.support = frozenset(action_set)
        self.priors = dict(priors)
        self.N = {a: 0 for a in self.support}
        self.W = {a: 0.0 for a in self.support}

    def expand(self, fresh_actions, fresh_priors, jaccard_reset_threshold=0.8):
        fresh = frozenset(fresh_actions)
        if fresh == self.support:
            return  # stationary: reuse statistics safely

        inter = len(fresh & self.support)
        union = len(fresh | self.support) or 1
        jaccard = inter / union

        if jaccard < jaccard_reset_threshold:
            # Support drifted too far; statistics are not comparable.
            raise NonStationarySupportError(
                f"node {self.node_id}: support Jaccard={jaccard:.2f} below "
                f"{jaccard_reset_threshold}; full reset required before expansion"
            )

        # Partial reset: drop statistics of vanished children, zero the
        # statistics of newly-appeared children, keep persisting ones.
        for a in list(self.N):
            if a not in fresh:
                del self.N[a]
                del self.W[a]
                del self.priors[a]
        for a in fresh:
            if a not in self.N:
                self.N[a] = 0
                self.W[a] = 0.0
            self.priors[a] = fresh_priors[a]  # renormalize over new support
        self.support = fresh


def expand_grounded_node(node, retriever, plan_prefix, memory_snapshot_id):
    # Re-retrieval is permitted only against a pinned memory snapshot;
    # a live store would make the support non-stationary by construction.
    actions, priors = retriever.query(plan_prefix, snapshot=memory_snapshot_id)
    if not actions:
        raise NonStationarySupportError(
            f"node {node.node_id}: retrieval returned empty support under "
            f"snapshot {memory_snapshot_id}; cannot expand"
        )
    node.expand(actions, priors)
    return node
