Skip to content

Commit 91feafb

Browse files
committed
Harden the governed loop after review
- Governor.__post_init__ rejects an unbounded config (all stop conditions None would loop forever) and dry_patience < 1 (would stop before any round runs) — fail fast at construction instead of hanging. - _best_of returns None for an all-killed batch instead of handing a killed entry (with no score) to the post-run Reflector/Advisor. 96 tests pass.
1 parent e39dbc3 commit 91feafb

4 files changed

Lines changed: 26 additions & 1 deletion

File tree

scholarloop/governor.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ class Governor:
4949
_best: float | None = None
5050
_alerted: set = field(default_factory=set)
5151

52+
def __post_init__(self) -> None:
53+
if self.dry_patience is not None and self.dry_patience < 1:
54+
raise ValueError("dry_patience must be >= 1 (rounds with no improvement before stopping)")
55+
if self.max_cost is None and self.max_rounds is None and self.dry_patience is None:
56+
raise ValueError("Governor needs at least one stop condition "
57+
"(max_cost / max_rounds / dry_patience) or the loop never ends")
58+
5259
def update_frontier(self, score: float | None, direction: str) -> bool:
5360
"""Fold a fresh result into the tracked frontier. Returns True iff it improved the best so far."""
5461
if score is None:

scholarloop/orchestrator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def _best_of(self, entries: list[LedgerEntry]):
9999
"""The best-scoring entry in a batch by the metric direction (None scores ignored)."""
100100
scored = [e for e in entries if e.primary_score() is not None]
101101
if not scored:
102-
return entries[-1] if entries else None
102+
return None # all killed/unscored — no result to reflect on
103103
return min(scored, key=lambda e: e.primary_score()) if self.profile.metric.direction == "minimize" \
104104
else max(scored, key=lambda e: e.primary_score())
105105

tests/test_governor.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
"""The loop Governor: budget / round-cap / convergence stop conditions (pure, no LLM)."""
22

3+
import pytest
4+
35
from scholarloop.governor import Governor, cost_of
46

57

8+
def test_governor_rejects_unbounded_and_degenerate_configs():
9+
with pytest.raises(ValueError):
10+
Governor() # no stop condition at all -> would loop forever
11+
with pytest.raises(ValueError):
12+
Governor(dry_patience=0) # 0 would stop before any round runs
13+
14+
615
def test_cost_of_known_and_unknown_model():
716
assert cost_of({"input_tokens": 1_000_000, "output_tokens": 1_000_000}, "claude-opus-4-8") == 30.0
817
assert cost_of({"input_tokens": 1000, "output_tokens": 0}, "no-such-model") is None # unmetered

tests/test_orchestrator.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,15 @@ def test_run_with_governor_stops_on_round_cap(tmp_path):
195195
assert gov.rounds == 2 # stopped exactly at the cap
196196

197197

198+
def test_best_of_returns_none_for_an_all_killed_batch(tmp_path):
199+
orch = Orchestrator(MockLLM(), PROFILE, ledger_path=tmp_path / "ledger.jsonl",
200+
registry_dir=tmp_path / "registry")
201+
killed = LedgerEntry(id="exp_k", domain="image-classification",
202+
hypothesis=Hypothesis("c", "arXiv:1"), metric_name="val_top1_err",
203+
fidelity=["smoke"], metric={}, verdict="killed") # no score
204+
assert orch._best_of([killed]) is None # nothing to reflect on, not a killed entry
205+
206+
198207
def test_promote_gate_uses_statistical_significance(tmp_path):
199208
orch = Orchestrator(MockLLM(), PROFILE, # promote_z=1.0 default
200209
ledger_path=tmp_path / "ledger.jsonl", registry_dir=tmp_path / "registry")

0 commit comments

Comments
 (0)