polystep 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. polystep/__init__.py +176 -0
  2. polystep/_compiled.py +334 -0
  3. polystep/_step_blockwise.py +994 -0
  4. polystep/_step_momentum.py +128 -0
  5. polystep/_step_monolithic.py +1070 -0
  6. polystep/adaptive_subspace.py +762 -0
  7. polystep/api.py +307 -0
  8. polystep/ask_tell.py +164 -0
  9. polystep/benchmarks/__init__.py +62 -0
  10. polystep/benchmarks/baselines.py +604 -0
  11. polystep/benchmarks/rl/__init__.py +13 -0
  12. polystep/benchmarks/rl/cartpole.py +260 -0
  13. polystep/benchmarks/rl/gym_evaluator.py +304 -0
  14. polystep/benchmarks/rl/metrics.py +53 -0
  15. polystep/benchmarks/rl/policies.py +133 -0
  16. polystep/benchmarks/utils.py +1262 -0
  17. polystep/blockwise.py +549 -0
  18. polystep/cma.py +400 -0
  19. polystep/cma_subspace.py +404 -0
  20. polystep/cost_nn.py +633 -0
  21. polystep/costs.py +86 -0
  22. polystep/dynamics.py +138 -0
  23. polystep/epsilon.py +217 -0
  24. polystep/geometry.py +410 -0
  25. polystep/hybrid_subspace.py +1335 -0
  26. polystep/layers/__init__.py +16 -0
  27. polystep/layers/attention.py +243 -0
  28. polystep/layers/rnn.py +276 -0
  29. polystep/objectives/__init__.py +25 -0
  30. polystep/objectives/base.py +77 -0
  31. polystep/objectives/synthetic.py +232 -0
  32. polystep/optimizer.py +1405 -0
  33. polystep/projection/__init__.py +5 -0
  34. polystep/projection/sparse.py +291 -0
  35. polystep/py.typed +0 -0
  36. polystep/quadratic_model.py +285 -0
  37. polystep/solver.py +654 -0
  38. polystep/solvers/__init__.py +34 -0
  39. polystep/solvers/_prelude.py +129 -0
  40. polystep/solvers/base.py +77 -0
  41. polystep/solvers/greedy.py +159 -0
  42. polystep/solvers/kl_softmax.py +200 -0
  43. polystep/solvers/sinkhorn.py +522 -0
  44. polystep/solvers/softmax.py +151 -0
  45. polystep/solvers/tempered_softmax.py +97 -0
  46. polystep/subspace.py +694 -0
  47. polystep/transform.py +365 -0
  48. polystep-0.7.0.dist-info/METADATA +285 -0
  49. polystep-0.7.0.dist-info/RECORD +52 -0
  50. polystep-0.7.0.dist-info/WHEEL +5 -0
  51. polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
  52. polystep-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1070 @@
