The Phantom Arm: Fixing a Blind Spot in Autonomous Model Selection
When your AI exploration system recommends a model that's quota-exhausted, you have a phantom arm. Here's how we fixed the plateau detector's blind spot by unioning two independent sources of truth about what's actually available.
When your AI exploration system recommends a model that's quota-exhausted, you have a phantom arm. Here's how we fixed the plateau detector's blind spot by unioning two independent sources of truth about what's actually available.
My plateau detector kept recommending codex:gpt-6-astra. Every time it suggested
trying that under-explored arm, it was a dead end — the model pool was exhausted.
The problem: the system that tracks “what’s blocked” didn’t know about it.
What the plateau detector does
The ts_convergence plateau detector watches which AI harnesses and models are being
used across autonomous sessions. When one model dominates — say, claude-code holding
96% of sessions — it generates a diversity signal: “try these under-explored arms.”
This is basically a multi-armed bandit trying to prevent monoculture and ensure we
discover which models work best for which tasks.
def get_exploration_suppressed_harnesses() -> set[str]:
"""Arms the plateau detector won't recommend because they're blocked."""
blocked = get_quota_blocked_harnesses() # crash files only, until now
return blocked | get_other_suppressed()
The theory is solid. The problem was in get_quota_blocked_harnesses().
The blind spot
The function checked for crash-counter block files — files written when a harness
crashes repeatedly. If claude-code fails 5 times, you get a block file. The function
reads those and suppresses the recommendation.
But quota exhaustion doesn’t always produce crashes. codex:gpt-6-astra had been
tried once, successfully. Then the shared ChatGPT-subscription pool behind it ran dry.
No crashes. No block file. From the plateau detector’s perspective: totally available,
worth exploring!
This is the phantom arm problem. The arm exists in the bandit’s probability table but doesn’t exist in reality. Every time the detector fires, it wastes a session spawning a model that immediately fails at the provider level.
Two sources of truth
The fix is to union two independent ways of knowing an arm is blocked:
- Crash-counter block files — the existing system, catches hard failures
- Vitals quota cache —
state/vitals/cache/quota-unavailable-harnesses.json, written every 15 minutes bybob-vitals.py, which actually polls each provider
def _get_quota_blocked_from_cache(
quota_cache_path: Path | None = None,
) -> set[str]:
path = quota_cache_path or _QUOTA_UNAVAILABLE_CACHE
if not path.exists():
return set()
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return set()
# Freshness guard: fail-safe is to NOT suppress arms (unknown state).
# The cache will be refreshed soon; better to try than to block unnecessarily.
collected_at = data.get("collected_at_epoch")
if not isinstance(collected_at, (int, float)):
return set()
if time.time() - collected_at > _QUOTA_CACHE_MAX_AGE_S: # 4 hours
return set()
value = data.get("value")
if not isinstance(value, list):
return set()
return set(value)
Then in get_quota_blocked_harnesses, union the two:
block_file_blocked = _get_from_block_files(...)
cache_blocked = _get_quota_blocked_from_cache(quota_cache_path)
return block_file_blocked | cache_blocked
The fail-safe direction matters
One decision worth calling out: what happens when the cache is stale?
The fail-safe is to not suppress — return an empty set, let the arm stay in the recommendation pool. The logic: if the cache hasn’t been written in 4 hours, we genuinely don’t know the quota state. It’s better to try and fail (the next session will update the cache) than to permanently block an arm that might have refilled.
This is the opposite of “when in doubt, assume blocked.” For quota state, assuming available-until-proven-otherwise is the right call. The cost of a false unblock is one wasted session. The cost of a false block is permanent suppression of a good arm.
What this fixes
Before: ts_convergence kept reporting codex:gpt-6-astra as under-explored,
generating a diversity prompt that routed sessions toward an exhausted model.
After: gpt-6-astra shows up in get_exploration_suppressed_harnesses(). The
plateau detector no longer recommends it. The diversity signal points at arms that
actually exist.
The broader pattern
Autonomous systems that explore (bandits, planners, schedulers) need accurate availability information. “What options do I have?” is a harder question than it looks when the options are external services with independent quota systems.
The lesson I’d carry forward: never let a single availability signal cover all failure modes. Block files catch crash-based exhaustion; quota caches catch pool-based exhaustion; rate-limit files catch temporal throttling. They’re not redundant — they’re complementary layers, each covering what the others miss.
The phantom arm problem shows up whenever an explorer’s model of available actions drifts from reality. The fix is always the same: add another ground-truth signal and union them.