Skip to content

Name the Monte-Carlo, make the fibre cheap, derive the AWGN curve, and run the examples nobody ran - #38

Merged
vincentchoqueuse merged 18 commits into
mainfrom
claude/comnumpy-v1-architecture-5dtrz3
Aug 13, 2026
Merged

vincentchoqueuse merged 18 commits into
mainfrom
claude/comnumpy-v1-architecture-5dtrz3

Conversation

@vincentchoqueuse

@vincentchoqueuse vincentchoqueuse commented Aug 13, 2026 •

Copy link
Copy Markdown
Owner

Twenty commits. The thread running through them: every claim was
measured before it was acted on, and three of the measurements
contradicted me.

Three defects

Nothing ran the nine SLOW examples. The smoke test skips them —
that is what SLOW is for — but no other job ran them either. A
regression lived in CD_compensation_part1.py for two commits:
TypeError: 'Constellation' object is not subscriptable on its first
pass, every check green. They now run weekly and on demand, and the
runner imports SLOW from the test module rather than repeating it, so
the list has one home.

A serial monte_carlo without seed= was silently unreproducible.
The parallel path refuses outright; the serial one said nothing. It
warns now — a first look at a curve does not need to be reproducible, but
a curve nobody can reproduce is not a result.

N_s did not mean what it said. channel(N_s * oversampling_sim)
handed a symbol count to the generator and the Upsampler applied the
oversampling again, so both optical examples simulated six times the
data their variable names promised — and the page quoted "8192 symbols
per point" for what is 49152.

The fibre is seven times cheaper, and says the same thing

apply_chromatic_dispersion rebuilt its transfer function on every
half-step: an fftfreq, a w**2 and a length-N complex exponential,
a thousand times per propagation, for something that depends only on
(n, dz, beta2, fs, alpha_dB, direction). Split into
linear_step_transfer (what the step is) and apply_frequency_response
(what it does), with step_transfers caching one row per distinct step
length — one for a linear schedule whatever StPS is, StPS for a
logarithmic one, so the loop indexes a table and never branches on the
schedule.

With StPS measured down from 200 to 50 — 0.005 dB at the sweep's most
nonlinear power — the launch-power sweep went from ~1400 s to 144 s
and the one-shot page from 73 s to 13 s. Every measured decibel on
both pages is unchanged; nine configurations agree with the old code to
3e-15.

The AWGN tutorial derives its reference curve

The Monte-Carlo page asked the constellation for a closed form and drew
it, which teaches that an object returns the right numbers rather than
why they are right. It now states the expression — a square QAM as two
independent PAM constellations, the argument of Q, Proakis and Salehi
section 4.3 — evaluates it in plain NumPy, and only then shows
constellation.metrics returning the same array to 3.3e-16.

It also no longer stops at one constellation. A closing section sweeps
4-, 16-, 64- and 256-QAM at equal energy per bit and reads off what
the density costs: 6.8, 10.5, 14.8 and 19.4 dB of Eb/N0 for a BER of
1e-3. Both floors of that figure are named rather than glossed — the
Gray-mapping approximation runs a factor 2.2 under the measurement for
256-QAM at 0 dB before settling onto it below 1e-2, and 100 000 symbols
per point cannot resolve a 4-QAM BER under 5e-6.

Two more defects surfaced there: the page still named sweep in two
cross-references after the rename, and the figure it displayed was
written by no script — monte_carlo_awgn.py ended on plt.show() and
saved nothing, so the committed PNG was whatever a human last produced
by hand.

Where I was wrong, three times

A batched FFT is not faster than B separate ones — 40.2 ms against
39.1. Nor was the gain the per-call overhead I then blamed: building the
per-span amplifier is 20 µs and a block dispatch 0.25 µs, together 0.03 %
of a pass. It was the transfer function all along. The batch axis is
still worth having and is now documented, tested and discoverable, but
for what it is: 1.76x by amortizing that exponential, and the
precondition for a GPU backend.

Smaller things

FiberLink.budget() puts the amplifier-noise formula next to the block
that generates the noise, where three tutorials had written it by hand
with three spellings of the polarization factor. ErrorCounter makes an
error rate a ratio of totals rather than a mean of per-trial rates.
sweep becomes monte_carlo, which says that its points are random
draws. Two "helpers" in validation/ turned out to be the library's own
functions under other names and were deleted. wdm.py was calling
np.fft instead of the backend, so that block could never have run on
cupy.

