0734d18989
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>
303 lines
12 KiB
Python
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]
|