odeanalysis 0.1.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 (49) hide show
  1. odeanalysis/__init__.py +296 -0
  2. odeanalysis/_api_policy.py +179 -0
  3. odeanalysis/_assumptions.py +89 -0
  4. odeanalysis/_block_common.py +106 -0
  5. odeanalysis/_formal_gauge.py +115 -0
  6. odeanalysis/_local.py +166 -0
  7. odeanalysis/_moser.py +271 -0
  8. odeanalysis/_power_simplify.py +32 -0
  9. odeanalysis/_spectral.py +215 -0
  10. odeanalysis/_symbolic_compare.py +16 -0
  11. odeanalysis/_symbolic_errors.py +19 -0
  12. odeanalysis/_zero.py +28 -0
  13. odeanalysis/analytic_continuation.py +343 -0
  14. odeanalysis/bell.py +87 -0
  15. odeanalysis/block_decomposition.py +1144 -0
  16. odeanalysis/canonical.py +471 -0
  17. odeanalysis/certified_continuation.py +160 -0
  18. odeanalysis/diagnostics.py +17 -0
  19. odeanalysis/dominance.py +134 -0
  20. odeanalysis/factorization.py +88 -0
  21. odeanalysis/formal.py +1028 -0
  22. odeanalysis/formal_basis.py +1009 -0
  23. odeanalysis/frobenius.py +349 -0
  24. odeanalysis/fuchsian.py +400 -0
  25. odeanalysis/interchange.py +604 -0
  26. odeanalysis/interoperability.py +143 -0
  27. odeanalysis/irregular.py +250 -0
  28. odeanalysis/kovacic.py +478 -0
  29. odeanalysis/levelt.py +679 -0
  30. odeanalysis/local_analysis.py +386 -0
  31. odeanalysis/local_structure.py +290 -0
  32. odeanalysis/matrix_series.py +357 -0
  33. odeanalysis/newton.py +501 -0
  34. odeanalysis/operator.py +193 -0
  35. odeanalysis/parameter_wkb.py +92 -0
  36. odeanalysis/py.typed +0 -0
  37. odeanalysis/series.py +199 -0
  38. odeanalysis/singularities.py +279 -0
  39. odeanalysis/stokes.py +782 -0
  40. odeanalysis/system.py +347 -0
  41. odeanalysis/system_analysis.py +615 -0
  42. odeanalysis/transition_loci.py +302 -0
  43. odeanalysis/turning.py +516 -0
  44. odeanalysis/wronskian.py +110 -0
  45. odeanalysis-0.1.0.dist-info/METADATA +180 -0
  46. odeanalysis-0.1.0.dist-info/RECORD +49 -0
  47. odeanalysis-0.1.0.dist-info/WHEEL +5 -0
  48. odeanalysis-0.1.0.dist-info/licenses/LICENSE +677 -0
  49. odeanalysis-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,615 @@
