stdlib: complex numbers (#1043) and general eigenvalues (#1042) - #1051
Merged
Conversation
Both found by the phugoid flight-simulator consumer, which hand-rolled
five complex functions plus Faddeev-LeVerrier and Durand-Kerner to extract
flight modes from a 4x4 state matrix.
lib/complex.eigs — arithmetic, polar form, comparison, polynomial roots.
`[re, im]` two-element lists, which is already what engineering.dft
returns, so its output feeds these directly.
linalg.charpoly / linalg.eigenvalues — Faddeev-LeVerrier for any square n,
and the full complex spectrum. linalg stopped at eigenvalues_2x2, which
returns a silent null for ANY complex spectrum: a rotation matrix's
perfectly ordinary +-i read as "no answer". That limitation is now
documented at the function and asserted in the tests, so the contrast with
the general path is recorded rather than left as a surprise.
THE TESTS ARE DIRECT UNIT CHECKS AGAINST HAND-COMPUTED VALUES, and that is
the design rather than an accident. A differential oracle is structurally
BLIND to this arithmetic: phugoid measured a HALVED `div` surviving a full
root-finding differential, because Durand-Kerner self-corrects under a
scaled delta — a wrong step size still converges, just slower. So
`(1+i)/(2+0i)` must be 0.5+0.5i is checked explicitly, since a halved
result still points the same DIRECTION. Planted, that exact fault reds 4
checks; a mul sign flip reds 11; conj-as-neg reds 1; four plants on the
Faddeev-LeVerrier recurrence red 10/5/1/crash.
The 4x4 eigenvalue case is graded by identities that tie the answer back
to the MATRIX rather than the solver: sum of eigenvalues = mat_trace,
product = mat_det (both independently tested), plus a residual check that
each eigenvalue annihilates the characteristic polynomial.
Two things measured rather than assumed while building:
- I had written that division by zero saturates at 1e308. It RAISES
("Error line N: division by zero"). The comment claiming otherwise was
the justification for `div`'s null guard, so it is rewritten to the real
reason: poly_roots DEPENDS on that null, because at a repeated root the
Durand-Kerner denominator is exactly zero and an unguarded `div` makes
every repeated root a crash. (z-1)^2 is in the tests.
- A first phase_spectrum test asserted the Nyquist bin's phase is +pi and
measured -pi. That was the test being wrong: the bin's imaginary part is
a NEGATIVE ZERO, so atan2 takes the -pi branch. Both denote the same
angle and the sign is decided by the sign of a zero, so the test grades
|angle| and complex.arg documents its range as atan2's [-pi, pi] with
that caveat, rather than the tidier-looking (-pi, pi].
ADOPTION, which is what keeps this from being dead code:
engineering.magnitude_spectrum and power_spectrum WERE complex.mag and
complex.mag2, hand-rolled — the duplication #1043 describes, present in
the stdlib itself. They now delegate, verified byte-identical across four
signals. engineering.phase_spectrum is new: a spectrum has a phase as well
as a magnitude, and it was absent because the arithmetic to express it did
not exist. dft's own O(N^2) inner loop is left alone deliberately — routing
each term through complex.mul would add call overhead for an identical
result.
linalg and engineering are the first stdlib modules to import another.
Every lib/*.eigs is installed together by the Makefile, so the dependency
travels with them. Consequence for the AOT, filed as ouroboros#121: it has
no emit case for `import` and refuses loudly, so adopting a stdlib module
costs a consumer its AOT path. Not a regression — the gap predates this and
applies to every module — but these raise its price, and the new transitive
import means a fix must walk a module graph rather than a flat list.
Suite: release 4119/4119, ASan with detect_leaks=1 4108/4108, doc drift
clean (it caught the lib/ module count in ARCHITECTURE.md).
Merged
InauguralPhysicist
added a commit
that referenced
this pull request
Aug 27, 2026
VERSION, CLAUDE.md's "Latest release" line, the CHANGELOG section and docs/llms.txt's stamp — the four sites the v0.41.0 cut touched. Contents since v0.41.0: the stdlib complex/eigenvalues work (#1051, closing #1042 and #1043) — lib/complex.eigs, linalg.charpoly and linalg.eigenvalues, engineering.phase_spectrum with magnitude/power_spectrum delegating to complex.mag/mag2 — plus the github-actions bump (#1050). Cutting now because the tag IS the version pin for consumers: phugoid's devcontainer clones EigenScript at a released tag, lib/complex.eigs does not exist in v0.41.0, and its modes.eigs stdlib swap (45 lines of hand-rolled complex arithmetic and root-finding deleted, chain eigenvalues verified bit-identical) is blocked until this tag exists. CLAUDE.md names a tag that does not exist yet, which is the documented ordering: doc-drift rule 2 compares that line to the latest tag, and the RELEASE build runs after the tag is created, so a stale line fails the release's own suite (it bit the v0.27.0 cut). It does not fail this PR's CI because only the first ci.yml job checks out with fetch-depth: 0 — the suite job's default checkout fetches no tags, so the rule reports "no tags; skipping" there. Verified against the v0.41.0 cut, which passed 18/18 in the same state. Rebuilt and re-ran tests/test_cli.sh because --version is baked in at compile time from VERSION: 15/15, --version prints 0.42.0.
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.
Closes #1043
Closes #1042
Both found by the phugoid flight-simulator consumer, which hand-rolled five complex functions plus Faddeev-LeVerrier and Durand-Kerner to extract flight modes from a 4×4 state matrix.
What
lib/complex.eigs(new) — arithmetic (add/sub/mul/div/neg/conj/scale),mag/mag2/arg,to_polar/from_polar,eq_near, andpoly_eval/poly_roots(Durand-Kerner). Complex numbers are[re, im]two-element lists — already whatengineering.dftreturns, so its output feeds these directly.linalg.charpoly/linalg.eigenvalues— Faddeev-LeVerrier for any square n, and the full complex spectrum.linalgstopped ateigenvalues_2x2, which returns a silent null for any complex spectrum: a rotation matrix's perfectly ordinary ±i read as "no answer". That limitation is now documented at the function and asserted in the tests, so the contrast with the general path is recorded rather than left as a surprise.The tests are direct unit checks, deliberately
A differential oracle is structurally blind to this arithmetic. phugoid measured a halved
divsurviving a full root-finding differential, because Durand-Kerner self-corrects under a scaled delta — a wrong step size still converges, just slower. So the discriminator is explicit:(1+i)/(2+0i)must be0.5+0.5i, since a halved result still points the same direction.Planted faults, all red:
div(the one that beat the differential)mulsign flipconjimplemented asnegThe 4×4 eigenvalue case is graded by identities that tie the answer back to the matrix rather than the solver — sum of eigenvalues =
mat_trace, product =mat_det, both independently tested — plus a residual check that each eigenvalue annihilates the characteristic polynomial.Two things measured rather than assumed
div's null guard, so it is rewritten to the real one:poly_rootsdepends on that null, because at a repeated root the Durand-Kerner denominator is exactly zero and an unguardeddivmakes every repeated root a crash.(z-1)^2is in the tests.phase_spectrumtest asserted the Nyquist bin's phase is+πand measured−π. The test was wrong: that bin's imaginary part is a negative zero, soatan2takes the−πbranch. Both denote the same angle and the sign is decided by the sign of a zero, so the test grades|angle|andcomplex.argdocuments its range as atan2's[-π, π]with the caveat, rather than the tidier-looking(-π, π].Adoption — what keeps this from being dead code
engineering.magnitude_spectrumandpower_spectrumwerecomplex.magandcomplex.mag2, hand-rolled: the duplication #1043 describes, present in the stdlib itself. They now delegate, verified byte-identical across four signals.engineering.phase_spectrumis new — a spectrum has a phase as well as a magnitude, and it was absent because the arithmetic to express it did not exist.dft's own O(N²) inner loop is left alone deliberately: routing each term throughcomplex.mulwould add call overhead for an identical result.Note for the AOT
linalgandengineeringare the first stdlib modules to import another. Everylib/*.eigsis installed together by the Makefile, so the dependency travels with them.The AOT has no emit case for
importand refuses loudly (AOT: cannot emit statement node 'import'), so adopting a stdlib module costs a consumer its AOT path. Not a regression — the gap predates this and applies to every module equally — but these raise its price, and the new transitive import means a fix must walk a module graph rather than a flat list. Filed as InauguralSystems/ouroboros#121.Gates
detect_leaks=14108/4108, no leak tallydoc_drift_check.shclean — it caught thelib/module count indocs/ARCHITECTURE.md🤖 Generated with Claude Code
https://claude.ai/code/session_01Aj9b82JBb8WS3b8ExoV5Yt