1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
//! Bucket-brigade-specific scoring metrics.
//!
//! Rust mirror of the small numerical helpers used by the bucket-brigade
//! Python experiments. Kept under `crate::multi_agent` because it's only
//! meaningful for the multi-agent bucket-brigade training paths
//! (PSRO/NFSP test assertions), and gated on `env-bucket-brigade` so the
//! published crate (which strips the bucket-brigade adapter) doesn't pay
//! for the dead module.
//!
//! # `gap_closed` baselines (base scenario)
//!
//! `MINSPEC_RANDOM` and `MINSPEC_SPECIALIST` are the per-step team
//! payoffs of the random and specialist policies on the **base**
//! `minimal_specialization-v1` scenario (4 agents, 10 houses), mirrored
//! from the canonical Python single-source-of-truth at
//! `envs/bucket-brigade/bucket_brigade/baselines/__init__.py:68-69`. Use
//! `gap_closed` to normalize a per-step team payoff against these
//! base-scenario baselines.
//!
//! **Not valid on non-base scenarios.** The three no-convergence cells of
//! the workshop-paper phase diagram (β ∈ {0.1, 0.5, 0.9} with κ=0.1,
//! c=0.5) have meaningfully different baseline payoffs and need their
//! own normalization endpoints — see `gap_closed_cell` and the
//! `MINSPEC_RANDOM_BETA0XX` / `MINSPEC_SPECIALIST_BETA0XX` constants.
//!
//! # `gap_closed_cell` baselines (per cell)
//!
//! The six cell-specific constants were measured via Monte Carlo in
//! `tests/test_bucket_brigade_baselines.rs::recompute_cell_baselines`
//! using the Python protocol from
//! `envs/bucket-brigade/experiments/p3_specialization/diagnostics/
//! random_baseline.py`: 1000 episodes per cell per baseline type, uniform
//! random sampling from `MultiDiscrete([10, 2, 2])` for the random policy, the
//! [`crate::multi_agent::bucket_brigade_baselines::specialist_action`]
//! function for the specialist policy. To regenerate them after a
//! scenario change, run:
//!
//! ```bash
//! cargo test --release --features "training,env-bucket-brigade" \
//! --test test_bucket_brigade_baselines -- --ignored --nocapture
//! ```
use crateBucketBrigadeCell;
/// Per-step team payoff baseline for the random policy on the
/// `minimal_specialization-v1` **base** scenario (4 agents, 10 houses).
///
/// Mirrors `MINSPEC_RANDOM = -87.72` in
/// `envs/bucket-brigade/bucket_brigade/baselines/__init__.py:68`
/// (canonical, post-#246 sampler-bug fix, n=1000 × 5 seeds). Three
/// values for this constant have historically coexisted upstream:
///
/// * `-96.07` — pre-#246, 2-dim sampler bug. The number PR #126 ported from
/// `experiments/p3_specialization/analyze_291.py:41` (stale).
/// * `-92.92` — n=50 intermediate.
/// * `-87.72` — n=1000 × 5 seeds, **post-#246 canonical** (what we ship).
///
/// See upstream issues #246 and #293 for the bug-fix history.
pub const MINSPEC_RANDOM: f32 = -87.72;
/// Per-step team payoff baseline for the optimal specialist policy on
/// the `minimal_specialization-v1` **base** scenario (4 agents, 10 houses).
///
/// Mirrors `MINSPEC_SPECIALIST = -22.07` in
/// `envs/bucket-brigade/bucket_brigade/baselines/__init__.py:69`.
pub const MINSPEC_SPECIALIST: f32 = -22.07;
// ---- Cell-specific baselines (no-convergence cells of the phase diagram) ----
//
// Measured by
// `tests/test_bucket_brigade_baselines.rs::recompute_cell_baselines`
// using a 200-step fixed-horizon rollout (matching the
// `EVAL_STEPS = 200` convention in the NFSP/PSRO integration tests):
// 1000 episodes × 200 steps × 4 agents per cell per baseline type, uniform
// sampling for random, `specialist_action` from `bucket_brigade_baselines.rs`
// for specialist.
//
// # Empirical note: tight random↔specialist band on these cells
//
// On all three no-convergence cells the random and specialist baselines
// land within ~3.5 points of each other (both clustered around -603 per
// step). This is the empirical reality of these scenarios — the env's
// `min_nights = 12` plus Bernoulli burn-out dynamics mean that once even a
// handful of fires ignite (initial `prob_house_catches_fire = 0.02` per
// house per night), houses transition to RUINED within ~12 nights and stay
// RUINED for the remaining ~188 steps. The per-step `team_penalty_house_burns *
// ruined_fraction` term then dominates the per-step reward, drowning out
// both the specialist's first-12-night fire-fighting work and the random
// policy's noise. The cell-specific β parameter therefore matters only
// during those first 12 nights, after which the trajectories collapse to
// the same "all-ruined wasteland" reward.
//
// What this means for `gap_closed_cell`: the function has a steep slope
// (≈0.3 gap-closed per reward unit) on these cells, so a strong assertion
// `gap_closed_cell >= 0` is essentially equivalent to "policy scores at
// least as well as uniform random over the 200-step horizon".
//
// **Wall-clock**: ~1.2 sec total for all six constants in release mode on
// a modern CPU (much faster than initial estimates because the env's
// no-NN dynamics are cheap). Re-run the `recompute_cell_baselines` test
// after any change to the bucket-brigade env dynamics or the specialist
// policy.
/// Random baseline (200-step fixed horizon) for cell `(β = 0.1, κ = 0.1, c =
/// 0.5)`.
pub const MINSPEC_RANDOM_BETA01: f32 = -605.5118;
/// Specialist baseline (200-step fixed horizon) for cell `(β = 0.1, κ = 0.1, c
/// = 0.5)`.
pub const MINSPEC_SPECIALIST_BETA01: f32 = -602.0938;
/// Random baseline (200-step fixed horizon) for cell `(β = 0.5, κ = 0.1, c =
/// 0.5)`.
///
/// This is the canonical no-convergence cell PR #126's NFSP test targets.
pub const MINSPEC_RANDOM_BETA05: f32 = -605.5118;
/// Specialist baseline (200-step fixed horizon) for cell `(β = 0.5, κ = 0.1, c
/// = 0.5)`.
pub const MINSPEC_SPECIALIST_BETA05: f32 = -602.0938;
/// Random baseline (200-step fixed horizon) for cell `(β = 0.9, κ = 0.1, c =
/// 0.5)`.
pub const MINSPEC_RANDOM_BETA09: f32 = -605.5118;
/// Specialist baseline (200-step fixed horizon) for cell `(β = 0.9, κ = 0.1, c
/// = 0.5)`.
pub const MINSPEC_SPECIALIST_BETA09: f32 = -602.0938;
/// Compute the fraction of the random→specialist gap closed by the
/// given per-step team payoff on the **base** `minimal_specialization`
/// scenario.
///
/// ```text
/// gap_closed = (per_step_team - MINSPEC_RANDOM)
/// / (MINSPEC_SPECIALIST - MINSPEC_RANDOM)
/// ```
///
/// * Returns `0.0` when the agent matches the random baseline.
/// * Returns `1.0` when the agent matches the specialist baseline.
/// * Negative values indicate the agent is *worse* than the random baseline.
///
/// # Scope (read before using)
///
/// **Only valid for the base `minimal_specialization-v1` scenario.** The
/// random/specialist baselines change with the scenario reward landscape;
/// the three no-convergence cells of the workshop-paper phase diagram
/// (β ∈ {0.1, 0.5, 0.9} with κ=0.1, c=0.5) have baselines that differ
/// from the base scenario by more than an order of magnitude. Use
/// [`gap_closed_cell`] for those cells.
///
/// For reference, on the canonical cell (β=0.5), uniform random scores
/// per-step team ≈ `-589` and the specialist scores per-step team ≈
/// `-115` — both far below the base-scenario baselines, so applying
/// `gap_closed` to a canonical-cell payoff produces a deeply negative
/// and misleading value.
///
/// # Example
///
/// ```
/// use thrust_rl::multi_agent::bucket_brigade_metrics::{
/// MINSPEC_RANDOM, MINSPEC_SPECIALIST, gap_closed,
/// };
///
/// assert!((gap_closed(MINSPEC_RANDOM) - 0.0).abs() < 1e-5);
/// assert!((gap_closed(MINSPEC_SPECIALIST) - 1.0).abs() < 1e-5);
/// ```
/// Compute the fraction of the random→specialist gap closed by the
/// given per-step team payoff for one of the three no-convergence cells
/// of the workshop-paper phase diagram.
///
/// Routes to the appropriate per-cell baselines:
///
/// * [`BucketBrigadeCell::Beta01`] → [`MINSPEC_RANDOM_BETA01`] /
/// [`MINSPEC_SPECIALIST_BETA01`]
/// * [`BucketBrigadeCell::Beta05`] → [`MINSPEC_RANDOM_BETA05`] /
/// [`MINSPEC_SPECIALIST_BETA05`]
/// * [`BucketBrigadeCell::Beta09`] → [`MINSPEC_RANDOM_BETA09`] /
/// [`MINSPEC_SPECIALIST_BETA09`]
///
/// Same shape as [`gap_closed`]: `0.0` at random baseline, `1.0` at
/// specialist baseline, negative if worse than random.
///
/// # Example
///
/// ```
/// use thrust_rl::multi_agent::{
/// bucket_brigade_baselines::BucketBrigadeCell,
/// bucket_brigade_metrics::{
/// MINSPEC_RANDOM_BETA05, MINSPEC_SPECIALIST_BETA05, gap_closed_cell,
/// },
/// };
///
/// assert!((gap_closed_cell(MINSPEC_RANDOM_BETA05, BucketBrigadeCell::Beta05)).abs() < 1e-5);
/// assert!(
/// (gap_closed_cell(MINSPEC_SPECIALIST_BETA05, BucketBrigadeCell::Beta05) - 1.0).abs() < 1e-5
/// );
/// ```