validation/optical_dbp_hager.py restores the reproduction of Häger and
Pfister's curves on their own setup — Gaussian stimulus, logarithmic
steps for the low-step back-propagators — which successive rewrites had
turned into a teaching script.

CONTRIBUTING.md now says which NumPy the strict-typing ratchet is read
against: the 1.26 stubs require ndarray to carry type arguments and
the 2.x stubs do not, so linting against the pinned oldest NumPy buries
a real error under several hundred that are not.

Also removed: pyright_all_tmp.json, a scratch copy of the ratchet list
committed by accident.

Verification

ruff check ., 860 tests + 1145 subtests, doctests, and
sphinx-build -W pass. 44 new tests.

claude added 15 commits August 12, 2026 21:47
Seven example scripts wrapped their chain in a factory, under four
spellings for the same idea: `get_link`, `link`, `link_chain`,
`get_full_chain`, `uncoded_chain`. A reader who learnt one had to
re-learn it on the next page, and nothing said when a factory was called
for in the first place.

The tutorial skill now carries three rules. A factory exists only when
the chain is built more than once; a chain built once and used once
stays inline, because wrapping four blocks in a function called once
with no argument puts an indirection between the reader and their first
chain. When it exists it is `get_<thing>()`, the noun following the
page's organisation: `get_chain()` for one chain, `get_channel()` and
`get_receiver()` for the two halves when a page cuts one in two,
`get_transmitter()` when what comes back is a sub-assembly, and
`get_uncoded_chain()` / `get_coded_chain(soft)` when two chains differ
in structure rather than in a parameter -- two blocks of difference is
an argument, four blocks against six is two functions, since merging
those would hide behind a flag exactly what the page compares.

The third rule is the one that motivated the other two, and it is the
same shape as the defect fixed in the previous commit: what the page
varies is a parameter, never a module global read behind the
signature's back. `get_full_chain(n_spans, steps, linear_only)` also
read eleven module-level names, so the call site did not say what
changed between two calls.

The rename reaches `validation/fec_coding_gain.py` as well: a layer that
contradicts the convention is where the convention starts to rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
Some blocks precompute in `__post_init__`, others in `prepare()`, and
nothing said which belonged where. `set_params` was never the ambiguity
-- it validates the field is an init field and re-runs `__post_init__`
on every block it touches, so both families are correct through it. The
hole is direct assignment, which stays legal and silently leaves the
majority of blocks inconsistent: of the 95 Processor subclasses, 57 of
the 67 that derive anything derive it in the constructor.

The rule splits by what a value depends on. `__post_init__` precomputes
what depends only on the block's parameters; `prepare(x)` derives what
depends on the signal. The direction is asymmetric, which is why there
are two places: signal-dependent state in `__post_init__` is wrong,
while parameter-only state in `prepare()` is merely repeated work --
`FiberLink` and `DBP` do the latter, negligibly against the split-step
cost, so deriving in `prepare()` is not evidence of signal dependence.

Blocks cannot be frozen to close the hole: `wiring` writes `reference`
during the pass and estimators write `theta_`, so a Processor must stay
mutable. The discipline is therefore carried by the rule rather than by
the type, and the corollary that makes it work is that `__post_init__`
must be re-callable with no side effect.

The measured example in CONVENTIONS.md is checked, not asserted:
`Downsampler(4, use_filter=True)` keeps a 1/4 cutoff after `.L = 2` and
rebuilds it to 1/2 after `set_params`.

The ratchet that would make an incomplete `__post_init__` unshippable --
build in A, `set_params` to B, require the output of a block built
directly in B -- is named as not done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
Three tutorials wrote the same amplifier-noise product by hand, with
three spellings of the polarization factor: `2 * N_SPANS * density *
BAUD`, `N_span * density * R_s`, `n_spans * n_ase * R_s`. That factor is
the one whose convention made the GN-model page and the
back-propagation page look like they disagreed, and `FiberLink` already
held every term of it.

