@@ -310,3 +310,81 @@ def test_multimodel_dead_model_keeps_prior_c():
310310 m .c = mx .array (np .stack ([np .zeros (NW , np .float32 ), marker ], axis = 1 ))
311311 m ._update_parameters (acc , xt .shape [1 ])
312312 assert np .allclose (np .array (m .c )[:, 1 ], marker ) # dead model kept its prior c
313+
314+
315+ def test_rholrate_ratchets_at_maxdecs_not_per_decrease ():
316+ """Issue #195 (mirrors #193/#194 for torch/numpy): the MLX rho learning rate is
317+ a maxdecs-ratcheted CEILING, not a per-LL-decrease monotone decay.
318+
319+ Fortran resets ``rholrate = rholrate0`` each iteration before the rho update
320+ (amica15.f90:1788) and only tightens the ceiling at ``maxdecs``
321+ (amica15.f90:1050, gated on ``iter > newt_start``). MLX previously decayed
322+ ``rholrate`` on EVERY LL decrease with no reset, collapsing it toward ~1e-5
323+ within a few hundred iterations and freezing rho at a stale shape.
324+
325+ An aggressive-lrate natural-gradient run on the real sample overshoots and
326+ triggers several LL decreases; with ``newt_start=0`` (gate always open) the
327+ ceiling ratchets once per ``maxdecs`` decreases, NOT once per decrease.
328+ """
329+ from pamica .mlx_impl import AMICAMLXNG
330+
331+ data = _load_real_data ()
332+ m = AMICAMLXNG (
333+ n_channels = NW , n_mix = NMIX , seed = SEED , block_size = 256 ,
334+ lrate = 0.5 , lratefact = 0.5 , rholrate = 0.05 , rholratefact = 0.5 ,
335+ maxdecs = 3 , newt_start = 0 ,
336+ ) # fmt: skip
337+ m .fit (data , max_iter = 400 , verbose = False )
338+
339+ ll = m .ll_history
340+ n_dec = sum (1 for i in range (1 , len (ll )) if ll [i ] < ll [i - 1 ])
341+ assert m .lrate > m .minlrate , "run hit the lrate floor; use a gentler config"
342+ assert n_dec >= m .maxdecs , "too few LL decreases to exercise the ratchet"
343+
344+ # The ceiling ratcheted a whole number of times at the maxdecs cadence
345+ # (rholrate0 * rholratefact**k), far fewer steps than one-per-decrease.
346+ assert m .rholrate < m .rholrate0
347+ k = round (np .log (m .rholrate / m .rholrate0 ) / np .log (m .rholratefact ))
348+ assert m .rholrate == pytest .approx (m .rholrate0 * m .rholratefact ** k )
349+ assert k <= n_dec // m .maxdecs + 1
350+ # Guard against a regression to the old per-decrease decay (orders below).
351+ buggy = m .rholrate0 * (m .rholratefact ** n_dec )
352+ assert m .rholrate > buggy * 10
353+
354+
355+ def test_rholrate_ceiling_ratchet_gated_on_newt_start ():
356+ """The rholrate ceiling ratchet is gated on ``iter > newt_start`` (Fortran
357+ amica15.f90:1049, independent of do_newton). With ``newt_start`` past the whole
358+ budget, LL decreases must NOT tighten the rho ceiling -- it stays at
359+ ``rholrate0`` exactly (only ``lrate_cap`` ratchets, which is not gated)."""
360+ from pamica .mlx_impl import AMICAMLXNG
361+
362+ data = _load_real_data ()
363+ m = AMICAMLXNG (
364+ n_channels = NW , n_mix = NMIX , seed = SEED , block_size = 256 ,
365+ lrate = 0.5 , lratefact = 0.5 , rholrate = 0.05 , rholratefact = 0.5 ,
366+ maxdecs = 3 , newt_start = 100_000 ,
367+ ) # fmt: skip
368+ m .fit (data , max_iter = 400 , verbose = False )
369+
370+ ll = m .ll_history
371+ n_dec = sum (1 for i in range (1 , len (ll )) if ll [i ] < ll [i - 1 ])
372+ assert n_dec >= m .maxdecs , "config did not exercise the decrease path"
373+ # Gated off for the whole run: the rho ceiling never moved.
374+ assert m .rholrate == m .rholrate0
375+
376+
377+ def test_rholrate_ceiling_resets_at_fit_start ():
378+ """Issue #195: the rho-rate ceiling is reset to rholrate0 at fit start
379+ (``_initialize_parameters``, the same call ``fit`` makes), so a previously
380+ ratcheted ``rholrate`` does not carry across a re-fit/restart -- parity with
381+ the numpy backend's ``test_reinitialize_for_restart_resets_rho_ceiling``."""
382+ from pamica .mlx_impl import AMICAMLXNG
383+
384+ m = AMICAMLXNG (n_channels = NW , n_mix = NMIX , seed = SEED )
385+ # Simulate a prior fit that ratcheted both ceilings down at maxdecs.
386+ m .rholrate = m .rholrate0 * 0.25
387+ m .lrate_cap = m .lrate0 * 0.25
388+ m ._initialize_parameters () # fit-start reset
389+ assert m .rholrate == m .rholrate0
390+ assert m .lrate_cap == m .lrate0
0 commit comments