Skip to content

The blind coherent receiver, and batch axes as a machine-checked contract - #41

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

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

Conversation

@vincentchoqueuse

Copy link
Copy Markdown
Owner

Five commits, two threads.

The blind coherent receiver (PMD, BPS), and locks the library owed

  • PMDEmulator (optical.channels): the standard section emulator — K Haar-uniform Jones rotations, each followed by a DGD of τ/√K applied exactly in the frequency domain, so the declared τ is the RMS DGD of a Maxwellian ensemble (Poole & Wagner). A 300-seed test measures the DGD from the eigenvalues of the group-delay operator and pins both moments within 10 %.
  • BlindPhaseSearchCompensator (core.compensators): Pfau's feedforward blind phase search, tested against a Wiener walk within its grid; the quadrant ambiguity is documented as unresolved, and tested as such. PhaseNoise now draws one walk per polarization pair — one laser.
  • optical_pdm tutorial: PDM-QPSK through laser, PMD and amplifier, undone blindly (CMA butterfly, BPS), the three receiver ambiguities — permutation, quadrant, equalizer delay — resolved explicitly by known data. Zero errors over 123 072 symbols, and the page says what that measures.
  • Gray labelling locked: nearest neighbours of every QAM/PSK alphabet differ by exactly one index bit; a nearest-neighbour error costs exactly one bit through compute_ber; a seeded run lands on the Gray closed form.
  • print(obj) renders info() — one key: value line per entry, repr when there is none.

Batch axes are a contract (D51), enforced by a ratchet

Leading axes ahead of a block's event axes are batch axes: independent trials in one call. Three families — deterministic blocks broadcast, stochastic blocks draw independently per event (PhaseNoise(per=) names the event; the ambiguous shape is refused), adaptive blocks carry state per event (one CMA butterfly per pair, H_ with batch axes in front). Stated in CONVENTIONS.md.

  • Silent bugs killed by the sweep: compute_ser/compute_ber raveled before truncating and compared misaligned rows (SER 0.47 for a truth of 0.02); FIRChannel, LinearEqualizer, BWFilter, CFO, Delay and both CD FIR compensators crashed on a batch; DCCorrector averaged across trials; HardClipper clipped every trial against the batch's pooled power; BlindCFOCompensator smeared one estimate over independent trials and now refuses, naming the loop.
  • The MIMO answer is a stacked channel: rayleigh_channel(size=K) → (K, N_r, N_t), draw-for-draw equal to the Python loop; FlatMIMOChannel applies channel k to frame k; a detector built with the same stack decides frame k against channel k — ZF/MMSE through numpy's stacked linalg, ML/sphere/OSIC through an internal per-draw loop that their docstrings call what it is.
  • The ratchet (tests/test_batch_contract.py): discovers all 91 Processor subclasses and fails unless each is verified (BROADCAST by row equality — 36 blocks, INDEPENDENT, REFUSES) or exempted with a written reason. A new block cannot land without declaring what a batch means for it.
  • Measured, not asserted: batching is ×4.1 where an operator amortizes (block ZF), ×2.2 on OFDM, and ×0.5 — slower — on a plain AWGN chain; the CHANGELOG says all three. Tutorials updated: the OFDM repeat-loop and both MIMO studies are one batched call each, pages re-pasted from real runs.

Full suite green: 938 tests + 1218 subtests, ruff, pyright strict (0 errors), sphinx -W, example smoke tests.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 5 commits August 13, 2026 21:08
…nt themselves

The expert review named the two absences that dated the optical layer:
no polarization impairment, and no time-varying carrier recovery. Both
close here. PMDEmulator is the standard section emulator -- K
Haar-uniform Jones rotations, each followed by a DGD of tau/sqrt(K)
applied exactly in the frequency domain, so the declared tau is the RMS
DGD of a Maxwellian ensemble (Poole & Wagner); a 300-seed test measures
the DGD from the eigenvalues of the group-delay operator and pins both
moments within 10 %. BlindPhaseSearchCompensator is Pfau's feedforward
search, tested against a Wiener walk within its grid, its quadrant
ambiguity documented as unresolved. The optical_pdm page runs PDM-QPSK
through laser, PMD and amplifier, then undoes it blindly -- CMA
butterfly, BPS, and the three ambiguities every blind receiver leaves
resolved explicitly by known data: zero errors over 123072 symbols.
PhaseNoise now draws one walk shared across leading axes: one laser.

The Gray labelling that every BER curve leaned on is locked by
deterministic tests: geometric nearest neighbours differ by exactly one
index bit for QAM 4-256 and PSK 4-16, a nearest-neighbour symbol error
costs exactly one bit through compute_ber, and a seeded 16-QAM run
lands on the Gray closed form.

