"""Shadow runner: decides what it *would* commit, and cannot commit it. P2A-03 requires that this phase never writes registry state. That is enforced structurally rather than promised: ShadowRunner is never given the transition engine, so it has no means to write. The gate goes further and hashes the whole registry directory around a run, which is a stronger claim than checking that a status field did not change. """ from __future__ import annotations import logging from typing import Any, Callable, Dict, Optional from lib_py.loop import policy, store logger = logging.getLogger("loop.shadow") class ShadowRunner(object): """Runs observation -> decision -> log. Holds no writer of any kind.""" def __init__(self, workspace=None, decide: Callable = policy.decide, sink=None): self.workspace = workspace self._decide = decide self._sink = sink if sink is not None else self._default_sink @staticmethod def _default_sink(job_id, entry, workspace=None): store.append_decision(job_id, entry, workspace=workspace) def evaluate(self, scenario: Dict[str, Any]) -> Dict[str, Any]: """Decide for one composite scenario and record the would-be outcome.""" job_id = (scenario.get("dispatch_context") or {}).get("job_id") or \ scenario.get("scenario_id") or "unknown" decision = self._decide(scenario) entry = { "job_id": job_id, "scenario_id": scenario.get("scenario_id"), "would_complete": decision.would_complete, "shadow": True, } entry.update(decision.as_dict()) self._sink(job_id, entry, workspace=self.workspace) if decision.would_complete: logger.info("shadow: WOULD-COMPLETE job %s (%s)", job_id, decision.reason) else: logger.debug("shadow: hold job %s (%s)", job_id, decision.reason) return entry def evaluate_all(self, scenarios) -> list: return [self.evaluate(s) for s in scenarios]