Repository navigation
Expand file tree
/
Copy pathtest_code.py
More file actions
3045 lines (2532 loc) · 143 KB
/
Copy pathtest_code.py
File metadata and controls
3045 lines (2532 loc) · 143 KB
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
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""CPU-only checks for the optimizers, the federated driver, and the plumbing.
python3 -m tests.test_code # run everything, print a report
pytest tests/test_code.py # or under pytest
Both from the ``code/`` directory.
No GPU, no dataset download, about a minute. What it pins down:
* the Newton-Schulz helper does not scribble on its input (it used to, whenever
the input was already bfloat16, because ``Tensor.to`` returns ``self``);
* it really does approximate the polar factor;
* every sign/LMO method is invariant to a positive rescaling of the gradient --
the property that makes the paper's heavy-ball main text and its EMA algorithm
boxes describe the same trajectories;
* the paper's Theorem 1-3 descent inner products, computed with torch;
* **the federated driver with one client reproduces the centralized optimizer
exactly**, for all eight matrix-parameter rules. This is the load-bearing test:
it ties ``federated_algorithms.run_federated`` to ``optimizers.py`` so the two
cannot silently diverge;
* several clients holding identical data reproduce the single-client run;
* EF21-MuonSign keeps its exact model ``X`` distinct from the broadcast ``W``;
* the metrics schema and the multi-seed aggregation.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from federated.algorithms import METHODS, run_federated
from common.optimizers import (
EF21MuonSign,
EF21MuonUSign,
EF21SignMuon,
Muon,
MuonSign,
MuonUSign,
SignMuon,
SignSGD,
muon_lmo,
zeropower_via_newtonschulz5,
)
from common.utils import History, split_param_names
CENTRAL_CLASSES = {
"signmuon": SignMuon,
"ef21signmuon": EF21SignMuon,
"muonusign": MuonUSign,
"muonsign": MuonSign,
"ef21muonusign": EF21MuonUSign,
"ef21muonsign": EF21MuonSign,
"muon": Muon,
"signsgd": SignSGD,
}
TOL = 1e-5
# --------------------------------------------------------------------------
# Fixtures
# --------------------------------------------------------------------------
class TinyNet(nn.Module):
"""Exactly one matrix parameter plus a 2-tensor 'head', so the routing is
unambiguous: ``fc1.weight`` is the matrix, everything else is auxiliary."""
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(6, 4)
self.fc2 = nn.Linear(4, 3)
def forward(self, x):
return self.fc2(torch.relu(self.fc1(x)))
class TallNet(nn.Module):
"""Same shape of test as ``TinyNet``, but with a TALL matrix parameter.
``fc1.weight`` is ``(12, 4)``, so ``sqrt(max(1, m/n)) = sqrt(3) != 1``. This
matters: on ``TinyNet`` the only matrix is ``(4, 6)``, where the aspect factor
is exactly 1 and the whole "the factor lives in ``lambda`` federated and in
``scale_aspect`` centrally, and the two coincide bit for bit" claim is
vacuously true. Here it has to actually hold.
"""
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(4, 12)
self.fc2 = nn.Linear(12, 3)
def forward(self, x):
return self.fc2(torch.relu(self.fc1(x)))
def tall_data(n: int = 8, seed: int = 0):
g = torch.Generator().manual_seed(seed)
return (torch.randn(n, 4, generator=g),
torch.randint(0, 3, (n,), generator=g))
def tall_loader(n: int = 8, seed: int = 0):
return [tall_data(n, seed)]
def tiny_data(n: int = 8, seed: int = 0):
g = torch.Generator().manual_seed(seed)
x = torch.randn(n, 6, generator=g)
y = torch.randint(0, 3, (n,), generator=g)
return x, y
def tiny_loader(n: int = 8, seed: int = 0):
"""One deterministic full batch per epoch (``shuffle=False``)."""
x, y = tiny_data(n, seed)
return DataLoader(TensorDataset(x, y), batch_size=n, shuffle=False)
def exact_polar(Y: torch.Tensor) -> torch.Tensor:
"""Rank-truncated ``U V^T`` via SVD, in float64."""
U, S, Vh = torch.linalg.svd(Y.double(), full_matrices=False)
r = int((S > 1e-9 * S[0]).sum())
return U[:, :r] @ Vh[:r, :]
# --------------------------------------------------------------------------
# Newton-Schulz helper
# --------------------------------------------------------------------------
def test_newton_schulz_does_not_mutate_input():
for dtype in (torch.float32, torch.bfloat16):
G = torch.randn(5, 7, dtype=dtype)
before = G.clone()
zeropower_via_newtonschulz5(G, steps=5, dtype=dtype)
assert torch.equal(G, before), f"input mutated for dtype={dtype}"
def _controlled_spectrum(shape, kappa: float, seed: int) -> torch.Tensor:
"""``U diag(s) V^T`` with ``s`` log-spaced over ``[1/kappa, 1]``.
Testing on Gaussian matrices conflates the implementation with the *input's*
conditioning -- a Gaussian's condition number is heavy-tailed, and 5 Newton-Schulz
steps genuinely cannot lift a singular value that starts too small. Controlling
the spectrum separates the two.
"""
g = torch.Generator().manual_seed(seed)
m, n = shape
r = min(m, n)
U, _ = torch.linalg.qr(torch.randn(m, r, generator=g))
V, _ = torch.linalg.qr(torch.randn(n, r, generator=g))
s = torch.logspace(math.log10(1.0 / kappa), 0.0, r)
return U @ torch.diag(s) @ V.T
def test_newton_schulz_gets_the_direction_but_not_the_scale():
"""What 5 steps of the Muon quintic actually deliver.
The quintic is *not* a convergent iteration: it drives the singular values into
a band around 1 and oscillates inside it. Two consequences, both measured over
80 (shape, seed) cases:
* On an **already orthogonal** input the direction is exact -- ``NS(Q) = c*Q`` for
a positive scalar ``c``, since ``A = QQ^T`` is a multiple of the identity and
each step just rescales. So cosine is 1.0000 and every sign agrees. But the
*relative Frobenius error reaches 0.31*, because ``c`` oscillates in
``[0.69, 1.07]``. Asserting a small Frobenius error would therefore be wrong
even for a perfect implementation -- the quintic does not fix the scale, which
is exactly why magnitude is handled separately by ``lambda`` / ``scale_aspect``.
* On a conditioned input the sign pattern agrees only 84-91% of the time. So
~1 entry in 8 of ``sign(polar(G))`` flips under the practical oracle -- the
reason the sign-family methods are sensitive to the LMO approximation
(cf. ``--lmo-dtype`` and ``counterexamples/verify_ns_oracle.py``).
The assertions below therefore test *direction* and *approximate orthogonality*,
with margins taken from the 80-case sweep.
"""
shapes = [(8, 8), (16, 16), (64, 64), (32, 96)]
# (a) orthogonal input: direction must be essentially exact.
for shape in shapes:
for seed in (0, 1):
G = _controlled_spectrum(shape, kappa=1.0, seed=seed)
D = muon_lmo(G, ns_steps=5, dtype=torch.float32, scale_aspect=False)
P = exact_polar(G).float()
cos = float((D * P).sum() / (D.norm() * P.norm()))
agree = float((torch.sign(D) == torch.sign(P)).float().mean())
assert cos > 0.999, f"{shape}: cosine {cos:.5f} on an orthogonal input"
assert agree > 0.98, f"{shape}: {agree:.1%} sign agreement on an " \
f"orthogonal input (should be exact)"
# (b) moderately conditioned input: direction close, scale within the band.
for shape in shapes:
for seed in (0, 1, 2):
G = _controlled_spectrum(shape, kappa=4.0, seed=seed)
D = muon_lmo(G, ns_steps=5, dtype=torch.float32, scale_aspect=False)
P = exact_polar(G).float()
cos = float((D * P).sum() / (D.norm() * P.norm()))
agree = float((torch.sign(D) == torch.sign(P)).float().mean())
sv = torch.linalg.svdvals(D.double())
assert cos > 0.95, f"{shape}: cosine {cos:.4f} -- wrong direction"
assert agree > 0.70, f"{shape}: only {agree:.1%} of signs agree"
assert 0.4 < float(sv.min()) and float(sv.max()) < 1.5, \
f"{shape}: singular values [{sv.min():.3f}, {sv.max():.3f}] -- not " \
f"approximately orthogonal"
def test_muon_lmo_shapes():
for shape in [(4, 6), (6, 4), (5, 5), (3, 2, 2, 2)]:
G = torch.randn(*shape)
assert muon_lmo(G, dtype=torch.float32).shape == G.shape
v = torch.randn(7) # ndim < 2 passes straight through
assert torch.equal(muon_lmo(v), v)
def test_aspect_ratio_scaling_is_invisible_to_output_signing():
"""SignMuon/MuonSign sign the LMO output, so Muon's sqrt(max(1,m/n)) factor
cannot change their step; Muon/MuonUSign it does scale."""
torch.manual_seed(1)
G = torch.randn(10, 4)
a = muon_lmo(G, dtype=torch.float32, scale_aspect=True)
b = muon_lmo(G, dtype=torch.float32, scale_aspect=False)
assert torch.equal(torch.sign(a), torch.sign(b))
assert not torch.allclose(a, b)
# --------------------------------------------------------------------------
# Gradient-scale invariance (the momentum-convention equivalence)
# --------------------------------------------------------------------------
def _drive(cls, grads, lr=0.1, momentum=0.7, nesterov=False):
p = nn.Parameter(torch.zeros(4, 5))
opt = cls([p], lr=lr, momentum=momentum, nesterov=nesterov,
lmo_dtype=torch.float32)
traj = []
for g in grads:
p.grad = g.clone()
opt.step()
traj.append(p.detach().clone())
return traj
def test_gradient_scale_invariance():
"""Every method's iterates are unchanged when G -> c*G for c > 0.
This is exactly why the paper's main-text heavy-ball momentum
(``mu*M + G``) and its algorithm boxes' EMA (``mu*M + (1-mu)*G``) give
identical trajectories: the two buffers differ by the constant factor
``1 - mu``, and sign, polar and the EF21 recursion are all positively
homogeneous.
"""
torch.manual_seed(2)
grads = [torch.randn(4, 5) for _ in range(6)]
for name, cls in CENTRAL_CLASSES.items():
for nesterov in (False, True):
base = _drive(cls, grads, nesterov=nesterov)
scaled = _drive(cls, [3.0 * g for g in grads], nesterov=nesterov)
for t, (u, v) in enumerate(zip(base, scaled)):
assert torch.allclose(u, v, atol=1e-4), \
f"{name} (nesterov={nesterov}) not scale invariant at step {t}"
# --------------------------------------------------------------------------
# The paper's counterexamples, in torch
# --------------------------------------------------------------------------
def _signmuon_instance(sigma1: float):
O = torch.tensor([[101., 20., 2., -2.],
[-20., 97., 20., -20.],
[-2., 20., 2., 101.],
[-2., 20., -101., -2.]], dtype=torch.float64) / 103.0
u1 = torch.tensor([10., -3., 10., 10.], dtype=torch.float64).reshape(-1, 1) / math.sqrt(309.)
v1 = torch.tensor([10., 3., -10., 10.], dtype=torch.float64).reshape(-1, 1) / math.sqrt(309.)
return sigma1 * (u1 @ v1.T) + O, O
def _muonsign_instance(M: float, eps: float = 1.0):
S = torch.tensor([[-1., -1., 1., 1., 1.],
[-1., -1., 1., -1., -1.],
[1., -1., 1., 1., -1.],
[1., 1., -1., -1., 1.],
[1., 1., 1., -1., 1.]], dtype=torch.float64)
G = eps * S.clone()
G[3, 1] = eps * S[3, 1] + (M - eps)
return G, S
def test_theorem1_exact_oracle():
"""<G, sign(polar(G))> = (-43*sigma1 + 532)/103."""
for sigma1 in (100.0, 1000.0):
G, O = _signmuon_instance(sigma1)
P = exact_polar(G)
assert (P - O).norm() < 1e-10, "polar(G) should equal O"
got = float((G * torch.sign(P)).sum())
want = (-43.0 * sigma1 + 532.0) / 103.0
assert abs(got - want) < 1e-6, f"sigma1={sigma1}: {got} != {want}"
assert got < 0, "SignMuon must ascend under the exact oracle"
def test_theorems23_exact_oracle():
G, S = _muonsign_instance(100.0)
assert torch.equal(torch.sign(G), S)
D = exact_polar(S)
assert abs(float(D[3, 1]) - (-0.2425)) < 1e-3, float(D[3, 1])
assert abs(float((G * D).sum()) - (-13.888)) < 1e-2 # MuonUSign, Thm 2
assert abs(float((G * torch.sign(D)).sum()) - (-76.0)) < 1e-9 # MuonSign, Thm 3
def test_exact_and_newton_schulz_oracles_differ_on_the_instances():
"""Regression test for a measured fact, not a defect.
The theorems are stated for the exact LMO and the counterexample code runs the
exact LMO; networks are trained with Newton-Schulz, as practitioners do. The
two oracles are different maps, and on these instances they disagree: at the
published constants (sigma1=1000, M=100) the 5-step oracle does not ascend for
Theorems 1-2, while sigma1=100 / M=500 ascend under both. Theorem 3 is
oracle-robust. Pinned here so the numbers cannot drift; see
``counterexamples/verify_ns_oracle.py``.
"""
def after(G): # SignMuon
return float((G * torch.sign(muon_lmo(G.float(), 5, torch.float32, False))).sum())
def before(G, S): # MuonUSign
return float((G * muon_lmo(S.float(), 5, torch.float32, False).double()).sum())
def both(G, S): # MuonSign
return float((G * torch.sign(muon_lmo(S.float(), 5, torch.float32, False)).double()).sum())
assert after(_signmuon_instance(1000.0)[0]) > 0, "sigma1=1000 does not ascend at 5 NS steps"
assert after(_signmuon_instance(100.0)[0]) < 0, "sigma1=100 ascends at 5 NS steps"
G100, S = _muonsign_instance(100.0)
G500, _ = _muonsign_instance(500.0)
assert before(G100, S) > 0, "M=100 does not ascend at 5 NS steps"
assert before(G500, S) < 0, "M=500 ascends at 5 NS steps"
assert both(G100, S) < 0 and both(G500, S) < 0, "Theorem 3 is oracle-robust"
# --------------------------------------------------------------------------
# Federated <-> centralized equivalence
# --------------------------------------------------------------------------
def _centralized_reference(method, rounds, lr, lr_aux, momentum, seed=0,
weight_decay=0.0, net=TinyNet, loader_fn=None):
"""Drive the centralized optimizer by hand, one full-batch step per round."""
torch.manual_seed(seed)
model = net()
matrix_names, aux_names = split_param_names(model, 2)
named = dict(model.named_parameters())
opt = CENTRAL_CLASSES[method]([named[n] for n in matrix_names],
lr=lr, momentum=momentum,
weight_decay=weight_decay,
decoupled_weight_decay=True,
lmo_dtype=torch.float32)
# weight_decay=0.0, not `weight_decay`: `centralized.train.build_optimizers`
# never decays the auxiliary group -- decaying normalization scales is not
# standard practice, and keeping it at zero for every method is what makes
# `--weight-decay` describe the matrix parameters alone. This reference used
# to decay it, which pinned the federated driver to a convention the real
# centralized path does not use.
aux = torch.optim.AdamW([named[n] for n in aux_names], lr=lr_aux,
weight_decay=0.0)
loader = (loader_fn or tiny_loader)()
criterion = nn.CrossEntropyLoss()
for _ in range(rounds):
for x, y in loader:
model.zero_grad(set_to_none=True)
criterion(model(x), y).backward()
opt.step()
aux.step()
if hasattr(opt, "restore_exact"):
opt.restore_exact()
return model
def _federated_run(method, rounds, lr, lr_aux, momentum, n_clients=1, seed=0,
weight_decay=0.0, net=TinyNet, loader_fn=None):
torch.manual_seed(seed)
model = net()
make = loader_fn or tiny_loader
loaders = [make() for _ in range(n_clients)]
run_federated(
method, model, loaders, [make()],
rounds=rounds, n_steps=1, lr=lr, lr_aux=lr_aux, momentum=momentum,
weight_decay=weight_decay, eval_freq=10 ** 9, device="cpu",
cosine_schedule=False, lmo_dtype=torch.float32, verbose=False,
# EXPLICIT, not inherited: the centralized reference above is built with
# ``scale_aspect=True`` and ``lambda_mult=1``, which IS the legacy
# convention. Relying on the driver's default would make this test's
# meaning depend on a default that has already changed once.
lr_scaling="legacy",
)
return model
def test_federated_one_client_equals_centralized():
"""The load-bearing test: N=1 federated == the centralized optimizer.
``sgd`` and ``adam`` are excluded: they are torch optimizers on the
centralized side and server-side aggregation rules on the federated side, and
``sgd`` deliberately keeps the heavy-ball momentum convention.
"""
failures = []
for method in CENTRAL_CLASSES:
ref = _centralized_reference(method, rounds=6, lr=0.05, lr_aux=0.01, momentum=0.8)
fed = _federated_run(method, rounds=6, lr=0.05, lr_aux=0.01, momentum=0.8)
for (n, a), (m, b) in zip(ref.named_parameters(), fed.named_parameters()):
assert n == m
if not torch.allclose(a, b, atol=TOL):
failures.append(f"{method}/{n}: max|diff| = {(a - b).abs().max():.3e}")
assert not failures, "federated != centralized:\n " + "\n ".join(failures)
def test_federated_one_client_equals_centralized_on_a_tall_matrix():
"""The same equivalence where the aspect factor is not 1.
``TinyNet``'s only matrix is ``(4, 6)``, so ``sqrt(max(1, m/n)) == 1`` and the
test above cannot distinguish "the factor is applied once, outside the oracle"
from "the factor is not applied at all". ``TallNet``'s ``(12, 4)`` gives
``sqrt(3)``, so the centralized ``scale_aspect=True`` convention and the
federated ``scale_aspect=False`` + ``lambda`` convention have to genuinely
agree -- which is the bit-for-bit claim the READMEs make about ``legacy``.
"""
m, n = 12, 4
assert math.sqrt(max(1.0, m / n)) > 1.0, "fixture no longer exercises the factor"
failures = []
for method in CENTRAL_CLASSES:
ref = _centralized_reference(method, rounds=6, lr=0.05, lr_aux=0.01,
momentum=0.8, net=TallNet, loader_fn=tall_loader)
fed = _federated_run(method, rounds=6, lr=0.05, lr_aux=0.01, momentum=0.8,
net=TallNet, loader_fn=tall_loader)
for (a_name, a), (b_name, b) in zip(ref.named_parameters(),
fed.named_parameters()):
assert a_name == b_name
if not torch.allclose(a, b, atol=TOL):
failures.append(
f"{method}/{a_name}: max|diff| = {(a - b).abs().max():.3e}")
assert not failures, "federated != centralized on a tall matrix:\n " + \
"\n ".join(failures)
def test_federated_muonserver_equals_centralized_muon_at_one_client():
"""``muonserver`` is excluded from ``CENTRAL_CLASSES``; pin it separately.
It has no centralized class of its own because it differs from ``muon`` only
in *where* the LMO runs relative to the average -- and with a single client
there is nothing to average, so the two must coincide exactly. That makes
centralized ``Muon`` the right reference, and leaves no federated matrix
method unpinned.
"""
for net, loader_fn in ((TinyNet, tiny_loader), (TallNet, tall_loader)):
ref = _centralized_reference("muon", rounds=6, lr=0.05, lr_aux=0.01,
momentum=0.8, net=net, loader_fn=loader_fn)
fed = _federated_run("muonserver", rounds=6, lr=0.05, lr_aux=0.01,
momentum=0.8, net=net, loader_fn=loader_fn)
for (a_name, a), (b_name, b) in zip(ref.named_parameters(),
fed.named_parameters()):
assert a_name == b_name
assert torch.allclose(a, b, atol=TOL), (
f"muonserver != Muon at N=1 on {net.__name__}/{a_name}: "
f"max|diff| = {(a - b).abs().max():.3e}")
def test_the_two_drivers_agree_on_the_weight_decay_convention():
"""Same equivalence, now with weight decay switched on.
The zero-decay test above passes under *either* convention, so it cannot see a
coupled/decoupled mismatch between the two drivers -- which is exactly the
discrepancy that existed. Decoupled is the only well-posed choice here: every
step direction is positively homogeneous of degree zero, so folding ``wd * X``
into the gradient leaves the step length untouched and merely rotates it.
"""
failures = []
for method in CENTRAL_CLASSES:
kw = dict(rounds=6, lr=0.05, lr_aux=0.01, momentum=0.8, weight_decay=0.02)
ref = _centralized_reference(method, **kw)
fed = _federated_run(method, **kw)
moved = max((p - q).abs().max().item()
for p, q in zip(_centralized_reference(method, rounds=6, lr=0.05,
lr_aux=0.01, momentum=0.8,
weight_decay=0.0).parameters(),
ref.parameters()))
# A decay too small to move the parameters would make this test vacuous.
assert moved > 10 * TOL, f"{method}: wd=0.02 changed nothing ({moved:.2e})"
for (n, a), (m, b) in zip(ref.named_parameters(), fed.named_parameters()):
assert n == m
if not torch.allclose(a, b, atol=TOL):
failures.append(f"{method}/{n}: max|diff| = {(a - b).abs().max():.3e}")
assert not failures, ("the drivers disagree once weight decay is nonzero:\n "
+ "\n ".join(failures))
def test_decoupled_decay_is_not_scaled_by_the_per_layer_multiplier():
"""``X *= 1 - lr*wd``, with NO ``lambda`` in it. Pinned at lambda != 1.
Both drivers apply decay unscaled by the per-layer factor, on purpose: decay
is a property of the parameter, not of the step geometry. The equality tests
above cannot see this -- ``TinyNet``'s only matrix is ``(4, 6)`` at the
default ``legacy`` rule, so every multiplier there is exactly 1 and scaling
by it is a no-op. Scaling decoupled decay by ``lambda_mult`` passes every
other test in this file.
"""
lr, wd, lam = 0.1, 0.5, 0.25
torch.manual_seed(0)
for cls in CENTRAL_CLASSES.values():
p = nn.Parameter(torch.ones(4, 4))
# A zero gradient no longer implies a zero step: under the randomized-zero
# convention the sign family transmits +-1 everywhere, so every coordinate
# moves by lr*lambda. Capture the direction and subtract it, which pins the
# decay factor itself -- the thing this test is about -- for every method.
p.grad = torch.zeros(4, 4)
opt = cls([{"params": [p], "lambda_mult": lam}], lr=lr, momentum=0.0,
weight_decay=wd, decoupled_weight_decay=True,
lmo_dtype=torch.float32, scale_aspect=False)
opt.capture_direction = True
opt.step()
if hasattr(opt, "restore_exact"):
opt.restore_exact()
d = opt.state[p]["last_direction"]
expected = (1.0 - lr * wd) - lr * lam * d
got = p.detach()
assert torch.allclose(got, expected, atol=1e-6), (
f"{cls.__name__}: decoupled decay gave {float(got[0, 0]):.6f}, expected "
f"{float(expected[0, 0]):.6f}. Scaled by lambda the decay factor would "
f"be {1 - lr * wd * lam:.6f} rather than {1 - lr * wd:.6f}")
def test_the_two_drivers_agree_on_weight_decay_at_a_nontrivial_multiplier():
"""The driver-level decay equivalence, where ``lambda`` is not 1.
``TallNet`` under ``unit-gain`` gives the LMO family ``sqrt(3)`` and the sign
family ``1/2`` at once, so a mismatch in *where* the multiplier sits relative
to the decay cannot cancel. The ``legacy``/``TinyNet`` version of this test
runs entirely at ``lambda == 1``.
"""
failures = _compare_drivers_under_rule("unit-gain", TallNet, tall_loader,
weight_decay=0.02)
assert not failures, ("the drivers disagree at lambda != 1 with decay on:\n "
+ "\n ".join(failures))
def test_the_exact_step_maps_pin_the_step_length():
"""The claim the appendix's weight-decay argument rests on, on the exact maps.
``sign`` and the polar factor are positively homogeneous of degree **zero**, and
their Frobenius norms are constants of the shape alone -- ``sqrt(m n)`` and
``sqrt(min(m, n))``. Hence a step along either has a length that no additive
perturbation of the gradient can change: coupled weight decay can only rotate it.
Stated on ``torch.linalg.svd``, not on the shipped Newton-Schulz iteration --
NS-5 approximates the polar factor's *direction* well but its norm only to a few
percent, which is what the next test measures.
"""
def exact_polar(A):
U, _, Vh = torch.linalg.svd(A, full_matrices=False)
return U @ Vh
torch.manual_seed(0)
for (m, n) in [(16, 12), (12, 16), (64, 27), (33, 33)]:
A = torch.randn(m, n, dtype=torch.float64)
r = min(m, n)
assert abs(exact_polar(A).norm().item() - math.sqrt(r)) < 1e-9
assert abs(torch.sign(A).norm().item() - math.sqrt(m * n)) < 1e-9
for c in (1e-6, 1e-3, 1.0, 1e3, 1e6):
assert torch.equal(torch.sign(c * A), torch.sign(A)), c
assert (exact_polar(c * A) - exact_polar(A)).abs().max() < 1e-9, c
# and the norm is untouched by an ADDITIVE perturbation of any size
B = torch.randn(m, n, dtype=torch.float64) * 1e4
assert abs(exact_polar(A + B).norm().item() - math.sqrt(r)) < 1e-9
assert abs(torch.sign(A + B).norm().item() - math.sqrt(m * n)) < 1e-9
def test_coupled_decay_does_not_shrink_the_implemented_step():
"""The same claim on the real optimizers, with the honest caveats.
* **Sign-terminated** steps (SignMuon, MuonSign, SignSGD) land on
``{-1,+1}^{m x n}`` whatever the input, so the invariance is *exact* here.
* **LMO-terminated** steps are pinned to ``sqrt(min(m,n))`` by the exact polar
factor, but the shipped Newton-Schulz iteration only approximates it, and
coupled decay changes the input's *spectrum* -- so the step norm moves a little.
That residual is Newton-Schulz's approximation error, not decay.
* **EF21-SignMuon** steps along the EF21 estimator of the Muon direction, whose
norm reaches ``sqrt(r)`` only asymptotically, so it is excluded from the norm
claim by construction (the appendix says so too).
In every case the direction *is* rotated, which is the actual harm.
"""
from common.lr_scaling import FAMILY_SIGN
torch.manual_seed(0)
m, n = 16, 12
# How far the shipped NS-5 iteration's output norm may sit from the exact
# sqrt(min(m,n)). MEASURED here rather than hard-coded, over the three input
# distributions the methods actually feed it (raw momentum, a sign matrix, a
# scaled-sign EF21 payload), so this test cannot go stale if the iteration or
# its coefficients change.
def ns_norm(A):
return muon_lmo(A, ns_steps=5, dtype=torch.float32,
scale_aspect=False).norm().item() / math.sqrt(min(m, n))
probe = []
for _ in range(16):
A = torch.randn(m, n)
probe += [ns_norm(A), ns_norm(torch.sign(A)), ns_norm(A.abs().mean() * torch.sign(A))]
NS_BAND = max(0.05, 2.0 * max(abs(x - 1.0) for x in probe))
for name, cls in CENTRAL_CLASSES.items():
base = torch.randn(m, n)
g = torch.randn(m, n) * 1e-3 # small gradient => large rho
steps = {}
for tag, wd in (("coupled", 0.5), ("plain", 0.0)):
param = nn.Parameter(base.clone())
opt = cls([param], lr=0.1, momentum=0.0, lmo_dtype=torch.float32,
weight_decay=wd, decoupled_weight_decay=False,
scale_aspect=False)
before = param.detach().clone()
param.grad = g.clone()
opt.step()
if hasattr(opt, "restore_exact"):
opt.restore_exact()
steps[tag] = param.detach() - before
n_c, n_p = steps["coupled"].norm().item(), steps["plain"].norm().item()
if cls.family == FAMILY_SIGN:
# 1e-4 relative is float32 differencing noise; a real shrinkage would be
# O(lr*wd) = 5e-2 relative, ~500x larger, so the test still has teeth.
assert abs(n_c - n_p) <= 1e-4 * n_p, (
f"{name}: coupled decay changed a sign step's norm "
f"{n_p:.8g} -> {n_c:.8g}; that is exactly impossible")
assert abs(n_p / (0.1 * math.sqrt(m * n)) - 1.0) <= 1e-4, n_p
elif name != "ef21signmuon":
exact = 0.1 * math.sqrt(min(m, n))
for tag, got in (("coupled", n_c), ("plain", n_p)):
assert abs(got / exact - 1.0) <= NS_BAND, (
f"{name}/{tag}: step norm {got:.6g} is {got / exact:.3f}x the "
f"exact polar value {exact:.6g} -- outside the Newton-Schulz band")
# No systematic shrinkage: the gap is bounded by the NS error, not by wd.
assert abs(n_c - n_p) <= 2 * NS_BAND * exact, (
f"{name}: {n_p:.6g} -> {n_c:.6g} exceeds twice the NS band")
# ... but the direction really is rotated, which is the harm.
cos = (steps["coupled"] * steps["plain"]).sum().item() / (n_c * n_p + 1e-30)
assert cos < 0.999, f"{name}: coupled decay had no effect at all (cos={cos:.4f})"
def test_identical_clients_reduce_to_one():
"""N clients with identical data give the same model as N=1.
Holds for every uplink: the majority vote over identical signs is that sign,
and the average of identical EF21 payloads is that payload.
"""
failures = []
for method in METHODS:
one = _federated_run(method, rounds=4, lr=0.05, lr_aux=0.01, momentum=0.8, n_clients=1)
many = _federated_run(method, rounds=4, lr=0.05, lr_aux=0.01, momentum=0.8, n_clients=3)
for (n, a), (_, b) in zip(one.named_parameters(), many.named_parameters()):
if not torch.allclose(a, b, atol=TOL):
failures.append(f"{method}/{n}: max|diff| = {(a - b).abs().max():.3e}")
assert not failures, "N=3 identical clients != N=1:\n " + "\n ".join(failures)
def test_all_federated_methods_run():
for method in METHODS:
model = TinyNet()
history = run_federated(
method, model, [tiny_loader()], [tiny_loader()],
rounds=3, n_steps=2, lr=0.01, lr_aux=0.01, momentum=0.9,
weight_decay=1e-4, eval_freq=1, device="cpu",
lmo_dtype=torch.float32, verbose=False,
)
assert history.steps == [0, 1, 2, 3], f"{method}: {history.steps}"
for value in history.series["test_acc"]:
assert value is not None and 0.0 <= value <= 100.0
for p in model.parameters():
assert torch.isfinite(p).all(), f"{method} produced non-finite parameters"
def test_eval_freq_records_only_evaluated_rounds():
history = run_federated(
"signmuon", TinyNet(), [tiny_loader()], [tiny_loader()],
rounds=5, n_steps=1, lr=0.01, eval_freq=2, device="cpu",
lmo_dtype=torch.float32, verbose=False,
)
assert history.steps == [0, 2, 4, 5], history.steps
# --------------------------------------------------------------------------
# EF21-MuonSign bookkeeping
# --------------------------------------------------------------------------
def test_ef21muonsign_separates_exact_and_broadcast_models():
torch.manual_seed(3)
p = nn.Parameter(torch.zeros(4, 4))
opt = EF21MuonSign([p], lr=0.1, momentum=0.0, lmo_dtype=torch.float32)
for _ in range(4):
p.grad = torch.randn(4, 4)
opt.step()
W = p.detach().clone()
X = opt.state[p]["exact_model"]
assert not torch.allclose(W, X), "W and X should differ once the downlink compresses"
with opt.using_exact():
assert torch.allclose(p.data, X), "using_exact must expose X"
assert torch.allclose(p.data, W), "using_exact must put W back"
opt.restore_exact()
assert torch.allclose(p.data, X), "restore_exact must install X"
def test_ef21_estimator_tracks_a_constant_target():
"""With a constant gradient the EF21 estimator converges to it, which is why
compressing before the oracle restores the descent property."""
p = nn.Parameter(torch.zeros(6, 6))
opt = EF21MuonUSign([p], lr=0.0, momentum=0.0, lmo_dtype=torch.float32)
target = torch.randn(6, 6)
for _ in range(1000):
p.grad = target.clone()
opt.step()
est = opt.state[p]["grad_estimator"]
assert (est - target).norm() / target.norm() < 1e-3, (est - target).norm()
# --------------------------------------------------------------------------
# Per-layer learning-rate scaling
# --------------------------------------------------------------------------
def test_unit_gain_reproduces_the_shipped_muon_factor():
"""The criterion's strongest validation.
Applying the unit-gain criterion to an LMO step must reproduce
``sqrt(max(1, fan_out/fan_in))``, the aspect factor in the reference Muon
implementation. If this failed, the criterion would be ad hoc.
"""
from common.lr_scaling import FAMILY_LMO, layer_multiplier, resolve_rule
ug, legacy = resolve_rule("unit-gain"), resolve_rule("legacy")
for shape in [(64, 3, 3, 3), (512, 512, 3, 3), (512, 256, 1, 1), (10, 512)]:
a = layer_multiplier(ug, FAMILY_LMO, shape)
b = layer_multiplier(legacy, FAMILY_LMO, shape)
m, n = shape[0], math.prod(shape[1:]) if len(shape) > 1 else 1
assert abs(a - b) < 1e-12, (shape, a, b)
assert abs(a - math.sqrt(max(1.0, m / n))) < 1e-12
def test_unit_gain_equalizes_the_per_step_gain_exactly():
"""The invariant the whole rule exists to enforce.
``unit-gain`` is exactly ``lambda = sqrt(fan_out)/||s||_F``, so the per-step RMS
gain ``gamma(eta_0 * lambda * s) = eta_0 * lambda * ||s||_F / sqrt(fan_out)``
equals ``eta_0`` on *every* layer and in *both* families. If this ever fails the
rule has been mis-implemented.
"""
from common.lr_scaling import (FAMILY_LMO, FAMILY_SIGN, RESNET18_SHAPES,
resolve_rule)
rule = resolve_rule("unit-gain")
for _, m, n in RESNET18_SHAPES:
for family, s_fro in ((FAMILY_LMO, math.sqrt(min(m, n))),
(FAMILY_SIGN, math.sqrt(m * n))):
lam = rule.multiplier(family, m, n)
assert abs(lam - math.sqrt(m) / s_fro) < 1e-12, (m, n, family)
gain = lam * s_fro / math.sqrt(m)
assert abs(gain - 1.0) < 1e-12, (m, n, family, gain)
def test_initialization_gain_is_shape_independent():
"""The rule's premise: a fan-in-scaled init has the same gain at every shape, so
the constant of proportionality is absorbed into eta_0 rather than varying."""
from common.lr_scaling import RESNET18_SHAPES
for _, m, n in RESNET18_SHAPES:
he = math.sqrt(2 / n) * math.sqrt(m * n) / math.sqrt(m) # He normal
torch_default = (1 / math.sqrt(3 * n)) * math.sqrt(m * n) / math.sqrt(m)
assert abs(he - math.sqrt(2)) < 1e-12
assert abs(torch_default - 1 / math.sqrt(3)) < 1e-12
def test_unit_gain_sign_family_is_inverse_sqrt_fan_in():
"""And it must be independent of fan-out, which is what the algebra says."""
from common.lr_scaling import FAMILY_SIGN, layer_multiplier, resolve_rule
rule = resolve_rule("unit-gain")
for shape in [(64, 3, 3, 3), (512, 512, 3, 3), (128, 64, 1, 1)]:
n = math.prod(shape[1:])
assert abs(layer_multiplier(rule, FAMILY_SIGN, shape) - 1 / math.sqrt(n)) < 1e-12
# same fan-in, different fan-out => same multiplier
a = layer_multiplier(rule, FAMILY_SIGN, (64, 64, 3, 3))
b = layer_multiplier(rule, FAMILY_SIGN, (512, 64, 3, 3))
assert abs(a - b) < 1e-12
def test_power_presets_match_named_rules():
from common.lr_scaling import FAMILY_SIGN, layer_multiplier, resolve_rule
shape = (256, 128, 3, 3)
for spec, named in [("power:0.5", "unit-gain"), ("power:1", "mup"),
("power:0.5,0.5", "mishra-analysis"), ("power:0", "none")]:
a = layer_multiplier(resolve_rule(spec), FAMILY_SIGN, shape)
b = layer_multiplier(resolve_rule(named), FAMILY_SIGN, shape)
assert abs(a - b) < 1e-12, (spec, named, a, b)
def test_rms_gain_identity():
"""``gamma(A) = ||A||_F / sqrt(m)`` really is the RMS gain, and the two families
have the exact Frobenius norms the derivation assumes."""
torch.manual_seed(7)
m, n = 40, 90
M = torch.randn(m, n)
P = exact_polar(M).float()
assert abs(float(P.norm()) - math.sqrt(min(m, n))) < 1e-4 # ||UV^T||_F = sqrt(r)
S = torch.sign(P)
assert abs(float(S.norm()) - math.sqrt(m * n)) < 1e-4 # +-1 entries
# rms(A u) / rms(u) -> ||A||_F / sqrt(m) in expectation
u = torch.randn(n, 2048)
for A in (P, S):
emp = float((A @ u).norm() / math.sqrt(m)) / float(u.norm() / math.sqrt(n))
pred = float(A.norm()) / math.sqrt(m)
assert abs(emp - pred) / pred < 0.05, (emp, pred)
def test_lambda_mult_equals_folding_the_factor_into_the_lmo():
"""Putting the aspect factor in ``lambda_mult`` (with ``scale_aspect=False``)
is exactly equivalent to leaving it inside the LMO -- so the refactor that
moved it out cannot have changed any Muon-family result."""
torch.manual_seed(11)
# Both regimes: fan_out > fan_in (factor sqrt(2) != 1, so the test bites) and
# fan_out < fan_in (factor clipped to 1).
for shape in [(16, 2, 2, 2), (20, 5), (64, 64, 3, 3)]:
n = math.prod(shape[1:])
lam = math.sqrt(max(1.0, shape[0] / n))
grads = [torch.randn(*shape) for _ in range(4)]
p_in = nn.Parameter(torch.zeros(*shape))
opt_in = Muon([p_in], lr=0.1, momentum=0.5, lmo_dtype=torch.float32,
scale_aspect=True)
p_out = nn.Parameter(torch.zeros(*shape))
opt_out = Muon([{"params": [p_out], "lambda_mult": lam}], lr=0.1, momentum=0.5,
lmo_dtype=torch.float32, scale_aspect=False)
for g in grads:
p_in.grad, p_out.grad = g.clone(), g.clone()
opt_in.step()
opt_out.step()
assert torch.allclose(p_in, p_out, atol=1e-6), \
f"{shape} (lambda={lam:.4f}): {(p_in - p_out).abs().max()}"
assert p_in.abs().sum() > 0, "the test must actually take steps"
def test_optimizer_families_are_tagged():
from common.lr_scaling import FAMILY_LMO, FAMILY_SIGN
expected = {
SignMuon: FAMILY_SIGN, MuonSign: FAMILY_SIGN, SignSGD: FAMILY_SIGN,
Muon: FAMILY_LMO, MuonUSign: FAMILY_LMO, EF21SignMuon: FAMILY_LMO,
EF21MuonUSign: FAMILY_LMO, EF21MuonSign: FAMILY_LMO,
}
for cls, fam in expected.items():
assert cls.family == fam, (cls.__name__, cls.family, fam)
def test_build_optimizers_assigns_one_group_per_matrix_param():
import argparse
from centralized.train import build_optimizers
args = argparse.Namespace(
optimizer="signmuon", lr=0.1, lr_aux=0.01, momentum=0.9, nesterov=False,
weight_decay=0.0, ns_steps=5, lmo_dtype="float32", lr_scaling="unit-gain",
head_adamw="always", n_head_tensors=2)
model = TinyNet()
opt_main, opt_aux, info = build_optimizers(model, args)
assert len(opt_main.param_groups) == 1 # TinyNet has one matrix
group = opt_main.param_groups[0]
assert group["name"] == "fc1.weight"
assert abs(group["lambda_mult"] - 1 / math.sqrt(6)) < 1e-12 # fan_in = 6
assert group["scale_aspect"] is False, "the factor must not be applied twice"
assert opt_aux is not None and len(opt_aux.param_groups[0]["params"]) == 3
assert info["rule"] == "unit-gain" and info["family"] == "sign"
# --------------------------------------------------------------------------
# Protocol: validation split and metric helpers
# --------------------------------------------------------------------------
def test_val_split_is_disjoint_and_seed_stable():
from centralized.data import _split_indices
tr1, va1 = _split_indices(50_000, 5_000, val_seed=12345)
tr2, va2 = _split_indices(50_000, 5_000, val_seed=12345)
assert (tr1 == tr2).all() and (va1 == va2).all(), "split must be deterministic"
assert len(va1) == 5_000 and len(tr1) == 45_000
assert not (set(tr1.tolist()) & set(va1.tolist())), "train and val must be disjoint"
_, va3 = _split_indices(50_000, 5_000, val_seed=999)
assert set(va1.tolist()) != set(va3.tolist()), "a different val_seed must differ"
def test_history_selection_helpers():
h = History()
for step, (va, te) in enumerate([(80.0, 79.0), (85.0, 84.0), (83.0, 90.0),
(84.0, 86.0), (84.5, 87.0)]):
h.record(step, val_acc=va, test_acc=te)
assert h.argbest("val_acc", "max") == 1 # best val, not best test
assert h.at("test_acc", 1) == 84.0 # the number you'd report
assert abs(h.last_k_mean("test_acc", 3) - (90.0 + 86.0 + 87.0) / 3) < 1e-12
assert h.steps_to_target("test_acc", 86.0) == 2
assert h.steps_to_target("test_acc", 99.0) is None
assert h.values("val_acc") == [80.0, 85.0, 83.0, 84.0, 84.5]
def test_split_param_names():
model = TinyNet()
matrix, aux = split_param_names(model, n_head_tensors=2)
assert matrix == ["fc1.weight"]
assert aux == ["fc1.bias", "fc2.weight", "fc2.bias"]
assert set(matrix) | set(aux) == {n for n, _ in model.named_parameters()}
def test_history_pads_missing_series():
h = History()
h.record(0, test_acc=1.0)
h.record(5, test_acc=2.0, test_loss=0.5)
assert h.to_dict() == {"steps": [0, 5], "test_acc": [1.0, 2.0],
"test_loss": [None, 0.5]}
assert h.last("test_acc") == 2.0
def test_aggregate_groups_by_seed(tmp_path=None):
import json
import tempfile
from pathlib import Path
import aggregate
root = Path(tmp_path) if tmp_path else Path(tempfile.mkdtemp())
for seed, acc in ((0, 80.0), (1, 82.0), (2, 84.0)):
d = root / "run" / f"seed{seed}"
d.mkdir(parents=True)
with open(d / "metrics.json", "w", encoding="utf-8") as f:
json.dump({"config": {"algorithm": "signmuon", "lr": 0.01, "seed": seed,
"device": f"cuda:{seed}"},
"history": {"steps": [0, 1], "test_acc": [10.0, acc]}}, f)
runs = aggregate.load_runs([root])
assert len(runs) == 3
keys = {aggregate.group_key(r["config"]) for r in runs}
assert len(keys) == 1, "seed and device must not split the group"
agg = aggregate.aggregate_group(runs, "test_acc")
assert agg["steps"] == [0, 1] and agg["n_runs"] == 3
assert abs(agg["mean"][-1] - 82.0) < 1e-12
assert abs(agg["std"][-1] - 2.0) < 1e-12 # sample std of 80, 82, 84
def test_seeds_from_two_machines_stay_one_group():
"""``hardware`` must not split a seed set.
``save_run`` stamps the GPU, driver and commit into every config, so before
``hardware`` was ignored a five-seed sweep spread over two boxes grouped as two
groups of one -- and every tool downstream then reported "1 seed" and printed a
blank std, which reads as agreement rather than as a missing measurement.
"""
import aggregate
base = {"algorithm": "signmuon", "lr": 0.05, "rounds": 2000}
a = dict(base, seed=0, hardware={"gpu_name": "A100", "torch_version": "2.8.0"})
b = dict(base, seed=1, hardware={"gpu_name": "RTX A4000", "torch_version": "2.9.0"})
assert aggregate.group_key(a) == aggregate.group_key(b)
# ...but a real difference still splits, or the grouping would be useless.
c = dict(base, seed=2, lr=0.02, hardware=a["hardware"])
assert aggregate.group_key(a) != aggregate.group_key(c)
def test_budget_rebalancing_keeps_every_requested_phase_or_says_it_did_not():
"""A phase the user asked for is either run or reported as dropped.
`autobalance` re-sorts `args.phases` into a canonical order at the end, and that
list was spelled out by hand. When `rules` was added it went into the argparse
choices, the schedule and the phase sequence but not into that one list, so
**any** --budget-hours deleted it -- at 24 h against a 14 h plan, with ten hours
of slack and no note. Silent is the part that matters: a dropped phase the