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>
This commit is contained in:
leetcrypt
2026-07-19 18:15:28 -07:00
parent 12fd2537e3
commit 0734d18989
4 changed files with 570 additions and 42 deletions
+173 -42
View File
@@ -1,16 +1,20 @@
"""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. Three
strategies, all deterministic and offline:
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 PATH-stream pick from the live pool;
* ``agent`` — a *local* stability heuristic (prefers nodes that have churned
least). The paid frontier-model agent arm (GOAL autonomy envelope (c)) is
**human-gated and intentionally NOT wired here** — this strategy spends
nothing and calls no external model; it is the offline stand-in used to
validate the rebuild loop.
* ``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``
@@ -20,14 +24,109 @@ nodes). Rebuild activity is emitted as R3 ``rebuild_start``/``rebuild_done`` +
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional
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
@@ -52,6 +151,7 @@ class SelectionResult:
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:
@@ -61,45 +161,41 @@ class SelectionResult:
class Selector:
"""Picks a ``hops``-length circuit from a live pool per its strategy. Pure and
deterministic; the same (seed, strategy, live pool, churn count) always yields
the same circuit."""
"""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) -> None:
if strategy not in STRATEGIES:
raise ValueError(f"unknown selector strategy {strategy!r} (of {STRATEGIES})")
self.strategy = strategy
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._draws = 0 # advances the random stream across rebuilds
self._kill_count: Dict[str, int] = {} # node -> times killed (agent signal)
self._ctx = SelectorContext(seed=seed, hops=hops)
@property
def policy(self) -> SelectorPolicy:
return self._policy
def note_kill(self, node: str) -> None:
self._kill_count[node] = self._kill_count.get(node, 0) + 1
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)."""
if len(live) < self.hops:
return None
if self.strategy == "static":
return sorted(live)[: self.hops]
if self.strategy == "random":
pool = sorted(live)
s = SorRng(self.seed).stream(Domain.SELECTOR)
for _ in range(self._draws): # replay to current position (determinism)
s.next_u64()
picked: List[str] = []
work = list(pool)
for _ in range(self.hops):
j = s.next_below(len(work))
picked.append(work.pop(j))
self._draws += 1
return picked
# agent: local stability heuristic — fewest kills first, ties by id. No
# external model, no spend (the paid arm is human-gated, not wired here).
ranked = sorted(live, key=lambda n: (self._kill_count.get(n, 0), n))
return ranked[: self.hops]
return self._policy.choose(list(live), self._ctx)
def run_selection(
@@ -109,18 +205,26 @@ def run_selection(
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."""
sel = Selector(strategy, seed, hops)
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(strategy, seed, hops, list(initial))
result = SelectionResult(sel.strategy, seed, hops, list(initial))
for ev in schedule:
if ev.kind == "kill":
@@ -160,9 +264,36 @@ def run_selection(
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)."""