`FiberLink.budget(bandwidth_Hz, polarizations=)` returns the noise the
block will actually add, integrated over the bandwidth a receiver keeps
-- the symbol rate, not the simulated `fs`, which is why the examples
were recomputing rather than reading `edfa_N_ase`. `prepare` and
`budget` now share `_ase_densities`, so the two cannot drift: one turns
the density into a variance over `fs`, the other into a power over the
receiver's bandwidth. The three call sites reproduce their published
numbers to the decimal: -21.88 dBm, -20.88 dBm.

`launch_amplitude(power_W, polarizations=)` names the other half of the
same trap -- the `sqrt(P/2)` of a dual-polarization launch, written by
hand at three call sites.

Two smaller things the examples were doing the long way:
`compute_effective_snr` already takes `unit="dB"` and two of its three
call sites converted by hand, and the optical chains decided outside the
chain with `hard_projector` where a `SymbolDemapper` and a tap give both
the soft estimate and the decisions.

D50 found a live regression within the hour. `CD_compensation_part1.py`
raised `TypeError: 'Constellation' object is not subscriptable`: it sets
`chain["mapper"].alphabet` by assignment, and the `Constellation`
migration put the coercion in `__post_init__`, which a direct write
skips. The example is in `SLOW`, so the smoke test never ran it and the
breakage shipped silently -- nine examples are in that position and
nothing executes them. Fixed here through `set_params`, along with the
three other scripts that assigned into a block (`monte_carlo_simulation_1`
and `_2` on `noise.sigma2` and `channel.H`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
The receiver gained a `SymbolDemapper`, so the profile table gained a
`data_rx` row and every wall time on the page moved. All four blocks now
come from a single execution of the example, which is the only way two
tables on one page can be read against each other: 12.3 ms of dispersion
compensation against 2892 ms of back-propagation, 235 times for 8.2 dB.

Every measured decibel is unchanged -- the effective SNRs, the fibre's
cost per span, the two checks and the launch-power sweep are identical
to the figures they replace, which is what the refactoring promised.

One sentence had to go with them. The span table's time column was
introduced as growing with the number of spans, and on a shared runner
it does not do so monotonically; what the column actually shows, and
what `profile_execution_time` then explains, is that the propagation is
the whole cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
A Monte-Carlo over a fading channel, over several detectors and over a
split-step link has a different shape each time, and that shape is what
the reader of an example came to see -- so the loop stays written out.
What is the same everywhere, and what hides mistakes, is the counting.

`ErrorCounter` accumulates errors and symbols across the trials of one
point. The rate it returns is a ratio of totals rather than a mean of
per-trial rates: identical while every trial has the same length, which
is where the examples stand, and a factor fifty apart when they do not
-- one error in ten symbols then none in a thousand is 1/1010, not 1/20.
A test pins both readings.

It also keeps `n_errors` readable, which is the number a point's
credibility rests on. A rate of 1e-4 from three errors and one from
three hundred are different claims, and the launch-power sweep now
prints, per receiver, how many errors it actually saw at its best power
-- so the page can state its resolution instead of apologising for it in
prose.

Only two of the four hand-written loops count what this counts, and the
other two are left alone rather than bent to fit: `monte_carlo_simulation_1`
accumulates a *block* error rate, and `CD_compensation_part1` runs one
trial per point and has nothing to accumulate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
`sweep` was the only bare verb in the root namespace -- beside
`compute_ser`, `get_alphabet`, `to_json`, `ebn0_to_snr_dB` -- and the
only exported name that said nothing about what it returns. More to the
point, it said nothing about the points being random: `sweep` is what a
frequency or DC scan is called, while every point here draws its own
noise and needs `seed=` to be reproducible. `monte_carlo` says that, and
it is the word the tutorials already use for these sections.

The module, its two test files and its API page move with it, and the
migration table carries the row. A pyright entry pinned the old path,
which is what the D37 ratchet is for: it failed on the rename.

The English noun is left alone. "A launch-power sweep", "a sweep over
N_cp", "the points of a sweep" are the domain's own words and still read
as such in the prose; only code references, ``sweep`` markup and the
identifier itself moved. A first blanket rewrite replaced the noun too
and was reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
The original `NLI_simulation.py` reproduced the effective-SNR curves of
Hager and Pfister (arXiv:2010.14258) on their own setup, and successive
rewrites turned it into a teaching script: 16-QAM instead of the paper's
Gaussian stimulus, linear steps where the paper uses logarithmic ones
for the one- and two-step back-propagators, and far fewer steps. The
reproduction was lost, and a reproduction is not a tutorial -- under D7
it belongs in `validation/`, which is where a claim of correctness
lives.

`validation/optical_dbp_hager.py` restores the setup: Gaussian symbols,
both links (25 x 80 km at 10.7 GBd and 10 x 100 km at 32 GBd),
logarithmic steps for the low-step receivers, and the paper's own
complex-scalar gain and phase correction -- which is
`DataAidedComplexGainCompensator(shared=True)` rather than four lines of
algebra.

The amplifier-noise reference spans 16.04 to 29.75 dB over -6 to +8 dBm,
against the 16.5 to 29.5 dB of the paper's figure for that link. What is
asserted is the structure those figures show -- the reference climbs
monotonically, every other receiver turns over inside the range, more
steps buy a higher peak at a higher power in order, and the best one
stays within a few decibels of the bound -- because the published values
are read off figures and transcribing them by eye would assert my
eyesight. The table is printed in full so the curves can be laid over
the paper's.

The default run is ten seconds; `--full` is the paper's StPS = 500 with
ten trials on both links.

One thing is deliberately not restored. The original band-limited with
`BWFilter(1 / oversampling_sim)` before decimating by
`oversampling_sim // oversampling_dsp`, which is the defect fixed in
#37: it put a distortion floor under every curve and is why a reference
that should climb as P/P_ASE saturated instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
Pyright strict caught what ruff cannot: `budget()` was annotated
`Dict[str, float]` while returning a span count and a polarization count
as ints, and `watt_to_dbm` as `ndarray | float`. The other dictionaries
of this kind -- `Constellation.info`, `SpaceTimeCode.info` -- are
`dict[str, Any]`, which is what a mapping holding a count beside a power
honestly is.

My miss: ruff passed locally and I did not run pyright, which is the job
that guards the py.typed marker (D37).
Both optical examples propagated at 200 steps per span on the strength
of one sentence -- "enough that the propagation is not in question, 500
steps give the same effective SNR to three decimals". True, and four
times more than needed.

Measured where the step-size error is worst, at the highest launch power
of the sweep, by refining until the answer stops moving:

    StPS = 200   11.758 dB   +0.000 dB   38.0 s
    StPS = 100   11.759 dB   +0.001 dB   19.3 s
    StPS =  50   11.763 dB   +0.005 dB    9.4 s
    StPS =  25   11.776 dB   +0.018 dB    5.0 s
    StPS =  12   11.845 dB   +0.087 dB    2.5 s

Fifty steps sit 0.005 dB from the converged answer, two orders of
magnitude below the hundredth of a decibel this page quotes at best. The
other link agrees: +0.002 dB on dispersion compensation and 0.000 dB on
back-propagation, at 5.4 s against 22.7 s.

The page now carries that table instead of the sentence, which shows a
reader how a step count is chosen rather than telling them one. The
one-shot example drops from 17.4 s to 4.4 s per 25-span run and its
profile from 17.2 s to 4.6 s; the effective SNRs move by 0.01 dB at
most, and the fibre's cost per span, the two checks and the error rates
are unchanged.

The launch-power sweep is re-running; its tables land in the next
commit.
The sweep at StPS = 50 against the same sweep at 200: every effective
SNR in the eight-by-six table is identical to the tenth of a decibel,
the GN-model comparison still reads 18.77 dB predicted against 18.78 dB
measured, and one entry of the summary moves by a hundredth -- four
steps per span, 23.81 to 23.82 dB. Every claim the prose makes from
those numbers survives unchanged: 4.5 dB from one step to four, 2.1 dB
more from four to fifty at twelve times the cost, 0.41 dB left to the
noise-only bound, 5.0 dB over dispersion compensation.

That is the point of the measurement in the previous commit: a
discretization two orders of magnitude below what is claimed from it
does not move what is claimed.

`SLOW` re-measured: the sweep drops from ~1400 s to 370 s, the one-shot
example from 73 s to 22 s.
`compute_ser_truncated` and `compute_ber_bits` are not library
functions: they are local definitions in two validation scripts, and
both duplicate something `comnumpy.core.metrics` already does.

    compute_ser_truncated(tx, rx)  ==  compute_ser(tx, rx)
    compute_ber_bits(a, b)         ==  compute_ber(a, b, 1)

Checked rather than asserted, and both scripts still pass with the same
numbers.

They exist for two different reasons, and only the second is the
library's fault. `compute_ser` already truncates to the common length --
its docstring says so in as many words -- so the first helper duplicates
documented behaviour that its author did not find. The second is a real
friction: `width` is a mandatory positional argument, so `compute_ber`
cannot be handed to `monte_carlo` as a metric without binding it, and a
lambda cannot cross the process boundary that `n_jobs` opens. The answer
is `partial(compute_ber, width=1)`, which is picklable and which nothing
in the documentation points to; the script now uses it, with the reason
written beside it.
…able

`is_polarization_pair` reads axes -2 and -1 only, so `(B, P, N)` has
always propagated B realizations in one call and nothing said so, tested
it, or pointed a user at it. For a capability that is the same as not
having it: the next refactor of `prepare()` could take it away and every
test would still pass.

It is worth having. Measured against the same B calls: 1.76x at 12288
samples and 3.14x at 1024, on the sweep's own configuration.

The gain is not where I first said it was, twice over. A batched FFT on
`(B, N)` is no faster than B FFTs -- 40.2 ms against 39.1 -- and the
per-call costs I then blamed are noise: constructing the per-span
amplifier is 20 us, a bare block dispatch 0.25 us, together 0.03 % of a
700 ms pass. What the batch amortizes is
`apply_chromatic_dispersion` rebuilding its transfer function on every
half-step: an `fftfreq`, a `w**2` and a length-N complex exponential,
0.458 ms against 0.768 ms for the FFT pair it serves, a thousand times
per propagation. Looped, that is 32000 exponentials; batched, 1000.

Which means the same speedup is available without any batch, by
precomputing what depends only on `(dz, fs, beta2, N)` -- and every step
of a linear span shares those. That is left for a separate change; this
one only makes the existing behaviour a promise.

The error message now names both readings of `(B, N)`: multiplex WDM
channels, or write the polarization axis as `(B, 1, N)`. Eight tests pin
that a batch is the same simulation as the loop, bit for bit with the
noise off -- including that a hundred-times-louder item does not leak
its Kerr phase into a quiet neighbour -- and that with the noise on each
item still carries the power the link budgets.
`apply_chromatic_dispersion` built its transfer function on every call
and threw it away: an `fftfreq`, a `w**2` and a length-N complex
exponential, for something that depends only on
`(n, dz, beta2, fs, alpha_dB, direction)` and is applied a thousand
times per propagation. Measured at 12288 samples, building it costs
0.42 ms against 0.77 ms for the FFT pair it serves, and the exponential
alone is 88 % of that.

Three functions where there was one, and the composed one keeps its name
and its behaviour so nothing downstream moved:

    linear_step_transfer(n, z, beta2, ...)   what the step *is*
    apply_frequency_response(x, H)           what it *does*
    apply_chromatic_dispersion(...)          the two composed

The attenuation is folded into the transfer function rather than left
beside it: both factors are the operator D of the split step, one real
and flat, the other complex and frequency-dependent, and separating them
would make every caller carry a scalar alongside the array.

`step_transfers` builds one row per *distinct* step length and returns
the row each step reads, so the two schedules stop being two code paths:
a linear one collapses to a single row whatever `StPS` is, a logarithmic
one keeps `StPS`, and the propagation loop indexes a table without ever
testing `step_type`. It is keyed on everything it depends on and
returned unchanged when the key matches, so a sweep builds it once
instead of once per pass. One implementation serves both directions:
`FiberLink` asks for +1, `DBP` for -1.

Propagation drops from 1.25 s to 0.683 s -- 1.83x, with no batch, on
every existing chain and with no signature changed. Output is unchanged
to 3e-15 relative across all nine configurations (both schedules, both
schemes, both blocks, plus `use_only_linear`), which is the rounding of
folding the attenuation in.

Two defects fixed on the way. `wdm.py` called `np.fft` directly instead
of the cupy-compatible backend, so that block could never have run on
GPU (D3); it and the two Raman tilt sites now use the shared helper.
Every measured decibel is identical -- the effective SNRs, the fibre's
cost per span, the two checks, the eight-by-six sweep and the
GN-model comparison at 18.77 against 18.78 dB. What moved is time, and
only time.

    25-span run          4.4 s ->  2.6 s
    the profile's link  4557 ms -> 2514 ms
    back-propagation    3026 ms -> 1653 ms
    the whole sweep      370 s ->  203 s
    the one-shot page     22 s ->   18 s

Two figures in the prose follow: back-propagation costs 262 times a
dispersion compensation rather than 235, because the cache helped the
cheap receiver proportionally more than the expensive one, and the
propagation is 99.5 % of the profile rather than 99.9 % for the same
reason. Everything the page concludes is unchanged, which is the point:
at 3e-15 the refactor cannot move a conclusion, only the clock.
…mbols

Three defects, none of them ergonomic.

**Nothing ran the nine SLOW examples.** The smoke test skips them, which
is what SLOW is for -- they cost minutes each and a pull request should
not wait. But no other job ran them either, so a regression lived in
`CD_compensation_part1.py` for two commits: it raised `TypeError:
'Constellation' object is not subscriptable` on its first pass while
every check stayed green. They now run weekly and on demand. The list is
not repeated in the workflow -- the runner imports `SLOW` from the test
module, so an entry added there is covered and an entry removed stops
being run, with no workflow to edit.

**A serial `monte_carlo` without `seed=` was silently unreproducible.**
The parallel path refuses outright; the serial one said nothing, and a
curve nobody can reproduce is not a result. It warns now, rather than
raising: a first look at a curve does not need to be reproducible. All
eleven call sites in the repository already pass a seed, so nothing
existing trips it.

**`N_s` did not mean what it said.** `channel(N_s * oversampling_sim)`
passed a *symbol* count to the generator and the `Upsampler` applied the
oversampling a second time, so the two optical examples simulated six
times the data their own variable names promised -- and the page quoted
"8192 symbols per point" and a 1.2e-4 floor for what is 49152 symbols
and 2.0e-5. `N_s` now holds the symbol count that runs, so nothing moved
numerically, and the floor paragraph is replaced by the counters' own
output: four receivers of six saw no error at all at their best power,
which is a statement about the estimator rather than about the link.

Every measured decibel across both pages is unchanged. The clock is not:
the sweep is 144 s and the one-shot page 13 s, against 1400 s and 73 s
this morning.
@vincentchoqueuse vincentchoqueuse changed the title Name the Monte-Carlo, count its errors in one place, and restore the Häger reproduction Name the Monte-Carlo, make the fibre cheap, and run the examples nobody ran Aug 13, 2026
claude added 3 commits August 13, 2026 11:20
…ssing

The strict ratchet had ten complaints about the step-transfer cache, and
two of them were real bugs wearing a type error's clothes.

`step_transfers` returned a bare `tuple`, so its shape lived only in the
call sites. It is now `TransferTable`, an alias beside `TransferKey`, and
`dbp.py` -- which annotated a field with `TransferKey` without importing
it, and would have raised `NameError` the moment anyone evaluated the
annotation -- imports it.

The second was the `previous=` argument. It was handed a three-tuple
built from three `Optional` fields, which on the first call is
`(None, None, None)`: a value that is not a transfer table and only
happened to work because the key comparison failed against it. It is now
`None` until there is a table to pass.

The rest is the guard the sibling fields already had: `prepare()` fills
`transfer_` and `transfer_index_` before any `forward`, and the assert
next to `beta2` and `step_size` now says so.

Also drops `pyright_all_tmp.json` -- a scratch copy of the ratchet list
that was committed by accident and has been shadowing the real
`pyrightconfig.json` in every directory listing since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
…d against

The previous commit guarded `previous=` on the key alone, which is the
one field whose type is not `ndarray`: the two arrays stayed `Optional`
in the tuple that was built from them, and strict rejected the call. All
three are now tested together, so the tuple that reaches `step_transfers`
is a `TransferTable` or nothing.

The reason that fix took two rounds is worth writing down, because it
will happen to anyone else: `pyright` disagrees with itself depending on
the NumPy in the environment. The 1.26 stubs make `ndarray` a generic
that must carry type arguments; the 2.x stubs do not. Against the pinned
oldest NumPy the ratchet reports several hundred errors that are not
real, which is a report nobody can read -- and a genuine error hides in
it perfectly well, which is exactly what happened here.

CONTRIBUTING now says to keep a second environment for the check and
pass it with `--pythonpath`, which is what CI effectively does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
…st it

The Monte-Carlo page asked the constellation for a closed form and drew
it. That shows theory and measurement agreeing without ever showing what
the theory is, which is the wrong lesson: the reader learns that an
object returns the right numbers, not why those numbers are right.

The page now writes the expression out -- a square QAM as two
independent PAM constellations, the argument of Q, the two lines that
follow, Proakis and Salehi section 4.3 -- evaluates it in NumPy, and
only then shows `constellation.metrics` returning the same array to
3.3e-16. The method is what the rest of the library uses afterwards, and
the page says why: one line instead of five, and it cannot describe a
modulation other than the one the chain transmits.

It also stopped at one constellation, which answers "is the simulation
right" and not "what is a constellation worth". A closing section sweeps
4-, 16-, 64- and 256-QAM at equal energy per bit -- the comparison
`ebn0_to_snr_dB` exists for, and the one that is fair -- and reads off
what the density costs: 6.8, 10.5, 14.8 and 19.4 dB for a BER of 1e-3,
three steps of 3.7, 4.3 and 4.6 dB. The section says where its two
floors are, because both are visible in the figure: the Gray-mapping
approximation P_b ~ P_s/k runs a factor 2.2 under the measurement for
256-QAM at 0 dB before settling onto it below 1e-2, and 100 000 symbols
per point cannot resolve a 4-QAM BER below 5e-6, so the axis stops
there.

Two defects turned up on the way. The page still pointed at `sweep` in
two cross-references after the rename. And the figure it displayed was
written by nothing: the script ended on `plt.show()` with no `savefig`,
so `img/monte_carlo_awgn.png` was whatever a human last produced by
hand. It is regenerated by the script now, like every other figure here.

The chain becomes `get_chain(constellation)` because the last section
builds one per order -- the naming rule, which the previous inline chain
predated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
@vincentchoqueuse vincentchoqueuse changed the title Name the Monte-Carlo, make the fibre cheap, and run the examples nobody ran Name the Monte-Carlo, make the fibre cheap, derive the AWGN curve, and run the examples nobody ran Aug 13, 2026
@vincentchoqueuse
vincentchoqueuse merged commit 3b4739c into main Aug 13, 2026
10 checks passed
vincentchoqueuse added a commit that referenced this pull request Aug 13, 2026
Three defects nothing was catching: the nine SLOW examples were run by
no job at all, a serial monte_carlo without seed= was silently
unreproducible, and N_s named a symbol count that the Upsampler then
multiplied again, so both optical examples simulated six times what
their variable names promised.

The fibre is seven times cheaper and says the same thing.
apply_chromatic_dispersion rebuilt its transfer function on every
half-step; it is now split into linear_step_transfer (what the step is)
and apply_frequency_response (what it does), with step_transfers caching
one row per distinct step length. With StPS measured down from 200 to 50
-- 0.005 dB at the sweep's most nonlinear power -- the launch-power
sweep went from ~1400 s to 144 s and the one-shot page from 73 s to
13 s. Nine configurations agree with the old code to 3e-15.

The AWGN tutorial now derives its reference curve instead of asking an
object for it: the two-PAM decomposition of a square QAM, the argument
of Q, Proakis and Salehi section 4.3, evaluated in plain NumPy, and only
then constellation.metrics returning the same array to 3.3e-16. It ends
on 4-, 16-, 64- and 256-QAM at equal energy per bit -- 6.8, 10.5, 14.8
and 19.4 dB for a BER of 1e-3 -- with both floors of that figure named.

Three measurements contradicted me along the way. A batched FFT is not
faster than B separate ones, 40.2 ms against 39.1. The per-call overhead
I then blamed is 0.03 % of a pass. It was the transfer function all
along.

Smaller things: FiberLink.budget() puts the amplifier-noise formula next
to the block that generates the noise; ErrorCounter makes an error rate
a ratio of totals rather than a mean of rates; sweep becomes
monte_carlo; wdm.py was calling np.fft instead of the backend, so that
block could never have run on cupy; validation/optical_dbp_hager.py
restores the reproduction of Hager and Pfister on their own setup; and
CONTRIBUTING says which NumPy the strict-typing ratchet must be read
against.

ruff, 860 tests + 1145 subtests, doctests and sphinx-build -W pass.
44 new tests.
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