Skip to content
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c11bd6b
fix: scale the default relaxation window to the system's own relaxati…
claude Aug 16, 2026
16fba4d
test: share diagnose() runs across the scaling assertions
claude Aug 16, 2026
86442a0
docs(citation): record the relaxation-window correction as pending fo…
claude Aug 16, 2026
f132bea
fix: disclose unsampled fast modes, store the time grid (PR #115 review)
claude Aug 16, 2026
72b7da3
fix: count the unsampled lead-in in the fast-mode resolution check
claude Aug 16, 2026
4c45527
fix(relaxation): repair the two-scale window instead of disclosing it…
Aug 29, 2026
48b5253
test: pin the repair and its limits (CAR(1) model + two-scale window)
Aug 29, 2026
5455642
docs: describe the two-scale window and the CAR(1) residual model
Aug 29, 2026
3d9b6b8
docs: replace the inherited whitening figures with own measurements
Aug 29, 2026
9288ee8
fix(relaxation): stop leaking Any out of the two-scale grid builder
Aug 29, 2026
bffb15a
Merge remote-tracking branch 'origin/main' into fix/115-zweiskalen-car1
marcohost33-maker Aug 29, 2026
4d9a99f
fix(certificate,fits): four external-review findings on PR #127
marcohost33-maker Aug 29, 2026
3abb353
docs: record the four PR #127 review findings and the new residual_mo…
marcohost33-maker Aug 29, 2026
21c99ee
Merge remote-tracking branch 'origin/main' into pr127-work
marcohost33-maker Sep 2, 2026
71e5f39
fix(#127): D3, D4 and the oscillation flag survived the withholding o…
marcohost33-maker Sep 2, 2026
c07141b
fix(#127): four values withheld at one layer, consumed as measurement…
marcohost33-maker Sep 3, 2026
a84a009
fix(#127 round-19): scale-relative Prony gate, withheld window, gauge…
marcohost33-maker Sep 3, 2026
3ce21a8
test(#127 round-19): repair two blind spots the mutation run found
marcohost33-maker Sep 3, 2026
bd06e65
fix(#127 round-20): the gauge shift overflowed and the gate then fail…
marcohost33-maker Sep 3, 2026
18cef39
fix(#127 round-20): an underflowed resolution ratio crashed instead o…
marcohost33-maker Sep 3, 2026
ce9ac67
perf(#127 round-20): the exact CAR(1) ESS no longer builds two n x n …
marcohost33-maker Sep 3, 2026
d68800a
docs(#127): the AICc likelihood mismatch bites only where the theta s…
marcohost33-maker Sep 4, 2026
df0a3e5
docs(#127): the Hermiticity verdict is not gauge invariant, and that …
marcohost33-maker Sep 4, 2026
216324c
fix(#127 CI): the pure-gauge fixture asked the BLAS for its defect
marcohost33-maker Sep 4, 2026
e0a6479
fix(#127 round-20): the bootstrap drew innovations the fitter had not…
marcohost33-maker Sep 4, 2026
10589f0
fix(#127): measure the Hermiticity defect against the generator, not …
marcohost33-maker Sep 11, 2026
ddcaceb
docs(#127): changelog for the generator-relative tolerance; mark the …
marcohost33-maker Sep 11, 2026
f925705
Merge origin/main (38f8652) into PR #127: one finding repaired twice,…
marcohost33-maker Sep 11, 2026
e95ca8b
fix(#127 review round 2): read the generator scale in the canonical L…
marcohost33-maker Sep 11, 2026
5bc1c44
docs(#127 review round 2): CHANGELOG precision and CITATION.cff entry…
marcohost33-maker Sep 11, 2026
1c65460
test(#127 review round 3): pin the rate in the Lindblad-gauge compens…
marcohost33-maker Sep 11, 2026
b4ade40
fix(#127 CI): widen the annotation of the dissipator accumulator for …
marcohost33-maker Sep 11, 2026
8ab22ad
Merge origin/main (4f9592b, #139) into PR #127: keep both pending CIT…
marcohost33-maker Sep 11, 2026
eafc1b7
chore: stage guarded E3 contract migration
marcohost33-maker Sep 12, 2026
63adafa
chore: run one-shot E3 contract migration
marcohost33-maker Sep 12, 2026
57ed1c0
chore: replace failed E3 one-shot workflow
marcohost33-maker Sep 12, 2026
1373c47
chore: rerun guarded E3 migration with duplicate-signature cleanup
marcohost33-maker Sep 12, 2026
52f3be4
fix(lindblad): bind H Hermiticity to coherent scale
github-actions[bot] Sep 12, 2026
100e1c0
chore: remove stale E3 open-question comments
marcohost33-maker Sep 12, 2026
e556c96
chore(pr127): remove completed one-shot cleanup workflow
marcohost33-maker Sep 12, 2026
bb397ff
fix(#127 review round 21): close four review findings and align the r…
claude Sep 13, 2026
141badf
fix(#154 review): do not forward an unresolved spectrum to the relaxa…
claude Sep 13, 2026
e7e17d2
fix(#154 review, round 2): derive the certificate verdict for caller-…
claude Sep 13, 2026
4dd8c61
fix(#154 review, round 3): refuse spectrum_resolved=True without eige…
claude Sep 13, 2026
2c5fcc0
Merge pull request #154 from marcohost33-maker/claude/next-steps-zmsugb
marcohost33-maker Sep 14, 2026
e7e7c65
fix(#127): kein doppelter Modul-Import im Runde-21-Test (CodeQL 34)
claude Sep 14, 2026
ae9f432
Merge pull request #155 from marcohost33-maker/claude/next-steps-zmsugb
marcohost33-maker Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
445 changes: 445 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,56 @@ abstract: >-
# likewise scale-relative rather than absolute (issue #109), which closes a
# fail-open path that admitted non-GKSL generators at small ||H||; this changes
# which inputs are ACCEPTED, not the numerics of accepted ones.
# Also pending for the next cut: the DEFAULT relaxation time grid spans a fixed
# number of e-foldings of the slowest mode, [0, 10 / Delta], instead of the
# absolute window [0, 10] (PR #115). This CHANGES NUMERICAL RESULTS -- for every
# run that does not pass an explicit t_grid and whose gap is not Delta = 1 --
# and must be described as a correction, not an addition: the previously
# reported rates were measured on a window whose relation to the dynamics
# depended on the caller's unit of time, so on an amplitude-damped qubit under
# the pure rescale L -> cL the fitted beta_D drifted 22%, the D17 linear rate
# was wrong by a factor ~430 at c = 1e-6, and four different A-classes were
# reported for one system. It must NOT be described as making the fitted
# relaxation rates invariant under an arbitrary change of rate units: the
# least-squares solver's own convergence controls remain unit-dependent (open in
# #111), and the mechanism verdict still gates on the rate-dimensioned
# henrici_eta threshold (open in #101). The claim is bounded to what was
# measured -- beta_D and beta_D_linear track the rescaling to <=1.2e-3 relative
# over c in [1e-6, 1e6] with a stable AICc winner, and the A-class is stable
# over c in [1e-6, 1] -- on the two qubit systems tested.
# Also pending for the next cut: on a system whose rates are separated by more
# than about eightfold the default window is now TWO-SCALE rather than uniform,
# and the GLS residual model switches with it from discrete AR(1) to
# continuous-time CAR(1) (per-step exp(-theta dt_k)). This CHANGES NUMERICAL
# RESULTS for exactly those runs and is a correction, not an addition: on two
# independent damped qubits with rates 1e-6 and 1 the uniform window's winning
# M2 fit reported a second rate of 2.17e-05 for a component that was never
# sampled (true value 1), and the D17 linear rate sat 0.58 relative from the
# certified gap; the two-scale window recovers 1.10 and 0.021 respectively.
# It must NOT be described as resolving arbitrary multiscale dynamics: the two
# segments resolve the FASTEST and the SLOWEST mode, and an intermediate
# timescale can still fall between the coarse late samples -- measured 0.0019
# samples per e-folding for the middle mode of a 1e-6 / 1e-3 / 1 system, where
# the UnderResolvedTransientWarning correctly still fires. Nor is the
# small-sample bias correction of the AR(1) path carried over to CAR(1): every
# way of anchoring it to a single step of a multi-decade grid was measured to
# destroy the estimator's discrimination, so it is disclosed rather than
# applied.
# Also pending for the next cut: where the zero-mode certificate is applicable
# but NOT resolved, the spectral layer now withholds D3 (oscillating_gap) and
# D4 (spectral_spread) as NaN and reports has_complex_pairs as None, under the
# same predicate that already withheld D1 (round-17 review, PR #127). This
# CHANGES REPORTED RESULTS for exactly those runs and is a correction, not an
# addition: the three values were previously read off the candidate spectrum
# that the layer's own warning declares unreliable (measured on the issue-#113
# stiff fixture: D3 = 0.0 and has_complex_pairs = False, i.e. an assertion that
# no oscillatory separation exists, taken from a spectrum whose slow modes lie
# below the eigensolver's backward error). Consumers see the A8 oscillatory
# rung become UNEVALUABLE instead of NOT_SUPPORTED for those runs. It must NOT
# be described as making the spectral layer reliable on stiff generators: the
# unresolved case is still unresolved, only no longer reported as measured.
# D2/D2b are unaffected -- they are computed from the operator and the steady
# state rather than from the candidate spectrum.
# Also pending for the next cut: the Gaussian likelihood behind AICc is
# evaluated in log-RSS space rather than by forming the residual sum of squares
# in ordinary units, and an exact-zero RSS is an explicit abstention rather than
Expand All @@ -101,6 +151,18 @@ abstract: >-
# interval is withheld (round-1 review of PR #147). Archived analyses whose
# selected model matters should be re-run; per-model likelihoods are not run-
# manifest fields, so this change is not visible in input_hash.
# Also pending for the next cut: the Hermiticity validation of the Hamiltonian
# measures its defect against the scale of the GENERATOR, not of H alone (PR
# #127): max(gauge-fixed max|H0|, max|sum_k gamma_k L0_k^dag L0_k| / 2), read in
# the canonical Lindblad gauge (traceless jump operators, compensated H0). As
# with issue #109 this changes which inputs are ACCEPTED, not the numerics of
# accepted ones: a numerically pure-gauge Hamiltonian with a dissipator is now
Comment thread
marcohost33-maker marked this conversation as resolved.
Outdated
# accepted, while an identity-dominated H such as [[1e308, 1], [0, 1e308]], a
# null dissipator c*I and the Lindblad gauge L -> L + c*I no longer excuse a
# Hermiticity defect. Without jump operators the verdict is main's gauge-fixed
# one, and an H whose gauge-fixed scale is not finite is refused. It must NOT be
# described as settling whether a large physical dissipation may excuse a
# defect of the coherent part: that question is open (cross-family review).
keywords:
- open quantum systems
- Lindblad
Expand Down
115 changes: 115 additions & 0 deletions docs/explanation/layers-and-taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,118 @@ not influence any verdict yet: the switch requires the preregistered
calibration study and independent physics review specified in issue #101
(slice C), including gapless-normal negative controls, before any threshold
is chosen.

## The relaxation window is measured in the system's own relaxation time

Everything the relaxation layer reports — D5, D6, D7, the M0..M3b AICc
comparison, `beta_D`, its BCa interval and the D17 gap-rate check — is fitted
on a time grid. A decay rate has dimension `1/time`, so an *absolute* default
window would be an unstated claim about the caller's unit of time.

When `t_grid` is omitted, `diagnose()` therefore spans

```text
t in [0, RELAXATION_HORIZON / Delta], 80 uniform samples
```

with `Delta` the D1 gap and `RELAXATION_HORIZON = 10` — a fixed number of
e-foldings of the slowest mode, which is the only window carried along by the
rescaling `L → cL`. The fitted rates track that rescaling to ≤1.2e-3 relative
over twelve decades of rate units
(`tests/test_relaxation_grid_scale.py`). At `Delta = 1` the grid is
bit-identical to the historical `linspace(0.0, 10.0, 80)`; when no decay scale
is resolved (`Delta <= 0`) that historical window is used, since there is then
no timescale to scale by.

The grid is **uniform whenever a uniform grid suffices** — which is whenever
the spread between the slowest and the fastest mode stays under about
`(n_points − 1) / horizon = 7.9`. Past that the window switches to a two-scale
grid (below), and the residual model switches with it.

Which window produced a given run is recorded on the report, so it never has
to be inferred — including the grid itself, which is the abscissa the exported
D5/D6/D7 curves are sampled on:

```python
report.relaxation.t_grid_source
# "caller" | "gap_scaled" | "gap_scaled_multiscale" | "legacy_fixed"
report.relaxation.t_grid_span
report.relaxation.t_grid # the sampling, not just its extent
report.relaxation.residual_model
# what the fits were ACTUALLY whitened with, not what the grid asked for:
# "ar1" uniform grid, discrete AR(1)
# "car1" non-uniform grid, every fit whitened continuous-time
# "car1_fallback_ar1" CAR(1) theta failed on every fit -> AR(1) fallback
# "car1_mixed" some fits CAR(1), some fallen back
# "car1_unavailable" non-uniform grid and no fit succeeded
```

### What one uniform window cannot do — and what replaced it

The two requirements pull against each other. The window must reach `~1/Δ` to
see the slowest mode relax; the step must stay below `~1/r` to see a mode at
rate `r` at all. Eighty uniform samples over ten e-foldings give

```text
samples_per_fast_efolding = min over modes of 1 / (r · blind_r)
```

where `blind_r` is the largest interval the grid leaves unsampled **while that
mode still has amplitude** — i.e. the largest gap that starts before `1/r`,
counting the lead-in `[0, t[0]]` as a gap. The lead-in matters because
`diagnose()` accepts any non-negative start: on `linspace(100, 101, 101)` a
rate-1 mode is sampled a hundred times per e-folding by its step and is still
long gone by the first sample. On a uniform grid starting at zero this reduces
to `1 / (r_max · dt) ≈ 7.9 · Δ / r_max`, so roughly an **eightfold** spread of
timescales is the most one uniform grid can straddle.

Widening the window does not help: an absolute window resolves the fast mode
and misses the relaxation entirely, which is worse for the quantity this layer
reports (measured `beta_D_linear` `3.5e4` relative from the true gap, against
`0.58` for the uniform gap-scaled window).

What *does* help is a non-uniform grid — and the reason this layer long
declined to use one turned out to be false. The objection was that the GLS
layer "whitens with a single AR(1) coefficient, which presumes a constant
sample interval". That is a property of the **discrete parametrisation**, not
of the noise. The stationary continuous-time process (Ornstein–Uhlenbeck,
equivalently CAR(1)) has `Corr(t, t+d) = exp(−θ·d)` for any `d`, so on an
arbitrary grid one whitens with the per-step `a_k = exp(−θ·dt_k)` and rescales
by `sqrt(1 − a_k²)` to keep the result homoskedastic. Measured on a two-scale
grid with exact OU noise, the median `|lag-1 autocorrelation|` of the whitened
residuals is `0.374` with one constant `ρ` — taken at the median step, that
scheme's best case — against `0.073` with the per-step coefficient. On a
uniform grid both schemes give `0.073`, which is what shows the contrast comes
from the grid rather than from the comparison.

So the default window is now **repaired**, not merely disclosed. When the
uniform grid cannot resolve the fastest mode (`max(−Re λ)`, taken from the
spectrum, never guessed), half the points cover `[0, horizon/r_max]` and the
rest carry the window out to `horizon/Δ`; `liouscope.fitting.car1` supplies the
whitening, the exact `N_eff = n² / Σ_jk exp(−θ|t_j − t_k|)` and the
exact-transition bootstrap resampler that go with it. On the reviewer's case
(rates `1e-6` and `1`) the AICc winner M2 now recovers the fast rate as `1.10`
against a true `1.0`, where the uniform window's M2 reported `2.17e-05` for it,
and the D17 linear rate lands `0.021` from the gap instead of `0.58`.

The disclosure remains for what the two-scale grid still cannot reach:

- a **caller-supplied** `t_grid` that does not resolve its own system;
- an **intermediate** timescale. The two segments resolve the fastest and the
slowest mode; a mode between them can fall entirely between the coarse late
samples. On three damped qubits at `1e-6`, `1e-3` and `1` the middle mode is
sampled `0.0019` times per e-folding and the warning fires, naming that mode.

Below one sample per e-folding the layer emits an
`UnderResolvedTransientWarning` and records
`report.relaxation.samples_per_fast_efolding`. The reported rates then describe
the dynamics the window *does* resolve, and a caller who needs the missing
component must supply a `t_grid` covering it — reading the resulting rates as
describing *that* window.

This closes the time-grid unit dependence only. The `henrici_eta > 1.0` gate
above is unaffected: `henrici_eta` is rate-dimensioned, so on an
amplitude-damped qubit it equals the rescaling factor `c` exactly and still
flips A5 → A10 between `c = 1` and `c = 3` whatever the grid. A third,
independent dependence sits in the least-squares solver's own convergence
controls (issue #111). Neither is asserted away by the grid work.
21 changes: 20 additions & 1 deletion src/liouscope/_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,20 @@ def diagnose(
rho_steady_state
Optional pre-computed steady state.
t_grid
Time grid for the relaxation layer.
Time grid for the relaxation layer. When omitted the window is derived
from the system's own slowest relaxation time — ``[0, 10 / Delta]``
with ``Delta`` the D1 gap, sampled uniformly at 80 points (see
:func:`liouscope.diagnostics.relaxation.default_relaxation_grid`). The
default is therefore invariant under a pure change of rate units
``L -> cL``; an absolute default would make every fitted rate, the AICc
model choice and the reported A-class depend on the caller's unit of
time. When the spectrum is spread too widely for one uniform grid to
resolve both ends (beyond roughly eightfold), the window becomes
two-scale and the residual model switches from AR(1) to CAR(1) with it.
Which window was used is recorded on
``report.relaxation.t_grid_source`` (``"caller"`` / ``"gap_scaled"`` /
``"gap_scaled_multiscale"`` / ``"legacy_fixed"``), ``.t_grid_span``,
``.t_grid`` and ``.residual_model``.
include_mpemba
Compute D19/D20.
bootstrap_B
Expand Down Expand Up @@ -162,6 +175,12 @@ def diagnose(
rho_initial=rho_initial,
rho_steady_state=rho_steady_state,
t_grid=t_grid,
# D1 is already computed above; forwarding it keeps the DEFAULT
# relaxation window tied to the system's own relaxation time instead of
# an absolute one (see relaxation.default_relaxation_grid). Without
# this, a pure change of rate units L -> cL moved beta_D by >20% and
# changed the reported A-class, on identical physics.
gap=spectral.gap,
bootstrap_B=bootstrap_B,
seed=resolved_seed,
)
Expand Down
47 changes: 47 additions & 0 deletions src/liouscope/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ class FitResult:
n_eff: float
residual_ar1_rho: float
success: bool
# Which residual model the GLS layer actually used, and hence what
# ``residual_ar1_rho`` and ``n_eff`` mean:
# NaN -- discrete AR(1) on a uniform grid; ``residual_ar1_rho`` is the
# fitted lag-1 correlation, ``n_eff`` the Geyer IPS estimate.
# finite -- continuous-time CAR(1) on a NON-uniform grid; the whitening
# used ``exp(-theta dt_k)`` per step, ``residual_ar1_rho`` is
# that correlation at the mean step (reported for continuity
# only), and ``n_eff`` is the exact CAR(1) value
# ``n^2 / sum_jk exp(-theta |t_j - t_k|)``.
# Additive + defaulted, so older callers and serialised fits stay valid.
residual_theta_car1: float = float("nan")
likelihood_degenerate: bool = False
#: True when the fit is a valid model-selection candidate -- its log-space
#: likelihood and AICc are finite -- but the positive MLE residual scale is
Expand Down Expand Up @@ -223,6 +234,42 @@ class RelaxationResult:
# uses. Additive + defaulted so older callers / serialised reports stay valid.
beta_D_linear: float = float("nan")
linear_fit_model: str = "none"
# Provenance of the time grid the whole layer was fitted on. Every fitted
# quantity above is conditional on that window, so recording HOW it was
# chosen is part of the audit trail rather than a convenience:
# "caller" -- the caller supplied t_grid explicitly
# "gap_scaled" -- default window [0, HORIZON / Delta], rate-unit invariant
# "legacy_fixed" -- no usable decay scale (Delta <= 0); absolute fallback
# Additive + defaulted, so older callers and serialised reports stay valid.
t_grid_source: str = "caller"
t_grid_span: float = float("nan")
# The grid ITSELF, not merely its span. The three curves above are y-values
# sampled on it, and a span alone does not identify the sampling: [0, 1, 10]
# and [0, 9, 10] share a span of 10 while describing materially different
# trajectories. Without this the exported report carries ordinates with no
# abscissa, so a downstream consumer cannot re-fit, re-plot or audit the
# rates it reports. Additive + defaulted like the fields above.
t_grid: np.ndarray | None = None
# How finely this grid samples the FASTEST decaying mode, as samples per
# e-folding. Below ``relaxation.MIN_SAMPLES_PER_FAST_EFOLD`` that mode was
# stepped over rather than measured, so the reported rates describe only
# the slow dynamics the window resolves and an
# ``UnderResolvedTransientWarning`` is emitted. ``inf`` when nothing decays.
samples_per_fast_efolding: float = float("nan")
# Residual model the reported M0..M3b hierarchy was ACTUALLY whitened
# with -- read off the fits, not off the grid (PR #127 review):
# "ar1" uniform grid, historical discrete AR(1);
# "car1" non-uniform grid, every fit whitened with the
# continuous-time exp(-theta dt_k) per step;
# "car1_fallback_ar1" non-uniform grid, but CAR(1) theta estimation
# failed on every fit (degenerate residuals), so
# ``fit_gls_ar1`` fell back to discrete AR(1);
# "car1_mixed" non-uniform grid, some fits CAR(1), some fallen
# back -- one label cannot cover the hierarchy;
# "car1_unavailable" non-uniform grid and no fit succeeded at all.
# The per-fit value is ``FitResult.residual_theta_car1``. Additive +
# defaulted.
residual_model: str = "ar1"


@dataclass(frozen=True, slots=True, kw_only=True)
Expand Down
Loading