1
+ """Public local, formal, Stokes, and parameter analysis for linear systems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from itertools import combinations
7
+
8
+ import sympy as sp
9
+ from semialg import is_satisfiable, parametric_cad
10
+
11
+ from ._local import rational_valuation
12
+ from ._zero import ZeroStatus, exact_zero_status
13
+ from .block_decomposition import LeveltTurrittinReduction, levelt_turrittin_reduce
14
+ from .matrix_series import MatrixLaurentSeries
15
+ from .system import FirstOrderSystem
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SystemResonance:
20
+ source: sp.Expr
21
+ target: sp.Expr
22
+ difference: sp.Expr
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SystemSingularityAnalysis:
27
+ point: sp.Expr
28
+ kind: str
29
+ pole_order: int
30
+ poincare_rank: int
31
+ leading_matrix: sp.ImmutableMatrix
32
+ leading_rank: int | None
33
+ residue: sp.ImmutableMatrix | None
34
+ exponents: tuple[sp.Expr, ...]
35
+ resonances: tuple[SystemResonance, ...]
36
+ local_system: FirstOrderSystem
37
+
38
+ @property
39
+ def regular_singular(self) -> bool:
40
+ return self.kind == "regular_singular"
41
+
42
+ @property
43
+ def irregular(self) -> bool:
44
+ return self.kind == "irregular"
45
+
46
+
47
+ def _local_system(system: FirstOrderSystem, point: sp.Expr) -> FirstOrderSystem:
48
+ point = sp.sympify(point)
49
+ t = sp.Dummy("t", positive=True)
50
+ if point == sp.oo:
51
+ return system.change_variable(t, 1 / t)
52
+ return system.change_variable(t, point + t)
53
+
54
+
55
+ def _matrix_pole_data(system: FirstOrderSystem) -> tuple[int, sp.ImmutableMatrix]:
56
+ t = system.variable
57
+ vals = []
58
+ for entry in system.matrix:
59
+ status = exact_zero_status(entry)
60
+ if status is ZeroStatus.ZERO:
61
+ continue
62
+ if status is ZeroStatus.UNKNOWN and (entry.free_symbols - {t}):
63
+ raise NotImplementedError(
64
+ "system singularity analysis cannot certify a local valuation "
65
+ "for a parameter-dependent entry whose zero status is unresolved; "
66
+ "stratify or specialize the parameters first"
67
+ )
68
+ # An expression depending only on the local coordinate may have unknown
69
+ # SymPy ``is_zero`` status while still being a nonzero meromorphic
70
+ # function (for example (t**2 + 2)/t**3). Its valuation is safe to
71
+ # compute directly.
72
+ valuation = rational_valuation(entry, t, 0)
73
+ if valuation is None:
74
+ raise NotImplementedError(
75
+ "system singularity analysis requires finite rational local valuations"
76
+ )
77
+ vals.append(valuation)
78
+ minimum = min(vals, default=0)
79
+ pole = max(0, -minimum)
80
+ scale = t**pole
81
+ leading = sp.Matrix(system.matrix).applyfunc(
82
+ lambda e: sp.simplify(sp.limit(scale * e, t, 0))
83
+ )
84
+ return pole, sp.ImmutableMatrix(leading)
85
+
86
+
87
+ def _resonances(exponents: tuple[sp.Expr, ...]) -> tuple[SystemResonance, ...]:
88
+ result: list[SystemResonance] = []
89
+ for a, b in combinations(exponents, 2):
90
+ d = sp.simplify(a - b)
91
+ # Resonance requires a *nonzero* integral difference. Syntactic
92
+ # ``d != 0`` is unsound for parameterized integer symbols because it
93
+ # is true even when the parameter may specialize to zero.
94
+ if d.is_zero is False and d.is_integer is True:
95
+ result.append(SystemResonance(a, b, d))
96
+ elif d.is_zero is False and (-d).is_integer is True:
97
+ result.append(SystemResonance(b, a, -d))
98
+ return tuple(result)
99
+
100
+
101
+ def analyze_system_singularity(
102
+ system: FirstOrderSystem, point: sp.Expr = 0
103
+ ) -> SystemSingularityAnalysis:
104
+ """Classify a finite point or infinity of ``Y' = A(x)Y+b(x)``.
105
+
106
+ Exponents are residue eigenvalues for regular-singular systems. Resonance
107
+ records nonzero integral differences between residue eigenvalues. For an
108
+ irregular point, ``poincare_rank`` is the raw connection pole order minus
109
+ one; formal gauge reduction may lower it.
110
+ """
111
+ local = _local_system(system, point)
112
+ pole, leading = _matrix_pole_data(local)
113
+ kind = "ordinary" if pole == 0 else "regular_singular" if pole == 1 else "irregular"
114
+ residue = leading if pole == 1 else None
115
+ exponents: tuple[sp.Expr, ...] = ()
116
+ if residue is not None:
117
+ roots = sp.Matrix(residue).eigenvals()
118
+ exponents = tuple(root for root, mult in roots.items() for _ in range(mult))
119
+ exponents = tuple(sorted(exponents, key=sp.default_sort_key))
120
+ rank = leading.rank() if not leading.free_symbols else None
121
+ return SystemSingularityAnalysis(
122
+ sp.sympify(point),
123
+ kind,
124
+ pole,
125
+ max(0, pole - 1),
126
+ leading,
127
+ rank,
128
+ residue,
129
+ exponents,
130
+ _resonances(exponents),
131
+ local,
132
+ )
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class FormalReductionCertificate:
137
+ """Evidence describing a bounded or adaptive formal reduction search."""
138
+
139
+ verified: bool
140
+ complete: bool
141
+ adaptive: bool
142
+ attempted_depths: tuple[int, ...]
143
+ attempted_cover_indices: tuple[int | None, ...]
144
+ final_depth: int
145
+ final_cover_index: int | None
146
+ ramification_index: int
147
+ limitation: str | None
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class FormalSystemAnalysis:
152
+ point: sp.Expr
153
+ singularity: SystemSingularityAnalysis
154
+ connection: MatrixLaurentSeries
155
+ reduction: LeveltTurrittinReduction
156
+ exponential_parts: tuple[sp.Expr, ...]
157
+ certificate: FormalReductionCertificate
158
+
159
+ @property
160
+ def complete(self) -> bool:
161
+ return self.reduction.complete
162
+
163
+ @property
164
+ def ramification_index(self) -> int:
165
+ return self.reduction.ramification_index
166
+
167
+ @property
168
+ def limitation(self) -> str | None:
169
+ return self.reduction.limitation
170
+
171
+ def verify(self) -> bool:
172
+ return self.reduction.verify()
173
+
174
+
175
+ def formal_system_analysis(
176
+ system: FirstOrderSystem,
177
+ point: sp.Expr = 0,
178
+ *,
179
+ max_power: int = 3,
180
+ max_depth: int = 2,
181
+ max_cover_index: int | None = None,
182
+ adaptive: bool = False,
183
+ max_adaptive_depth: int = 6,
184
+ max_adaptive_cover_index: int | None = 12,
185
+ ) -> FormalSystemAnalysis:
186
+ """Run the exact bounded Moser/Levelt--Turrittin pipeline on a system."""
187
+ singularity = analyze_system_singularity(system, point)
188
+ connection = MatrixLaurentSeries.from_matrix(
189
+ singularity.local_system.matrix, singularity.local_system.variable
190
+ )
191
+ if max_adaptive_depth < max_depth:
192
+ raise ValueError("max_adaptive_depth must be at least max_depth")
193
+ attempted_depths: list[int] = []
194
+ attempted_covers: list[int | None] = []
195
+ depth = max_depth
196
+ cover = max_cover_index
197
+ while True:
198
+ attempted_depths.append(depth)
199
+ attempted_covers.append(cover)
200
+ reduction = levelt_turrittin_reduce(
201
+ connection,
202
+ max_power=max_power,
203
+ max_depth=depth,
204
+ max_cover_index=cover,
205
+ )
206
+ if reduction.complete or not adaptive or depth >= max_adaptive_depth:
207
+ break
208
+ depth = min(max_adaptive_depth, max(depth + 1, 2 * max(1, depth)))
209
+ if max_adaptive_cover_index is not None:
210
+ if cover is None:
211
+ cover = min(4, max_adaptive_cover_index)
212
+ elif cover < max_adaptive_cover_index:
213
+ cover = min(max_adaptive_cover_index, max(cover + 1, 2 * cover))
214
+ q_parts: list[sp.Expr] = []
215
+ if reduction.complete:
216
+ for block in reduction.final_diagonalization.blocks:
217
+ q = sp.S.Zero
218
+ dimension = block.rows
219
+ for power, coefficient in block.terms:
220
+ if power >= -1:
221
+ continue
222
+ matrix = sp.Matrix(coefficient)
223
+ scalar = sp.simplify(sp.trace(matrix) / dimension)
224
+ if matrix == scalar * sp.eye(dimension):
225
+ q += scalar * block.variable ** (power + 1) / (power + 1)
226
+ q_parts.append(sp.expand(q))
227
+ certificate = FormalReductionCertificate(
228
+ verified=reduction.verify(),
229
+ complete=reduction.complete,
230
+ adaptive=adaptive,
231
+ attempted_depths=tuple(attempted_depths),
232
+ attempted_cover_indices=tuple(attempted_covers),
233
+ final_depth=depth,
234
+ final_cover_index=cover,
235
+ ramification_index=reduction.ramification_index,
236
+ limitation=reduction.limitation,
237
+ )
238
+ return FormalSystemAnalysis(
239
+ point, singularity, connection, reduction, tuple(q_parts), certificate
240
+ )
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class SystemStokesPair:
245
+ blocks: tuple[int, int]
246
+ difference: sp.Expr
247
+ equal_magnitude_rays: tuple[sp.Expr, ...]
248
+ phase_alignment_rays: tuple[sp.Expr, ...]
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class SystemStokesGeometry:
253
+ point: sp.Expr
254
+ exponential_parts: tuple[sp.Expr, ...]
255
+ pairs: tuple[SystemStokesPair, ...]
256
+ structural_only: bool = True
257
+
258
+
259
+ def _leading_monomial(q: sp.Expr, t: sp.Symbol) -> tuple[sp.Expr, int] | None:
260
+ q = sp.expand(q)
261
+ terms = []
262
+ for term in sp.Add.make_args(q):
263
+ c, p = term.as_coeff_exponent(t)
264
+ if p.is_Integer and p < 0 and not c.has(t):
265
+ terms.append((int(p), c))
266
+ if not terms:
267
+ return None
268
+ p, c = min(terms, key=lambda z: z[0])
269
+ return c, -p
270
+
271
+
272
+ def _rays(c: sp.Expr, k: int, phase: bool) -> tuple[sp.Expr, ...]:
273
+ if c.is_real is not True and c.is_number is not True:
274
+ return ()
275
+ arg = sp.arg(c)
276
+ offset = 0 if phase else sp.pi / 2
277
+ return tuple(
278
+ sp.simplify((arg - offset - m * sp.pi) / k % (2 * sp.pi)) for m in range(2 * k)
279
+ )
280
+
281
+
282
+ def system_stokes_geometry(
283
+ formal: FormalSystemAnalysis,
284
+ *,
285
+ exponential_parts: tuple[sp.Expr, ...] | None = None,
286
+ ) -> SystemStokesGeometry:
287
+ """Return structural Stokes rays between formal exponential blocks.
288
+
289
+ ``exponential_parts`` may be supplied when the bounded reducer's block
290
+ objects do not expose scalar exponential polynomials. Analytic Stokes
291
+ multipliers are intentionally outside this result contract.
292
+ """
293
+ if exponential_parts is None:
294
+ exponential_parts = formal.exponential_parts
295
+ t = formal.reduction.transformed_connection.variable
296
+ pairs = []
297
+ for i, j in combinations(range(len(exponential_parts)), 2):
298
+ d = sp.expand(exponential_parts[i] - exponential_parts[j])
299
+ lead = _leading_monomial(d, t)
300
+ if lead is None:
301
+ pairs.append(SystemStokesPair((i, j), d, (), ()))
302
+ continue
303
+ c, k = lead
304
+ pairs.append(SystemStokesPair((i, j), d, _rays(c, k, False), _rays(c, k, True)))
305
+ return SystemStokesGeometry(formal.point, tuple(exponential_parts), tuple(pairs))
306
+
307
+
308
+ @dataclass(frozen=True)
309
+ class SystemParameterStratum:
310
+ condition: sp.Expr
311
+
312
+
313
+ @dataclass(frozen=True)
314
+ class ParameterizedSystemAnalysis:
315
+ point: sp.Expr
316
+ parameters: tuple[sp.Symbol, ...]
317
+ transition_polynomials: tuple[sp.Expr, ...]
318
+ rank_loci: tuple[sp.Expr, ...]
319
+ collision_loci: tuple[sp.Expr, ...]
320
+ resonance_loci: tuple[sp.Expr, ...]
321
+ block_loci: tuple[sp.Expr, ...]
322
+ stokes_loci: tuple[sp.Expr, ...]
323
+ strata: tuple[SystemParameterStratum, ...]
324
+ exhaustive: bool
325
+
326
+
327
+ def _parameter_factors(
328
+ expr: sp.Expr, parameters: tuple[sp.Symbol, ...]
329
+ ) -> list[sp.Expr]:
330
+ expr = sp.factor(expr)
331
+ if expr == 0:
332
+ return []
333
+ try:
334
+ factors = sp.factor_list(expr)[1]
335
+ except (sp.PolynomialError, TypeError, ValueError):
336
+ return []
337
+ return [sp.factor(f) for f, _ in factors if f.free_symbols & set(parameters)]
338
+
339
+
340
+ def system_parameter_analysis(
341
+ system: FirstOrderSystem,
342
+ parameters: tuple[sp.Symbol, ...],
343
+ point: sp.Expr = 0,
344
+ *,
345
+ assumptions: sp.Expr | bool = True,
346
+ max_resonance_order: int = 4,
347
+ exponential_parts: tuple[sp.Expr, ...] = (),
348
+ ) -> ParameterizedSystemAnalysis:
349
+ """Stratify exact algebraic system transition loci with ``semialg``.
350
+
351
+ The initial public contract detects leading-rank transitions and residue
352
+ eigenvalue collisions. More refined formal-block and Stokes loci can be
353
+ added without changing the result shape.
354
+ """
355
+ assumptions = sp.sympify(assumptions)
356
+ local = _local_system(system, point)
357
+ t = local.variable
358
+ structural_vals: list[int] = []
359
+ for entry in local.matrix:
360
+ if entry == 0:
361
+ continue
362
+ num, den = sp.fraction(sp.cancel(entry))
363
+ pn, pd = sp.Poly(num, t), sp.Poly(den, t)
364
+ nv = min(k for k in range(pn.degree() + 1) if pn.nth(k) != 0)
365
+ dv = min(k for k in range(pd.degree() + 1) if pd.nth(k) != 0)
366
+ structural_vals.append(nv - dv)
367
+ minimum = min(structural_vals, default=0)
368
+ pole = max(0, -minimum)
369
+ leading = sp.ImmutableMatrix(
370
+ sp.Matrix(local.matrix).applyfunc(
371
+ lambda e: sp.cancel(sp.limit(t**pole * e, t, 0))
372
+ )
373
+ )
374
+ rank_loci: list[sp.Expr] = []
375
+ collision_loci: list[sp.Expr] = []
376
+ resonance_loci: list[sp.Expr] = []
377
+ block_loci: list[sp.Expr] = []
378
+ stokes_loci: list[sp.Expr] = []
379
+ # Rank transitions: all nonconstant minors can change the leading rank.
380
+ for size in range(1, min(leading.shape) + 1):
381
+ for rows in combinations(range(leading.rows), size):
382
+ for cols in combinations(range(leading.cols), size):
383
+ rank_loci.extend(
384
+ _parameter_factors(
385
+ sp.Matrix(leading).extract(rows, cols).det(), parameters
386
+ )
387
+ )
388
+ # Regular-singular eigenvalue collisions and, for 2x2 residues, exact
389
+ # bounded integer-resonance hypersurfaces from the characteristic discriminant.
390
+ if pole == 1:
391
+ lam = sp.Symbol("_lambda")
392
+ char_poly = sp.Poly(sp.Matrix(leading).charpoly(lam).as_expr(), lam)
393
+ disc = sp.factor(sp.discriminant(char_poly.as_expr(), char_poly.gens[0]))
394
+ collision_loci.extend(_parameter_factors(disc, parameters))
395
+ block_loci.extend(_parameter_factors(disc, parameters))
396
+ if leading.rows == 2:
397
+ for n in range(1, max_resonance_order + 1):
398
+ resonance_loci.extend(_parameter_factors(disc - n * n, parameters))
399
+ # Optional formal exponential blocks expose algebraic degeneracy and phase
400
+ # alignment transitions. Complex coefficients must be represented with
401
+ # explicit real symbols for these expressions to be semialgebraic.
402
+ for qi, qj in combinations(exponential_parts, 2):
403
+ diff = sp.expand(qi - qj)
404
+ if point == sp.oo:
405
+ diff = sp.cancel(diff.subs(system.variable, 1 / local.variable))
406
+ else:
407
+ diff = sp.cancel(
408
+ diff.subs(system.variable, sp.sympify(point) + local.variable)
409
+ )
410
+ lead = _leading_monomial(diff, local.variable)
411
+ if lead is None:
412
+ continue
413
+ coefficient, _ = lead
414
+ re, im = sp.expand_complex(coefficient).as_real_imag()
415
+ stokes_loci.extend(_parameter_factors(sp.expand(re**2 + im**2), parameters))
416
+ # Phase transitions relative to the real axis occur when Im(c)=0 or Re(c)=0.
417
+ stokes_loci.extend(_parameter_factors(re, parameters))
418
+ stokes_loci.extend(_parameter_factors(im, parameters))
419
+ rank_loci = list(dict.fromkeys(rank_loci))
420
+ collision_loci = list(dict.fromkeys(collision_loci))
421
+ resonance_loci = list(dict.fromkeys(resonance_loci))
422
+ block_loci = list(dict.fromkeys(block_loci))
423
+ stokes_loci = list(dict.fromkeys(stokes_loci))
424
+ loci = list(
425
+ dict.fromkeys(
426
+ [*rank_loci, *collision_loci, *resonance_loci, *block_loci, *stokes_loci]
427
+ )
428
+ )
429
+ marker = sp.Ne(sp.prod(loci), 0, evaluate=False) if loci else sp.S.true
430
+ geometry = parametric_cad(
431
+ marker, (), parameters=parameters, assumptions=assumptions, output="result"
432
+ )
433
+ strata = tuple(
434
+ SystemParameterStratum(case.condition)
435
+ for case in geometry.cases
436
+ if is_satisfiable(sp.And(assumptions, case.condition), parameters)
437
+ )
438
+ return ParameterizedSystemAnalysis(
439
+ sp.sympify(point),
440
+ parameters,
441
+ tuple(loci),
442
+ tuple(rank_loci),
443
+ tuple(collision_loci),
444
+ tuple(resonance_loci),
445
+ tuple(block_loci),
446
+ tuple(stokes_loci),
447
+ strata,
448
+ geometry.status == "complete",
449
+ )
450
+
451
+
452
+ @dataclass(frozen=True)
453
+ class SystemFormalTypeSignature:
454
+ """Discrete formal invariants used to compare parameter strata."""
455
+
456
+ singularity_kind: str
457
+ leading_rank: int | None
458
+ spectral_multiplicities: tuple[int, ...]
459
+ resonance_orders: tuple[int, ...]
460
+ ramification_index: int | None
461
+ block_dimensions: tuple[int, ...]
462
+ exponential_parts: tuple[sp.Expr, ...]
463
+ stokes_ray_counts: tuple[int, ...]
464
+ complete: bool
465
+
466
+
467
+ @dataclass(frozen=True)
468
+ class SystemFormalTypeStratum:
469
+ condition: sp.Expr
470
+ sample: tuple[tuple[sp.Symbol, sp.Expr], ...]
471
+ signature: SystemFormalTypeSignature
472
+ certified: bool
473
+
474
+
475
+ @dataclass(frozen=True)
476
+ class ParameterizedSystemFormalTypes:
477
+ """Semialgebraic parameter cells annotated by verified formal signatures."""
478
+
479
+ base: ParameterizedSystemAnalysis
480
+ strata: tuple[SystemFormalTypeStratum, ...]
481
+ exhaustive: bool
482
+
483
+
484
+ def _formal_type_signature(
485
+ system: FirstOrderSystem,
486
+ point: sp.Expr,
487
+ *,
488
+ max_power: int,
489
+ max_depth: int,
490
+ max_cover_index: int | None,
491
+ max_resonance_order: int,
492
+ ) -> SystemFormalTypeSignature:
493
+ singularity = analyze_system_singularity(system, point)
494
+ formal = formal_system_analysis(
495
+ system,
496
+ point,
497
+ max_power=max_power,
498
+ max_depth=max_depth,
499
+ max_cover_index=max_cover_index,
500
+ adaptive=True,
501
+ max_adaptive_depth=max(max_depth, 6),
502
+ max_adaptive_cover_index=max_cover_index if max_cover_index is not None else 12,
503
+ )
504
+ block_dimensions: tuple[int, ...] = ()
505
+ if formal.reduction.formal_stages:
506
+ block_dimensions = tuple(
507
+ block.rows for block in formal.reduction.final_diagonalization.blocks
508
+ )
509
+ stokes = system_stokes_geometry(formal)
510
+ return SystemFormalTypeSignature(
511
+ singularity.kind,
512
+ singularity.leading_rank,
513
+ tuple(sorted(sp.Matrix(singularity.residue).eigenvals().values()))
514
+ if singularity.residue is not None
515
+ else (),
516
+ tuple(
517
+ sorted(
518
+ abs(int(r.difference))
519
+ for r in singularity.resonances
520
+ if r.difference.is_Integer
521
+ and abs(int(r.difference)) <= max_resonance_order
522
+ )
523
+ ),
524
+ formal.ramification_index if formal.certificate.verified else None,
525
+ block_dimensions,
526
+ tuple(formal.exponential_parts),
527
+ tuple(len(pair.equal_magnitude_rays) for pair in stokes.pairs),
528
+ formal.complete and formal.certificate.verified,
529
+ )
530
+
531
+
532
+ def system_formal_type_stratification(
533
+ system: FirstOrderSystem,
534
+ parameters: tuple[sp.Symbol, ...],
535
+ point: sp.Expr = 0,
536
+ *,
537
+ assumptions: sp.Expr | bool = True,
538
+ max_resonance_order: int = 4,
539
+ exponential_parts: tuple[sp.Expr, ...] = (),
540
+ max_power: int = 3,
541
+ max_depth: int = 2,
542
+ max_cover_index: int | None = None,
543
+ ) -> ParameterizedSystemFormalTypes:
544
+ """Annotate certified transition cells with exact representative formal types.
545
+
546
+ Certification is conservative: a cell is certified only when the semialgebraic
547
+ transition decomposition is exhaustive and the representative formal reduction
548
+ independently verifies and completes. The transition set covers leading-rank,
549
+ residue collision/resonance and supplied exponential/Stokes degeneracy loci.
550
+ """
551
+ base = system_parameter_analysis(
552
+ system,
553
+ parameters,
554
+ point,
555
+ assumptions=assumptions,
556
+ max_resonance_order=max_resonance_order,
557
+ exponential_parts=exponential_parts,
558
+ )
559
+ # Recompute the CAD result to retain exact representative samples.
560
+ marker = (
561
+ sp.Ne(sp.prod(base.transition_polynomials), 0, evaluate=False)
562
+ if base.transition_polynomials
563
+ else sp.S.true
564
+ )
565
+ geometry = parametric_cad(
566
+ marker,
567
+ (),
568
+ parameters=parameters,
569
+ assumptions=sp.sympify(assumptions),
570
+ output="result",
571
+ )
572
+ strata: list[SystemFormalTypeStratum] = []
573
+ for case in geometry.cases:
574
+ sample = dict(case.sample)
575
+ specialized = FirstOrderSystem(
576
+ system.variable,
577
+ sp.ImmutableMatrix(sp.Matrix(system.matrix).subs(sample)),
578
+ sp.ImmutableMatrix(sp.Matrix(system.forcing).subs(sample))
579
+ if system.forcing is not None
580
+ else None,
581
+ system.ramification_index,
582
+ )
583
+ signature = _formal_type_signature(
584
+ specialized,
585
+ point,
586
+ max_power=max_power,
587
+ max_depth=max_depth,
588
+ max_cover_index=max_cover_index,
589
+ max_resonance_order=max_resonance_order,
590
+ )
591
+ # The initial transition vocabulary is complete enough to certify
592
+ # bounded discrete formal type for ordinary/regular-singular 2x2
593
+ # families. Irregular formal-block/ramification transitions require
594
+ # additional transition polynomials and remain representative-only.
595
+ cell_certified = (
596
+ base.exhaustive
597
+ and signature.complete
598
+ and system.dimension <= 2
599
+ and signature.singularity_kind in {"ordinary", "regular_singular"}
600
+ )
601
+ strata.append(
602
+ SystemFormalTypeStratum(
603
+ case.condition,
604
+ tuple(
605
+ sorted(
606
+ sample.items(), key=lambda item: sp.default_sort_key(item[0])
607
+ )
608
+ ),
609
+ signature,
610
+ cell_certified,
611
+ )
612
+ )
613
+ return ParameterizedSystemFormalTypes(
614
+ base, tuple(strata), base.exhaustive and all(s.certified for s in strata)
615
+ )