Skip to content

Commit 1af513c

Browse files
dimaq12claude
andcommitted
1.4.0 — the deprecation release: epistemic parity, zero breaks
EPIC3 Phase 2. Every old name still works (removals are 2.0-only, MIGRATION.md ships now): - error-bar parity: density/cumulants/extreme grow with_err=True (per-probe scatter, atoms exact); kappa_w grows full=True (the whole per-direction curvature distribution) - the trace split: trace_certified(f, support=) — one name per return shape - defect.defect_barycentres (canonical; spectroscopy stays as legacy alias) - resona.synthesize — the discoverable verb for from_measure - lift.r_inverse / s_inverse — the R/S duals for spectral design (monotone-window check + bisection, round-trip < 1e-10) 108 tests; full-gallery ratchet vs baseline: zero metric diffs (timing columns only) — all 1.4 additions are strictly additive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c889104 commit 1af513c

10 files changed

Lines changed: 342 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,39 @@
33
All notable changes to resona. The discipline throughout: every number below
44
is printed by a test or a gallery stand, not asserted by hand.
55

6+
## [1.4.0] — 2026-06-12
7+
EPIC3 "the deprecation release" — honesty completion, zero breaks (every old
8+
name still works; removals happen in 2.0 only). See MIGRATION.md.
9+
- **EPISTEMIC PARITY** — the 2.0 north star lands early: every stochastic
10+
read now offers its scatter. `density(xs, with_err=True)`,
11+
`cumulants(order, with_err=True)` (nonlinearity propagated by per-probe
12+
recomputation, not linearization), `extreme(with_err=True)` (a
13+
reproducibility bar, stated as such), `wkernel.kappa_w(..., full=True)`
14+
(the whole per-direction distribution — curvature anisotropy).
15+
- **The trace split**: `s.trace_certified(f, support=)` — one name per
16+
return shape; `trace(certified=True)` keeps working through 1.x.
17+
- **`defect.defect_barycentres`** — the honest canonical name
18+
(`spectroscopy` promised a spectrum it does not deliver; it remains as a
19+
legacy alias until 2.0).
20+
- **`resona.synthesize`** — the discoverable verb for `from_measure`
21+
(operator construction; both names stay).
22+
- **`lift.r_inverse` / `lift.s_inverse`** — the missing duals of the R/S
23+
transforms for spectral design: monotone-window check + bisection to
24+
machine tightness (round-trip < 1e-10 in tests).
25+
- **The measured retrofits** (EPIC3 Phase 0/1 — every delta measured in a
26+
scratch BEFORE the gallery; falsified predictions recorded in
27+
.audit/EPIC3_phase0.md): Chern marker via a precomputed Jackson/Chebyshev
28+
projector (C = +0.985 at L=20 in seconds; 7× faster at L=12);
29+
Heisenberg thermodynamics by deflating −H (ground states become exact
30+
atoms: max Z err 19.4% → 0.09% at L=8, 16.3% → 0.67% at L=12); GP log-det
31+
deflate=64 (0.84% → 0.53%); Koopman VdP at 4× data (|Δf| = 3e-4, finer
32+
than the FFT reference's own 1e-3 bin).
33+
- **Persona landing pages** (docs/personas/): five half-page doors — quants,
34+
physicists, ML, dynamics, numerical analysts — each mapping the persona's
35+
first five tasks to five verified calls.
36+
- 108 tests; full-gallery ratchet: only the four retrofitted stands moved,
37+
all by improvement; default paths bit-identical.
38+
639
## [1.3.0] — 2026-06-12
740
EPIC2 "the defect listens back" — every port stress-tested in the source
841
corpus FIRST (FA/revise_stress/STRESS_REPORT.md: 4 of 5 gold survived; the

MIGRATION.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ bit-frozen default paths, same numbers.
1616

1717
## New surface in 1.4 (no old equivalent)
1818

19-
- `resona.free.r_inverse` / `resona.free.s_inverse` — the missing duals of the
20-
R/S-transforms, for spectral design (monotone, bisectable).
19+
- `resona.lift.r_inverse` / `resona.lift.s_inverse` — the missing duals of the
20+
R/S-transforms, for spectral design (monotone window required, bisected to
21+
machine tightness; they live beside `r_transform`/`s_transform`).
2122
- **Error-bar parity**: `density(xs, with_err=True)`, `cumulants(n, with_err=True)`,
2223
`extreme(with_err=True)`, `wkernel.kappa_w(..., full=True)` — every
2324
stochastic read now offers its scatter, the same way `trace`/`moment`/

docs/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,17 @@ matvec = lambda v: A @ v # your operator, however you can apply it
4141
| collapse the variance on a spiked operator (Hutch++) | `resona.of(mv, N, deflate=K)` | [reading-spectra](reading-spectra.md) ||
4242
| high-resolution density without reorthogonalization | `resona.of(mv, N, k=256, engine="kpm")` | [reading-spectra](reading-spectra.md) ||
4343
| get a CERTIFIED bracket (the answer provably inside) | `resona.quadform(mv, "inv", v, certified=True, support=(a,None))` | [precision-and-defects](precision-and-defects.md) | [`certified_logdet.py`](../examples/certified_logdet.py) |
44-
| certify the k-truncation of a trace estimate | `s.trace("log", certified=True, support=(a,None))` | [precision-and-defects](precision-and-defects.md) | [`certified_logdet.py`](../examples/certified_logdet.py) |
44+
| certify the k-truncation of a trace estimate | `s.trace_certified("log", support=(a,None))` | [precision-and-defects](precision-and-defects.md) | [`certified_logdet.py`](../examples/certified_logdet.py) |
45+
| error bars on density / cumulants / extremes | `s.density(xs, with_err=True)` etc. — every stochastic read has one | [reading-spectra](reading-spectra.md) ||
4546
| resolve INTERIOR eigenvalues (spectrum slicing) | `s.zoom(a, b)` → polish nodes | [reading-spectra](reading-spectra.md) | [`spectra_to_machine_precision.py`](../examples/spectra_to_machine_precision.py) |
4647
| check how close a disorder computation is to the critical edge | `resona.subordination.contraction(s, xs, σ²)` → compare to 1 | [composing-operators](composing-operators.md) ||
4748
| read the Koopman generator out of a legacy solver's error | `resona.defect.generator_read(P_n, P_2n, t, n)` | [precision-and-defects](precision-and-defects.md) | [`defect_spectroscopy.py`](../examples/defect_spectroscopy.py) |
48-
| read a band spectrum from the same error (blind-zone-free) | `resona.defect.spectroscopy(power, bands, coords)` | [precision-and-defects](precision-and-defects.md) | [`defect_spectroscopy.py`](../examples/defect_spectroscopy.py) |
49+
| read per-band barycentres from the same error (blind-zone-free) | `resona.defect.defect_barycentres(power, bands, coords)` | [precision-and-defects](precision-and-defects.md) | [`defect_spectroscopy.py`](../examples/defect_spectroscopy.py) |
4950
| follow eigenvalues through CROSSINGS along a parameter path | `resona.wkernel.track(A0, Bs, path)` | [inverse-problems](inverse-problems.md) ||
5051
| size a safe parameter step (trust region for frozen W) | `resona.wkernel.kappa_w(A0, Bs, k0)` | [inverse-problems](inverse-problems.md) ||
5152
| disorder-average a DOS (no realizations) | `resona.subordination.averaged_dos(sA,σ,xs)` | [composing-operators](composing-operators.md) | [`anderson_localization.py`](../examples/anderson_localization.py) |
52-
| CONSTRUCT an operator with a prescribed spectrum | inverse-CDF levels → `resona.from_measure(levels, 1/N)` | [inverse-problems](inverse-problems.md) | [`spectral_phenomena/operator_synthesis.py`](../examples/spectral_phenomena/operator_synthesis.py) |
53+
| CONSTRUCT an operator with a prescribed spectrum | inverse-CDF levels → `resona.synthesize(levels, 1/N)` | [inverse-problems](inverse-problems.md) | [`spectral_phenomena/operator_synthesis.py`](../examples/spectral_phenomena/operator_synthesis.py) |
54+
| invert an R/S-transform for spectral design | `resona.lift.r_inverse(s, target)` | [composing-operators](composing-operators.md) ||
5355
| probe a NON-HERMITIAN operator (Markov, Koopman, damping) | `resona.cloud(mv, N)``.radius() .abscissa() .nodes` | [reading-spectra](reading-spectra.md) | [`science/koopman_dynamics.py`](../examples/science/koopman_dynamics.py) |
5456
| read a topological invariant matrix-free | P = `apply(H, step, v)` chains → Chern marker | [solving-and-evolving](solving-and-evolving.md) | [`quantum/chern_from_noise.py`](../examples/quantum/chern_from_noise.py) |
5557
| turn a TIME SERIES into an operator | `mv, rmv, r = resona.lift.koopman(snapshots)` | [lifting-nonlinear](lifting-nonlinear.md) | [`science/koopman_dynamics.py`](../examples/science/koopman_dynamics.py) |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "resona"
7-
version = "1.3.0"
7+
version = "1.4.0"
88
description = "The FFT of operators: probe, compose, read — matrix-free spectral computation with a built-in cost law."
99
readme = "README.md"
1010
requires-python = ">=3.9"

resona/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@
1919
# for self-adjoint operators; cloud for general)
2020
resona.solve.rayleigh_polish # PRECISION — spend effort only on the defect
2121
"""
22-
from .spectral import Spectral, apply, quadform, local_spectrum, local_density, from_measure, from_eigenbasis
22+
from .spectral import (Spectral, apply, quadform, local_spectrum, local_density,
23+
from_measure, from_eigenbasis, synthesize)
2324
from . import wkernel, lift, beta, defect, free, subordination, cost, flow, solve, thermal
2425
from .cloud import cloud, Cloud
2526

2627
#: convenience: ``resona.of(matvec, N)`` == ``Spectral.of(matvec, N)``
2728
of = Spectral.of
2829

29-
__version__ = "1.3.0"
30+
__version__ = "1.4.0"
3031
__all__ = ["Spectral", "of", "apply", "quadform", "local_spectrum", "local_density",
31-
"from_measure", "from_eigenbasis", "cloud", "Cloud", "wkernel", "lift", "beta",
32-
"defect", "free", "subordination", "cost", "flow", "solve", "thermal"]
32+
"from_measure", "from_eigenbasis", "synthesize", "cloud", "Cloud",
33+
"wkernel", "lift", "beta", "defect", "free", "subordination", "cost",
34+
"flow", "solve", "thermal"]

resona/defect.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,14 @@ def generator_read(P_n, P_2n, t, n, solver="be"):
181181
return (4.0 * n / t ** 2) * D
182182

183183

184-
def spectroscopy(power, bands, coords=None):
184+
def defect_barycentres(power, bands, coords=None):
185185
"""COMPRESS each band of a defect power distribution to ONE coordinate —
186-
its energy barycentre (the BDS read). Despite the name, this does not
187-
return a spectrum: it returns one (location, amplitude) pair per band —
188-
that compression is exactly what survives noise where ratio estimators
189-
die.
186+
its energy barycentre (the BDS read). One (location, amplitude) pair per
187+
band — that compression is exactly what survives noise where ratio
188+
estimators die.
189+
190+
(1.4) This is the canonical name; `spectroscopy` is the legacy alias —
191+
it promised a spectrum it does not deliver — and is removed in 2.0.
190192
191193
`power` : |D̂|² — the defect's energy in the caller's diagonalizing
192194
basis (resona does NOT choose your basis: you transform).
@@ -219,3 +221,7 @@ def spectroscopy(power, bands, coords=None):
219221
kbar.append(float((coords[b] * w).sum() / tot))
220222
signal.append(float(np.sqrt(tot)))
221223
return np.array(kbar), np.array(signal)
224+
225+
226+
#: legacy alias (≤1.3 name) — same function; removed in 2.0 (see MIGRATION.md)
227+
spectroscopy = defect_barycentres

resona/lift.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,52 @@ def s_transform(s, w):
8181
return float(out[0]) if np.isscalar(w) else out
8282

8383

84+
def r_inverse(s, value, w_max=1.0, n=2001):
85+
"""w such that R(w) = value — the dual of `r_transform`, for spectral
86+
DESIGN (choose the coordinate w that realizes a target R). Scalar or
87+
array `value`.
88+
89+
Samples R on (0, w_max], requires monotonicity there (raises otherwise —
90+
the inverse is ill-posed; shrink `w_max`), then bisects to machine
91+
tightness. R is analytic with R'(0) = κ₂ > 0, so a monotone window
92+
always exists near 0."""
93+
ws = np.linspace(w_max / n, w_max, n)
94+
rs = r_transform(s, ws)
95+
return _monotone_invert(lambda w: r_transform(s, w), ws, rs, value,
96+
"R", w_max)
97+
98+
99+
def s_inverse(s, value, w_max=1.0, n=2001):
100+
"""w such that S(w) = value — the dual of `s_transform` (positive
101+
spectra), same contract as `r_inverse`."""
102+
ws = np.linspace(w_max / n, w_max, n)
103+
rs = s_transform(s, ws)
104+
return _monotone_invert(lambda w: s_transform(s, w), ws, rs, value,
105+
"S", w_max)
106+
107+
108+
def _monotone_invert(fn, ws, rs, value, name, w_max):
109+
d = np.diff(rs)
110+
if not (np.all(d > 0) or np.all(d < 0)):
111+
raise ValueError(f"{name} is not monotone on (0, {w_max}]: the "
112+
"inverse is ill-posed there — shrink w_max")
113+
sign = 1.0 if d[0] > 0 else -1.0
114+
v = np.atleast_1d(np.asarray(value, float))
115+
lo_r, hi_r = min(rs[0], rs[-1]), max(rs[0], rs[-1])
116+
if v.min() < lo_r or v.max() > hi_r:
117+
raise ValueError(f"value outside {name}'s range [{lo_r:.6g}, "
118+
f"{hi_r:.6g}] on (0, {w_max}]")
119+
idx = np.searchsorted(sign * rs, sign * v).clip(1, len(ws) - 1)
120+
lo, hi = ws[idx - 1].astype(float), ws[idx].astype(float)
121+
for _ in range(60): # machine-tight
122+
mid = 0.5 * (lo + hi)
123+
below = sign * np.atleast_1d(fn(mid)) < sign * v
124+
lo = np.where(below, mid, lo)
125+
hi = np.where(below, hi, mid)
126+
out = 0.5 * (lo + hi)
127+
return float(out[0]) if np.isscalar(value) else out
128+
129+
84130
def free_convolution(sA, sB, order=6):
85131
"""Moments of A ⊞ B (free additive convolution) from the two spectra ALONE.
86132

resona/spectral.py

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,8 @@ def trace(self, f, with_err: bool = False, certified: bool = False,
831831
plus ``support=(a, b)`` with the needed endpoint) → (lo, hi), the
832832
Gauss–Radau bracket (Golub–Meurant) of the K-TRUNCATION error: the
833833
fully-converged SLQ value of these same probes provably lies inside.
834+
(1.4+: prefer `trace_certified` — one name per return shape; this
835+
spelling remains through 1.x and is removed in 2.0.)
834836
It does NOT certify the Monte-Carlo probe scatter — that is a separate
835837
error source, reported by ``with_err``; the two are stated apart by
836838
design. For an unconditional certificate of a probe-free quantity,
@@ -875,19 +877,69 @@ def trace(self, f, with_err: bool = False, certified: bool = False,
875877
stderr = float(np.std(ests, ddof=1) / np.sqrt(p))
876878
return total, stderr
877879

880+
def trace_certified(self, f, support=None):
881+
"""(lo, hi) — the Gauss–Radau bracket of Tr f(A)'s k-truncation.
882+
883+
The 1.4+ canonical name for ``trace(f, certified=True, support=…)``
884+
(one name per return shape: `trace` returns a number, this returns
885+
the bracket). f must be a family name ('log', 'inv', 'sqrt',
886+
'exp'); see `trace` for the full epistemics."""
887+
return self.trace(f, certified=True, support=support)
888+
878889
def moment(self, p: int, with_err: bool = False):
879890
"""Tr(A^p). ``with_err=True`` → (value, stderr), see `trace`."""
880891
return self.trace(lambda x: x ** p, with_err=with_err)
881892

882-
def density(self, xs, eta: float = 0.1) -> np.ndarray:
883-
"""Density of states ρ(x), Lorentzian-broadened by `eta`."""
893+
def _probe_blocks(self):
894+
"""(sizes, n_atoms) — the per-probe node-block structure, validated."""
895+
if not self.probe_sizes or len(self.probe_sizes) < 2:
896+
raise ValueError("error bars need the probe structure from "
897+
"Spectral.of with probes >= 2")
898+
used = int(np.sum(self.probe_sizes))
899+
return list(self.probe_sizes), len(self.nodes) - used
900+
901+
def density(self, xs, eta: float = 0.1, with_err: bool = False):
902+
"""Density of states ρ(x), Lorentzian-broadened by `eta`.
903+
904+
``with_err=True`` → (rho, stderr): the per-x standard error from the
905+
independent-probe scatter (same epistemics as `trace`; deflate atoms
906+
are exact and contribute no scatter)."""
884907
xs = np.atleast_1d(np.asarray(xs, float))
885-
return (self.weights[None, :] * (eta / np.pi)
886-
/ ((xs[:, None] - self.nodes[None, :]) ** 2 + eta ** 2)).sum(1)
908+
ker = (self.weights[None, :] * (eta / np.pi)
909+
/ ((xs[:, None] - self.nodes[None, :]) ** 2 + eta ** 2))
910+
rho = ker.sum(1)
911+
if not with_err:
912+
return rho
913+
sizes, _ = self._probe_blocks()
914+
p = len(sizes)
915+
ests, i = [], 0
916+
for sz in sizes:
917+
ests.append(p * ker[:, i:i + sz].sum(1))
918+
i += sz
919+
atom = ker[:, i:].sum(1) # exact, zero scatter
920+
ests = np.stack([e + atom for e in ests])
921+
return rho, np.std(ests, axis=0, ddof=1) / np.sqrt(p)
922+
923+
def extreme(self, with_err: bool = False):
924+
"""Extreme eigenvalues (Lanczos resolves these first / most reliably).
887925
888-
def extreme(self) -> tuple[float, float]:
889-
"""Extreme eigenvalues (Lanczos resolves these first / most reliably)."""
890-
return float(self.nodes.min()), float(self.nodes.max())
926+
``with_err=True`` → ((lo, hi), (lo_err, hi_err)): the standard error
927+
of the per-probe extreme reads — a REPRODUCIBILITY bar (how much the
928+
read moves probe to probe), not a certified enclosure."""
929+
lo, hi = float(self.nodes.min()), float(self.nodes.max())
930+
if not with_err:
931+
return lo, hi
932+
sizes, _ = self._probe_blocks()
933+
p = len(sizes)
934+
atoms = self.nodes[int(np.sum(sizes)):]
935+
mins, maxs, i = [], [], 0
936+
for sz in sizes:
937+
blk = self.nodes[i:i + sz]
938+
cand = np.concatenate([blk, atoms]) if len(atoms) else blk
939+
mins.append(float(cand.min())); maxs.append(float(cand.max()))
940+
i += sz
941+
return (lo, hi), (float(np.std(mins, ddof=1) / np.sqrt(p)),
942+
float(np.std(maxs, ddof=1) / np.sqrt(p)))
891943

892944
# ── TRANSFORMS (the lifted coordinates, read off the measure) ─────────────
893945
def cauchy(self, z):
@@ -911,12 +963,31 @@ def s(self, w):
911963
r_transform = r # full-name aliases (same methods; pick what reads
912964
s_transform = s # better at the call site)
913965

914-
def cumulants(self, order: int = 6):
966+
def cumulants(self, order: int = 6, with_err: bool = False):
915967
"""Free cumulants κ_1..κ_order — the canonical coordinates in which
916-
composition is ADDITION (κ_n(A⊞B) = κ_n(A) + κ_n(B))."""
968+
composition is ADDITION (κ_n(A⊞B) = κ_n(A) + κ_n(B)).
969+
970+
``with_err=True`` → (kappa, stderr): per-cumulant standard error from
971+
the independent-probe scatter (each probe's moments → its cumulants;
972+
the nonlinearity is propagated by recomputation, not linearization)."""
917973
from .free import free_cumulants
918974
m = [self.moment(p) / self.N for p in range(1, order + 1)]
919-
return free_cumulants(m)
975+
kappa = free_cumulants(m)
976+
if not with_err:
977+
return kappa
978+
sizes, _ = self._probe_blocks()
979+
p = len(sizes)
980+
per_probe = []
981+
for r in range(p):
982+
m_r = []
983+
for q in range(1, order + 1):
984+
vals = self.weights * (self.nodes ** q)
985+
i = int(np.sum(sizes[:r]))
986+
blk = float(np.sum(vals[i:i + sizes[r]])) * p
987+
atom = float(np.sum(vals[int(np.sum(sizes)):]))
988+
m_r.append(blk + atom)
989+
per_probe.append(free_cumulants(m_r))
990+
return kappa, np.std(np.stack(per_probe), axis=0, ddof=1) / np.sqrt(p)
920991

921992
# ── FLOW / DISORDER (the resolvent fixed point, vectorized) ───────────────
922993
def flow(self, t, xs, eta: float = 1e-3, g0=None):
@@ -1235,6 +1306,13 @@ def from_measure(nodes, weights, k=None):
12351306
return _lanczos(lambda x: lam * x, np.sqrt(np.clip(w, 0.0, None)), k or len(lam))
12361307

12371308

1309+
def synthesize(nodes, weights, k=None):
1310+
"""CONSTRUCT an operator with the prescribed spectral measure — the
1311+
discoverable verb (1.4+) for `from_measure` (the same function; both
1312+
names stay, `from_measure` remains the precise synonym)."""
1313+
return from_measure(nodes, weights, k=k)
1314+
1315+
12381316
def from_eigenbasis(eigenvalues, eigenvectors):
12391317
"""Reconstruct a Jacobi (tridiagonal) operator's band from its FULL
12401318
eigendecomposition A = V·diag(λ)·Vᵀ — EXACT (machine precision) for ANY

0 commit comments

Comments
 (0)