freealg 0.7.12__py3-none-any.whl → 0.7.14__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.
@@ -0,0 +1,738 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026, Siavash Ameli
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ # SPDX-FileType: SOURCE
4
+ #
5
+ # Free Decompression (FD) solver for algebraic Stieltjes transforms.
6
+ #
7
+ # Public API (used by AlgebraicForm.decompress):
8
+ # build_time_grid(size, n0, min_n_times=0) -> (t_all, idx_req)
9
+ # decompress_newton(z_list, t_grid, a_coeffs, w0_list=None, **opts) -> (W, ok)
10
+ #
11
+ # Core equation (FD):
12
+ # tau = exp(t) - 1
13
+ # zeta = z - tau*w
14
+ # Solve: P(zeta, w) = 0 where P(z,w)=sum_{i,j} a[i,j] z^i w^j
15
+ # i.e. F(w) := P(z - tau*w, w) = 0.
16
+ #
17
+ # This rewrite focuses on *robust branch tracking* (multi-start Newton + 2-pass
18
+ # Viterbi with "active-region" tiny-im penalty), and optional per-time density
19
+ # renormalization for mass preservation when the polynomial fit is imperfect.
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import numpy as np
25
+
26
+ __all__ = ["build_time_grid", "decompress_newton"]
27
+
28
+
29
+ # =================
30
+ # Time grid helper
31
+ # =================
32
+
33
+ def build_time_grid(size, n0, min_n_times=0):
34
+ """
35
+ Build a monotone time grid for FD.
36
+
37
+ Parameters
38
+ ----------
39
+ size : array_like
40
+ Requested size ratios, i.e., n(t)/n0 = size. Can include 1.
41
+ n0 : int
42
+ Initial matrix size.
43
+ min_n_times : int, default=0
44
+ Ensures at least this many intermediate time points between
45
+ successive requested times, based on implied integer sizes.
46
+
47
+ Returns
48
+ -------
49
+ t_all : numpy.ndarray
50
+ Full time grid (including intermediates), sorted.
51
+ idx_req : numpy.ndarray
52
+ Indices into t_all corresponding to the originally requested times.
53
+ """
54
+ size = np.asarray(size, dtype=float).ravel()
55
+ if size.size == 0:
56
+ raise ValueError("size must be non-empty")
57
+ if np.any(size <= 0.0):
58
+ raise ValueError("size must be > 0")
59
+
60
+ t_req = np.log(size)
61
+ order = np.argsort(t_req)
62
+ t_req_sorted = t_req[order]
63
+
64
+ n0 = int(n0)
65
+ if n0 <= 0:
66
+ raise ValueError("n0 must be a positive integer")
67
+
68
+ t_all = [float(t_req_sorted[0])]
69
+ for k in range(1, t_req_sorted.size):
70
+ t0 = float(t_req_sorted[k - 1])
71
+ t1 = float(t_req_sorted[k])
72
+ if t1 <= t0:
73
+ continue
74
+
75
+ if int(min_n_times) <= 0:
76
+ t_all.append(t1)
77
+ continue
78
+
79
+ nA = max(1, int(round(n0 * np.exp(t0))))
80
+ nB = max(1, int(round(n0 * np.exp(t1))))
81
+ dn = max(1, nB - nA)
82
+ step_n = max(1, int(np.ceil(dn / float(min_n_times))))
83
+ n_grid = list(range(nA, nB, step_n))
84
+ if n_grid[-1] != nB:
85
+ n_grid.append(nB)
86
+ for nn in n_grid[1:]:
87
+ t_all.append(float(np.log(nn / float(n0))))
88
+
89
+ t_all = np.asarray(t_all, dtype=float)
90
+
91
+ idx_req_sorted = np.empty(t_req_sorted.size, dtype=int)
92
+ for i, t in enumerate(t_req_sorted):
93
+ idx_req_sorted[i] = int(np.argmin(np.abs(t_all - float(t))))
94
+
95
+ inv = np.empty_like(order)
96
+ inv[order] = np.arange(order.size)
97
+ idx_req = idx_req_sorted[inv]
98
+ return t_all, idx_req
99
+
100
+
101
+ # ===================
102
+ # Polynomial utilities
103
+ # ===================
104
+
105
+ def _poly_coef_in_w(z, a_coeffs):
106
+ """
107
+ For fixed z, return coefficients c[j] so that P(z,w)=sum_j c[j] w^j.
108
+ a_coeffs[i,j] corresponds to z^i w^j.
109
+ """
110
+ z = complex(z)
111
+ a = np.asarray(a_coeffs, dtype=np.complex128)
112
+ deg_z = int(a.shape[0] - 1)
113
+ # Horner in z for each j
114
+ zp = 1.0 + 0.0j
115
+ c = np.array(a[0, :], dtype=np.complex128)
116
+ for i in range(1, deg_z + 1):
117
+ zp *= z
118
+ c = c + a[i, :] * zp
119
+ return c # shape (s+1,)
120
+
121
+
122
+ def _eval_P(z, w, a_coeffs):
123
+ c = _poly_coef_in_w(z, a_coeffs)
124
+ # Horner in w
125
+ ww = complex(w)
126
+ out = 0.0 + 0.0j
127
+ for cj in c[::-1]:
128
+ out = out * ww + cj
129
+ return out
130
+
131
+
132
+ def _eval_dP_dw(z, w, a_coeffs):
133
+ """
134
+ d/dw P(z,w)
135
+ """
136
+ c = _poly_coef_in_w(z, a_coeffs) # c[j] w^j
137
+ ww = complex(w)
138
+ # derivative coefficients: j*c[j]
139
+ out = 0.0 + 0.0j
140
+ for j in range(c.size - 1, 0, -1):
141
+ out = out * ww + (j * c[j])
142
+ return out
143
+
144
+
145
+ def _eval_dP_dz(z, w, a_coeffs):
146
+ """
147
+ d/dz P(z,w)
148
+ """
149
+ z = complex(z)
150
+ w = complex(w)
151
+ a = np.asarray(a_coeffs, dtype=np.complex128)
152
+ deg_z = int(a.shape[0] - 1)
153
+ # compute b[j] = sum_{i>=1} i*a[i,j]*z^{i-1}
154
+ if deg_z <= 0:
155
+ return 0.0 + 0.0j
156
+ b = np.zeros((a.shape[1],), dtype=np.complex128)
157
+ zp = 1.0 + 0.0j
158
+ for i in range(1, deg_z + 1):
159
+ b = b + (i * a[i, :]) * zp
160
+ zp *= z
161
+ # evaluate in w: sum_j b[j] w^j
162
+ out = 0.0 + 0.0j
163
+ for bj in b[::-1]:
164
+ out = out * w + bj
165
+ return out
166
+
167
+
168
+ def _fd_F_and_dF(w, z, tau, a_coeffs):
169
+ """
170
+ F(w) = P(z - tau*w, w).
171
+ dF/dw = dP/dz * (-tau) + dP/dw evaluated at (zeta, w).
172
+ """
173
+ zeta = z - tau * w
174
+ F = _eval_P(zeta, w, a_coeffs)
175
+ dPdw = _eval_dP_dw(zeta, w, a_coeffs)
176
+ dPdz = _eval_dP_dz(zeta, w, a_coeffs)
177
+ dF = dPdw - tau * dPdz
178
+ return F, dF
179
+
180
+
181
+ # =================
182
+ # Newton (scalar)
183
+ # =================
184
+
185
+ def _newton_fd_scalar(
186
+ z,
187
+ t,
188
+ a_coeffs,
189
+ w_init,
190
+ *,
191
+ max_iter=60,
192
+ tol=1e-12,
193
+ armijo=True,
194
+ min_lam=1e-6,
195
+ w_min=0.0,
196
+ ):
197
+ """
198
+ Newton solve for one z at one t.
199
+ Returns (w, ok, n_iter, final_res).
200
+ """
201
+ z = complex(z)
202
+ t = float(t)
203
+ tau = float(np.expm1(t)) # exp(t)-1, stable for small t
204
+ w = complex(w_init)
205
+
206
+ # guard against nan seeds
207
+ if not (np.isfinite(w.real) and np.isfinite(w.imag)):
208
+ w = -1.0 / z
209
+
210
+ # optional floor on imaginary (avoid falling to lower half due to roundoff)
211
+ if w_min > 0.0 and w.imag < w_min:
212
+ w = complex(w.real, w_min)
213
+
214
+ # initial
215
+ F, dF = _fd_F_and_dF(w, z, tau, a_coeffs)
216
+ res0 = abs(F)
217
+ if not np.isfinite(res0):
218
+ return complex(np.nan, np.nan), False, 0, np.inf
219
+
220
+ for it in range(max_iter):
221
+ if abs(F) <= tol * (1.0 + res0):
222
+ return w, True, it + 1, abs(F)
223
+
224
+ # if derivative is degenerate, bail
225
+ if not np.isfinite(dF.real) or not np.isfinite(dF.imag) or abs(dF) == 0.0:
226
+ break
227
+
228
+ step = -F / dF
229
+ lam = 1.0
230
+
231
+ if armijo:
232
+ # Armijo on |F| (cheap, robust)
233
+ f0 = abs(F)
234
+ # Try to avoid huge steps
235
+ if abs(step) > 10.0 * (1.0 + abs(w)):
236
+ step = step * (10.0 * (1.0 + abs(w)) / abs(step))
237
+
238
+ while lam >= min_lam:
239
+ w_new = w + lam * step
240
+ if w_min > 0.0 and w_new.imag < w_min:
241
+ w_new = complex(w_new.real, w_min)
242
+ F_new, dF_new = _fd_F_and_dF(w_new, z, tau, a_coeffs)
243
+ f1 = abs(F_new)
244
+ if np.isfinite(f1) and (f1 <= (1.0 - 1e-4 * lam) * f0):
245
+ w, F, dF = w_new, F_new, dF_new
246
+ break
247
+ lam *= 0.5
248
+ else:
249
+ # failed to find descent
250
+ break
251
+ else:
252
+ w = w + step
253
+ if w_min > 0.0 and w.imag < w_min:
254
+ w = complex(w.real, w_min)
255
+ F, dF = _fd_F_and_dF(w, z, tau, a_coeffs)
256
+
257
+ # final
258
+ F, _ = _fd_F_and_dF(w, z, tau, a_coeffs)
259
+ ok = np.isfinite(F.real) and np.isfinite(F.imag) and (abs(F) <= 1e3 * tol * (1.0 + res0))
260
+ return w, bool(ok), max_iter, abs(F)
261
+
262
+
263
+ # ==========================
264
+ # Candidate generation (per z)
265
+ # ==========================
266
+
267
+ def _make_default_seeds(z, w_prev, w_left, w_right):
268
+ seeds = []
269
+ if w_prev is not None and np.isfinite(w_prev.real) and np.isfinite(w_prev.imag):
270
+ seeds.append(complex(w_prev))
271
+ if w_left is not None and np.isfinite(w_left.real) and np.isfinite(w_left.imag):
272
+ seeds.append(complex(w_left))
273
+ if w_right is not None and np.isfinite(w_right.real) and np.isfinite(w_right.imag):
274
+ seeds.append(complex(w_right))
275
+ seeds.append(complex(-1.0 / z))
276
+ return seeds
277
+
278
+
279
+ def _dedup_cands(cands, tol=1e-10):
280
+ if len(cands) == 0:
281
+ return np.empty((0,), dtype=np.complex128)
282
+ out = []
283
+ for w in cands:
284
+ keep = True
285
+ for u in out:
286
+ if abs(w - u) <= tol * (1.0 + abs(u)):
287
+ keep = False
288
+ break
289
+ if keep:
290
+ out.append(w)
291
+ return np.asarray(out, dtype=np.complex128)
292
+
293
+
294
+ def _fd_candidates(
295
+ z,
296
+ t,
297
+ a_coeffs,
298
+ seeds,
299
+ *,
300
+ max_iter=60,
301
+ tol=1e-12,
302
+ armijo=True,
303
+ min_lam=1e-6,
304
+ w_min=0.0,
305
+ keep_best=8,
306
+ ):
307
+ """
308
+ Multi-start Newton candidates.
309
+ Returns (cands, ok_flags, resids).
310
+ """
311
+ cands = []
312
+ oks = []
313
+ ress = []
314
+ for s in seeds:
315
+ w, ok, _, res = _newton_fd_scalar(
316
+ z, t, a_coeffs, s,
317
+ max_iter=max_iter, tol=tol,
318
+ armijo=armijo, min_lam=min_lam, w_min=w_min
319
+ )
320
+ if np.isfinite(w.real) and np.isfinite(w.imag):
321
+ cands.append(w)
322
+ oks.append(ok)
323
+ ress.append(res)
324
+
325
+ if len(cands) == 0:
326
+ return np.empty((0,), np.complex128), np.empty((0,), bool), np.empty((0,), float)
327
+
328
+ cands = np.asarray(cands, dtype=np.complex128)
329
+ oks = np.asarray(oks, dtype=bool)
330
+ ress = np.asarray(ress, dtype=float)
331
+
332
+ # sort by residual
333
+ idx = np.argsort(ress)
334
+ cands = cands[idx]
335
+ oks = oks[idx]
336
+ ress = ress[idx]
337
+
338
+ # keep unique / best
339
+ keep = []
340
+ for i in range(cands.size):
341
+ w = cands[i]
342
+ if len(keep) >= int(keep_best):
343
+ break
344
+ dup = False
345
+ for j in keep:
346
+ if abs(w - cands[j]) <= 1e-10 * (1.0 + abs(cands[j])):
347
+ dup = True
348
+ break
349
+ if not dup:
350
+ keep.append(i)
351
+
352
+ cands = cands[keep]
353
+ oks = oks[keep]
354
+ ress = ress[keep]
355
+ return cands, oks, ress
356
+
357
+
358
+ # =====================
359
+ # Viterbi (1D tracking)
360
+ # =====================
361
+
362
+ def _viterbi_track(
363
+ z_list,
364
+ cand_list,
365
+ w_prev=None,
366
+ *,
367
+ lam_space=1.0,
368
+ lam_time=0.25,
369
+ lam_asym=0.5,
370
+ lam_tiny_im=0.0,
371
+ tiny_im=1e-7,
372
+ lam_res=0.5,
373
+ edge_k=8,
374
+ ):
375
+ """
376
+ Track one candidate per z along the 1D grid using DP.
377
+
378
+ cand_list: list of arrays of candidates for each iz (variable length)
379
+ Returns: w_path (nz,), ok (nz,)
380
+ """
381
+ nz = z_list.size
382
+ K = max((c.size for c in cand_list), default=0)
383
+ if K == 0:
384
+ return np.full((nz,), np.nan + 1j*np.nan, np.complex128), np.zeros((nz,), bool)
385
+
386
+ # pad to rectangular with NaNs
387
+ R = np.full((nz, K), np.nan + 1j*np.nan, dtype=np.complex128)
388
+ for i in range(nz):
389
+ c = cand_list[i]
390
+ if c.size:
391
+ R[i, :c.size] = c
392
+
393
+ # unary costs
394
+ unary = np.full((nz, K), np.inf, dtype=np.float64)
395
+
396
+ # asymptotic anchors (ends)
397
+ targetL = -1.0 / z_list[0]
398
+ targetR = -1.0 / z_list[-1]
399
+
400
+ for i in range(nz):
401
+ zi = z_list[i]
402
+ for k in range(K):
403
+ w = R[i, k]
404
+ if not np.isfinite(w.real) or not np.isfinite(w.imag):
405
+ continue
406
+ c = 0.0
407
+
408
+ # residual proxy: prefer smaller |z*w + 1| far from support
409
+ c += lam_asym * float(abs(zi * w + 1.0))
410
+
411
+ # time continuity
412
+ if w_prev is not None and np.isfinite(w_prev[i].real) and np.isfinite(w_prev[i].imag):
413
+ c += lam_time * float(abs(w - w_prev[i]))
414
+
415
+ # tiny-im penalty (used in pass-2, inside active regions)
416
+ if lam_tiny_im != 0.0:
417
+ im = float(w.imag)
418
+ if im < tiny_im:
419
+ c += lam_tiny_im * float((tiny_im - im) / max(tiny_im, 1e-30))
420
+
421
+ unary[i, k] = c
422
+
423
+ # boundary anchoring (stronger at ends)
424
+ if edge_k > 0:
425
+ kk = min(int(edge_k), max(1, nz // 2))
426
+ for i in range(kk):
427
+ unary[i, :] += 10.0 * lam_res * np.abs(R[i, :] - targetL)
428
+ for i in range(nz - kk, nz):
429
+ unary[i, :] += 10.0 * lam_res * np.abs(R[i, :] - targetR)
430
+
431
+ # DP
432
+ dp = np.full((nz, K), np.inf, dtype=np.float64)
433
+ prev = np.full((nz, K), -1, dtype=np.int64)
434
+
435
+ dp[0, :] = unary[0, :]
436
+
437
+ for i in range(1, nz):
438
+ wi = R[i, :]
439
+ wj = R[i - 1, :]
440
+ for k in range(K):
441
+ if not np.isfinite(unary[i, k]) or not np.isfinite(wi[k]):
442
+ continue
443
+ best_val = np.inf
444
+ best_j = -1
445
+ for j in range(K):
446
+ if not np.isfinite(dp[i - 1, j]) or not np.isfinite(wj[j]):
447
+ continue
448
+ val = dp[i - 1, j] + lam_space * float(abs(wi[k] - wj[j]))
449
+ if val < best_val:
450
+ best_val = val
451
+ best_j = j
452
+ if best_j >= 0:
453
+ dp[i, k] = best_val + unary[i, k]
454
+ prev[i, k] = best_j
455
+
456
+ k_end = int(np.argmin(dp[-1, :]))
457
+ w_path = np.full((nz,), np.nan + 1j*np.nan, dtype=np.complex128)
458
+ if not np.isfinite(dp[-1, k_end]):
459
+ return w_path, np.zeros((nz,), bool)
460
+
461
+ k = k_end
462
+ for i in range(nz - 1, -1, -1):
463
+ w_path[i] = R[i, k]
464
+ k = prev[i, k]
465
+ if i > 0 and k < 0:
466
+ break
467
+
468
+ ok = np.isfinite(w_path.real) & np.isfinite(w_path.imag)
469
+ return w_path, ok
470
+
471
+
472
+ # ======================
473
+ # Active region detection
474
+ # ======================
475
+
476
+ def _infer_active_mask(w_path, *, imag_floor, q=0.90, pad=8):
477
+ """
478
+ Active region: where imag is meaningfully above floor.
479
+ Two-bulk friendly: uses quantile-based threshold (robust).
480
+ """
481
+ im = np.maximum(np.asarray(w_path.imag, dtype=float), 0.0)
482
+ im_finite = im[np.isfinite(im)]
483
+ if im_finite.size == 0:
484
+ return np.zeros((im.size,), dtype=bool)
485
+
486
+ thr = max(float(imag_floor), float(np.quantile(im_finite, q) * 0.10))
487
+ active = im >= thr
488
+
489
+ if pad > 0 and active.any():
490
+ idx = np.flatnonzero(active)
491
+ lo = max(0, int(idx[0]) - int(pad))
492
+ hi = min(active.size, int(idx[-1]) + int(pad) + 1)
493
+ active2 = np.zeros_like(active)
494
+ active2[lo:hi] = True
495
+
496
+ # also pad each contiguous block
497
+ # (fast enough for nz<=1e4)
498
+ active = active2
499
+ return active
500
+
501
+
502
+ # =====================
503
+ # Mass renormalization
504
+ # =====================
505
+
506
+ def _renormalize_density(z_list, w_path, target_mass=1.0):
507
+ """
508
+ Scale imag(w) to match target_mass using trapezoidal rule on x = Re(z).
509
+ """
510
+ x = np.asarray(z_list.real, dtype=float)
511
+ rho = np.maximum(w_path.imag / np.pi, 0.0)
512
+ m = float(np.trapezoid(rho, x))
513
+ if not np.isfinite(m) or m <= 0.0:
514
+ return w_path, m, False
515
+ s = float(target_mass / m)
516
+ # scale only imaginary part (keep Hilbert approx)
517
+ w_new = w_path.real + 1j * (w_path.imag * s)
518
+ return w_new.astype(np.complex128), m, True
519
+
520
+
521
+ # ==========================
522
+ # Main decompression API
523
+ # ==========================
524
+
525
+ def decompress_newton(
526
+ z_list: np.ndarray,
527
+ t_grid: np.ndarray,
528
+ a_coeffs: np.ndarray,
529
+ w0_list: np.ndarray | None = None,
530
+ *,
531
+ dt_max: float = 0.05,
532
+ max_iter: int = 80,
533
+ tol: float = 1e-12,
534
+ armijo: bool = True,
535
+ min_lam: float = 1e-6,
536
+ w_min: float = 0.0,
537
+ keep_best: int = 8,
538
+ # branch tracking
539
+ lam_space: float = 1.0,
540
+ lam_time: float = 0.25,
541
+ lam_asym: float = 0.5,
542
+ # pass-2 active region penalty
543
+ active_imag_eps: float = 1e-8,
544
+ lam_tiny_im: float = 5.0,
545
+ active_q: float = 0.90,
546
+ sweep_pad: int = 10,
547
+ # mass
548
+ renorm_mass: bool = True,
549
+ target_mass: float = 1.0,
550
+ # debug
551
+ viterbi_opt: dict | None = None,
552
+ sweep: bool = False,
553
+ time_rel_tol: float = 1e-3,
554
+ ):
555
+ """
556
+ Solve FD for a set of complex query points z_list over t_grid.
557
+
558
+ Parameters (key ones)
559
+ ---------------------
560
+ z_list : (nz,) complex
561
+ Query points (typically x + 1j*delta), in the desired x order.
562
+ t_grid : (nt,) float
563
+ Times (typically log(size ratios)), increasing.
564
+ w0_list : (nz,) complex
565
+ Initial w at t=t_grid[0] (typically physical m(z)).
566
+ renorm_mass : bool
567
+ If True, scales Im(w) at each time to match target_mass.
568
+
569
+ Returns
570
+ -------
571
+ W : (nt, nz) complex
572
+ ok : (nt, nz) bool
573
+ """
574
+ z_list = np.asarray(z_list, dtype=np.complex128).ravel()
575
+ t_grid = np.asarray(t_grid, dtype=np.float64).ravel()
576
+ nt = t_grid.size
577
+ nz = z_list.size
578
+ if nz == 0 or nt == 0:
579
+ raise ValueError("z_list and t_grid must be non-empty")
580
+
581
+ if w0_list is None:
582
+ w0_list = -1.0 / z_list
583
+ w0_list = np.asarray(w0_list, dtype=np.complex128).ravel()
584
+ if w0_list.size != nz:
585
+ raise ValueError("w0_list must have same length as z_list.")
586
+
587
+ # debug / overrides
588
+ vopt = {} if viterbi_opt is None else dict(viterbi_opt)
589
+ lam_space = float(vopt.get("lam_space", lam_space))
590
+ lam_time = float(vopt.get("lam_time", lam_time))
591
+ lam_asym = float(vopt.get("lam_asym", lam_asym))
592
+ lam_tiny_im = float(vopt.get("lam_tiny_im", lam_tiny_im))
593
+ active_q = float(vopt.get("active_q", active_q))
594
+ debug_path = vopt.get("debug_path", None)
595
+ debug_every = int(vopt.get("debug_every", max(1, nt // 10)))
596
+ debug_iz = vopt.get("debug_iz", None)
597
+
598
+ W = np.empty((nt, nz), dtype=np.complex128)
599
+ ok = np.ones((nt, nz), dtype=bool)
600
+
601
+ W[0, :] = w0_list
602
+
603
+ debug_pack = []
604
+ if debug_iz is None:
605
+ debug_iz = []
606
+
607
+ for it in range(1, nt):
608
+ t_target = float(t_grid[it])
609
+ t_prev = float(t_grid[it - 1])
610
+ dt = t_target - t_prev
611
+ n_sub = max(1, int(np.ceil(abs(dt) / max(float(dt_max), 1e-12))))
612
+ sub_ts = np.linspace(t_prev, t_target, n_sub + 1)[1:]
613
+
614
+ w_prev = W[it - 1].copy()
615
+
616
+ # optional "sweep": warm-start by single Newton sweep at same t_target
617
+ # (kept for API compatibility; not required)
618
+ if sweep:
619
+ pass
620
+
621
+ for t_sub in sub_ts:
622
+ cand_list = []
623
+ # build candidates for each z
624
+ for iz in range(nz):
625
+ wL = w_prev[iz - 1] if iz > 0 else None
626
+ wR = w_prev[iz + 1] if iz + 1 < nz else None
627
+ seeds = _make_default_seeds(z_list[iz], w_prev[iz], wL, wR)
628
+
629
+ # also: push-forward seed from characteristic (often helps)
630
+ tau = float(np.expm1(float(t_sub)))
631
+ # w solves at same z, so zeta ~ z - tau*w_prev
632
+ seeds.append(complex(w_prev[iz])) # already
633
+ seeds.append(complex(-1.0 / (z_list[iz] - tau * w_prev[iz] + 1e-30)))
634
+
635
+ cands, oks, ress = _fd_candidates(
636
+ z_list[iz], float(t_sub), a_coeffs, seeds,
637
+ max_iter=max_iter, tol=tol,
638
+ armijo=armijo, min_lam=min_lam, w_min=w_min,
639
+ keep_best=keep_best,
640
+ )
641
+ # keep only finite
642
+ cand_list.append(cands)
643
+
644
+ # pass-1 viterbi (smooth/continuous)
645
+ w1, ok1 = _viterbi_track(
646
+ z_list, cand_list, w_prev,
647
+ lam_space=lam_space,
648
+ lam_time=lam_time,
649
+ lam_asym=lam_asym,
650
+ lam_tiny_im=0.0,
651
+ tiny_im=active_imag_eps,
652
+ edge_k=int(vopt.get("edge_k", 8)),
653
+ )
654
+
655
+ # infer active regions (for 2-bulk cases)
656
+ active = _infer_active_mask(
657
+ w1,
658
+ imag_floor=max(active_imag_eps, 1e-12),
659
+ q=active_q,
660
+ pad=int(vopt.get("sweep_pad", sweep_pad)),
661
+ )
662
+
663
+ # pass-2 viterbi: penalize tiny imag inside active region only
664
+ if lam_tiny_im != 0.0 and np.any(active):
665
+ cand_list2 = cand_list # same candidates
666
+ # create per-node tiny-im penalty by splitting into blocks:
667
+ # we'll do it by running viterbi twice: first on full, then
668
+ # override tiny-im penalty by masking.
669
+ # implement by modifying candidates by adding imaginary floor costs
670
+ # in unary: easiest is to run a custom viterbi with per-index lam.
671
+ # We'll approximate by selecting w2 where active via high penalty,
672
+ # else use pass-1 result.
673
+ # To keep it simple/robust: run global viterbi with penalty, but
674
+ # only at active indices.
675
+ # We'll do it by temporarily replacing non-active candidates with
676
+ # the best-by-residual (pass-1) to avoid over-penalizing gap.
677
+ cand_mod = []
678
+ for i in range(nz):
679
+ if active[i]:
680
+ cand_mod.append(cand_list2[i])
681
+ else:
682
+ # keep only the best candidate close to pass-1
683
+ c = cand_list2[i]
684
+ if c.size == 0:
685
+ cand_mod.append(c)
686
+ else:
687
+ j = int(np.argmin(np.abs(c - w1[i])))
688
+ cand_mod.append(c[j:j+1])
689
+
690
+ w2, ok2 = _viterbi_track(
691
+ z_list, cand_mod, w_prev,
692
+ lam_space=lam_space,
693
+ lam_time=lam_time,
694
+ lam_asym=lam_asym,
695
+ lam_tiny_im=lam_tiny_im,
696
+ tiny_im=max(active_imag_eps, 1e-12),
697
+ edge_k=int(vopt.get("edge_k", 8)),
698
+ )
699
+ else:
700
+ w2, ok2 = w1, ok1
701
+
702
+ # finalize this substep
703
+ w_prev = w2
704
+ ok_sub = ok2
705
+
706
+ # mass renorm (optional)
707
+ mass0 = np.nan
708
+ if renorm_mass:
709
+ w_prev, mass0, _ = _renormalize_density(z_list, w_prev, target_mass=target_mass)
710
+
711
+ # debug snapshot
712
+ if debug_path is not None and ((it % debug_every) == 0):
713
+ pack = {
714
+ "it": int(it),
715
+ "t": float(t_sub),
716
+ "z_real": z_list.real.copy(),
717
+ "w": w_prev.copy(),
718
+ "ok": ok_sub.copy(),
719
+ "active": active.copy(),
720
+ "mass": float(mass0) if np.isfinite(mass0) else np.nan,
721
+ }
722
+ if debug_iz:
723
+ pack["debug_iz"] = np.asarray(debug_iz, dtype=int)
724
+ pack["w_debug"] = w_prev[np.asarray(debug_iz, dtype=int)].copy()
725
+ debug_pack.append(pack)
726
+
727
+ W[it, :] = w_prev
728
+ ok[it, :] = np.isfinite(w_prev.real) & np.isfinite(w_prev.imag)
729
+
730
+ if debug_path is not None and len(debug_pack) > 0:
731
+ try:
732
+ # store as npz with object array
733
+ os.makedirs(os.path.dirname(debug_path) or ".", exist_ok=True)
734
+ np.savez_compressed(debug_path, debug=np.array(debug_pack, dtype=object))
735
+ except Exception:
736
+ pass
737
+
738
+ return W, ok