Repository navigation
qmatlib.H: remove a pole that was never really there - #23
Merged
Merged
Conversation
H(s) = QAA + QAF X^-1 (I - exp(-X tres)) QFA, with X = sI - QFF, was computed
by inverting X. That breaks down whenever s reaches an eigenvalue of QFF -- and
the asymptotic root search that calls H walks s across exactly that range,
because the roots of the determinant equation are interlaced with those
eigenvalues.
The quantity itself is fine there. X^-1 (I - exp(-X t)) is the integral of
exp(-Xu) over [0, t], and the offending mode contributes (1 - e^{-mu t})/mu,
whose limit as mu -> 0 is t. The singularity is removable; only the factored
form has a pole. integral_expQt evaluates it spectrally, taking that limit
explicitly, and H uses it.
Found in the reproduction of Colquhoun, Hatton & Hawkes (2003): two of the
first twelve fits of one scenario raised LinAlgError from HJC_shut_time_pdf at
10 uM. Both had a rate constant sitting at its upper limit of 1e6, which puts
an eigenvalue of QAA exactly at -1e6 for the shut-time problem to walk into.
All twelve now work.
Whether the old form raised or merely returned nonsense depended on whether the
eigenvalue was exactly representable. Round-numbered rates -- a limit of 1e6 --
land on the pole exactly and raise; otherwise the matrix is simply
catastrophically ill-conditioned and the answer is quietly wrong. The second is
the worse failure and the reason this is a fix rather than a guard.
dW has the same-looking inversion and is deliberately left alone: its pole is
genuine, not removable, and the bisection brackets roots between poles by
design.
19 tests. The integral is checked against inv(M)(exp(Mt) - I) on random
matrices, against a quadrature, for continuity as an eigenvalue passes through
zero, and for giving exactly t at the zero mode; H is checked unchanged away
from the pole and finite and continuous through it. Nothing else moves: 635
pass, and the asymptotic areas still agree with the 2003 Fortran to 5e-7, which
is its print precision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
remislp
added a commit
that referenced
this pull request
Sep 11, 2026
Seven merged pull requests have been waiting since 1.1.0, and one of them is now blocking work elsewhere: HJCFIT's fitting layer is duck-typed on a mechanism and needs Rate.is_free, which exists here on master and not in the published 1.1.0. Its [fitting] extra pins scalcs>=1.1.0, which is too loose -- the integration tests fail on the released wheel with "'Rate' object has no attribute 'is_free'". A minor rather than a patch release, because it corrects computed values as well as adding API. #17 root bracketing for the asymptotic pdf. Counts were not recomputed when a search bound moved, widening happened once where it needed to repeat, and the upper bound was compared against the wrong state count. det[W(s)] is also unusable at large |s|, where it is of order 1e53 and changes sign hundreds of times in noise, so roots are now refined by bisecting the eigenvalue count where the determinant cannot be trusted. Roots are validated before being returned. #18 scsim re-exports burst segmentation from dcio; one implementation in the stack rather than two. #20 the dARSdS sign. exact_mean_open_shut_time was too large by exactly twice the dead time and the conditional means by once. This changes numbers a caller will have seen. #21 impose_resolution checked against RESHJC2.FOR interval for interval. #22 the simulator checked against Q-matrix theory for multi-state mechanisms, not only the two-state case where a reciprocal rate is the answer. #23 qmatlib.H inverted sI - QFF directly, which fails where the root search walks. The singularity is removable; only the factored form has a pole. #24 GPL-2.0-or-later rather than GPL-2.0-only, without which HJCFIT (GPL-3.0-or-later) could not depend on this at all. Both version files are bumped; the versions-agree gate added in #16 is what makes that hard to get wrong. Verified: the built wheel reports 1.2.0, carries its three data files, declares GPL-2.0-or-later, and Rate.is_free is present when installed from it into a clean environment. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
qmatlib.Hevaluatesand computed
X⁻¹directly. That breaks down whenever s reaches an eigenvalue of QFF — and the asymptotic root search that callsHwalks s across exactly that range, because the roots of the determinant equation are interlaced with those eigenvalues.It was never really a pole
X⁻¹ (I − exp(−X·t))is the integral ofexp(−Xu)over[0, t]. The offending mode contributes(1 − e^{−μt})/μ, whose limit as μ → 0 is simplyt. The singularity is removable; only the factored form has a pole.integral_expQtevaluates the integral spectrally, taking that limit explicitly withexpm1for accuracy near zero, andHuses it.How it was found
In the reproduction of Colquhoun, Hatton & Hawkes (2003), 2 of the first 12 fits of one scenario raised
LinAlgError("Singular matrix")out ofHJC_shut_time_pdfat 10 µM. The rates looked entirely ordinary — α₂ = 2119 against a true 2000.The pattern turned out to be that both fits had a rate constant sitting at its upper limit of 1e6, which puts an eigenvalue of
QAAexactly at −1e6 for the shut-time problem to walk into. All twelve now work.The worse failure is the silent one
Whether the old form raised or merely returned nonsense depended on whether the eigenvalue was exactly representable:
That second case is why this is a fix rather than a guard: a
try/exceptwould have papered over the loud failure and left the silent one.dWis deliberately untoucheddWcontains a similar-lookinginv(sI − QFF), and its pole is genuine, not removable —Wreally does have poles at the eigenvalues, which is why the bisection brackets roots between them by design. Changing it would be wrong.Tests
19 in
tests/test_integral_expQt.py. The integral is checkedinv(M)(exp(Mt) − I)on 8 random matrices — agrees to 1e-8;tat the zero mode.and
His checked unchanged away from the pole, and finite and continuous through it.One test failed honestly while I wrote it: I had asserted the old form raises at any computed eigenvalue, and for CH82 it doesn't — the eigenvalue isn't exactly representable, so
invmerely becomes ill-conditioned. The test now asserts the property that actually matters (finiteness and continuity) plus a measured condition number, and the docstrings were corrected to match.Nothing else moves
🤖 Generated with Claude Code