|
| 1 | +"""Reproducible random-failure and noisy-execution robustness experiment.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import random |
| 6 | +import json |
| 7 | +from pathlib import Path |
| 8 | + |
| 9 | +if __package__: |
| 10 | + from ._bootstrap import prepare_script_imports |
| 11 | +else: |
| 12 | + from _bootstrap import prepare_script_imports |
| 13 | + |
| 14 | + prepare_script_imports(__file__) |
| 15 | + |
| 16 | +from loop_engineering.actions import Action, ActionResult |
| 17 | +from loop_engineering.artifacts import save_run_artifact |
| 18 | +from loop_engineering.evaluators import GoalEvaluator |
| 19 | +from loop_engineering.metrics import MetricReport |
| 20 | +from loop_engineering.models import LoopState |
| 21 | +from loop_engineering.policies import Decision |
| 22 | +from loop_engineering.runner import LoopRunner |
| 23 | +from loop_engineering.stopping import MaxSteps, SuccessReached |
| 24 | + |
| 25 | +from experiments.adaptive_strategy import ( |
| 26 | + AdaptivePolicy, |
| 27 | + ErrorAwarePolicy, |
| 28 | + FixedPolicy, |
| 29 | + MemoryAwarePolicy, |
| 30 | +) |
| 31 | + |
| 32 | +STRATEGIES = ("fixed", "error_aware", "memory_aware", "adaptive") |
| 33 | +SEEDS = (101, 211, 307, 401, 503, 601, 701, 809) |
| 34 | +LEVELS = (("low", 0.05, 0.10), ("medium", 0.15, 0.30), ("high", 0.30, 0.60)) |
| 35 | + |
| 36 | + |
| 37 | +class StochasticAction(Action): |
| 38 | + """Apply an increment with independent random failure and bounded noise.""" |
| 39 | + |
| 40 | + def __init__( |
| 41 | + self, failure_rate: float, noise_amplitude: float, rng: random.Random |
| 42 | + ) -> None: |
| 43 | + if not 0.0 <= failure_rate <= 1.0: |
| 44 | + raise ValueError("failure_rate must be between 0 and 1.") |
| 45 | + if noise_amplitude < 0.0: |
| 46 | + raise ValueError("noise_amplitude must be non-negative.") |
| 47 | + self._failure_rate = failure_rate |
| 48 | + self._noise_amplitude = noise_amplitude |
| 49 | + self._rng = rng |
| 50 | + |
| 51 | + def apply(self, state: LoopState, decision: Decision) -> ActionResult: |
| 52 | + if self._rng.random() < self._failure_rate: |
| 53 | + return ActionResult( |
| 54 | + state=state.with_value(state.value, stochastic_failure=True), |
| 55 | + success=False, |
| 56 | + cost=0.0, |
| 57 | + ) |
| 58 | + amount = float(decision.parameters["amount"]) |
| 59 | + actual_amount = amount + self._rng.uniform( |
| 60 | + -self._noise_amplitude, self._noise_amplitude |
| 61 | + ) |
| 62 | + return ActionResult( |
| 63 | + state=state.with_value(state.value + actual_amount), |
| 64 | + success=True, |
| 65 | + cost=abs(actual_amount), |
| 66 | + ) |
| 67 | + |
| 68 | + |
| 69 | +def _policy_for(strategy: str): |
| 70 | + return { |
| 71 | + "fixed": FixedPolicy, |
| 72 | + "error_aware": ErrorAwarePolicy, |
| 73 | + "memory_aware": MemoryAwarePolicy, |
| 74 | + "adaptive": AdaptivePolicy, |
| 75 | + }[strategy]() |
| 76 | + |
| 77 | + |
| 78 | +def _run(root: Path, level: str, failure_rate: float, noise: float, strategy: str, seed: int) -> dict[str, object]: |
| 79 | + trace = LoopRunner( |
| 80 | + policy=_policy_for(strategy), |
| 81 | + action=StochasticAction(failure_rate, noise, random.Random(seed)), |
| 82 | + evaluator=GoalEvaluator(tolerance=0.25), |
| 83 | + stop_conditions=[SuccessReached(), MaxSteps(8)], |
| 84 | + ).run(LoopState(step=0, value=0.0, goal=6.0)) |
| 85 | + metrics = MetricReport.from_trace(trace) |
| 86 | + artifact = save_run_artifact(root / f"{level}--{strategy}--{seed}.json", trace, metrics) |
| 87 | + return {"level": level, "strategy": strategy, "seed": seed, "success": metrics.success, "cost": metrics.cost, "steps": metrics.steps, "final_score": metrics.final_score, "artifact_path": str(artifact)} |
| 88 | + |
| 89 | + |
| 90 | +def _summary(level: str, strategy: str, runs: list[dict[str, object]]) -> dict[str, object]: |
| 91 | + costs = sorted(float(item["cost"]) for item in runs) |
| 92 | + steps = sorted(int(item["steps"]) for item in runs) |
| 93 | + index = 7 |
| 94 | + return {"level": level, "strategy": strategy, "run_count": len(runs), "success_count": sum(bool(item["success"]) for item in runs), "success_rate": sum(bool(item["success"]) for item in runs) / len(runs), "mean_cost": sum(costs) / len(costs), "worst_cost": costs[-1], "cost_p90": costs[index], "mean_steps": sum(steps) / len(steps), "steps_p90": steps[index]} |
| 95 | + |
| 96 | + |
| 97 | +def run_stochastic_robustness(output_dir: str | Path = ".loop/runs/stochastic-robustness") -> dict[str, object]: |
| 98 | + """Run the fixed stochastic matrix and persist every run artifact.""" |
| 99 | + root = Path(output_dir).resolve() |
| 100 | + runs = [_run(root, level, rate, noise, strategy, seed) for level, rate, noise in LEVELS for strategy in STRATEGIES for seed in SEEDS] |
| 101 | + summaries = [_summary(level, strategy, [item for item in runs if item["level"] == level and item["strategy"] == strategy]) for level, _, _ in LEVELS for strategy in STRATEGIES] |
| 102 | + rankings = {level: sorted([item for item in summaries if item["level"] == level], key=lambda item: (-float(item["success_rate"]), float(item["cost_p90"]), float(item["mean_steps"]), STRATEGIES.index(str(item["strategy"])))) for level, _, _ in LEVELS} |
| 103 | + result = {"levels": [item[0] for item in LEVELS], "strategies": list(STRATEGIES), "runs": runs, "summaries": summaries, "rankings": rankings} |
| 104 | + root.mkdir(parents=True, exist_ok=True) |
| 105 | + (root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
| 106 | + return result |
| 107 | + |
| 108 | + |
| 109 | +def main() -> None: |
| 110 | + """Print the complete stochastic robustness report as JSON.""" |
| 111 | + |
| 112 | + print(json.dumps(run_stochastic_robustness(), ensure_ascii=False, indent=2)) |
| 113 | + |
| 114 | + |
| 115 | +if __name__ == "__main__": |
| 116 | + main() |
0 commit comments