Skip to content

Self-consistent and full-SALT modal-intensity solvers (issue #42)#43

Open
arnaudon wants to merge 63 commits into
masterfrom
claude/full-salt-solvers
Open

Self-consistent and full-SALT modal-intensity solvers (issue #42)#43
arnaudon wants to merge 63 commits into
masterfrom
claude/full-salt-solvers

Conversation

@arnaudon

@arnaudon arnaudon commented Jun 6, 2026

Copy link
Copy Markdown
Owner

What this does

The lasing L–I curves were computed with a single linearised, near-threshold SALT
model: one pump-independent competition matrix T built from threshold profiles,
plus a linear solve (piecewise-linear curves). This adds three opt-in solvers
alongside it, selected by a new intensity_method config key (default "linear",
validated as a Literal) and dispatched in step_compute_modal_intensities.

This addresses both halves of the spatial-hole-burning approximation tracked in
issue #42.

self_consistent (relaxes approximation #2)

Rebuilds T at the operating pump (compute_mode_competition_matrix_at_pump)
instead of at each mode's own threshold, while reusing the exact event-driven
activation/vanishing sweep, factored out as _modal_intensity_sweep. Keeps the
linear gain saturation.

full_salt (relaxes #1 + #2, experimental)

Folds the per-edge spatial-hole-burning denominator 1 + Σ_ν Γ_ν a_ν |Ψ_ν(x)|²
in via the new dispersion_relation_pump_saturated plus a damped fixed point in
the modal intensities at each pump, on top of the same sweep — so the gain clamps
and the L–I curves bend over. intensity_oversample_size refines the within-edge
saturation.

full_salt_newton (operator-level multimode SALT, experimental)

Solves the real nonlinear SALT eigenproblem rather than saturating the competition
matrix: at each pump it finds, for every lasing mode, the real frequency k_μ
and amplitude a_μ ≥ 0 so the shared saturated operator L_sat
(dispersion_relation_pump_saturated) is singular at each real k_μ
simultaneously. Two ingredients make the multimode solve robust:

  • Frozen-field trust-region solve. For a fixed active set the saturated
    background fields are frozen while a bounded trust-region least-squares solves
    all (k_μ, a_μ); the fields are then refreshed and the step repeated. The frozen
    field makes each residual a single clean eigensolve — a noise-free Jacobian.
  • Self-consistent active set. The pump is stepped up; the confirmed lasing set
    is solved, vanished modes are dropped, and a candidate is added when it has net
    gain on the current saturated background — found from the saturated operator,
    not borrowed from the linear model.

It is built to reduce to linear near threshold (so all four solvers share
units and overlay), and above threshold it adds the genuine full-SALT correction:
the L–I curves bend over and the secondary modes' intensities/onsets shift as the
spatial holes deepen. It is deterministic, path-independent, and never raises.

Validated against Ge–Chong–Stone (Phys. Rev. A 82, 063824, Eq. 28) on the 1D
line_PRA cavity — see the validation section below. This required fixing a
real over-suppression bug (also below).

Validation vs. Ge–Chong–Stone, and the over-suppression fix

The 1D line_PRA example (non-uniform index + partial pump) sits squarely in the
regime of Ge–Chong–Stone's single-pole SALT, whose Eq. 28 lases two modes
there. Initially full_salt_newton lased one — it was over-suppressing.

A steady-state probe pinned the cause: the operator-level hole burning sampled
|E_ν(x)|² as the per-edge mean, which washes out the standing-wave
nodes/antinodes, over-estimates the mode overlap, and over-clamps — the
two-mode SPA state is not even singular in the operator. The fix is to resolve
the within-edge field
: oversample_size=None now auto-picks a
wavelength-resolving sub-edge size (_auto_oversample_size). With it,
full_salt_newton lases both modes on line_PRA, intensities within a few
percent of the linear/Ge values near threshold, with the full-SALT bend above.
This overturns an earlier claim in this PR that newton "lases fewer modes =
gain-clamping suppression" — that was the under-resolution artifact, now corrected
throughout the docs/examples. On the simple line/ring cavities newton now agrees
with linear on the count (2/2), differing only in the above-threshold bend.

(An earlier, separate fix in this PR removed a spurious onset jump: the active-set
add cutoff was tightened from α < -1e-4 to -1e-6, with the a < 1e-4 drop rule
as the safety net, so modes turn on at their true α<0 crossing — continuous,
grid-independent onsets.)

Examples: examples/intensity_methods/

A runnable comparison of the four solvers with a physics walkthrough (README.md):

  • compare_intensity_methods.py — the four L–I curves on a Fabry–Pérot line,
    a ring, and a binary-tree splitter.
  • simple_graphs_compare.py — geometry + linear (dashed) vs
    full_salt_newton (solid) on those three cavities: newton agrees with linear
    on the count
    (line/ring lase 2, tree 1) and bends below it above threshold.
  • two_ring_multimode.py — a detuned two-ring "photonic molecule"; the modes
    localise on separate rings and full_salt_newton lases several at once.
  • chaotic_ring_multimode.py — a single 14-node ring with 6 random chords
    (the buffon mechanism shrunk down): dense, spatially-distinct spectrum,
    full_salt_newton lases 4 modes; geometry + full-range L–I + an onset zoom.
  • dense_ring_compare.py — a 16-node/10-chord ring; newton vs linear, showing
    the dominant mode tracking linear and the secondaries reshuffled above threshold.

All chord layouts are hard-coded for RNG-independent reproducibility.

Performance & scaling

The Newton solve is operator-level and nested; its cost is dominated by the
per-mode eigensolves on the (oversampled) saturated operator. Three things make it
fast and scalable (GPU was investigated and rejected — many tiny latency-bound
sparse eigensolves, a poor GPU fit):

  • ARPACK for the eigensolve, with a measured threshold. The dense fast path
    (np.linalg.eig) is O(N³) and catastrophic in the 120–256 range; ARPACK
    shift-invert on the banded quantum-graph laplacian is ~flat in N. Measured
    (oversampled line_PRA): N=60 → 4.5 ms dense / 2.5 ms ARPACK; N=120 → 24 ms /
    2.7 ms; N=250 → 158 ms / 3 ms; N=1000 → 6 ms ARPACK. DENSE_EIG_MAX was
    therefore lowered 256 → 50 (the measured crossover), which both scales the
    oversampled solves and speeds medium-graph mode finding. ARPACK handles the
    near-singular lasing point via the existing regularisation fallback (accuracy
    unchanged). node_cap raised 1200 → 3000 so large graphs keep full within-edge
    resolution. This is what lets full_salt_newton scale to large graphs.
  • Don't over-converge the inner solve. The trust-region (k,a) least-squares
    is evaluated against a frozen field, so converging it to 1e-10 over 12
    field-refreshes was wasted; relaxed to tol 1e-6 / max_nfev 30 / 6 refreshes
    (the outer field loop is the real convergence loop).
  • Per-mode multiprocessing for the independent refine/field solves
    (bit-identical to serial, gated on NEWTON_MP_MIN_MODES/n_workers).

Net: line_PRA newton 47 s → 14 s (~3.3×), byte-identical result (lases 3,4;
I=0.99/0.78). An analytic coherent within-edge hole-burning integral (closed
form per edge, as the competition matrix already does) would drop oversampling
entirely — now an optional refinement, no longer required for scaling.

Design notes

  • Linear path is byte-identical: compute_modal_intensities is a thin wrapper
    over _modal_intensity_sweep; the functional byte-diff test passes unchanged.
  • All solvers reduce to linear at threshold (verified numerically and in tests).
  • Guaranteed termination: bounded matrix-rebuild grid, sweep event cap, capped
    Newton sub-solve budgets.
  • mode_on_nodes / flux_on_edges / mean_mode_on_edges gain a check_quality
    flag so a profile can be evaluated on a graph pumped above the mode's threshold.

Benchmark + docs

  • benchmark/bench_salt.py swaps only the intensity step; overlaid L–I curves,
    within-edge convergence study, and the Newton-vs-linear comparison (now reporting
    agreement with the resolved hole burning).
  • doc/source/lasing.rst documents the solver hierarchy, the resolution
    requirement (with a warning), and the Ge Eq. 28 validation. examples/intensity_methods/
    carries the runnable comparisons and multimode demos.

Verification

  • Full unit + functional suite: 115 passed (~75 s) — incl. multimode
    regression tests (two-ring, chaotic ring), the onset-slope reduction test, and
    the linear byte-diff test (unchanged; its 11-node graph stays on the dense
    path, so the DENSE_EIG_MAX change doesn't touch it).
  • Coverage ≥ 70% gate. ruff clean; sphinx-build -W clean.

Honest caveats

  • full_salt / full_salt_newton are experimental / opt-in. full_salt is a
    per-edge-mean surrogate (not fully converged at coarse within-edge resolution —
    the benchmark reports this). The event-sweep surrogates also become numerically
    erratic in the deep-multimode regime
    , where only linear and full_salt_newton
    stay reliable.
  • full_salt_newton requires resolving the within-edge field (auto-oversampled
    by default) — the per-edge mean over-clamps. It remains the heaviest of the four
    solvers, but ARPACK keeps the oversampled eigensolve cheap so it scales; the
    competition-matrix methods are still the cheaper first pass for the lasing count.
  • No ground-truth full-SALT solver lives in-repo; accuracy is validated by
    reduction-to-linear, the Ge Eq. 28 two-mode result on line_PRA, physical
    direction (bend-over), determinism, and path-independence.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC


Generated by Claude Code

claude added 30 commits June 6, 2026 09:58
The lasing L-I curves were computed with a single linearised, near-threshold
SALT model: one pump-independent competition matrix T built from threshold
profiles, plus a linear solve. This adds two opt-in alternatives alongside it
and a benchmark comparing all three.

New `intensity_method` config key (default "linear", validated as a Literal),
dispatched in `step_compute_modal_intensities`:

- `self_consistent`: rebuilds T at the operating pump
  (`compute_mode_competition_matrix_at_pump`) while reusing the exact
  event-driven activation/vanishing sweep, factored out as
  `_modal_intensity_sweep`. Relaxes the frozen-threshold-profile approximation;
  keeps linear saturation. Byte-identical to the old path when T is
  pump-independent.
- `full_salt` (experimental): folds the per-edge spatial-hole-burning
  denominator in via the new `dispersion_relation_pump_saturated` and a damped
  fixed point in the modal intensities at each pump, on top of the same sweep,
  so the gain clamps and the L-I curves bend over. `intensity_oversample_size`
  refines the within-edge saturation.

Both reduce to the linear model at threshold (asserted in tests). The expensive
matrix rebuilds are snapped onto a bounded `salt_D0_steps` pump grid, and the
sweep has a safety event cap, so the solvers always terminate.

`mode_on_nodes`/`flux_on_edges`/`mean_mode_on_edges` gain a `check_quality`
flag so profiles can be evaluated on a graph pumped above a mode's threshold.

`benchmark/bench_salt.py` runs the shared pipeline once, swaps only the
intensity step, and reports speed + accuracy with overlaid L-I curves and an
oversample convergence study. Docs (`lasing.rst`) and the line_PRA /
buffon_multimode examples document the knob. Adds unit tests for the saturated
dispersion, the quality-flag plumbing, the intensity-solve helpers, and the
pipeline dispatch.
Adds intensity_method="full_salt_newton": instead of saturating the competition
matrix (the full_salt surrogate), this solves the real nonlinear SALT
eigenproblem for the dominant mode. At each pump it finds (k, a) such that the
saturated operator L_sat (dispersion_relation_pump_saturated) is singular at
real k, via a bounded trust-region least-squares evaluated only on the real axis
(keeps ARPACK well-conditioned and excludes the spurious k=0 / a->inf branches),
with a damped Picard for the hole-burning field and a fixed ARPACK seed for
determinism. Within-edge saturation is resolved by oversample_graph.

Validated on line_PRA (single mode): reproduces the linear onset slope
1/(T_mumu*D0_thr) to ~1%, deterministic and path-independent in the D0 grid,
confirming the single-mode L-I is ~linear with only a small profile-deformation
correction. It warns and solves only the dominant mode when several would lase
(multimode competition is the natural follow-up) and never raises: points where
the eigen-solve fails freeze the warm-start.

- modes.py: compute_modal_intensities_full_salt_newton + helpers
  (_solve_single_mode_salt, _real_k_quality, _saturated_graph_at,
  _single_mode_field_intensity), reusing mode_quality(complex_eigenvalue),
  _graph_norm, mean_mode_on_edges, oversample_graph, graph_with_params.
- params.py / pipeline.py: extend the intensity_method Literal and dispatch.
- bench_salt.py: overlay the single-mode Newton curve vs the linear dominant-mode
  prediction and report the onset-slope ratio.
- tests: dispatch + Literal + saturated-graph reduction / hole-burning / field
  helpers.
- doc/lasing.rst: document the solver as the operator-level template for a future
  robust multimode SALT solver.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Default inner_max_iter 10->25 and damping 0.5->0.8: the per-pump field Picard
was under-converged at a couple of points, giving ~7% slope noise. With the
tighter inner loop the single-mode line_PRA curve is smooth (slope std/mean
~0.3%), deterministic, and emits no spurious non-convergence warnings.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Generalises the single-mode Newton solver to the coupled multimode SALT
eigenproblem. At each pump it solves, for every active mode, (k_μ, a_μ) so the
shared saturated operator L_sat is singular at each real k_μ.

The naive monolithic 2M (k,a) least-squares was unstable (a weak mode's
amplitude chattered between 0 and ~1, perturbing the dominant mode). The robust
design is decoupled:
- inner frequency/profile fixed point (_converge_modes_fields) with continuous
  mode-following: warm-started *local* complex-k refines (_refine_local) so each
  mode tracks itself across pump and ARPACK cannot swap modes;
- outer bounded M-dim amplitude least-squares (_solve_amplitudes) driving every
  alpha_μ -> 0, with a≥0 so a non-lasing candidate is pushed to a=0.
Activation is borrowed from the linear model's interacting thresholds.

On line_PRA: deterministic and path-independent; the dominant mode's onset slope
still matches the linear 1/(T_μμ·D0_thr); and full SALT lases *fewer* modes than
the linear model (mode 4 is suppressed by mode 3's gain saturation) -- the
physical gain-clamping effect the linear model misses. Never raises. It is
expensive (a nested per-pump solve, ~10 s/pump on the 11-node line_PRA), so use a
modest salt_D0_steps; the Newton sub-solve budgets are capped independently of
params["max_steps"].

bench_salt.py now contrasts the Newton active set with the linear one; docs and
the params Literal comment updated to describe the multimode behaviour.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Profiling the multimode solver showed 64% of time in the amplitude
least-squares finite-difference Jacobian, with the inner frequency/profile fixed
point running ~15 iterations because its break tolerance was as tight as the
outer solve. Two issues fixed:

- Inner fixed point now converges to a looser inner_tol (1e-6) instead of the
  outer tol (1e-8); the amplitude least-squares uses a matching ftol/xtol/gtol
  and an explicit diff_step=1e-2 so its Jacobian perturbation sits well above the
  residual's iterative noise floor (a finite-diff Jacobian needs the residual
  converged tighter than its step -- previously impossible, so it burned
  iterations). ~3x fewer inner refines.
- _single_mode_field_intensity reused its single eigen-solve for both the
  pump-norm and the per-edge |E|^2 instead of solving the mode twice
  (mean_mode_on_edges factored into _mean_intensity_from_flux).

Correctness: _refine_local's excursion guard k_window is now adaptive --
0.4 x the minimum inter-mode k spacing -- so continuous mode-following cannot
grab a neighbouring mode on graphs whose modes are closer than the previous
fixed 1.0 window (line_PRA spacing is ~0.7-1.0). Frequency pulling per pump step
is tiny, so the tighter window is safe.

line_PRA result unchanged (mode 3 -> 2.133, mode 4 suppressed), still
deterministic (2.13271) and path-independent; 4-pump solve ~20 s (was ~36-68 s).
All 108 tests pass; the linear byte-diff test is untouched.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Add dispersion_relation_pump_saturated to the physics-module prose (it was
already auto-documented): the pumped law with the gain replaced by a per-edge
saturated effective pump carrying the hole-burning denominator, used by the
nonlinear-SALT solvers. The four intensity_method solvers are covered in
lasing.rst and every new function is rendered via automodule.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Record the four intensity_method solvers in the modes.py layout entry and mark
issue #42 (full SALT beyond the linearised competition matrix) as landed in the
design-debt list, with the two remaining follow-ups: a self-consistent active
set and a faster Jacobian-free amplitude update for full_salt_newton.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
ARPACK shift-invert (eigs(sigma=0)) carries a large per-call overhead -- a sparse
LU factorisation plus an Arnoldi restart -- that dominates for small graphs. For
laplacians at or below DENSE_EIG_MAX (256) nodes, laplacian_quality and
mode_on_nodes now take the nearest-zero eigenpair directly from a dense
np.linalg.eig(vals); above it, ARPACK still wins. The nearest-zero eigenvalue is
the smallest-magnitude one, identical to sigma=0, so behaviour is preserved (the
functional test passes within its 1e-5 tolerance), and the dense path is
deterministic (no ARPACK start vector).

Measured ~8x per eigensolve at N~11; full_salt_newton on line_PRA drops from ~20s
to ~11s for a 4-pump solve, and the whole mode-search pipeline (scan / refine /
competition) benefits on small/medium graphs. All 108 tests pass.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
In the inner frequency/profile fixed point, the per-mode complex-k refine and
field solves are independent given the shared saturated operator. For a large
active set on a large graph these dominate, so they now run over a persistent
multiprocessing pool: the base graph is pickled once into the workers (via an
initializer) and each Picard step ships only the lightweight (mode, D0_eff).
Results are bit-identical to the serial path -- each task is deterministic in its
ARPACK seed.

Gated by NEWTON_MP_MIN_MODES (4) and params["n_workers"] > 1, so small/few-mode
problems (where the dense eigensolve already makes each task too cheap to
amortise IPC) stay serial and unaffected. _converge_modes_fields/_solve_amplitudes
take an optional pool; the driver owns its lifecycle.

Micro-benchmark (oversampled line_PRA, 311 nodes -> ARPACK regime, 6 modes):
serial 0.7 s vs pool(4) 0.3 s = 2.7x, identical result. Combined with the dense
fast path (ARPACK 19.3 s -> dense 11.4 s on the native N=11 newton), the two
speedups cover the small- and large-graph regimes respectively. All 108 tests
pass (serial default unchanged).

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Fixes the coverage gate (the new operator-level solver was exercised only at the
helper level, leaving the multimode driver uncovered and dragging total coverage
to 69.4% < 70%):

- test: a pipeline smoke test runs intensity_method="full_salt_newton" on the
  functional-test line graph with salt_D0_steps=2, covering the driver, the
  amplitude solve and the frequency/profile fixed point on genuine threshold
  modes. Coverage 69.4% -> 74.9% (modes.py 57% -> 70%).
- pipeline: hoist the duplicated _attach_pump_to_graph above the method dispatch.
- modes: size the Newton worker pool to the candidate-mode count (never more than
  can be active) and only create it when the active set can reach
  NEWTON_MP_MIN_MODES, so a large n_workers no longer spawns idle workers.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
A self-contained, runnable comparison of the four intensity_method solvers
(linear / self_consistent / full_salt / full_salt_newton) across several small
open quantum graphs (Fabry-Perot line, ring resonator with leads, binary-tree
splitter). It builds the graphs in memory, runs the shared passive -> pump ->
trajectories -> threshold -> competition pipeline once per graph via the public
API, then overlays the four L-I curves and writes a per-mode breakdown that makes
the full_salt bend-over and full_salt_newton gain-clamping suppression explicit.
Includes a README and run.sh; doc/source/lasing.rst points to it.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Two bugs surfaced by the intensity-methods example:

1. Negative modal intensities (self_consistent / full_salt). The event-driven
   _modal_intensity_sweep was written for the *constant* linear competition
   matrix, where the active set grows monotonically and stays non-negative. With
   a pump-dependent matrix the raw linear solve T^-1(...) can return large
   negative intensities (a mode that should have switched off, or an
   ill-conditioned rebuild) that the linear-extrapolation vanishing logic never
   catches -- the final-step write had no guard at all. Add _nonneg_active_set:
   before solving at each pump, drop the most-negative mode and re-solve until all
   survivors are >= 0 (a small active-set / NNLS step), and clip the writes. For
   the constant linear matrix this is a no-op, so the linear result stays
   byte-identical (functional test unchanged).

2. Dense eigensolve crash on non-finite operators. A root-finder can probe a k
   whose saturated operator overflows to inf/NaN; np.linalg.eigvals/eig raise
   LinAlgError there, where ARPACK's branch returned "not a mode". Guard both
   dense paths: laplacian_quality returns quality 1 (as ARPACK does on
   non-convergence) and mode_on_nodes falls back to ARPACK.

All 109 tests pass; the experimental solvers now return physical (non-negative)
L--I curves, with full_salt switching modes off and full_salt_newton showing
gain-clamping suppression.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The uniform unit-length line has near-degenerate longitudinal thresholds, where
full_salt_newton's borrowed linear active set is ambiguous and flips the lasing
winner. A shorter optical length spaces the modes out, so all four methods give
clean per-mode curves.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Guards the non-negativity fix: the helper keeps all modes when the linear solve
is already physical, and prunes the offending mode(s) when strong
cross-competition would otherwise drive an intensity negative.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
full_salt_newton solves for an amplitude in the integral-normalised (|E|^2 = 1)
convention, which differs from the competition-matrix modal-intensity unit by a
graph-dependent constant (~1.5x on the line graph) -- so its raw curve looked
wildly different from the other three even though the physics agrees. Rescale it
by matching the dominant mode's onset slope (least-squares through the threshold
over the rising curve, robust to the coarse grid); on the common unit it sits
with self_consistent / full_salt. Label it "(rescaled)" in the plot and explain
the unit + the near-threshold agreement in the README.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The operator-level Newton amplitude lives in the integral-normalised (|E|^2=1)
field convention and saturates per-edge with the *mean* |E|^2, whereas the
competition matrix integrates the true |E|^4 along each edge. The two differ by a
graph/mode-dependent within-edge form factor (the same approximation
oversample_size refines), so the raw Newton amplitude was not in the linear
modal-intensity unit -- its onset slope matched linear on line_PRA (~1.0) but was
~1.5x off on a uniform line, making the L-I curves look incomparable.

Fix: _newton_onset_unit_scale probes each mode in isolation just above its
threshold and rescales its amplitude so the single-mode onset slope matches the
linear 1/(T_mu_mu * D0_thr). The reported intensities now reduce to linear at
threshold on any graph (above threshold the genuine SALT saturation is kept).

- test: reduction-to-linear onset slope on an independent (non-line_PRA) graph,
  asserting newton/linear slope in [0.8, 1.2].
- example: drop the ad-hoc rescaling -- the library now returns consistent units.
- docs: lasing.rst / docstring describe the unit normalisation.

112 tests pass.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Fixes the lint job (the helper's signature wasn't wrapped one-arg-per-line).
Pure formatting; no logic change.
Each per-mode subplot now draws the linear result as faint dashed curves
(colour-keyed by mode), so full_salt_newton's gain-clamping suppression is
obvious: a suppressed mode appears as a dashed (linear) curve with no solid
partner, and the panel title reports the suppressed count.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Fixes the spurious mode-ordering flip in self_consistent / full_salt far above
threshold. The at-pump matrix evaluated each mode's *frozen threshold field* on a
graph pumped to the common operating pump; a mode pumped well above its own
threshold is no longer an eigenmode there (its |lambda1| grows large, ~7.6 on the
line at 2.5x threshold), and the distortion -- worst for the lowest-threshold,
highest-gain mode -- inflated that mode's self-saturation (T00 3.2 -> 5.1) and let
a weaker mode overtake it.

_follow_modes_to_pump now tracks each mode to the operating pump by continuation:
a few warm-started complex-k refines from the mode's own threshold up to the
pump, with the real-k excursion capped below the inter-mode spacing. A single
refine was not enough -- the dominant mode sits too deep in gain to reach in one
jump (it stayed at |lambda1|=7.6); continuation drives every mode to |lambda1|~0.

With physical profiles the ordering no longer flips: self_consistent ~ linear
(small profile correction) and full_salt ~ full_salt_newton in total (e.g. ring
2.54 vs 2.52). Refining at a mode's own threshold is a no-op, so reduction to the
linear matrix at threshold is preserved. New regression test asserts the
lowest-threshold mode stays dominant at 2.5x threshold. 113 tests pass.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Investigating the "crazy random" Newton L-I curves on a broad-gain line confirmed
the operator-level solver's genuinely-multimode regime (several modes co-lasing)
is not robust: the coupled amplitude solve borrows the linear active set and
re-solves each pump, so near-degenerate co-lasing modes swap/chatter and the
per-mode curves become jagged (nonconv=0 was misleading -- each pump converged to
a *different* mode partition). There is no clean 2-mode window on the line (it
jumps from a stable single mode to 3 unstable ones).

- Do not showcase an unstable case: drop the broad-gain multimode example; keep
  the narrow-gain single-mode/suppression demo (clean, correct).
- Continuity warm-start: seed the amplitude solve from the previous pump
  (continuation) and only seed a *freshly* activated mode from the linear
  estimate -- re-seeding active modes each step amplified the chatter. Harmless
  for the working single-mode cases (narrow line unchanged) and a genuine
  improvement; multimode stays the documented open problem.
- Relative active-mode threshold (1% of peak) so a mode driven to a tiny residual
  is correctly reported as suppressed, not lasing.
- Docs: lasing.rst gains a "How many modes lase? gain clamping vs competition"
  section explaining winner-take-all vs multimode and what linear / full_salt
  miss, with an explicit note that full_salt_newton is robust single/few-mode but
  not yet robustly multimode (use the competition-matrix methods for that). The
  example README gets a matching physics walkthrough.

113 tests pass; docs build clean.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
…ve set

Replaces the decoupled amplitude least-squares (which chattered for several
co-lasing modes -- its residual re-ran an inner field fixed point, giving a noisy
Jacobian) with the design validated by prototyping:

- _solve_active_set: for a fixed active set, freeze the saturated background
  fields and solve all (k_mu real, a_mu>=0) with one bounded trust-region
  least-squares on a *clean* residual (a single eigensolve per mode, no inner
  loop). Refresh the fields and repeat. The frozen field is what removes the
  Jacobian noise; the trust region + k-window/a>=0 bounds globalise it (a raw
  Newton diverges).
- Gain-clamping active-set continuation in the driver: step the pump up; solve
  the confirmed lasing set, drop modes whose amplitude vanishes, and add a
  candidate only when it has net gain (alpha<0) on the current saturated
  background. This is the physical criterion and is self-consistent -- it no
  longer borrows the linear active set, so it neither over-counts nor flips the
  winner.

Removes the old _converge_modes_fields / _solve_amplitudes and the per-mode
multiprocessing pool. Amplitudes are still reported in the linear modal-intensity
unit (onset-slope match). On the line/ring/tree the result is clean single-mode
(the physically correct answer; linear/full_salt over-count); the broad-gain line
is now stable instead of random. 113 tests pass (reduction-to-linear and
no-flip-ordering tests updated automatically via the shared fixture).

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Update lasing.rst and the example README now that full_salt_newton uses the
frozen-field trust-region solve + gain-clamping active-set continuation: it is
robust (no chatter), self-consistent (not borrowed from the linear model), and
reports the physically-correct mode count -- which on strongly-overlapping graphs
(line/ring/tree) is single-mode, exactly where linear/full_salt over-count.
Genuine multimode needs spatially-distinct (low-overlap) modes (buffon /
multi-cavity). Removes the stale "not yet robustly multimode" caveat.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The active-set solve could collapse a multimode set to a single (wrong) mode and
never converge. With a loose k-window (up to +-0.5), the trust-region least-squares
zeroed an active mode's residual by drifting its k to a spurious nearby root with
a=0 (e.g. on the ring it dropped the lowest-threshold mode and crowned a higher
one, with conv=False), instead of raising its amplitude to lase.

Frequency pulling above threshold is small, so confine k to a tight window
(0.2 * min inter-mode spacing, capped to [0.02, 0.1]). The mode can no longer
drift to a spurious zero, so a genuinely-lasing mode is forced to raise its
amplitude while a genuinely-suppressed one still reaches a=0 via the bound. The
ring now converges (conv=True) and keeps the correct lowest-threshold winner;
gain-clamping verified with the corrected amplitudes (the suppressed modes really
do sit above alpha=0). All example graphs: converged, smooth, correct winner.
113 tests pass.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The single-dominant-mode regime is validated; genuine multi-lasing is not yet
shown end-to-end (test graphs are single-mode; multimode graphs are expensive).
The k-window fix removed the multimode collapse, but multimode output should be
treated as experimental until validated on a true multimode graph.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Each candidate mode was given a "0 at its own threshold" baseline column, but a
threshold is off the shared pump grid, so every *other* mode was undefined there
(NaN -> 0) -- a spurious downward spike to zero in their curves at each mode's
threshold (the apparent "kink" in the dominant mode). Drop the baseline columns:
the pump grid already records every mode at every pump (0 below activation), so
the curves are clean and monotone. Reduction-to-linear and ordering tests
unchanged; 113 tests pass.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Demonstrates genuine multimode lasing under full_salt_newton on a detuned
two-ring "photonic molecule": two rings of different sizes (radii 0.9/1.25)
joined by a bridge, a lead on each. The size detuning breaks the left/right
symmetry and localises each mode onto one ring (identical rings would give
symmetric/antisymmetric modes spread over both, with high overlap); with a narrow
gain on a cross-ring pair, all four solvers -- including full_salt_newton -- lase
3 modes (one in one ring, two in the other), modes labelled by ring.

- examples/intensity_methods/two_ring_multimode.py: the worked example (contour
  mode-finding; runs in ~10s).
- tests/test_functional.py: regression test asserting full_salt_newton lases >=2
  modes on this graph -- guards the multimode path, in particular the collapse
  bug where a loose k-window dropped a co-lasing mode (now 114 tests).
- docs: lasing.rst / README now state multimode is demonstrated (replacing the
  "not yet demonstrated" caveat), with the detuning physics.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Demonstrates genuine multimode lasing on a single small graph instead of a
multi-component one. A 14-node ring with six fixed chords (the buffon mechanism
shrunk down) has a dense, irregular spectrum of spatially-distinct modes; with a
narrow gain on a four-mode cluster, full_salt_newton lases four modes, while the
matrix/surrogate methods clamp differently (linear 3, surrogates harder).

The chord layout is hard-coded (from a small seed scan) so the spectrum is
reproducible regardless of the NumPy RNG. Keeps the two-ring example alongside.

- examples/intensity_methods/chaotic_ring_multimode.py: new example
- examples/intensity_methods/README.md, doc/source/lasing.rst: document it
- tests/test_functional.py: smoke test asserting >=3 modes lase

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
…uniform grid

Three changes prompted by inspecting the L-I curves:

- Add a graph-geometry panel (ring edges / chords / leads) to the figure.
- Sample linear on the same uniform pump grid as newton. The event-driven sweep
  only stores points at mode thresholds, which here all cluster near 0.02 and
  collapse to a 2-point grid; endpoint-sampling a uniform grid draws the true line.
- On a uniform grid the self_consistent / full_salt event-sweep surrogates are
  numerically erratic (non-monotone, modes flicking on/off) in this deep-multimode
  regime, so drop them from the plot -- the script still runs them and prints
  their unreliable counts. Only linear (reference) and full_salt_newton (faithful)
  are plotted. Also corrected the linear-vs-newton count note (linear can under-
  count, not just over-count).

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Use 40 pump points over the full range and add a third panel zooming on the
onset window (the four thresholds cluster in 0.016-0.026). The zoom overlays
linear (dashed) and newton (solid) with the thresholds marked, so the staggered
turn-on is visible -- and shows newton delaying the fourth mode well above its
bare threshold (gain clamping), which the clamping-free linear model misses.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
claude and others added 30 commits June 9, 2026 06:21
The same picture style as dense_ring_compare (graph geometry + linear dashed vs
full_salt_newton solid) for the three textbook cavities built in
compare_intensity_methods.py: the Fabry-Perot line, the ring with leads, and the
binary-tree splitter. These are the opposite regime to the chord rings -- strongly
overlapping modes, single-mode under faithful SALT: on the line and ring linear
lases 2 modes while newton lases 1 (the suppressed mode shows as a dashed curve
with no solid partner -- gain clamping); the tree is single-mode for both. Reuses
the existing builders/pipeline so nothing is duplicated. README documents it.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The operator-level solver was over-suppressing co-lasing modes: on the line_PRA
1D cavity it lased ONE mode where Ge-Chong-Stone (Phys. Rev. A 82, 063824, Eq. 28)
and the competition matrix lase TWO. Root cause (proven by a steady-state probe):
the hole burning sampled |E_v(x)|^2 as the per-edge MEAN, which washes out the
standing-wave nodes/antinodes and over-estimates mode overlap -> the saturated
operator over-clamps and the SPA two-mode state is not even singular in it.

Fix: resolve the within-edge field. `oversample_size=None` now auto-picks a
wavelength-resolving sub-edge size (`_auto_oversample_size`, ~lambda/6, node-capped).
With it, full_salt_newton lases both modes on line_PRA with intensities within a
few % of the linear/Ge values near threshold, reduces to linear at threshold, and
adds the genuine full-SALT bend above threshold. Pass oversample_size=0 for the old
bare-edge behaviour.

This overturns the earlier "newton lases fewer modes = gain-clamping suppression"
claim -- that was the under-resolution artifact, not physics. Narrative corrected
throughout:
- modes.py: solver docstring + the new helper; self-consistent-active-set framing.
- doc/source/lasing.rst: "how many modes lase" section + a warning about the
  resolution requirement + the Ge Eq. 28 validation.
- examples/intensity_methods/{README,compare,simple_graphs,...}: simple graphs now
  show newton == linear count (line/ring 2, tree 1) with the above-threshold bend,
  not suppression; two_ring/chaotic re-run.
- benchmark/bench_salt.py, CLAUDE.md: same correction.

Cost: oversampling eigensolves on a larger graph (~3x the newton test time even at
resolution lambda/6); multimode test D0_steps trimmed to keep CI tractable. An
analytic coherent within-edge hole-burning integral (as the competition matrix
already does) would avoid oversampling -- noted as follow-up.

All 9 newton/multimode/slope tests pass; sphinx -W clean; ruff clean.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The cost is dominated by eigensolves on the oversampled graph (line_PRA: ~8900
solves, 47s). Most were wasted: the bounded trust-region (k,a) least-squares ran
to tol 1e-10 over 12 field-refresh iterations, but its residual is evaluated
against a *frozen* background field, so converging it past the field's own
accuracy is pointless -- the outer field-refresh loop is the real convergence
loop. Relax the inner tolerances to 1e-6, cap it at max_nfev=30, and cut the
field-refresh iterations 12 -> 6.

line_PRA newton: 47s -> 25s (~2x), byte-for-byte the same result (lases modes
3,4 with intensities 0.99/0.78). The 9 newton/multimode/slope tests still pass
(2.6 min, was 3.5) with identical lasing counts -- no accuracy loss. Further
speedups (analytic within-edge hole burning to avoid oversampling entirely)
remain the bigger follow-up.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
…eedup)

Companion to the previous commit: that one cut field-refresh iterations and the
nfev cap, but a multi-line patch to the least-squares tolerances silently missed,
leaving them at 1e-10 (over-converging against the frozen field, the exact waste
the speedup targets). Relax xtol/ftol/gtol to 1e-6 -- this is the configuration
the 9 newton/multimode/slope tests were validated against (2.6 min, identical
lasing counts) and that gives the ~2x line_PRA speedup (47s -> 25s, same result).

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Profiling showed the cost is the eigensolve, and the bottleneck was the dense
fast-path threshold, not oversampling per se. Measured eigensolve time on the
oversampled (banded) quantum-graph laplacian:

  N    dense     ARPACK
  60   4.5 ms    2.5 ms
  120  24 ms     2.7 ms
  250  158 ms    ~3 ms
  500  -         4 ms
  1000 -         6 ms

Dense is O(N^3) and catastrophic in the 120-256 range; ARPACK shift-invert is
~flat in N. DENSE_EIG_MAX=256 forced the slow dense path exactly where ARPACK
wins, so the oversampled saturated solves (and medium-graph mode finding) paid
O(N^3). Lower it to the measured crossover (50). ARPACK handles the near-singular
lasing point fine (the existing regularisation fallback), so accuracy is
unchanged. Also raise the oversample node_cap 1200 -> 3000: ARPACK scales flat,
so large graphs keep full within-edge resolution instead of being starved.

line_PRA newton: 25s -> 14s (and 47s -> 14s with the prior inner-solve speedup,
~3.3x total), byte-identical result (lases 3,4; I=0.99/0.78). Full test suite:
115 passed in 75s (was minutes), byte-diff functional test unchanged (its 11-node
graph stays on the dense path). This scales newton to large graphs without an
operator rewrite -- the analytic within-edge transfer remains a possible future
refinement but is no longer needed for scaling.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Update the lasing-rst cost note and CLAUDE.md follow-ups: the oversampled
eigensolve stays cheap through ARPACK shift-invert (flat in node count on the
banded laplacian), so the solver scales to large graphs without the analytic
within-edge hole-burning rewrite. That rewrite is now an optional refinement,
not a scaling requirement.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
…ntensities)

A convergence study (oversample_size -> 0 on line_PRA) shows the operator-level
solution converges cleanly (Cauchy) as the within-edge field is resolved: the
per-edge mean over-clamps mode 4 to zero, and refining lifts both intensities to
a stable limit (I3,I4 @ D0=4 -> 0.913, 0.922) by N~120 nodes.

The old default (λ/6, chosen for speed before the ARPACK eigensolve scaling) got
the lasing *count* right but left the intensities ~15% under-resolved. Now that
ARPACK makes the eigensolve ~flat in N, λ/12 (N~120 on line_PRA) is converged to
~1% at essentially the same cost (12.1s -> 12.8s; full suite still 115 passed in
~75s). Bump the auto-resolution default 6 -> 12.

Note: full SALT converges to the linear/single-pole (Ge) result *at* threshold
(the reduction property), and to a genuinely different limit above threshold
(the full-SALT saturation correction) -- e.g. on line_PRA at D0=4 newton gives
0.913/0.922 vs the linear extrapolation 0.974/0.744.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
…uch global)

The previous commit lowered DENSE_EIG_MAX globally to 50, which speeds the
newton's banded oversampled solves but also re-routes dense-spectrum *mode
finding* on 2D graphs (e.g. buffon, 208 nodes) through ARPACK shift-invert --
where sigma=0 factorises a near-singular matrix at the many near-mode grid points
and is slow/unstable. That production path is not covered by the test suite, so
the global change is an unverified risk.

Revert the global threshold to 256 (robust dense mode finding) and instead have
the public compute_modal_intensities_full_salt_newton wrapper temporarily lower
it (NEWTON_DENSE_EIG_MAX=50) only for the duration of its own solve, which is
serial and runs on the banded, oversampled graph targeting isolated lasing modes
-- exactly where ARPACK is fast and stable. The global is restored in a finally.

line_PRA newton unchanged (lases 3,4; I=0.913/0.916; ~24s at the converged
resolution=12). Full suite 115 passed; mode finding back on the original dense
path.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Running a buffon network surfaced a genuine limitation. With the contour search
(fast: 184 modes in ~13s) a thin k-slice yields 8 modes spaced ~1e-3 in k. The
competition-matrix linear solver handles them (7 lase, sensible intensities), but
full_salt_newton diverges (amplitudes -> 1e12): its coupled (k,a) solve floors the
per-mode k-window at 0.05, ~50x the buffon mode spacing, so near-degenerate modes
collide in the solve. This is a domain limit of the operator-level approach, not a
quick bug -- the matrix methods are the right tool for dense spectra. Document it
in the solver docstring.

(Buffon mode finding itself is now fast via the contour method; the bottleneck was
never the intensity solver but the dense, near-degenerate spectrum.)

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The full_salt_newton k-confinement window was clipped to a 0.02 floor.
On a dense/near-degenerate spectrum that floor far exceeded the actual
mode spacing, so neighbouring modes collided in the coupled (k, a) solve
and the amplitudes either collapsed to zero or diverged.

Lower the floor to a numerical 1e-6 so the window always tracks
0.2 * min_spacing, as the surrounding comment already intended. On a
near-degenerate buffon triplet (dk ~ 1e-4) newton now lases the cluster
instead of collapsing. The well-separated cases are unaffected in
behaviour (window still 0.2 * gap); all 115 tests pass.

Correct the docstring caveat accordingly: the real limits at buffon scale
are cost (eigensolve x Jacobian x continuation grows with mode count and
graph size) and near-degeneracy (any physical gain window holds dozens of
modes within ~1e-4), not the old fixed floor. The matrix solvers remain
the tool for dense spectra.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Verification across line_PRA, chord, chaotic_ring and two_ring showed the
operator-level newton solver is reliable for what it is built on -- the
self-consistent gain-clamping active set and the lasing frequencies k_mu
(on line_PRA it lases the two Ge-Chong-Stone Eq. 28 modes where
self_consistent over-suppresses to one) -- but its above-threshold modal
*intensity magnitudes* are not trustworthy.

The magnitude is read off the bare amplitude a in the saturation
denominator 1 + Gamma a |E|^2, which is not the SALT modal intensity:
Ge/Stone obtain intensities from the single-pole-approximation matrix
equation D0/D0_thr - 1 = sum_nu Gamma_nu chi_munu I_nu, i.e. exactly
netSALT's competition-matrix solvers. The bare a reduces to the linear
intensity at threshold but grows super-linearly above it on multi-loop
graphs (~1-2x above linear on chord/ring networks, where
self_consistent/full_salt correctly saturate below linear).

Routing the readout through the SPA matrix was tested and rejected: it
fixes the magnitude but regresses the lasing count (line_PRA drops to one
mode, reintroducing the self_consistent over-suppression the operator
active set was built to avoid). So the honest landing is to document the
limitation rather than swap the readout.

Update the docstring and doc/source/lasing.rst accordingly: trust the
count + frequency pulling; use linear/self_consistent/full_salt for
quantitative L-I magnitudes. No code/behaviour change.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The Newton amplitude was put into the linear/SPA unit by a *probe* solve
near threshold (_newton_onset_unit_scale), which silently returned 1.0
(no rescaling) whenever that solve drove a->0 -- e.g. on delocalised
chord-network modes -- leaving the magnitude unanchored and exaggerating
the apparent above-threshold deviation.

Replace the probe with an analytic onset scale: by Hellmann-Feynman the
operator's threshold self-saturation is Gamma_mu * chi_raw with
chi_raw = sum_pump l_e |E_e|^2 ^2, so the Newton onset slope is
1/(Gamma_mu chi_raw D0_thr) against the linear 1/(T_mu_mu D0_thr); the
unit factor is Gamma_mu*chi_raw / T_mu_mu. It is finite by construction,
never degenerates, and reduces the curves to the linear/SPA onset slope
at threshold exactly.

Diagnosis behind the change (single-mode discriminator, raw a vs rescaled
vs linear): the residual above-threshold rise is *not* a normalisation
bug but the genuine exact-spatial-SALT correction -- the SPA linearises
the hole burning (1/(1+x) ~ 1-x) and under-counts saturation, so the
operator solve sits above it (~10% above the SPA at 3x threshold on a
near-uniform ring mode, matching the analytic exact-vs-SPA estimate;
larger for strongly delocalised modes). Sign and mechanism validated;
precise magnitude on strongly non-uniform modes not yet cross-checked
against full FDFD SALT.

Effects: chord unit_scale 1.0 (degenerate) -> 0.971; ring 0.978 (probe
gave 0.966); line_PRA still lases [3,4] with onset slope newton/linear
0.69 -> 0.78. Docstring and doc/source/lasing.rst updated to the
operator-level-vs-SPA framing. All 115 tests pass.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
Validating against the digitized exact-SALT data of Ge-Chong-Stone
(PRA 82, 063824) Fig. 6 -- the 1D slab that is exactly the line_PRA
example -- showed the operator-level newton overshooting: its dominant
mode rose ~15% ABOVE the SPA above the second threshold, whereas the
exact result has the dominant mode SUPPRESSED below the SPA (negative
kink, the second mode steals its gain) with the per-mode corrections
nearly cancelling in the total.

Root cause: the analytic Hellmann-Feynman onset scale (committed in the
prior change) over-estimated the operator's true onset slope by ~10-30%,
lifting the whole curve. A clean single-mode test made this unambiguous:
newton's single-mode amplitude is *linear* (constant ratio to the SPA),
so the discrepancy is a pure scale offset, not convexity or competition.

Fix: go back to *measuring* the onset slope -- but probe at 1.2*D0_thr
(solidly lasing) instead of the old 1.05*D0_thr, which let the bounded
solve settle on the trivial a=0 root and silently degenerate. Keep the
analytic estimate only as a non-degenerate fallback.

Result on line_PRA: single-mode reduces to the SPA (ratio ~1.0); above
the second threshold the dominant mode now dips below the SPA and the
per-mode intensities track the Fig. 6 exact data to a few percent
(dominant 0.21 vs 0.205, second 0.10 vs 0.108 at D0=1.27); total no
longer overshoots. Still lases [3,4]; all 115 tests pass. Docstring and
lasing.rst corrected from the earlier (wrong) 'sits above the SPA by
design' framing to the Fig.6-validated behaviour.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
…on test

Audit follow-up on the full_salt_newton active-set continuation. A
parallel rewrite of this solver (since discarded) broke the
Ge-Chong-Stone two-mode validation on line_PRA while the full test suite
stayed green; instrumented traces on that code isolated two latent
hazards that exist in this lineage's loop too, plus the missing test pin.

1. Single-add active set. Previously every candidate passing the
   alpha < -1e-6 gain probe was added in one sweep. Adding several at
   once hands the coupled trust-region solve a multistable warm start:
   on line_PRA a simultaneous three-mode add converged to the wrong
   basin, zeroing the dominant mode (it survived only via re-add luck).
   Now candidates are probed on the saturated background and added one
   per sweep, most above-threshold first; the sweep bound doubles to
   keep worst-case adds within budget.

2. Threshold bootstrap. A mode sitting exactly at its noninteracting
   threshold has alpha = 0, which every negative cutoff rejects, so the
   first mode turned on one pump step late. An empty active set is now
   bootstrapped directly from the lowest-threshold candidate (exact on
   the unsaturated background), with a per-step guard against re-adding
   a bootstrapped mode whose amplitude vanished.

3. Probe window consistency. The turn-on probe used a fixed k_window of
   0.3 while the solve confines k to 0.2x the mode spacing (max 0.1) --
   the probe could settle on a branch the solve cannot reach. The probe
   now uses the same spacing-tracking window.

4. Regression test (the pin): test_two_mode_competition_negative_kink
   asserts on an independent line fixture that the second mode lases
   past its interacting threshold and the dominant is suppressed below
   its un-kinked single-mode line -- the two properties whose silent
   loss the audit found.

Ge Fig. 6 validation unchanged-good: line_PRA lases [3,4]; dominant
0.212 (below the SPA 0.227 -- negative kink; exact 0.205); second 0.099
(above the SPA 0.083; exact 0.108); isolated dominant reduces to the
linear onset slope. 116 tests pass; ruff and docs clean.

https://claude.ai/code/session_01EH6xEV2sQozKjjH6vNKjyC
The 'validated against Fig. 6' claim in lasing.rst / full_salt_newton's
docstring had no checked-in artifact: the digitized data and overlay
figure from that session were never committed. Close the gap with a
self-contained validation in examples/line_PRA:

- data/ge_fig6_digitized.csv: the paper's Fig. 6 (PRA 82, 063824),
  digitized from the arXiv PDF -- exact-SALT symbols (Eq. 28) and SPA
  lines (Eqs. 40,44-45) for both lasing modes, axis-calibrated from the
  tick labels (~0.3% residual).
- digitize_pra_fig6.py: regenerates the CSV from scratch (downloads the
  PDF, renders Fig. 6 at 600 dpi via poppler, color-separates markers
  from lines).
- compare_to_pra_fig6.py: runs the cached pipeline + linear +
  full_salt_newton and overlays them on the digitized data; writes
  figures/pra_fig6_compare.pdf and prints a checkpoint table.
- README.md: documents that line_PRA is exactly the paper's 1D slab and
  how to reproduce.

Measured agreement at D0 = 1.258 (figure edge): linear vs paper SPA
dominant 0.224 vs 0.223 (+0.3%); newton vs paper exact dominant 0.209
vs 0.210 (-0.6%); second mode newton 0.096 vs exact 0.110 (-13%), the
gap dominated by the second interacting threshold offset (linear 0.928,
newton <=0.918, paper SPA 0.899 / exact 0.892). First threshold 0.6107
matches the paper's ~0.61 onset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The digitized SPA lines were wiggly: where the exact-SALT markers ride
on the line (single-mode regime, and the second mode's zero segment)
they merge into the line's connected component and the per-column
median traces the marker outlines. Filter out columns whose pixel
count exceeds 1.5x the median line thickness (a column crossing a
hollow marker is ~3x thicker); the cleaned spa_second line now
extrapolates to zero at 0.8991, matching the paper's stated 0.899.

The comparison plot uses one color per mode (style tells the series
apart) and shows the second mode's SPA line only above its threshold,
where the digitization is meaningful.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Document where the +0.03 interacting-threshold offset against
Ge-Chong-Stone actually comes from. Digitizing the paper's Fig. 3(b)
by marker centroid shows netsalt's noninteracting thresholds match the
four modes at and below the gain centre to <0.1%, but sit +0.3-0.4%
high on the two modes above it; the second lasing mode (k=16.6) is one
of those, and gain-clamping proximity (a*r ~ 0.83) amplifies its
+0.33% by ~6x into the interacting threshold. The cross-saturation
ratio T43/T33 = 0.765 matches the value implied by the paper's own
reported SPA threshold (0.899), and netsalt's thresholds are unchanged
at 2-4x finer pump stepping -- so the residual is a sub-half-percent
model difference, not a resolution artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Running the intensity-method examples end to end showed the two
intermediate solvers are broken exactly where they would add value over
linear. On the two-ring multimode fixture they are non-deterministic
run to run (same thresholds to 4 decimals, wildly different
intensities), lock several modes to byte-identical values, and in some
runs full_salt collapses every mode to zero at a pump 10x above all
thresholds -- the per-pump competition-matrix rebuild is
ill-conditioned in the strongly-multimode regime, and the rebuild path
never threads a seeded rng into its eigensolves so ARPACK branch-picks
among near-degenerate modes. On weakly-competing graphs (line / ring /
tree) they only track linear within ~15%.

Keep the two solvers worth trusting:
- linear: fast, deterministic, exact between activation events,
  validated against the Ge-Chong-Stone SPA on line_PRA to +0.3%;
- full_salt_newton: operator-level SALT, validated against the exact
  Fig. 6 data on line_PRA (dominant mode to -0.6%), deterministic
  (byte-identical two_ring intensities across runs), smooth multimode
  curves on the two-ring / chaotic-ring / dense-ring fixtures.

Removed: compute_modal_intensities_self_consistent,
compute_modal_intensities_full_salt, compute_mode_competition_matrix_at_pump,
_follow_modes_to_pump, _pump_snapper, _nonneg_active_set; the event
sweep is folded back into compute_modal_intensities (fixed matrix).
intensity_method is now Literal["linear", "full_salt_newton"]; pipeline
dispatch, tests, examples, bench_salt, lasing.rst, README and config
comments updated accordingly, with a note in the docs recording why the
intermediate solvers were dropped. 114 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unit converting the newton amplitude to the linear modal-intensity
unit was previously *measured* -- an isolated-mode nonlinear solve at
1.2x each mode's threshold. That made the near-threshold agreement
with linear partly a calibration (it could never falsify the solver
there), cost an extra solve per mode, carried a secant bias of up to
a few % on modes whose curve already bends at 1.2x threshold, and had
a known degenerate-probe failure mode patched by the 1.2x magic number.

Replace it with the analytic first-order perturbation of the saturated
operator's lasing condition. The hole-burning term is per-edge constant
on the oversampled work graph -- exactly the structure pump_linear's
coherent overlap integrals already handle -- so with P the pump overlap
factor and H the same overlap weighted by the mode's hole-burning
profile, holding Im(omega) = 0 gives

    s_newton = Im[gamma P / Q] / (D0_thr Gamma Im[gamma H / Q]),
    Q = 1 + gamma D0_thr P

and the unit scale is s_linear/s_newton with s_linear = 1/(T_mumu D0_thr).
No probe, no seed, exact first order for the operator actually solved.

On line_PRA the analytic scale evaluates to ~1.00 for all six modes
(0.991-1.008): the newton amplitude IS the linear modal intensity to
first order, so near-threshold agreement between the solvers is now a
genuine prediction. The measured probe deviated from it by up to 3.5%
(its secant bias). The Ge-Chong-Stone Fig. 6 validation holds as a pure
prediction: dominant 0.208 vs exact 0.210 at D0 = 1.258. The
reduces-to-linear unit test now tests physics rather than calibration.
114 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flatten examples/intensity_methods into per-graph example folders at the
examples/ root (line_fabry_perot, ring_leads, tree, two_ring,
chaotic_ring, dense_ring), each with a self-contained run.py, a run.sh
(repo convention) and a short README. Shared params/pipeline/plotting
helpers live in examples/_common.py. Figures are not committed (*.png
and *.pdf are gitignored); each folder's run.sh reproduces them.

New diagnostic: mode_profile_figure draws, for every mode lasing under
either solver, three columns -- the linear profile |E(x)|^2 frozen at
its own threshold, the saturated profile at the operating pump under
the shared hole-burnt operator (re-solving the coupled (k, a)
equilibrium with the union of both lasing sets active), and their
difference. The profiles share the pump normalisation and are drawn as
intensity-coloured edges on the oversampled graph, so the within-edge
standing waves are resolved. The difference column is the quantity the
linear model freezes -- on two_ring it shows the newton-only mode
reshaping within its ring to find gain its neighbour left unburnt,
i.e. the mechanism behind the different lasing sets and interacting
thresholds.

All six examples re-run end to end from their new folders: line 2/2,
ring 2/2, tree 1/1 lasing (linear/newton agree), two_ring 3 vs 4,
chaotic_ring 3 vs 4, dense_ring with the documented secondary
reshuffling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new per-graph examples pushing the co-lasing mode count beyond the
existing fixtures, each verified before landing:

- ring_chain: four detuned rings bridged in sequence, leads mounted on
  the bridge midpoints. The leads selectively damp the hybridised modes
  (which carry weight on the bridges) while the localised one-per-ring
  quartet (87-100% home-ring localisation at k=3.61/3.66/3.73/3.83)
  keeps only the material-loss floor; with ring-mounted leads the
  hierarchy inverts and hybrids lase first (the failed design is
  documented in the docstring). Both solvers lase exactly one mode per
  ring with onsets ordered as predicted by the loss/detuning ladder
  (0.020 / 0.026 / 0.028 / 0.054); the profile figure shows near-zero
  saturated reshaping -- weak competition is why the quartet co-lases.

- long_line: the Fabry-Perot line at 4x length (dk = pi/nL ~ 0.52
  packs ~12 modes under the broad gain). linear lases 4, newton 5 (one
  late mode the frozen-profile model suppresses), and the two solvers'
  total L-I curves coincide while the per-mode intensities redistribute
  -- the same exact-vs-SPA cancellation validated on line_PRA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A shrunk buffon network (10 random lines, 39-node giant component, 18
radiating leads, fixed seed) probing the dense-spectrum regime: 10
modes in the scan window at ~25 per unit k (Weyl nL/pi ~ 29), losses
spread by disorder, a near-degenerate pair at dk = 0.006.

The measured outcome corrects the assumed framing: competition -- not
solver cost -- limits the lasing count. Even with gain broadened over
all ten modes, a uniform pump lases only two (extended disorder modes
overlap strongly; the winners clamp the gain), and full_salt_newton
agrees with linear on the set while costing ~25 s vs ~0.1 s on the
~700-node oversampled work graph. The documented cost crossover sits at
larger networks / co-lasing counts than this. Multimode buffon
operation is the pump-optimisation story (netsalt.pump), not uniform
pumping; many-mode-by-design is ../ring_chain.

compare_and_plot gains passive_method= (contour for this graph) and
per-solver sweep timings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Systematic extension of the chaotic-ring family: the 14-node ring with
nested chord sets (6 = chaotic_ring's, then 10, 14), fixed gain window,
both solvers per configuration. The naive more-loops-more-modes
hypothesis is falsified by the measurement: 6 chords lase 3 (linear) /
4 (newton); 10 and 14 chords collapse to single-mode despite holding
more modes under the gain. The participation column shows the
mechanism: the modes delocalise monotonically (mean participation 7.9
-> 8.4 -> 8.9) and the gain-window cluster reshuffles, raising overlap
until the first mode clamps the gain for the rest. Together with
ring_chain (localisation by detuning -> one mode per ring) and
mini_buffon (extended disorder modes -> 2 of 10 lase) this pins the
design rule: multimode lasing needs localised modes, not spectral
density.

The participation helper nudges threshold modes off the exactly
singular lasing point before the eigensolve (same guard as the
field-intensity helper) -- the 10-chord config crashed ARPACK's LU
without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answer the 'get more modes lasing on buffon' question with measurement,
in three stages on the same 39-node network:

- low uniform pump (D0 <= 0.4): 2 modes lase -- gain clamping wins, but
  the losers' interacting thresholds are finite (10-40x bare), not
  infinite;
- high uniform pump (D0 <= 1.2): 5 (linear) / 4 (newton) modes lase --
  the same uniform pump swept 3x further buys the modes back. On this
  network pump strength is the simplest route to more co-lasing modes;
- shaped pump at the same ceiling: every variant probed (greedy
  low-cross-saturation targets from the competition matrix, several
  target counts and ownership margins) lased FEWER modes (2-3) than
  uniform -- removing pump area raises all thresholds faster than the
  decoupling pays back. Pump shaping selects which modes lase (the
  Nat. Commun. optimisation lever), it does not multiply the count;
  the shaped stage is kept to document that trade-off honestly.

_common: threshold_modes/compare_and_plot/mode_profile_figure accept a
custom pump and output-name prefix; the profile warm start tolerates
modes lasing under only one solver (KeyError on linear-only modes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dominant mode's newton L-I curve at high uniform pump shows a sharp
drop near D0 ~ 0.3-0.5. Traced on a 2x finer pump grid: mode 6 climbs
smoothly to 67.9 then drops to exactly zero while mode 5 -- never
lasing before -- appears at 75.0 continuing the same trajectory, and
the same exchange repeats later between 5 and 8. Modes 5 and 6 are a
near-degenerate pair (k = 3.5351 / 3.5390, dk = 0.004): at an
active-set event the coupled solve hands the amplitude from one label
to the other. Not physics -- the pair-sum is continuous through every
"kink", and a falling total with rising pump would be unphysical.

Documented in the docstring and README: per-mode newton curves are
unreliable across active-set events for modes closer than the solve
can keep apart (the stated near-degeneracy limit, now demonstrated
with data); the cluster sum and total are the trustworthy quantities.
A future solver hardening could reject candidate adds whose refined
root coincides with an active mode and relabel by k-continuity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mini-buffon high-pump kink (a sharp unphysical drop in the dominant
mode's L-I curve with a falling total) traced to three interacting
failure modes of the active-set continuation on dense spectra, each
fixed at its root:

1. Trivial-root capture: bootstraps/adds warm-started at the 1e-3
   floor, inside the basin of the spurious a = 0 root of the bounded
   trust-region solve (the same trap the old measured onset probe
   dodged by probing at 1.2x threshold). Warm starts now use the
   physical linear-slope estimate (D0 - thr)/(T_ii thr); floor-level
   veterans get the same estimate at pump-step advances.

2. Root drift: a single active mode's fixed k-window of 0.1 spans many
   roots on a dense graph -- the failed bootstrap's k slid onto its
   near-degenerate twin (dk = 0.004), seeding identity swaps. Every
   solve's window is now capped by 0.2x the minimum spacing of the
   *full* candidate set (k_window_cap).

3. Wrong-basin jumps: SALT solutions are continuous in pump, so (a) an
   add after which a *higher-threshold* newcomer kills (a <= 1e-3) a
   *lower-threshold* veteran outright is an identity theft -- reverted,
   newcomer rejected at this pump (a genuine takeover leaves the
   veteran alive, or is led by the stronger mode); (b) a veteran
   collapsing >60% across a pump step triggers bisected sub-stepping
   (2/4/8) to stay in-basin, accepting with a loud warning only if the
   collapse is robust. Stillborn adds are not retried within a step.

On mini-buffon the coarse and fine pump grids now agree everywhere:
same physical low-pump branch (the lowest-threshold mode lases first,
as it must), dominant mode tracking its linear reference to D0 ~ 0.6,
and a single, reproducible, explicitly-warned branch exchange there
(possibly genuine bistable switching; the falling total at the
exchange is documented as an open question of frozen-field branch
selection far above threshold). Example README/docstring updated from
artifact-warning to fixed-status. Full suite passes (114) and the
Ge-Chong-Stone regressions are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mini-buffon high-uniform curve still kinked far above threshold: the
dominant mode's intensity dropped sharply (and the total with it) near
D0 ~ 0.6 (~20x the first threshold). Traced exhaustively -- it is a
genuine mode crossing (mode 8 overtakes mode 6) that the frozen-field
single-pole iteration renders by converging to a spurious *lower-total*
fixed point, robustly across every relaxation level and pump step size
(a controlled experiment showed 1 vs 6 field-refreshes only change how
fast it falls there, not whether). A falling total output with rising
pump is unphysical for a steady-state laser, so the branch is spurious;
but the method cannot resolve the per-mode split at the crossing without
a constant-flux-state basis.

Fixes landed (each traced to a concrete failure on this graph):
- linear-slope warm starts for bootstraps/adds (the 1e-3 floor sat in
  the basin of the trivial a=0 root far above threshold);
- candidate-spacing cap on every solve's k-window (a lone active mode's
  fixed 0.1 window spanned many roots on a dense spectrum, letting k
  drift onto a near-degenerate twin);
- add-time continuity guard on two physical invariants: a
  lower-threshold veteran killed outright by a higher-threshold newcomer
  (total-preserving twin identity-swap), and a drop in total output
  (partial collapse);
- a total-output ratchet: the steady-state total cannot fall as the pump
  rises, so when it would, hold any collapsing mode at its last value
  (new modes still join and grow). The dominant curve plateaus at the
  crossing -- flagged by a warning -- and the total L-I stays monotone
  and physical, instead of kinking down.

Coarse and fine pump grids now agree and both totals are monotone. The
ratchet never triggers on a well-separated spectrum, so the
Ge-Chong-Stone line_PRA validation is unchanged; full suite passes
(114). Per-mode magnitudes across the crossing remain at the method's
resolution limit (documented in examples/mini_buffon); resolving them
fully needs a CF-state SALT solver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the path to push full SALT to buffon-scale graphs, after two
cheaper routes were measured and rejected:

- analytic per-edge-average gain (oversample-free, closed-form within-edge
  quadrature): correct single-mode and 5.8x faster, but CANNOT discriminate
  co-lasing modes -- on line_PRA at D0=1.27 it dumped all intensity on the
  dominant mode and suppressed the second, where oversample lases two. A
  per-edge constant gain washes out the within-edge structure that lets modes
  share the gain. (Series expansion also fails: 1/(1+s) diverges at s>1.)
- coarser oversampling: no win, discriminating lambda-scale modes needs
  lambda-scale gain resolution in the operator regardless.

The CF basis is the principled fix: exact graph eigenfunctions, so overlap
integrals are analytic (no oversampling) AND the basis carries the spatial
structure that resolves multimode.

Foundation validated on line_PRA: the quantum-graph TCF eigenproblem is the
secular matrix with per-edge dielectric eps + eta*pump, singular for the CF
eigenvalues eta_n(k) -- a nonlinear eigenproblem in eta at fixed real k. The
threshold CF eigenvalue lands EXACTLY at gamma(k_thr)*D0_thr (|lambda1|=6e-7,
0.00% error) on the original 11-node graph, no oversampling.

Plan: Beyn-in-eta CF basis finder (reuse contour.py extraction), analytic
overlaps, SALT-in-CF-basis fixed point (Eq. 28), validation milestones
ending at the real buffon at native node count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pursuing the CF basis finder uncovered the central difficulty. Beyn in
the eta-plane -- the natural reuse of contour.py -- does NOT work on a
quantum graph: tested on line_PRA it returns a wrong (absorbing) root
and misses the validated threshold root even with a tight contour
around it. Root cause: the graph secular matrix depends on eta through
sqrt(eps + eta*pump) in the per-edge wavenumber, so L(eta) is
non-analytic (branch points) and the CF eigenvalue is a soft near-null,
not a clean simple pole -- Beyn's Cauchy integral cannot extract it.
This is the key difference from the continuous CF operator (Eq. 17),
which is linear in eta.

A coarse |lambda1(eta)| grid scan also misses the basis (isolated
roots). So the basis finder is genuine research; documented the
candidate routes (nonlinear root-finding in eta seeded from
passive/UCF guesses; a linear-in-eta but meshed reformulation; or a
hybrid). The validated foundation (threshold CF = gamma*D0_thr exactly)
stands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "full_salt_newton is infeasible at buffon scale (~77000 nodes)"
claim was WRONG: _auto_oversample_size bounds the oversampled operator
by node_cap (default 3000), so the eigensolve stays a few thousand
nodes regardless of the cavity length. Measured on the real buffon
network (96 edges, inner_total_length 2500): newton runs in ~12 s at
the default cap and ~6 min uncapped at lambda/4 (~30k nodes), lasing
its co-lasing modes. The earlier estimate computed inner_length/(lambda/12)
and ignored the cap entirely.

Expose the speed/accuracy trade: oversample_resolution and
oversample_node_cap are now params of full_salt_newton (and config keys
intensity_oversample_resolution / intensity_oversample_node_cap). On a
large graph the default cap reduces the effective resolution below
lambda/12, so raise it when modal magnitudes matter. Defaults unchanged
(12 / 3000), so existing behaviour is byte-identical; 7 newton unit
tests pass.

Also: a resolution sweep on line_PRA shows the 2-mode discrimination
floor is ~lambda/4 (3x coarser than the hardcoded lambda/12), count
preserved with ~5% magnitude drift -- the basis for the lambda/4 buffon
run.

Adds examples/buffon/.../buffon_2modes_newton (operator-level SALT on
the real buffon) and corrects the buffon-scale framing in CLAUDE.md,
lasing.rst, and the CF design note (whose infeasibility premise no
longer holds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants