Closest-retained-level opponent gap¶
The opponent half of the anytime coverage assumption (A2 in
robust-margin.md) needs, at every step t,
some retained imagined level l >= f_t within eps_OM of the real opponent:
g_t = min_{l >= f_t} TV(mu*_t(. | s_t), pi_l(. | s_t)) <= eps_OM.
mu*_t is the law the real (joint) opponent samples from at s_t, pi_l is IOP
level l, and f_t is the shield's monotone level floor at that step. The worst
case of g_t over a run sets eps_OM.
Why the floor matters¶
The shield only certifies against levels >= f_t, so a level below the floor
cannot be the one that covers the opponent. The minimum over all levels (floor
0) is therefore optimistic once the floor has risen. The gap between the two is
the overshoot failure mode of A2: the floor rose past the level the opponent
resembles. floor_excludes_closest_level_fraction in the script output counts
the steps where a level below the floor is strictly closer than every retained
one.
Definition details (shared by trainer and script)¶
src/rl/opponent_level_gap.py holds the NumPy definitions used by both:
- The live law is each opponent's policy within the action mask it samples under, so a shielded opponent (Congestion shields both players) is measured as it acts. An all-true mask is the plain softmax.
- Opponents are independent given their observations. The joint law is their
product, first opponent most significant, matching
encode_joint_actionand the shield's opponent order (possible_agentswithout the focal index). closest_retained_level_tv(per_level_tv, floors)takes the per-sample minimum overl >= floor.suffix_min_level_tvgives the same for every floor at once.
Training-time logging (src/rl/ippo.py)¶
IPPO computes the live-opponent vs IOP discrepancy on up to
diagnostic_state_sample rollout states every diagnostic_interval_updates
updates. It used to be gated by verbose_data, but notebooks pass one
verbose_data to both the trainer and the shield wrapper. The shield then keeps
its per-step data traces, which are heavy, and the diagnostics export writes them
out. log_opponent_level_gap (default False) enables only the discrepancy.
verbose_data=True still enables it as before.
When enabled, IPPO records level_floor per step for every shield-protected
agent. That is the controller's floor read just before env.step, which is the
floor the step's action was certified against. The controllers come from
_shield_level_controllers: a single-role TransitionShield is its own
controller, and the coordinated MultiAgentTransitionShield exposes one per role
through .controllers. Each protected agent's metrics then gain:
opponent_policy_closest_level_tv_sampled_empirical_{mean,p95,max}, using the recorded floor of each sampled step;opponent_policy_level_floor_sampled_{min,max}.
The existing opponent_policy_level_{l}_tv_* and opponent_policy_mixture_tv_*
keys are unchanged, except that the opponent's mask is now applied. This only
matters for a shielded opponent, and that case was never logged before: the
coordinated wrapper has no top-level iop. If an environment exposes an iop
but no level_floor, the gap is logged for every floor f as
opponent_policy_closest_level_tv_floor{f}_sampled_empirical_{mean,p95,max}.
ParameterizedIPPO accepts the flag (it shares the config) but does not compute
the discrepancy.
Notebook wiring: build_algo_cfg (notebooks/utils/jax_experiments.py) forwards
log_opponent_level_gap from the notebook config dict to every trainer whose
default config has the key (IPPO and IPPO-Shielded, not IPPO-Lagrangian). A
notebook therefore only needs "log_opponent_level_gap": False next to
"verbose_data" in the dict it passes to apply_cli_overrides. That makes it a
known CLI key, so a launch can pass log_opponent_level_gap=True. The values
land in metrics/<stem>_updates.json per agent.
Offline measurement (scripts/measure_opponent_level_gap.py)¶
The campaign did not log the gap, so the script measures it on the frozen final policies. For each run of a tagged OmSh condition it:
- rebuilds the run's shield from its manifest config and the cached point-kernel
bundles. This mirrors
build_shielded_env_factory_from_config, includingshield_padding_radius, but skips the factory's diagnostic exports, so nothing is written into the export tree. Bundle staleness is checked on content stamps only (mtime and size), because recorded paths are absolute cluster paths, plus the build parameters the notebook cache compares; - restores the post-training controller snapshot and the frozen policies, then
replays
evaluate_frozen_policies. It uses the same masked sampling and per-agent generators, and the first--episodesseeds of the run'sfinal_policy_eval_seed_offset + run * final_policy_eval_episodesblock. Its episodes are therefore a prefix of the campaign's own evaluation. The script checks this against the run's final-policy report (evaluation_reproduction); - records per step the binarised base state (the IOP's input in the shield), every agent's observation and mask, and every controller's floor, then evaluates policies and IOP levels in batches.
Output: <output-dir>/<env>.json (default exports/opponent_level_gap/). It
holds per-run and pooled summaries per shielded role: closest_retained_level_tv
and min_level_tv_floor0 (count/max/p99/p95/mean), per_level_tv,
floor_distribution, closest_retained_level_distribution,
closest_retained_level_tv_by_floor, floor_excludes_closest_level_fraction,
and the worst step with both laws. --save-steps also writes
<env>_steps.npz with the per-step arrays.
uv run python scripts/measure_opponent_level_gap.py bertrand chicken pursuit congestion
# Cluster CPU runner (forwards key=value as --key value):
submit -p amd48 --mem=16G py_cpu.sh omsh/scripts/measure_opponent_level_gap \
environments=markov_stag_hunt save-steps=1 venv=omsh
Each step runs batch-one shield, policy and IOP calls, so the script pins torch, BLAS and XLA to one thread. Multi-threaded runtimes were about 2x slower here. To use more cores, run environments as separate processes. Peak RSS is about 1.5 GB plus about 3.4x the bundle pickle size.
Results (successor_campaign_v1, 200 episodes x 5 runs)¶
Pooled g_t at the actual floor, then the floor-0 minimum. All 1,000 replayed
episodes per environment reproduce the campaign's own evaluation reward traces
exactly. No bundle stamp problems were found.
| Env (role) | Steps | g_t max / p99 / p95 / mean |
Floor-0 min max / p99 / p95 / mean | Floors (steps) | Overshoot fraction |
|---|---|---|---|---|---|
| Bertrand | 200,000 | 0.415 / 0.415 / 0.415 / 0.409 | 0.389 / 0.389 / 0.389 / 0.384 | 2 (all) | 0.995 |
| Chicken | 200,000 | 0.488 / 0.488 / 0.488 / 0.485 | 0.486 / 0.486 / 0.486 / 0.483 | 2 (all) | 0.982 |
| Pursuit | 50,423 | 0.923 / 0.889 / 0.875 / 0.782 | 0.724 / 0.700 / 0.676 / 0.633 | 2 (42,188), 0 (8,235; run 2) | 0.740 |
| Congestion (player_0) | 64,000 | 0.646 / 0.646 / 0.646 / 0.524 | 0.618 / 0.613 / 0.613 / 0.496 | 2 (all) | 0.999 |
| Congestion (player_1) | 64,000 | 0.621 / 0.621 / 0.621 / 0.495 | 0.621 / 0.621 / 0.621 / 0.477 | 2 (all) | 0.418 |
Reading:
- A2 fails at any useful
eps_OMfor the campaign's final policies. The gap is mostly misspecification: even the floor-0 minimum is 0.39 to 0.72. The final co-trained opponents are nearly deterministic. At the worst steps they put 97% on one action in Bertrand, 99% in Chicken, 95 to 98% in Congestion and 85% in Pursuit (78% on average in Pursuit run 0). The IOP levels there are near-uniform: about 44/56 in Bertrand, 50/50 in Chicken, and uniform over three actions in Congestion. In Pursuit, levels 1 and 2 put their mass on the actions the final opponent avoids. Level 0 is fit to the opponent data collected before the campaign training (cross-entropy 1.51 nats over five actions in Pursuit, close to uniform), and the higher levels are imagined from it. The opponent then kept learning, so the gap is the drift from OM-time behaviour to the final co-trained policy. - Overshoot comes on top of that. Every run except Pursuit run 2 ends training with the floor at the top level (2). In Pursuit, level 0 is closer than every retained level in 74% of steps, and excluding it raises the worst gap from 0.72 to 0.92.
- Within an evaluation episode the floor never moves. The controller snapshot already sits at the top level, or patience never completes in Pursuit run 2. So the per-step floor equals the end-of-training floor here. During training it would not.
- The cross-check against executed opponent actions (Pursuit run 0) agrees. The
levels' probability of the executed action, through
observed_log_probs(the shield's Bayes-update path), matches the dense per-level path exactly. Levels 1 and 2 give the executed action only 5 to 8% on average, against 64% under the live law.
Cost: Bertrand and Chicken took about 28 minutes each (200,000 steps), Pursuit 9.5 minutes, Congestion 16.5 minutes. They ran as four parallel single-thread processes on a shared 22-core machine. Peak RSS was 1.6 to 1.8 GB.
Alternatives not taken¶
- Reading the floor from the shield-augmented observation (
[obs, budget, floor]) would avoid a trajectory field, but it depends on the wrapper's layout (the Bayesian mode appends the posterior), so the controller'slevel_flooris read directly. - Measuring on the rollout states of training would need retraining with the flag on. The offline script covers the deployed final policies. The flag covers the opponent drift during training.