print(obj) now renders info() -- one key: value line per entry, repr
when there is no info() -- and the tutorials' items() loops become
print(channel). The AWGN page drops its interpolation coda.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leading axes ahead of a block's event axes are batch axes -- independent
trials run in one call -- and each of the three block families owes the
batch something different. Deterministic blocks broadcast their one
configuration: FIRChannel now reshapes its kernel so scipy convolves
along the last axis, and LinearEqualizer applies its matrix as a column
product; both crashed on a batch before. Stochastic blocks draw
independently per event: PhaseNoise declares its event with per= --
"pair" by default, one laser per polarization pair and independent
draws across trials -- and refuses the shape it cannot read one way,
naming the resolutions. Adaptive blocks carry independent state per
event: BlindDualMIMOCompensator on (..., 2, N) adapts one butterfly per
pair and exposes H_ with the batch axes in front, so partial_fit
resumes each pair from its own state. CONVENTIONS.md states the
contract (D51) and tests/core/test_batch_axes.py locks one block of
each family.

Two silent traps died on the way. compute_ser and compute_ber used to
ravel before truncating to the common length, so a batch whose rows
carried a convolution tail compared misaligned rows -- SER 0.47 where
the truth was 0.02; both now truncate along the last axis first, and
the pooled rate is exactly the mean of the per-row rates. The OFDM
tutorial's repeat-loop became one batched monte_carlo call over
(n_trials, N), and its page re-pastes the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sweep of the block catalogue against the D51 contract found four more
deterministic blocks that crashed on a batch; all now broadcast over
leading axes. BWFilter's 1-D guard was refusing what already worked --
the FFT mask acts on the last axis. CFO read len(x), which on a batch
is the number of trials, as the signal length. Delay sliced and padded
axis 0. Both chromatic-dispersion FIR compensators get the same
kernel-rank fix as FIRChannel. BlindCFOCompensator keeps its documented
event -- one oscillator per signal or per polarization pair (2, N),
estimated jointly, as test_estimand_scope pins -- and now refuses a
wider batch instead of silently smearing one scalar estimate over
independent trials. tests/core/test_batch_axes.py locks each of these,
and the data-aided estimators' existing refusals already conform.

What batching buys is now measured, not asserted, and the CHANGELOG
says all three numbers: x4.1 on the single-carrier chain whose block ZF
equalizer rebuilds its pseudo-inverse on every call, x2.2 on the OFDM
chain over 16 trials, and x0.5 -- slower -- on a plain AWGN chain where
Python overhead was never the cost. Batch for correctness and to
amortize per-call operator builds, not as a blanket speed knob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MIMO question the batch contract had to answer: the channel is
redrawn every frame, and every detector needs that frame's matrix. The
answer is a stack. rayleigh_channel(size=K) returns (K, N_r, N_t),
drawn sequentially from one generator so draw k equals the k-th draw of
the equivalent Python loop; FlatMIMOChannel applies channel k to frame
k, since matmul batches over leading axes; and a detector built with
the same stack decides frame k against channel k. The linear detectors
batch through numpy's stacked linear algebra -- mmse_estimator now
transposes with swapaxes(-1, -2) so its normal equations batch too --
while ML, the sphere decoder and OSIC loop per draw internally: an
enumeration, a tree and a cancellation order each depend on the one
matrix in front of them, so their batch is convenience and correctness,
not a speedup, and their docstrings say so. A stack of K channels
refuses K+1 frames. tests/mimo/test_stacked_channel.py locks the draw
equivalence, the per-frame alignment of the channel and all five
detectors, and the refusal.

Both MIMO studies now run on the stack. one_shot_mimo replaces its
zipped monte_carlo sweep -- 200 chain runs per detector per SNR point
-- with one batched pass, 10.9 s to 9.0 s, the sphere decoder's
per-sample search dominating what remains; its page re-pastes the run.
monte_carlo_simulation_1 drops the 500-draw Python loop for one stacked
pass per point and reads the BLER directly off the batch as
np.mean(np.any(S_est != S_ref, axis=(-2, -1))): 4.7 s to 2.5 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A convention over 91 blocks is only worth the sweep that checks it.
tests/test_batch_contract.py discovers every Processor subclass in the
library and fails unless each one is declared and verified: BROADCAST
(row i of a batch equals the block on row i alone -- 36 blocks checked
by equality), INDEPENDENT (trials do not share a realization), REFUSES
(the ambiguous batch raises), or EXEMPT with the reason written next to
it -- an exemption is visible debt, not a pass. Like the pyright
ratchet, the contract is now self-enforcing: a new block cannot land
without saying, and proving, what a batch means for it.

Its first sweep caught two more silent couplings, both fixed.
DCCorrector's default axis=0 averaged across the batch -- one converter
offset smeared over independent trials; the default is now -1, the
canonical serial layout, one offset per row. HardClipper computed its
threshold from the mean power of the whole array, so the weakest trial
of a batch was clipped against the strongest; the power is now per row,
one transmitter per trial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vincentchoqueuse
vincentchoqueuse merged commit 2c77f38 into main Aug 13, 2026
8 checks passed
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