1
+ """Monolithic step: single OT solve over all particles.
2
+
3
+ Extracted from optimizer.py for maintainability. Called via delegation
4
+ from PolyStepOptimizer.step().
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Callable
11
+
12
+ import torch
13
+
14
+ from .costs import scale_cost_matrix
15
+ from .solvers._prelude import sanitize_cost
16
+ from .dynamics import apply_momentum, compute_momentum_coefficient, update_adaptive_radius
17
+ from .geometry import apply_biased_rotation, get_random_rotation_matrices
18
+ from .solvers import SinkhornSolver
19
+ from .solvers.base import SolverResult
20
+ from .adaptive_subspace import AdaptiveSubspace
21
+ from .hybrid_subspace import HybridSubspace
22
+ from .cma import (
23
+ update_evolution_path_sigma,
24
+ compute_heaviside_sigma,
25
+ update_evolution_path_c,
26
+ update_covariance_diagonal,
27
+ update_step_size_csa,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def step_monolithic(opt, closure: Callable) -> float:
34
+ """Monolithic step: single OT solve over all particles.
35
+
36
+ Multi-particle architecture: X is (P, particle_dim) where P is
37
+ num_particles and particle_dim is typically 2. For each particle i,
38
+ polytope vertices are sampled in particle_dim space. The cost for
39
+ entry (i, v, k) is evaluated by constructing the full model config
40
+ with particle i replaced by the probe position. The OT problem is
41
+ (P, V) which is tractable.
42
+ """
43
+ state = opt._state
44
+ X = state.X # (P, particle_dim)
45
+ iteration = state.iteration_count
46
+ device = X.device
47
+
48
+ # Cache the coord->param projection for this step (covariance-scaled for CMA).
49
+ opt._update_sampling_projection()
50
+
51
+ # 1. Resolve epsilon and radii
52
+ current_eps = opt._get_epsilon(iteration)
53
+ # Use CSA sigma or heuristic radius_multiplier
54
+ if opt.use_csa and state.use_csa:
55
+ radius_mult = state.sigma
56
+ elif opt.use_adaptive_radius:
57
+ radius_mult = state.radius_multiplier
58
+ else:
59
+ radius_mult = 1.0
60
+ # Scheduled radii: if step_radius/probe_radius has .at(), the schedule
61
+ # handles annealing (no epsilon multiplication). Float values use the
62
+ # original behavior (radius * eps * radius_mult).
63
+ _sr = opt._get_step_radius(iteration)
64
+ _pr = opt._get_probe_radius(iteration)
65
+ _sr_scheduled = hasattr(opt.step_radius, "at")
66
+ _pr_scheduled = hasattr(opt.probe_radius, "at")
67
+ if opt.trust_region:
68
+ step_r = _sr * opt._trust_region_multiplier * (1.0 if _sr_scheduled else current_eps) * radius_mult
69
+ else:
70
+ step_r = _sr * (1.0 if _sr_scheduled else current_eps) * radius_mult
71
+ probe_r = _pr * (1.0 if _pr_scheduled else current_eps) * radius_mult
72
+
73
+ # Probe-radius jitter (Thm. 4.2 condition (iv); no-op when probe_radius_jitter == 0).
74
+ probe_r = opt._apply_probe_radius_jitter(probe_r)
75
+
76
+ # Ensure 2D
77
+ if X.dim() == 1:
78
+ X = X.unsqueeze(0)
79
+ P, pdim = X.shape
80
+
81
+ # Move templates to device (cache after first transfer)
82
+ if opt._polytope_vertices.device != device or opt._polytope_vertices.dtype != X.dtype:
83
+ opt._polytope_vertices = opt._polytope_vertices.to(device=device, dtype=X.dtype)
84
+ polytope_verts = opt._polytope_vertices
85
+ if opt._probes.device != device or opt._probes.dtype != X.dtype:
86
+ opt._probes = opt._probes.to(device=device, dtype=X.dtype)
87
+ probes = opt._probes
88
+ V = polytope_verts.shape[0] # num vertices
89
+ K = probes.shape[0] # num probes
90
+
91
+ # Adaptive probe count: reduce K during exploitation
92
+ K_eff = K # effective probe count for this step
93
+ if opt.adaptive_num_probe and iteration >= opt._adaptive_probe_warmup:
94
+ # Check if last 3 OT-step costs are all decreasing (and all > 0)
95
+ costs_history = opt._ot_step_costs
96
+ if len(costs_history) >= 3:
97
+ recent = list(costs_history)[-3:]
98
+ if all(c > 0 for c in recent) and all(recent[i] > recent[i + 1] for i in range(len(recent) - 1)):
99
+ opt._loss_decreasing_count += 1
100
+ else:
101
+ opt._loss_decreasing_count = 0
102
+
103
+ if opt._loss_decreasing_count >= 3:
104
+ K_eff = 1
105
+
106
+ # Select reduced probes if K_eff < K
107
+ if K_eff < K:
108
+ probes = probes[K // 2 : K // 2 + 1] # center scale, shape (1,)
109
+
110
+ # Adaptive probes: decide reuse before generating rotations. A stagnant
111
+ # particle reuses its previous cost row AND its previous rotation, so the
112
+ # reused row still matches the vertices it was evaluated at. Reuse therefore
113
+ # requires a cached rotation of matching shape.
114
+ stagnant_mask = opt._get_stagnant_mask(P)
115
+ _can_reuse = (
116
+ stagnant_mask is not None
117
+ and opt._prev_cost_matrix is not None
118
+ and opt._prev_cost_matrix.shape == (P, V)
119
+ and opt._prev_rot_mats is not None
120
+ and opt._prev_rot_mats.shape == (P, pdim, pdim)
121
+ and opt._prev_k_eff == K_eff
122
+ and opt._prev_step_r == step_r
123
+ )
124
+ if _can_reuse:
125
+ active_mask = ~stagnant_mask
126
+ active_indices = torch.where(active_mask)[0]
127
+ P_active = active_indices.shape[0]
128
+ else:
129
+ active_indices = None
130
+ P_active = P
131
+
132
+ # 2. Generate rotation matrices: (P, pdim, pdim)
133
+ rot_mats = get_random_rotation_matrices(
134
+ P,
135
+ pdim,
136
+ device=device,
137
+ dtype=X.dtype,
138
+ generator=opt._generator,
139
+ )
140
+
141
+ # 2b. Transport-biased rotation: replace first column with previous OT descent direction
142
+ if (
143
+ opt.biased_rotation
144
+ and opt._prev_descent_direction is not None
145
+ and opt._prev_descent_direction.shape == (P, pdim)
146
+ and opt._prev_descent_direction_finite
147
+ ):
148
+ bias_dir = opt._prev_descent_direction # (P, pdim)
149
+ bias_norms = torch.norm(bias_dir, dim=-1, keepdim=True).clamp(min=1e-10)
150
+ bias_dir_norm = bias_dir / bias_norms # (P, pdim)
151
+ rot_mats = apply_biased_rotation(rot_mats, bias_dir_norm)
152
+
153
+ # Stagnant particles reuse their previous rotation so the cost rows reused
154
+ # below describe the same vertices those rows were evaluated at.
155
+ if _can_reuse and P_active < P:
156
+ rot_mats[stagnant_mask] = opt._prev_rot_mats[stagnant_mask]
157
+
158
+ # 3. Rotate + translate: X_vertices (P, V, pdim), rotated (P, V, pdim)
159
+ X_vertices, rotated = opt._compiled.rotate_and_translate(
160
+ rot_mats,
161
+ polytope_verts,
162
+ X,
163
+ step_r,
164
+ )
165
+
166
+ # 4. Probe generation: X_probe (P, V, K, pdim)
167
+ X_probe = opt._compiled.compute_probe_points(
168
+ X,
169
+ rotated,
170
+ probes,
171
+ probe_r,
172
+ )
173
+
174
+ # 5. Build full model configs and evaluate cost
175
+ # For each (i, v, k), construct a full (P, pdim) config with row i
176
+ # replaced by X_probe[i, v, k]. Then unflatten to params and evaluate.
177
+
178
+ # Stagnant particles (adaptive probes) reuse their previous cost row; their
179
+ # stagnation and reuse were decided up front, before rotation generation.
180
+
181
+ # All-stagnant shortcut: reuse entire previous cost matrix, skip evaluation
182
+ if _can_reuse and P_active == 0:
183
+ cost_matrix = opt._prev_cost_matrix.clone()
184
+ else:
185
+ # More efficient: process all probes for each particle in a batch
186
+ # Total evaluations: P_active * V * K_eff. Each builds full config (P, pdim)
187
+ # flattened to (padded_size,), then batch-unflattened.
188
+ #
189
+ # Strategy: process in chunks to control memory.
190
+ # Build indices for all (active_i, v, k) combinations.
191
+ total_evals = P_active * V * K_eff
192
+
193
+ # batch_configs is (chunk, P, pdim). At chunk == total_evals that is
194
+ # ~O(n_params^2) in full-space mode (P ~ n_params/pdim), which OOMs at the
195
+ # default chunk_size=None. Cap the default to ~256 MB of fp32 configs;
196
+ # chunking only splits the loop, so results are unchanged.
197
+ if opt.chunk_size:
198
+ chunk = opt.chunk_size
199
+ else:
200
+ config_size = max(1, X.numel()) # P * pdim
201
+ chunk = min(total_evals, max(1, (1 << 26) // config_size))
202
+ # Pre-allocate losses tensor to avoid list+cat overhead
203
+ losses = torch.empty(total_evals, dtype=torch.float32, device=device)
204
+
205
+ # Pre-allocate batch_configs buffer for reuse across chunks
206
+ _batch_configs_buf = X.unsqueeze(0).expand(chunk, -1, -1).clone() if chunk <= total_evals else None
207
+ # Pre-allocate local range tensors (avoid repeated arange calls)
208
+ _local_range_full = torch.arange(chunk, device=device) if chunk <= total_evals else None
209
+
210
+ # Cache subspace info for the loop
211
+ _is_subspace = opt.subspace is not None
212
+ _sub_dim = state.subspace.subspace_dim if _is_subspace else 0
213
+
214
+ # Fused subspace reconstruct + in-place forward path: avoids
215
+ # materialising N full weight dicts when the evaluator supports it.
216
+ _use_fused_inplace = (
217
+ opt._hybrid
218
+ and hasattr(state.subspace, "apply_perturbation_inplace")
219
+ and hasattr(opt, "_cost_evaluator")
220
+ and getattr(opt._cost_evaluator, "_use_inplace", False)
221
+ )
222
+
223
+ _all_indices = torch.arange(total_evals, device=device)
224
+
225
+ for chunk_start in range(0, total_evals, chunk):
226
+ chunk_end = min(chunk_start + chunk, total_evals)
227
+ chunk_size_actual = chunk_end - chunk_start
228
+
229
+ # Vectorized index computation (replaces Python for-loop)
230
+ global_indices = _all_indices[chunk_start:chunk_end] # view, no alloc
231
+ active_idx = global_indices // (V * K_eff)
232
+ vk = global_indices % (V * K_eff)
233
+ v_idx = vk // K_eff
234
+ k_idx = vk % K_eff
235
+
236
+ if active_indices is not None:
237
+ i_idx = active_indices[active_idx]
238
+ else:
239
+ i_idx = active_idx
240
+
241
+ # Reuse pre-allocated buffer when chunk size matches, otherwise allocate
242
+ if _batch_configs_buf is not None and chunk_size_actual == chunk:
243
+ batch_configs = _batch_configs_buf.copy_(X.unsqueeze(0).expand(chunk, -1, -1))
244
+ local_range = _local_range_full
245
+ else:
246
+ batch_configs = X.unsqueeze(0).expand(chunk_size_actual, -1, -1).clone()
247
+ local_range = torch.arange(chunk_size_actual, device=device)
248
+ batch_configs[local_range, i_idx] = X_probe[i_idx, v_idx, k_idx]
249
+
250
+ # Flatten each config to (padded_size,)
251
+ flat_configs = batch_configs.reshape(chunk_size_actual, -1)
252
+ # flat_configs: (chunk_size, P * pdim)
253
+
254
+ if _use_fused_inplace and _is_subspace and opt._hybrid:
255
+ # Fused path (EGGROLL-inspired): reconstruct + forward one
256
+ # config at a time via in-place weight swap. Never materializes
257
+ # the full (N, *param_shape) stacked dict. Memory: O(1 x activation).
258
+ flat_sub = flat_configs[:, :_sub_dim]
259
+ if opt._mixed_precision and getattr(state, "projection", None) is not None:
260
+ flat_sub = flat_sub.to(dtype=state.projection.dtype)
261
+ chunk_losses = opt._cost_evaluator.evaluate_subspace_inplace(
262
+ state.subspace,
263
+ state.hybrid_projections,
264
+ state.base_params,
265
+ flat_sub,
266
+ opt._fused_inputs,
267
+ opt._fused_targets,
268
+ )
269
+ elif _is_subspace:
270
+ # Subspace mode: reconstruct full params from subspace coords
271
+ flat_sub = flat_configs[:, :_sub_dim]
272
+ if opt._mixed_precision and state.projection is not None:
273
+ flat_sub = flat_sub.to(dtype=state.projection.dtype)
274
+ if opt._adaptive or opt._cma_subspace:
275
+ chunk_params = state.subspace.reconstruct_batch(
276
+ opt._sampling_projection,
277
+ state.base_params,
278
+ flat_sub,
279
+ )
280
+ elif opt._hybrid:
281
+ chunk_params = state.subspace.reconstruct_batch(
282
+ state.hybrid_projections,
283
+ state.base_params,
284
+ flat_sub,
285
+ )
286
+ else:
287
+ chunk_params = state.subspace.reconstruct_batch(
288
+ state.base_params,
289
+ flat_sub,
290
+ )
291
+ chunk_losses = closure(chunk_params)
292
+ else:
293
+ # Trim to layout padded_size if needed
294
+ layout_flat = opt.layout.padded_size
295
+ if flat_configs.shape[1] >= layout_flat:
296
+ flat_for_layout = flat_configs[:, :layout_flat]
297
+ else:
298
+ flat_for_layout = torch.nn.functional.pad(
299
+ flat_configs,
300
+ (0, layout_flat - flat_configs.shape[1]),
301
+ )
302
+ chunk_params = opt.layout.batch_unflatten(flat_for_layout)
303
+ chunk_losses = closure(chunk_params)
304
+
305
+ # Write directly into pre-allocated tensor (FP32 for Sinkhorn stability)
306
+ losses[chunk_start:chunk_end] = chunk_losses.float()
307
+
308
+ if _can_reuse and P_active < P:
309
+ # Build full cost matrix: reuse previous rows for stagnant particles,
310
+ # fill active rows from fresh evaluations
311
+ if K_eff == 1:
312
+ # K=1 fast path: no averaging needed
313
+ active_cost = losses.reshape(P_active, V)
314
+ else:
315
+ active_losses_3d = losses.reshape(P_active, V, K_eff)
316
+ active_cost = active_losses_3d.mean(dim=-1) # (P_active, V)
317
+ cost_matrix = opt._prev_cost_matrix.clone()
318
+ cost_matrix[active_indices] = active_cost
319
+ # Retain full losses_3d for quadratic model (merge active into prev)
320
+ if opt.use_quadratic_model and opt._prev_losses_3d is not None:
321
+ losses_3d_full = opt._prev_losses_3d.clone()
322
+ if K_eff == 1:
323
+ losses_3d_full[active_indices] = losses.reshape(P_active, V, 1)
324
+ else:
325
+ losses_3d_full[active_indices] = active_losses_3d
326
+ opt._prev_losses_3d = losses_3d_full.detach()
327
+ elif opt.use_quadratic_model:
328
+ opt._prev_losses_3d = None # no full data yet
329
+ else:
330
+ if K_eff == 1:
331
+ # K=1 fast path: no averaging needed
332
+ cost_matrix = losses.reshape(P, V)
333
+ if opt.use_quadratic_model:
334
+ opt._prev_losses_3d = losses.reshape(P, V, 1).detach()
335
+ else:
336
+ losses_3d_full = losses.reshape(P, V, K_eff)
337
+ cost_matrix = losses_3d_full.mean(dim=-1) # (P, V)
338
+ if opt.use_quadratic_model:
339
+ opt._prev_losses_3d = losses_3d_full.detach()
340
+
341
+ # Sanitize cost (FP32 promote + finite penalty), branch-free / no host sync.
342
+ # Protects the downstream trust-region / multifidelity readers and BOTH
343
+ # solver paths -- the fused softmax scales this same sanitized tensor.
344
+ cost_matrix = sanitize_cost(cost_matrix)
345
+
346
+ # Raw cost for next step's contrast/trust-region baseline; dampening below
347
+ # only shapes the current OT solve.
348
+ raw_cost_matrix = cost_matrix
349
+
350
+ # Deferred trust-region update: the cost matrix at this step is evaluated
351
+ # at the particle position produced by the *previous* step. Comparing the
352
+ # prediction stored on the previous step against this step's pre-OT min cost
353
+ # gives a real predicted-vs-actual reduction ratio.
354
+ if opt.trust_region and opt._prev_predicted_improvement is not None and opt._prev_pre_step_loss is not None:
355
+ current_loss_proxy = cost_matrix.min(dim=1).values.mean().item()
356
+ # Sign convention matches update_trust_region and predicted improvement:
357
+ # negative means loss decreased.
358
+ actual_improvement = torch.tensor([current_loss_proxy - opt._prev_pre_step_loss])
359
+ from .quadratic_model import update_trust_region
360
+
361
+ opt._trust_region_multiplier = update_trust_region(
362
+ opt._prev_predicted_improvement,
363
+ actual_improvement,
364
+ opt._trust_region_multiplier,
365
+ min_radius=0.1,
366
+ max_radius=3.0,
367
+ )
368
+ state.trust_region_multipliers.append(opt._trust_region_multiplier)
369
+ opt._prev_predicted_improvement = None
370
+ opt._prev_pre_step_loss = None
371
+
372
+ # Multi-fidelity screening: dampen low-contrast vertex directions
373
+ # Uses previous step's cost to identify uninformative directions and
374
+ # blend their cost toward the row mean, making OT focus on informative ones.
375
+ if opt.multifidelity_screen and opt._prev_cost_matrix is not None and opt.polytope_type == "orthoplex":
376
+ prev_cost = opt._prev_cost_matrix # (P, V)
377
+ if prev_cost.shape == (P, V):
378
+ pdim_local = V // 2
379
+ # Cost contrast: |L(+e_i) - L(-e_i)| for each direction
380
+ dir_contrast = (prev_cost[:, :pdim_local] - prev_cost[:, pdim_local : 2 * pdim_local]).abs()
381
+ # Mean contrast across particles per direction
382
+ mean_contrast = dir_contrast.mean(dim=0) # (pdim_local,)
383
+ # Threshold: keep top screen_keep_ratio directions at full weight
384
+ keep_k = max(1, int(pdim_local * opt.screen_keep_ratio))
385
+ if keep_k < pdim_local:
386
+ threshold = mean_contrast.topk(keep_k)[0][-1]
387
+ # Dampen low-contrast directions: push their cost toward row mean
388
+ dampen_mask = mean_contrast < threshold # (pdim_local,) bool
389
+ # Expand to vertex mask: dampen both +e_i and -e_i
390
+ vertex_dampen = torch.zeros(V, dtype=torch.bool, device=cost_matrix.device)
391
+ vertex_dampen[:pdim_local] = dampen_mask
392
+ vertex_dampen[pdim_local:] = dampen_mask
393
+ # Dampen: blend dampened vertices toward row mean
394
+ row_mean = cost_matrix.mean(dim=1, keepdim=True) # (P, 1)
395
+ dampen_factor = 0.8 # blend 80% toward mean
396
+ cost_matrix = cost_matrix.clone()
397
+ cost_matrix[:, vertex_dampen] = (1 - dampen_factor) * cost_matrix[
398
+ :, vertex_dampen
399
+ ] + dampen_factor * row_mean.expand_as(cost_matrix)[:, vertex_dampen]
400
+
401
+ # 6. Resolve OT epsilon
402
+ ent_eps = opt._get_ent_epsilon(iteration)
403
+ ot_epsilon = ent_eps if ent_eps is not None else current_eps
404
+
405
+ # If the schedule jumped epsilon by more than 2x in one step,
406
+ # invalidate the dual-momentum history. prev_prev_f / prev_prev_g
407
+ # were computed at the old epsilon; extrapolating across a big
408
+ # epsilon change pushes the warm-start far from the new fixed
409
+ # point and the next solve has to undo the bad init.
410
+ if state.last_solve_eps is not None and (
411
+ ot_epsilon / state.last_solve_eps > 2.0 or state.last_solve_eps / ot_epsilon > 2.0
412
+ ):
413
+ state.prev_prev_f = None
414
+ state.prev_prev_g = None
415
+
416
+ # 7. Dual potential momentum: extrapolate warm-start duals
417
+ init_f_for_solve = state.f
418
+ init_g_for_solve = state.g
419
+ if (
420
+ opt._dual_momentum_beta > 0.0
421
+ and state.f is not None
422
+ and state.prev_prev_f is not None
423
+ and state.prev_prev_g is not None
424
+ ):
425
+ beta = opt._dual_momentum_beta
426
+ init_f_for_solve = state.f + beta * (state.f - state.prev_prev_f)
427
+ init_g_for_solve = state.g + beta * (state.g - state.prev_prev_g)
428
+ # Clamp to prevent overflow (same bounds as warm-start validation)
429
+ max_abs = 80.0 * max(ot_epsilon, 0.01)
430
+ init_f_for_solve = init_f_for_solve.clamp(-max_abs, max_abs)
431
+ init_g_for_solve = init_g_for_solve.clamp(-max_abs, max_abs)
432
+
433
+ # 7b. OT solve
434
+ opt.solver.epsilon = ot_epsilon
435
+ if opt._use_fused_softmax:
436
+ # Fused path: softmax + vertex-free projection in one compiled call
437
+ # Scale the cost outside the compiled kernel so every scale_cost mode
438
+ # ('mean'/'max_cost'/float/None) matches the non-fused solvers; the
439
+ # kernel then runs on the already-scaled cost (scale_cost_mean=False).
440
+ scaled_cost = scale_cost_matrix(cost_matrix, opt.scale_cost)
441
+ X_new_fused, transport_matrix = opt._compiled.fused_softmax_project(
442
+ scaled_cost,
443
+ ot_epsilon,
444
+ state.a,
445
+ opt._polytope_vertices,
446
+ rot_mats,
447
+ step_r,
448
+ X,
449
+ scale_cost_mean=False,
450
+ )
451
+ # state.costs tracks cost_matrix.mean, so the OT cost is unused here.
452
+ ot_result = SolverResult(
453
+ matrix=transport_matrix,
454
+ cost=0.0,
455
+ f=None,
456
+ g=None,
457
+ converged=True,
458
+ n_iters=1,
459
+ ent_reg_cost=0.0,
460
+ )
461
+ else:
462
+ # Forward the previous solve's epsilon so SinkhornSolver
463
+ # can rescale the warm-started duals when epsilon changed.
464
+ # a defaults to uniform 1/P inside the solver, which equals state.a; pass
465
+ # None so the solver skips the host-syncing user-marginal validation.
466
+ solve_kwargs = dict(
467
+ cost_matrix=cost_matrix,
468
+ init_f=init_f_for_solve,
469
+ init_g=init_g_for_solve,
470
+ scale_cost=opt.scale_cost,
471
+ )
472
+ if isinstance(opt.solver, SinkhornSolver) and state.last_solve_eps is not None:
473
+ solve_kwargs["init_eps"] = state.last_solve_eps
474
+ ot_result = opt.solver.solve(**solve_kwargs)
475
+ state.last_solve_eps = ot_epsilon
476
+
477
+ # Auto-epsilon feedback: update progressive epsilon from solver stats
478
+ if opt._progressive_epsilon is not None:
479
+ opt._progressive_epsilon.update(
480
+ n_iters=ot_result.n_iters,
481
+ max_iterations=getattr(opt.solver, "max_iterations", 1),
482
+ converged=ot_result.converged,
483
+ )
484
+
485
+ # 7b. Save pre-step subspace coords for AdaptiveSubspace displacement
486
+ # history and CMA evolution paths. Declared here, populated conditionally.
487
+ _pre_step_sub_coords = None
488
+
489
+ if opt._adaptive or opt._hybrid or (opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa)):
490
+ sub_dim = opt.subspace.subspace_dim
491
+ _pre_step_sub_coords = state.X.reshape(-1)[:sub_dim].clone()
492
+
493
+ # 7c. Capture X_vertices and X for OT-bias rotation
494
+ # ORDERING: This capture MUST happen:
495
+ # - AFTER X_vertices computation (step 3, line ~427)
496
+ # - AFTER OT solve (step 7, line ~586) so ot_result.matrix exists
497
+ # - BEFORE barycentric projection (step 8, line ~614) which updates state.X
498
+ _X_vertices_for_ot_bias = X_vertices # (P, V, pdim) - already computed at step 3
499
+ _X_pre_barycentric = X.clone() # (P, pdim) - clone to preserve pre-update state
500
+
501
+ # 7d. Compute rotation bias direction
502
+ # The finite-difference model feeds two independent features: biased_rotation
503
+ # (descent/Newton directions) and trust_region (predicted-vs-actual ratio).
504
+ # Build it whenever either is active so trust_region no longer needs
505
+ # biased_rotation to be on.
506
+ _quad_ready = (
507
+ opt.use_quadratic_model
508
+ and opt._prev_losses_3d is not None
509
+ and K_eff >= 2
510
+ and opt._prev_losses_3d.shape == (P, V, K_eff)
511
+ and opt.polytope_type == "orthoplex" # FD extractors assume orthoplex vertex order
512
+ )
513
+ if (opt.biased_rotation or opt.trust_region) and _quad_ready:
514
+ from .quadratic_model import compute_newton_step, extract_fd_gradient, extract_fd_hessian_diag
515
+
516
+ # FD gradient and diagonal Hessian in the rotated frame.
517
+ fd_grad = extract_fd_gradient(opt._prev_losses_3d, probes, probe_r, pdim) # (P, pdim)
518
+ fd_hess = extract_fd_hessian_diag(opt._prev_losses_3d, probes, probe_r, pdim) # (P, pdim)
519
+ newton_rot = compute_newton_step(fd_grad, fd_hess, max_step_norm=step_r, hessian_reg=1e-4) # (P, pdim)
520
+
521
+ if opt.biased_rotation:
522
+ # Descent direction = negative gradient in original space (rot_mats @ .).
523
+ grad_orig = torch.einsum("bij,bj->bi", rot_mats, fd_grad) # (P, pdim)
524
+ opt._prev_descent_direction = -grad_orig.detach()
525
+ opt._prev_descent_direction_finite = True
526
+ newton_orig = torch.einsum("bij,bj->bi", rot_mats, newton_rot)
527
+ opt._newton_direction = newton_orig.detach()
528
+
529
+ if opt.trust_region:
530
+ # Store predicted improvement and pre-step proxy loss. Both are
531
+ # consumed at the start of the next ``step`` call, where the actual
532
+ # loss reduction is measured from the new cost matrix.
533
+ from .quadratic_model import compute_predicted_improvement
534
+
535
+ opt._prev_predicted_improvement = compute_predicted_improvement(fd_grad, fd_hess, newton_rot).detach()
536
+ opt._prev_pre_step_loss = raw_cost_matrix.min(dim=1).values.mean().item()
537
+ elif opt.biased_rotation:
538
+ # Fallback: OT descent direction when the quadratic model is unavailable.
539
+ transport_weights = ot_result.matrix # (P, V)
540
+ vertex_offsets = X_vertices - X.unsqueeze(1) # (P, V, pdim)
541
+ weight_sums = transport_weights.sum(dim=1, keepdim=True).clamp(min=1e-10)
542
+ normalized_weights = transport_weights / weight_sums # (P, V)
543
+ weighted_dir = (normalized_weights.unsqueeze(-1) * vertex_offsets).sum(dim=1) # (P, pdim)
544
+ opt._prev_descent_direction = weighted_dir.detach()
545
+ opt._prev_descent_direction_finite = True
546
+
547
+ # 8. Barycentric projection: (P, pdim)
548
+ if opt._use_fused_softmax:
549
+ X_bary = X_new_fused # Already computed by fused function
550
+ else:
551
+ X_bary = opt._compiled.barycentric_projection(
552
+ ot_result.matrix,
553
+ state.a,
554
+ X_vertices,
555
+ )
556
+
557
+ # 9. Momentum
558
+ if opt.use_momentum and state.velocity is not None:
559
+ beta = compute_momentum_coefficient(
560
+ iteration,
561
+ opt.max_iterations,
562
+ opt.momentum_init,
563
+ opt.momentum_final,
564
+ )
565
+ X_new, vel_new = apply_momentum(
566
+ X,
567
+ X_bary,
568
+ state.velocity,
569
+ beta,
570
+ opt.velocity_lr,
571
+ )
572
+ state.velocity = vel_new
573
+ state.X = X_new
574
+ else:
575
+ state.X = X_bary
576
+
577
+ # 9a. Newton refinement: post-OT correction using quadratic model
578
+ # Tracks whether a post-solve move (refinement) makes the solve's duals stale.
579
+ _duals_invalidated = False
580
+ if opt._newton_refinement and opt._prev_losses_3d is not None and K_eff >= 2 and opt.polytope_type == "orthoplex":
581
+ from .quadratic_model import apply_newton_refinement
582
+
583
+ X_refined = apply_newton_refinement(
584
+ X_bary=state.X,
585
+ losses_3d=opt._prev_losses_3d,
586
+ scales=probes,
587
+ probe_radius=probe_r,
588
+ pdim=pdim,
589
+ rot_mats=rot_mats,
590
+ X_current=X,
591
+ alpha=opt._newton_refinement_alpha,
592
+ max_step_norm=step_r * 0.5,
593
+ hessian_reg=1e-4,
594
+ )
595
+ if torch.isfinite(X_refined).all():
596
+ state.X = X_refined
597
+ # Newton refinement moved particles, so the solve's dual potentials
598
+ # encode the old positions. Mark them stale so the dual-save block
599
+ # below does not warm-start the next step from them.
600
+ _duals_invalidated = True
601
+
602
+ # 9b. CMA-ES updates
603
+ if opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa):
604
+ cma_sub = opt.subspace # CMAAdaptiveSubspace
605
+ sub_dim = cma_sub.subspace_dim
606
+
607
+ # Compute mean displacement in subspace coordinates
608
+ post_step_coords = state.X.reshape(-1)[:sub_dim]
609
+ raw_displacement = post_step_coords - _pre_step_sub_coords
610
+
611
+ # Sigma-normalize the raw subspace step to the isotropic z coordinate.
612
+ normalized_displacement = raw_displacement / state.sigma
613
+ # Sampling scales the projection columns by sqrt(C_diag), so the offspring
614
+ # step in covariance-metric coordinates is y = sqrt(C_diag) * z. The
615
+ # evolution paths and rank-mu are defined on y, not the raw z.
616
+ sqrt_C = torch.sqrt(torch.clamp(state.C_diag, min=opt._cma_params["cov_min"]))
617
+ y_displacement = sqrt_C * normalized_displacement
618
+
619
+ # 1. Update p_sigma; its internal C^{-1/2} whitens y back to z.
620
+ state.p_sigma = update_evolution_path_sigma(
621
+ p_sigma=state.p_sigma,
622
+ displacement=y_displacement,
623
+ C_diag=state.C_diag,
624
+ c_sigma=opt._cma_params["c_sigma"],
625
+ mu_eff=opt._cma_params["mu_eff"],
626
+ )
627
+
628
+ # 2. Compute Heaviside for stall detection. Compare ||p_sigma|| to its
629
+ # running mean, then fold the current norm in. This tracks the OT step
630
+ # scale instead of the Gaussian sqrt(n), which OT never reaches.
631
+ p_sigma_norm = torch.norm(state.p_sigma).item()
632
+ prev_ema = state.csa_norm_ema
633
+ csa_expected_norm = max(prev_ema if prev_ema is not None else p_sigma_norm, 1e-8)
634
+ state.csa_norm_ema = p_sigma_norm if prev_ema is None else 0.9 * prev_ema + 0.1 * p_sigma_norm
635
+ h_sigma = compute_heaviside_sigma(
636
+ p_sigma_norm=p_sigma_norm,
637
+ expected_norm=csa_expected_norm,
638
+ n=sub_dim,
639
+ c_sigma=opt._cma_params["c_sigma"],
640
+ generation=state.generation,
641
+ )
642
+
643
+ # 3. Update p_c (covariance evolution path) on the y-scaled step.
644
+ state.p_c = update_evolution_path_c(
645
+ p_c=state.p_c,
646
+ displacement=y_displacement,
647
+ h_sigma=h_sigma,
648
+ c_c=opt._cma_params["c_c"],
649
+ mu_eff=opt._cma_params["mu_eff"],
650
+ )
651
+
652
+ # 4. Update diagonal covariance with rank-mu (if enabled)
653
+ if opt.use_covariance_adaptation:
654
+ # Rank-mu offspring are the polytope vertices; the transport masses
655
+ # are the recombination weights. Each subspace coordinate belongs to
656
+ # one particle, so its variance is the transport-weighted mean of its
657
+ # V squared vertex steps, laid out to match the flattened C_diag.
658
+ vertex_steps = (X_vertices - X.unsqueeze(1)) / state.sigma # (P, V, pdim), raw z
659
+ transport = ot_result.matrix # (P, V)
660
+ recomb = transport / transport.sum(dim=1, keepdim=True).clamp(min=1e-12)
661
+ rank_mu_local = (recomb.unsqueeze(-1) * vertex_steps**2).sum(dim=1) # (P, pdim)
662
+ # rank-mu is sum_v w_v * y_v^2 = C_diag * sum_v w_v * z_v^2.
663
+ rank_mu = state.C_diag * rank_mu_local.reshape(-1)[:sub_dim]
664
+
665
+ state.C_diag = update_covariance_diagonal(
666
+ C_diag=state.C_diag,
667
+ p_c=state.p_c,
668
+ rank_mu=rank_mu,
669
+ c_1=opt._cma_params["c_1"],
670
+ c_mu=opt._cma_params["c_mu"],
671
+ h_sigma=h_sigma,
672
+ c_c=opt._cma_params["c_c"],
673
+ )
674
+ state.C_diag = torch.clamp(
675
+ state.C_diag,
676
+ opt._cma_params["cov_min"],
677
+ opt._cma_params["cov_max"],
678
+ )
679
+
680
+ # 5. Update step-size via CSA (if enabled). Reuse p_sigma_norm and the
681
+ # calibrated expected norm from the Heaviside check above.
682
+ if opt.use_csa:
683
+ state.sigma = update_step_size_csa(
684
+ sigma=state.sigma,
685
+ p_sigma=state.p_sigma,
686
+ c_sigma=opt._cma_params["c_sigma"],
687
+ d_sigma=opt._cma_params["d_sigma"],
688
+ n=sub_dim,
689
+ p_sigma_norm=p_sigma_norm,
690
+ expected_norm=csa_expected_norm,
691
+ )
692
+ # Floor sigma to prevent collapse to zero
693
+ state.sigma = max(state.sigma, 1e-6)
694
+
695
+ # 6. Increment generation
696
+ state.generation += 1
697
+
698
+ # Cache cost_matrix mean as tensor (defer .item() sync until needed)
699
+ _cost_mean_tensor = cost_matrix.mean()
700
+
701
+ # 10. Adaptive radius (use model loss, not OT regularized cost)
702
+ # Single GPU-CPU sync point for cost mean
703
+ _cost_mean = _cost_mean_tensor.item()
704
+ if opt.use_adaptive_radius:
705
+ rm, sc, pl = update_adaptive_radius(
706
+ _cost_mean,
707
+ state.prev_loss,
708
+ state.stagnation_count,
709
+ state.radius_multiplier,
710
+ stagnation_threshold=opt.stagnation_threshold,
711
+ stagnation_patience=opt.stagnation_patience,
712
+ radius_increase=opt.radius_increase,
713
+ radius_decrease=opt.radius_decrease,
714
+ radius_min=opt.radius_min,
715
+ radius_max=opt.radius_max,
716
+ )
717
+ state.radius_multiplier = rm
718
+ state.stagnation_count = sc
719
+ state.prev_loss = pl
720
+
721
+ # NaN-safe state update - revert X, velocity, and duals if NaN after projection
722
+ _nan_reverted = False
723
+ if not torch.isfinite(state.X).all():
724
+ state.X = X.clone()
725
+ # Reset velocity to prevent NaN propagation through momentum
726
+ if opt.use_momentum and state.velocity is not None:
727
+ state.velocity = torch.zeros_like(state.velocity)
728
+ _nan_reverted = True
729
+
730
+ # Clear biased rotation descent direction and Newton direction on NaN revert
731
+ if _nan_reverted and opt.biased_rotation:
732
+ opt._prev_descent_direction = None
733
+ opt._prev_descent_direction_finite = False
734
+ if _nan_reverted:
735
+ opt._newton_direction = None
736
+
737
+ # Capture transport direction for amortized OT (after NaN check)
738
+ if opt.amortize_steps > 1:
739
+ if _nan_reverted:
740
+ opt._transport_direction = None # NaN step, no valid direction
741
+ opt._transport_direction_ema = None
742
+ else:
743
+ # Pure OT step from X_bary, not state.X: momentum and Newton are
744
+ # applied separately, so the coasting direction must not re-carry them.
745
+ raw_direction = (X_bary - X).detach()
746
+ opt._transport_direction = raw_direction
747
+ # EMA blend: smooth transport direction across OT steps
748
+ alpha = opt.amortize_ema
749
+ if opt._transport_direction_ema is None:
750
+ opt._transport_direction_ema = raw_direction
751
+ else:
752
+ opt._transport_direction_ema = alpha * opt._transport_direction_ema + (1.0 - alpha) * raw_direction
753
+
754
+ # NB: trust-region update happens at the start of the next call to
755
+ # ``step`` (deferred), where we have a real post-step pre-OT measurement.
756
+
757
+ # 11. Update diagnostics (defer GPU-CPU sync for displacement)
758
+ per_particle_disp_sqnorms = torch.sum((state.X - X) ** 2, dim=-1) # (P,)
759
+ disp_sqnorm_tensor = torch.mean(per_particle_disp_sqnorms)
760
+ state.costs.append(_cost_mean)
761
+ state.linear_convergence.append(ot_result.converged)
762
+ state.displacement_sqnorms.append(disp_sqnorm_tensor.item())
763
+ state.iteration_count += 1
764
+ # Track OT-step costs separately for adaptive_num_probe (excludes momentum steps)
765
+ opt._ot_step_costs.append(_cost_mean)
766
+
767
+ # 11-AP. Adaptive probes: store per-particle displacement and cost matrix
768
+ # for next step's stagnation detection and cost-row reuse
769
+ if opt._adaptive_probes:
770
+ opt._prev_displacement_sqnorms = per_particle_disp_sqnorms.detach()
771
+ opt._prev_cost_matrix = raw_cost_matrix.detach()
772
+ opt._prev_rot_mats = rot_mats.detach()
773
+ opt._prev_k_eff = K_eff
774
+ opt._prev_step_r = step_r
775
+ # 11-MF. Multi-fidelity screening: store cost matrix for next step's
776
+ # direction contrast analysis (independent of adaptive probes)
777
+ if opt.multifidelity_screen and not opt._adaptive_probes:
778
+ opt._prev_cost_matrix = raw_cost_matrix.detach()
779
+ opt._prev_k_eff = K_eff
780
+ opt._prev_step_r = step_r
781
+ # Save duals for warm-starting; reset if the step reverted on NaN or a
782
+ # post-solve refinement moved the particles (duals now encode old positions).
783
+ if _nan_reverted or _duals_invalidated:
784
+ state.f = None
785
+ state.g = None
786
+ # Also clear momentum history - can't extrapolate from invalid state
787
+ if opt._dual_momentum_beta > 0.0:
788
+ state.prev_prev_f = None
789
+ state.prev_prev_g = None
790
+ else:
791
+ # Dual momentum: save previous duals before overwriting
792
+ if opt._dual_momentum_beta > 0.0:
793
+ state.prev_prev_f = state.f.clone() if state.f is not None else None
794
+ state.prev_prev_g = state.g.clone() if state.g is not None else None
795
+ state.f = ot_result.f.detach() if ot_result.f is not None else None
796
+ state.g = ot_result.g.detach() if ot_result.g is not None else None
797
+ state.epsilon = current_eps
798
+
799
+ # 11a. Adaptive subspace: displacement tracking, absorb, and rotation
800
+ if opt._adaptive and isinstance(opt.subspace, AdaptiveSubspace):
801
+ adaptive_sub = opt.subspace
802
+
803
+ # 1. Compute displacement in subspace coords
804
+ # The displacement is the change in the flattened subspace coordinate
805
+ # vector after the barycentric projection + momentum update.
806
+ post_step_sub_coords = state.X.reshape(-1)[: adaptive_sub.subspace_dim]
807
+ displacement = post_step_sub_coords - _pre_step_sub_coords
808
+
809
+ # 2. Update displacement history (rolling buffer)
810
+ idx = state.displacement_history_idx
811
+ state.displacement_history[idx] = displacement
812
+ state.displacement_history_idx = (idx + 1) % adaptive_sub.displacement_history_size
813
+ state.displacement_history_count = min(
814
+ state.displacement_history_count + 1,
815
+ adaptive_sub.displacement_history_size,
816
+ )
817
+
818
+ # 3. Check for absorb trigger
819
+ should_absorb = adaptive_sub.should_absorb(
820
+ state.stagnation_count,
821
+ state.iteration_count, # already incremented above
822
+ )
823
+
824
+ if should_absorb:
825
+ # Absorb: fold perturbation into base, zero coords, new random basis.
826
+ full_flat_sub = state.X.reshape(-1)[: adaptive_sub.subspace_dim]
827
+ new_base, _zeroed = adaptive_sub.absorb(
828
+ state.projection,
829
+ state.base_params,
830
+ full_flat_sub,
831
+ )
832
+ state.base_params = new_base
833
+ # Zero the subspace coordinates rather than re-projecting onto
834
+ # the new basis. The new and old bases are largely uncorrelated
835
+ # after a random redraw, so re-projection adds complexity for
836
+ # little benefit; the next OT solve will discover a fresh
837
+ # descent direction.
838
+ state.X = torch.zeros_like(state.X)
839
+ # New random projection
840
+ # Sparse projection: create new SparseRandomProjection with fresh seed
841
+ from .projection import SparseRandomProjection
842
+
843
+ if isinstance(state.projection, SparseRandomProjection):
844
+ # Increment seed based on absorb count for fresh random basis
845
+ new_seed = state.projection.seed + state.absorb_count + 1000
846
+ state.projection = SparseRandomProjection(
847
+ full_dim=state.projection.full_dim,
848
+ subspace_dim=state.projection.subspace_dim,
849
+ seed=new_seed,
850
+ )
851
+ else:
852
+ state.projection = adaptive_sub.init_projection(
853
+ generator=opt._generator,
854
+ device=state.X.device,
855
+ dtype=state.X.dtype,
856
+ )
857
+ # Reset displacement history
858
+ state.displacement_history.zero_()
859
+ state.displacement_history_idx = 0
860
+ state.displacement_history_count = 0
861
+ # Reset duals (cost landscape changed)
862
+ state.f = None
863
+ state.g = None
864
+ state.prev_prev_f = None
865
+ state.prev_prev_g = None
866
+ # Invalidate EMA transport direction (cost geometry changed)
867
+ opt._transport_direction_ema = None
868
+ opt._transport_direction = None
869
+ # Increment absorb count
870
+ state.absorb_count += 1
871
+ # Invalidate cached cost/probe state (cost landscape changed after absorb)
872
+ opt._invalidate_reuse_cache()
873
+ opt._newton_direction = None
874
+ opt._prev_descent_direction = None
875
+ opt._prev_descent_direction_finite = False
876
+ # Momentum velocity lives in the pre-absorb basis; zero it.
877
+ if opt.use_momentum and state.velocity is not None:
878
+ state.velocity = torch.zeros_like(state.velocity)
879
+ # CMA-ES: Reset evolution paths and covariance after absorb
880
+ if opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa):
881
+ state.p_c = torch.zeros_like(state.p_c)
882
+ state.p_sigma = torch.zeros_like(state.p_sigma)
883
+ state.C_diag = torch.ones_like(state.C_diag)
884
+ state.sigma = 1.0
885
+ # Keep generation counter (don't reset to preserve cumulation history)
886
+ else:
887
+ # 4. Rotate projection basis for next step
888
+ # Design decision: rotate EVERY step (not every N steps) because the
889
+ # OT solve already provides a natural information-extraction per step,
890
+ # and rotating ensures maximum exploration of the full parameter space.
891
+ # Note: for large full_dim the basis QR/SVD dominates the step and far
892
+ # exceeds the tiny-V Sinkhorn solve, so it runs on the GPU in fp32.
893
+
894
+ # Sparse projection: use seed increment instead of QR rotation
895
+ from .projection import SparseRandomProjection
896
+
897
+ if isinstance(state.projection, SparseRandomProjection):
898
+ # Sparse projection doesn't support QR rotation; increment seed
899
+ new_seed = state.projection.seed + state.iteration_count
900
+ state.projection = SparseRandomProjection(
901
+ full_dim=state.projection.full_dim,
902
+ subspace_dim=state.projection.subspace_dim,
903
+ seed=new_seed,
904
+ )
905
+ else:
906
+ # Dense projection: use existing displacement-based rotation
907
+ hist = (
908
+ state.displacement_history[: state.displacement_history_count]
909
+ if state.displacement_history_count > 0
910
+ else None
911
+ )
912
+
913
+ # Pass OT info for ot_bias mode
914
+ ot_kwargs = {}
915
+ if hasattr(adaptive_sub, "rotation_mode") and adaptive_sub.rotation_mode == "ot_bias":
916
+ ot_kwargs = {
917
+ "transport_matrix": ot_result.matrix,
918
+ "X_vertices": _X_vertices_for_ot_bias,
919
+ "X_current": _X_pre_barycentric,
920
+ }
921
+
922
+ state.projection = adaptive_sub.rotate(
923
+ state.projection,
924
+ step=state.iteration_count,
925
+ total_steps=opt.max_iterations,
926
+ displacement_history=hist,
927
+ generator=opt._generator,
928
+ **ot_kwargs,
929
+ )
930
+ # Reset duals after rotation (cost geometry changed)
931
+ state.f = None
932
+ state.g = None
933
+ state.prev_prev_f = None
934
+ state.prev_prev_g = None
935
+ # Invalidate EMA transport direction and reuse cache (geometry changed)
936
+ opt._transport_direction_ema = None
937
+ opt._transport_direction = None
938
+ opt._invalidate_reuse_cache()
939
+
940
+ # 11a-2. HybridSubspace: displacement tracking, absorb, and rotation
941
+ # Similar to AdaptiveSubspace but uses per-layer projections dict
942
+ if opt._hybrid and isinstance(opt.subspace, HybridSubspace):
943
+ hybrid_sub = opt.subspace
944
+
945
+ # 1. Compute displacement in subspace coords
946
+ post_step_sub_coords = state.X.reshape(-1)[: hybrid_sub.subspace_dim]
947
+ displacement = post_step_sub_coords - _pre_step_sub_coords
948
+
949
+ # 2. Update displacement history (rolling buffer)
950
+ idx = state.displacement_history_idx
951
+ state.displacement_history[idx] = displacement
952
+ state.displacement_history_idx = (idx + 1) % hybrid_sub.displacement_history_size
953
+ state.displacement_history_count = min(
954
+ state.displacement_history_count + 1,
955
+ hybrid_sub.displacement_history_size,
956
+ )
957
+
958
+ # 3. Check for absorb trigger
959
+ should_absorb = hybrid_sub.should_absorb(
960
+ state.stagnation_count,
961
+ state.iteration_count,
962
+ )
963
+
964
+ if should_absorb:
965
+ # Absorb: fold perturbation into base, zero coords, new projections
966
+ full_flat_sub = state.X.reshape(-1)[: hybrid_sub.subspace_dim]
967
+ new_base, _zeroed = hybrid_sub.absorb(
968
+ state.hybrid_projections,
969
+ state.base_params,
970
+ full_flat_sub,
971
+ )
972
+ state.base_params = new_base
973
+ # Reset subspace coordinates to zero
974
+ state.X = torch.zeros_like(state.X)
975
+ # Regenerate ALL per-layer projections
976
+ state.hybrid_projections = hybrid_sub.init_projections(
977
+ state.X.device,
978
+ state.X.dtype,
979
+ )
980
+ # Reset displacement history
981
+ state.displacement_history.zero_()
982
+ state.displacement_history_idx = 0
983
+ state.displacement_history_count = 0
984
+ # Reset duals (cost landscape changed)
985
+ state.f = None
986
+ state.g = None
987
+ state.prev_prev_f = None
988
+ state.prev_prev_g = None
989
+ # Invalidate EMA transport direction (cost geometry changed)
990
+ opt._transport_direction_ema = None
991
+ opt._transport_direction = None
992
+ # Increment absorb count
993
+ state.absorb_count += 1
994
+ # Invalidate cached cost/probe state (cost landscape changed after absorb)
995
+ opt._invalidate_reuse_cache()
996
+ opt._newton_direction = None
997
+ opt._prev_descent_direction = None
998
+ opt._prev_descent_direction_finite = False
999
+ # Momentum velocity lives in the pre-absorb basis; zero it.
1000
+ if opt.use_momentum and state.velocity is not None:
1001
+ state.velocity = torch.zeros_like(state.velocity)
1002
+ else:
1003
+ # 4. Rotate all per-layer projections for next step
1004
+ hist = (
1005
+ state.displacement_history[: state.displacement_history_count]
1006
+ if state.displacement_history_count > 0
1007
+ else None
1008
+ )
1009
+ new_projections = hybrid_sub.rotate_all(
1010
+ state.hybrid_projections,
1011
+ step=state.iteration_count,
1012
+ total_steps=opt.max_iterations,
1013
+ displacement_history=hist,
1014
+ )
1015
+ # Only act when projections changed (a rotation happened). With the
1016
+ # default rotation_interval=0 rotate_all returns the same dict, so the
1017
+ # block_diag rebuild below is skipped: it is a full, wasteful rebuild.
1018
+ if new_projections is not state.hybrid_projections:
1019
+ state.f = None
1020
+ state.g = None
1021
+ state.prev_prev_f = None
1022
+ state.prev_prev_g = None
1023
+ # Invalidate EMA transport direction and reuse cache (geometry changed)
1024
+ opt._transport_direction_ema = None
1025
+ opt._transport_direction = None
1026
+ opt._invalidate_reuse_cache()
1027
+ state.hybrid_projections = new_projections
1028
+ if hasattr(hybrid_sub, "build_fused_projection"):
1029
+ hybrid_sub.build_fused_projection(new_projections)
1030
+
1031
+ # 11b. Periodic absorb: fold perturbation into base, zero subspace
1032
+ # Only for non-adaptive/non-hybrid subspaces; their absorb handled separately.
1033
+ # Semantics: absorb AFTER every N steps (iteration_count already incremented above).
1034
+ # E.g., absorb_every=10 triggers at iteration_count=10,20,30,...
1035
+ if (
1036
+ not opt._adaptive
1037
+ and not opt._hybrid
1038
+ and not opt._cma_subspace
1039
+ and opt.subspace is not None
1040
+ and opt.absorb_every > 0
1041
+ and state.iteration_count % opt.absorb_every == 0
1042
+ ):
1043
+ flat_sub = state.X.reshape(-1)[: state.subspace.subspace_dim]
1044
+ new_base, _zeroed = state.subspace.absorb(state.base_params, flat_sub)
1045
+ state.base_params = new_base
1046
+ # Zero the particle array (subspace coords reset)
1047
+ state.X = torch.zeros_like(state.X)
1048
+ # Reset dual potentials since cost landscape changed
1049
+ state.f = None
1050
+ state.g = None
1051
+ state.prev_prev_f = None
1052
+ state.prev_prev_g = None
1053
+ # Invalidate EMA transport direction and reuse cache (geometry changed)
1054
+ opt._transport_direction_ema = None
1055
+ opt._transport_direction = None
1056
+ opt._invalidate_reuse_cache()
1057
+ if opt.use_momentum and state.velocity is not None:
1058
+ state.velocity = torch.zeros_like(state.velocity)
1059
+
1060
+ # 11c. Rank schedule transition check
1061
+ if opt._rank_schedule is not None and opt.subspace is not None:
1062
+ current_rank = opt._rank_schedule.at(state.iteration_count)
1063
+ prev_rank = opt._rank_schedule.at(state.iteration_count - 1)
1064
+ if current_rank != prev_rank:
1065
+ opt._transition_rank(current_rank)
1066
+
1067
+ # 12. Write particles back to model
1068
+ opt._sync_model()
1069
+
1070
+ return _cost_mean