Skip to content

stdlib: complex numbers (#1043) and general eigenvalues (#1042) - #1051

Merged
InauguralPhysicist merged 1 commit into
mainfrom
stdlib-complex-eigenvalues
Aug 27, 2026
Merged

stdlib: complex numbers (#1043) and general eigenvalues (#1042)#1051
InauguralPhysicist merged 1 commit into
mainfrom
stdlib-complex-eigenvalues

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

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, and poly_eval/poly_roots (Durand-Kerner). Complex numbers are [re, im] two-element lists — 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, deliberately

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 the discriminator is explicit: (1+i)/(2+0i) must be 0.5+0.5i, since a halved result still points the same direction.

Planted faults, all red:

plant result
halved div (the one that beat the differential) 4 checks red
mul sign flip 11 red
conj implemented as neg 1 red
Faddeev-LeVerrier sign / no-divide / drops-last / unguarded non-square 10 / 5 / 1 / loud crash

The 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

  • I had written that division by zero saturates at 1e308. It raises. That wrong claim was the stated justification for div's null guard, so it is rewritten to the real one: 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 and measured −π. The test was wrong: that bin's imaginary part is a negative zero, so atan2 takes the −π 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 [-π, π] with the caveat, rather than the tidier-looking (-π, π].

Adoption — 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²) inner loop is left alone deliberately: routing each term through complex.mul would add call overhead for an identical result.

Note for the AOT

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.

The AOT has no emit case for import and 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

  • release suite 4119/4119
  • ASan with detect_leaks=1 4108/4108, no leak tally
  • doc_drift_check.sh clean — it caught the lib/ module count in docs/ARCHITECTURE.md

🤖 Generated with Claude Code

https://claude.ai/code/session_01Aj9b82JBb8WS3b8ExoV5Yt

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).
@InauguralPhysicist
InauguralPhysicist merged commit 42b0714 into main Aug 27, 2026
18 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the stdlib-complex-eigenvalues branch August 27, 2026 20:44
@InauguralPhysicist InauguralPhysicist mentioned this pull request Aug 27, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant