Files
hack-house/cmd_chat/sor/selector.py
T
leetcrypt 0734d18989 R7: pluggable SelectorPolicy seam + local-OSS (Ollama) confirmatory agent arm
Adds the RQ3 agent-selector backend as a reproducible, offline measurement
decision — not relay/data-plane traffic. Containment is untouched: the model
query is a local call to localhost:11434.

- selector.py: SelectorPolicy ABC seam; static/random/agent(heuristic) built-ins;
  make_policy() resolves (strategy, agent_backend); run_selection writes a
  write-once selector.json provenance sidecar.
- agent_selector.py: OllamaAgentPolicy — reproducible confirmatory arm
  (temp=0 + per-run seed, decisions cached keyed by (seed, state-hash),
  deterministic heuristic fallback on any model/parse failure, model weights
  digest pinned for the manifest). ClaudeExploratoryPolicy — EXPLORATORY-only
  stub: makes no paid call, refuses to serve as a confirmatory backend.
- provenance.py: additive selector_backend field so an agent-selected run pins
  the exact model id + weights digest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-19 18:15:28 -07:00

303 lines
12 KiB
Python

"""R7 — Path selector that rebuilds circuits under churn.
The selector consumes a churn schedule (``churn.py``) and, whenever a live circuit
loses a node to a kill, **rebuilds** it from the currently-live pool. The rebuild
decision is a pluggable :class:`SelectorPolicy` so the RQ3 arms can be swapped
without touching the replay loop. Three built-in strategies, all deterministic
and offline:
* ``static`` — canonical-order pick (stable, no randomness);
* ``random`` — seeded SELECTOR-stream pick from the live pool;
* ``agent`` — an agent policy. The default backend is a *local* stability
heuristic (prefers nodes that have churned least). A reproducible
local-open-source-model backend (Ollama, temp=0, seed-pinned, decision-cached)
lives in ``agent_selector.py`` and is selected with ``agent_backend="ollama"``.
The paid frontier-model / Claude Code arm is **human + budget gated** (GOAL
autonomy envelope (c)) and is only exposed as an EXPLORATORY stub that makes
no external call — it is never wired as a confirmatory arm here.
The R7 acceptance predicate this satisfies: under a fixed churn seed the selector
rebuilds every dropped circuit (as long as the live pool can supply ``hops``
nodes). Rebuild activity is emitted as R3 ``rebuild_start``/``rebuild_done`` +
``churn_kill``/``churn_spawn`` events when an :class:`EventLog` is supplied.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from cmd_chat.sor.churn import ChurnEvent
from cmd_chat.sor.config import Domain, SorRng
from cmd_chat.sor.events import EventLog
STRATEGIES = ("static", "random", "agent")
AGENT_BACKENDS = ("heuristic", "ollama", "claude")
# --------------------------------------------------------------------------- #
# Pluggable selection policy — the RQ3 arm seam.
# --------------------------------------------------------------------------- #
@dataclass
class SelectorContext:
"""State a policy may read when choosing a circuit. ``kill_counts`` is the
running per-node churn history; ``draws`` advances a random stream across
rebuilds so a stochastic policy stays deterministic within a run."""
seed: int
hops: int
kill_counts: Dict[str, int] = field(default_factory=dict)
draws: int = 0
class SelectorPolicy(ABC):
"""Chooses a ``hops``-length circuit from a live pool. Implementations must be
deterministic given ``(live, ctx)`` so a run is reproducible from its seed."""
name: str = "policy"
@abstractmethod
def choose(self, live: List[str], ctx: SelectorContext) -> Optional[List[str]]:
"""Return a ``ctx.hops``-length circuit of distinct nodes drawn from
``live``, or ``None`` if the pool is too small."""
def provenance(self) -> Dict[str, Any]:
"""Auditable description of this policy for the run manifest sidecar."""
return {"policy": self.name}
class StaticPolicy(SelectorPolicy):
name = "static"
def choose(self, live: List[str], ctx: SelectorContext) -> Optional[List[str]]:
if len(live) < ctx.hops:
return None
return sorted(live)[: ctx.hops]
class RandomPolicy(SelectorPolicy):
name = "random"
def choose(self, live: List[str], ctx: SelectorContext) -> Optional[List[str]]:
if len(live) < ctx.hops:
return None
work = sorted(live)
s = SorRng(ctx.seed).stream(Domain.SELECTOR)
for _ in range(ctx.draws): # replay to current position (determinism)
s.next_u64()
picked: List[str] = []
for _ in range(ctx.hops):
j = s.next_below(len(work))
picked.append(work.pop(j))
ctx.draws += 1
return picked
class HeuristicAgentPolicy(SelectorPolicy):
"""Local, model-free agent stand-in: prefer the nodes that have churned least
(fewest kills), ties broken by id. Spends nothing, calls nothing."""
name = "agent-heuristic"
def choose(self, live: List[str], ctx: SelectorContext) -> Optional[List[str]]:
if len(live) < ctx.hops:
return None
ranked = sorted(live, key=lambda n: (ctx.kill_counts.get(n, 0), n))
return ranked[: ctx.hops]
def make_policy(strategy: str, agent_backend: str = "heuristic", **kwargs: Any) -> SelectorPolicy:
"""Resolve a ``(strategy, agent_backend)`` pair to a concrete policy. The
``ollama``/``claude`` agent backends are imported lazily so the base selector
carries no network/model dependency."""
if strategy == "static":
return StaticPolicy()
if strategy == "random":
return RandomPolicy()
if strategy == "agent":
if agent_backend == "heuristic":
return HeuristicAgentPolicy()
if agent_backend == "ollama":
from cmd_chat.sor.agent_selector import OllamaAgentPolicy
return OllamaAgentPolicy(**kwargs)
if agent_backend == "claude":
from cmd_chat.sor.agent_selector import ClaudeExploratoryPolicy
return ClaudeExploratoryPolicy(**kwargs)
raise ValueError(f"unknown agent backend {agent_backend!r} (of {AGENT_BACKENDS})")
raise ValueError(f"unknown selector strategy {strategy!r} (of {STRATEGIES})")
@dataclass
class Rebuild:
"""One rebuild triggered by a drop: the step, the node that dropped, and the
replacement circuit (node ids in order)."""
t: int
dropped: str
circuit: List[str]
@dataclass
class SelectionResult:
"""Outcome of replaying a churn schedule under a selector strategy."""
strategy: str
seed: int
hops: int
initial_circuit: List[str]
rebuilds: List[Rebuild] = field(default_factory=list)
drops: int = 0
deferred: int = 0 # drops that could not be rebuilt (pool < hops at that step)
events_sha256: Optional[str] = None
policy_provenance: Optional[Dict[str, Any]] = None
@property
def every_drop_rebuilt(self) -> bool:
"""True iff every drop that broke the circuit was met by a rebuild — the
R7 acceptance predicate (no deferred/unhealed drops)."""
return self.deferred == 0 and len(self.rebuilds) == self.drops
class Selector:
"""Picks a ``hops``-length circuit from a live pool via a pluggable
:class:`SelectorPolicy`. Pure and deterministic; the same (seed, policy, live
pool, churn history) always yields the same circuit."""
def __init__(
self,
strategy: str,
seed: int,
hops: int,
*,
agent_backend: str = "heuristic",
policy: Optional[SelectorPolicy] = None,
**policy_kwargs: Any,
) -> None:
if policy is not None:
self._policy = policy
self.strategy = getattr(policy, "name", "custom")
else:
self._policy = make_policy(strategy, agent_backend, **policy_kwargs)
self.strategy = strategy
self.seed = seed
self.hops = hops
self._ctx = SelectorContext(seed=seed, hops=hops)
@property
def policy(self) -> SelectorPolicy:
return self._policy
def note_kill(self, node: str) -> None:
self._ctx.kill_counts[node] = self._ctx.kill_counts.get(node, 0) + 1
def build(self, live: List[str]) -> Optional[List[str]]:
"""Return a ``hops``-length circuit from ``live``, or ``None`` if the pool
is too small (fewer than ``hops`` live nodes)."""
return self._policy.choose(list(live), self._ctx)
def run_selection(
seed: int,
nodes: List[str],
hops: int,
schedule: List[ChurnEvent],
strategy: str = "static",
log: Optional[EventLog] = None,
*,
agent_backend: str = "heuristic",
policy: Optional[SelectorPolicy] = None,
run_dir: Optional[Path] = None,
**policy_kwargs: Any,
) -> SelectionResult:
"""Replay ``schedule`` over ``nodes`` under ``strategy`` and rebuild the circuit
whenever a kill drops one of its nodes. Deterministic from the inputs.
Returns a :class:`SelectionResult`; ``result.every_drop_rebuilt`` is the R7
acceptance predicate. If ``log`` is given, emits R3 churn/rebuild events. If
``run_dir`` is given, writes the policy provenance (model id/digest, decision
cache) to ``selector.json`` so an agent-backed run is auditable + replayable."""
sel = Selector(strategy, seed, hops, agent_backend=agent_backend,
policy=policy, **policy_kwargs)
live = {n: True for n in nodes}
live_list = [n for n in nodes if live[n]]
initial = sel.build(live_list) or []
circuit: List[str] = list(initial)
result = SelectionResult(sel.strategy, seed, hops, list(initial))
for ev in schedule:
if ev.kind == "kill":
live[ev.node] = False
sel.note_kill(ev.node)
if log is not None:
log.emit("churn_kill", node_fp=_fp(ev.node), hop_index=ev.t)
if ev.node in circuit:
# The live circuit lost a hop -> must rebuild.
result.drops += 1
if log is not None:
log.emit("rebuild_start", circuit_id=f"t{ev.t}", hop_index=ev.t)
rebuilt = sel.build([n for n in nodes if live[n]])
if rebuilt is None:
result.deferred += 1 # pool too small right now
circuit = []
else:
circuit = rebuilt
result.rebuilds.append(Rebuild(ev.t, ev.node, list(rebuilt)))
if log is not None:
log.emit("rebuild_done", circuit_id=f"t{ev.t}",
hop_index=len(rebuilt))
else: # spawn
live[ev.node] = True
if log is not None:
log.emit("churn_spawn", node_fp=_fp(ev.node), hop_index=ev.t)
if not circuit:
# A deferred drop can now be healed once the pool recovers.
rebuilt = sel.build([n for n in nodes if live[n]])
if rebuilt is not None:
circuit = rebuilt
result.deferred = max(0, result.deferred - 1)
result.rebuilds.append(Rebuild(ev.t, ev.node, list(rebuilt)))
if log is not None:
log.emit("rebuild_done", circuit_id=f"t{ev.t}",
hop_index=len(rebuilt))
if log is not None:
result.events_sha256 = log.close()
result.policy_provenance = sel.policy.provenance()
if run_dir is not None:
_write_selector_sidecar(Path(run_dir), sel, result)
return result
def _write_selector_sidecar(run_dir: Path, sel: "Selector", result: SelectionResult) -> Path:
"""Persist the policy provenance (+ any decision cache) to ``selector.json`` —
a write-once, hashable audit artifact folding the agent backend into the run's
provenance (model id/digest, per-state decisions)."""
import json
run_dir.mkdir(parents=True, exist_ok=True)
doc: Dict[str, Any] = {
"strategy": result.strategy,
"seed": result.seed,
"hops": result.hops,
"provenance": result.policy_provenance,
}
decisions = getattr(sel.policy, "decisions", None)
if callable(decisions):
doc["decisions"] = decisions()
path = run_dir / "selector.json"
if path.exists():
raise FileExistsError(f"selector.json already exists (immutable): {path}")
path.write_text(json.dumps(doc, sort_keys=True, indent=2) + "\n", encoding="utf-8")
return path
def _fp(node: str) -> str:
"""A short, stable fingerprint of a node id for event metadata (never the id
verbatim in case ids ever carry structure)."""
import hashlib
return hashlib.sha256(node.encode("utf-8")).hexdigest()[:8]