Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/comnumpy-tutorial/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Before writing a helper, look for it. The recurring failure is
reimplementing from scratch what `comnumpy` already does, and the reader
then learns the wrong lesson -- they came to see how the library is used.

- Chains: `Sequential`, `taps=` to observe a signal inside one, `wiring=`
- Chains: `Sequential`, `observations=` to retain a signal inside one, `wiring=`
to feed a data-aided block a reference the chain produces itself,
`set_params` (dotted or `__`) to change one parameter, `seed` for
reproducibility, `elapsed_` for the wall time of the last pass,
Expand Down Expand Up @@ -170,7 +170,7 @@ from inside.
- **No inline `for`.** List, dict and set comprehensions, and generator
expressions inside `join`/`sum`/`np.array`/`tuple`, are banned in tutorial
scripts. Write the loop out, with a body. This is a hard rule.
- Name blocks (`name="data_tx"`) so that taps, wiring, `set_params` and the
- Name blocks (`name="data_tx"`) so that observations, wiring, `set_params` and the
profile table all read as the same vocabulary.
- Comments in the script explain *why*, and they are part of what the page
shows -- the `literalinclude` ranges quote them.
Expand Down
14 changes: 7 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,10 @@ from comnumpy import Sequential, SymbolGenerator, SymbolMapper, \

alphabet = get_alphabet("QAM", 16)
chain = Sequential([SymbolGenerator(M=16, name="tx"), SymbolMapper(alphabet),
AWGN(snr_dB=15), SymbolDemapper(alphabet)], taps=["tx"])
AWGN(snr_dB=15), SymbolDemapper(alphabet)], observations=["tx"])
chain.seed(42) # D6 seeded, D42 records "tx"
detected = chain(10_000)
ser = compute_ser(chain.tap("tx"), detected) # SER = 0.0165
ser = compute_ser(chain.observation("tx"), detected) # SER = 0.0165
```

Huit lignes, un seul import, aucune connaissance de l'arborescence
Expand Down Expand Up @@ -656,14 +656,14 @@ qu'exécuté.

| # | Décision | Motif | Alternatives rejetées | Statut |
|---|----------|-------|-----------------------|--------|
| D42 | **`module_list` ne contient que le système de communication.** (a) Tout bloc dont la seule fonction est d'observer est **supprimé** — `Recorder`, `Logger`, `Debugger`, `PowerReporter`, `TimeSignalMonitor`, `MetricRecorder`, la famille `Scope`, `FFTMonitor` ; `core/monitors.py` disparaît. (b) L'observation devient une **métadonnée de chaîne** : `Sequential(..., taps=["id"])` enregistre la sortie des blocs nommés pendant `forward`, relue par `chain.tap("id")`. (c) Une arête de donnée supplémentaire se déclare de la même façon : `Sequential(..., wiring={"bloc.param": "source"})` affecte au bloc cible, avant son exécution, le signal produit par la source **dans la même passe**. (d) Le tracé et le compte rendu sont des **fonctions** appliquées aux tableaux extraits (`plot_iq`, `plot_spectrum`, …, `signal_report`), jamais des blocs. (e) Ce qu'un bloc calcule en interne et que sa sortie ne contient pas s'expose en attribut souligné D23 (`pilots_` de `CarrierExtractor`), pas par un puits externe | Un `Recorder` inséré dans `module_list` fait mentir toutes les vues qui décrivent la chaîne — `__repr__`, `summary()`, `to_mermaid()` (D33), l'export JSON et les indices de `set_params` (D31/D34) voient un système à N+2 blocs. La ligne de partage est nette : un tap capture ce qui **circule sur le fil**, un attribut souligné expose ce qu'un bloc a calculé et que sa sortie ne porte pas. Le coût est une écriture de dictionnaire par bloc tappé — une **référence**, sans copie ; ce qui repose sur l'invariant, désormais écrit dans CONVENTIONS.md, qu'aucun bloc ne modifie son entrée sur place. (c) traite le seul cas que (b) ne couvre pas et que le `Recorder` servait vraiment : un estimateur assisté par les données dont la référence est **produite par la chaîne**. Un `reference=` figé y est faux et **silencieusement** faux — en Monte-Carlo il continue de comparer aux symboles du premier tirage. C'est la forme bornée du champ `inputs` de D31 : une seconde entrée déclarée par la chaîne, sans DAG général (§8) | Filtrer les `Recorder` à l'affichage (le mensonge change de place : sérialisation, indices et `set_params` les voient toujours) ; mode « tout tracer » (coût mémoire réel sur les chaînes optiques full-field, contraire à P2) ; laisser les blocs se référencer entre eux comme avant (arête de graphe passant par la configuration — non sérialisable : un paramètre *callable* est une frontière assumée de D31, donc toute chaîne enregistrant ses pilotes était inexportable) ; hooks à la PyTorch (machinerie invisible, contraire à P7) | **Acté** |
| D42 | **`module_list` ne contient que le système de communication.** (a) Tout bloc dont la seule fonction est d'observer est **supprimé** — `Recorder`, `Logger`, `Debugger`, `PowerReporter`, `TimeSignalMonitor`, `MetricRecorder`, la famille `Scope`, `FFTMonitor` ; `core/monitors.py` disparaît. (b) L'observation devient une **métadonnée de chaîne** : `Sequential(..., observations=["id"])` conserve la sortie des blocs nommés pendant `forward`, relue par `chain.observation("id")`. (c) Une arête de donnée supplémentaire se déclare de la même façon : `Sequential(..., wiring={"bloc.param": "source"})` affecte au bloc cible, avant son exécution, le signal produit par la source **dans la même passe**. (d) Le tracé et le compte rendu sont des **fonctions** appliquées aux tableaux extraits (`plot_iq`, `plot_spectrum`, …, `signal_report`), jamais des blocs. (e) Ce qu'un bloc calcule en interne et que sa sortie ne contient pas s'expose en attribut souligné D23 (`pilots_` de `CarrierExtractor`), pas par un puits externe | Un `Recorder` inséré dans `module_list` fait mentir toutes les vues qui décrivent la chaîne — `__repr__`, `summary()`, `to_mermaid()` (D33), l'export JSON et les indices de `set_params` (D31/D34) voient un système à N+2 blocs. La ligne de partage est nette : une observation capture ce qui **circule sur le fil**, un attribut souligné expose ce qu'un bloc a calculé et que sa sortie ne porte pas. Le coût est une écriture de dictionnaire par bloc observé — une **référence**, sans copie ; ce qui repose sur l'invariant, désormais écrit dans CONVENTIONS.md, qu'aucun bloc ne modifie son entrée sur place. (c) traite le seul cas que (b) ne couvre pas et que le `Recorder` servait vraiment : un estimateur assisté par les données dont la référence est **produite par la chaîne**. Un `reference=` figé y est faux et **silencieusement** faux — en Monte-Carlo il continue de comparer aux symboles du premier tirage. C'est la forme bornée du champ `inputs` de D31 : une seconde entrée déclarée par la chaîne, sans DAG général (§8) | Filtrer les `Recorder` à l'affichage (le mensonge change de place : sérialisation, indices et `set_params` les voient toujours) ; mode « tout tracer » (coût mémoire réel sur les chaînes optiques full-field, contraire à P2) ; laisser les blocs se référencer entre eux comme avant (arête de graphe passant par la configuration — non sérialisable : un paramètre *callable* est une frontière assumée de D31, donc toute chaîne enregistrant ses pilotes était inexportable) ; hooks à la PyTorch (machinerie invisible, contraire à P7) | **Acté** |

**Trois besoins, trois mécanismes.** La distinction est normative ; toute
demande d'observation doit tomber dans l'une des trois cases.

| Besoin | Mécanisme |
|---|---|
| Regarder un signal après coup | `taps=[...]` puis `chain.tap(id)` |
| Regarder un signal après coup | `observations=[...]` puis `chain.observation(id)` |
| Alimenter un bloc avec un signal amont **du tirage courant** | `wiring={"bloc.param": "source"}` |
| Référence connue d'avance (préambule, séquence d'apprentissage) | paramètre tableau du bloc |

Expand All @@ -678,12 +678,12 @@ chain = Sequential([
BlindIQCompensator(name="gsop"), BlindCFOCompensator(name="cfo"),
DataAidedPhaseCompensator(reference=np.zeros(1), name="phase"),
SymbolDemapper(alphabet),
], taps=["data_tx", "awgn", "gsop", "phase"],
], observations=["data_tx", "awgn", "gsop", "phase"],
wiring={"phase.reference": "signal_tx"})

y = chain(5_000)
ser = compute_ser(chain.tap("data_tx"), y)
plot_iq(chain.tap("gsop"), title="after GSOP") # D25: plotting is a function
ser = compute_ser(chain.observation("data_tx"), y)
plot_iq(chain.observation("gsop"), title="after GSOP") # D25: plotting is a function
```

`len(chain.module_list) == 9`, et les neuf sont des blocs de
Expand Down
40 changes: 39 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ one release; there is no compatibility layer.
| Optional block parameters accepted positionally | keyword-only (D40b): only the principal first argument is positional |
| Bare `ValueError` on shape mismatch | `comnumpy.ShapeError` (still a `ValueError` subclass — existing `except ValueError` keeps working) |
| `import comnumpy` loaded matplotlib (~1 s) | lazy imports: no matplotlib at import time, ~90 ms (enforced in CI) |
| `Recorder(name="tx")` inserted in `module_list`, read with `chain["tx"].get_data()` | name the block itself and declare a tap: `Sequential([SymbolGenerator(16, name="tx"), ...], taps=["tx"])`, read with `chain.tap("tx")` |
| `Recorder(name="tx")` inserted in `module_list`, read with `chain["tx"].get_data()` | name the block itself and declare an observation: `Sequential([SymbolGenerator(16, name="tx"), ...], observations=["tx"])`, read with `chain.observation("tx")` (first shipped as `taps=`/`tap()`, renamed before release) |
| `Logger`, `Debugger`, `PowerReporter`, `TimeSignalMonitor` blocks | removed — tap the block and call `signal_report(chain.tap("x"))`, then log the returned dict |
| `MetricRecorder(metric_fn=f)` block | removed — `f(chain.tap("x"))` |
| `Scope(scope_type="iq", ...)`, `TimeScope`, `SpectrumScope`, `IQScope`, `KDEScope`, `WelchScope` blocks | removed — functions `plot_iq`, `plot_time`, `plot_spectrum`, `plot_kde`, `plot_welch` applied to a tapped signal |
Expand All @@ -45,6 +45,44 @@ one release; there is no compatibility layer.
| `core.metrics.calculate_acpr` | `compute_acpr` — it was the only `calculate_*` in the library, against 17 `compute_*` |
| `core.metrics.compute_effective_SNR`, `ofdm.metrics.compute_PAPR` | `compute_effective_snr`, `compute_papr` — the two capitalized outliers among functions otherwise all lowercase (`compute_ser`, `compute_ber`, `compute_evm`, `compute_ccdf`, `compute_mi`) |

### Changed — QPSK carries the pi/4 offset of the standards

`get_alphabet("PSK", 4)` sampled the raw circle
:math:`e^{2j\pi m/4}`: points on the axes, a constellation rotated by
pi/4 from the QPSK of the standards (3GPP, 802.11) and of every
textbook figure. QPSK now sits on the diagonals -- the same point set
as QAM-4; BPSK and :math:`M \ge 8` keep the phase-0 convention. No
error rate moves (mapper and demapper share the alphabet, the noise is
circular -- the Monte-Carlo tables of the Alamouti and MIMO pages shift
only by the draws now hitting rotated symbols), but the pi/4 surprise
against any other tool is gone, along with the tx-`PSK`/rx-`QAM` mixing
trap whose points landed exactly on the decision boundaries. The
historical-table pin now asserts the one deliberate deviation as the
relationship it is (built = table times :math:`e^{j\pi/4}`), and the
Gauss-Hermite accuracy table of `capacity.py` is re-measured for the
diagonal orientation (the node grid is axis-aligned, so the quadrature
error is not rotation-invariant even though the capacity is).

### Changed — `taps` becomes `observations`

The retained-signal mechanism of `Sequential` is renamed after what it
is: an **observation** is simply the output of a named mechanism,
explicitly retained for experimental analysis. It performs no
computation and does not alter the signal -- same shape, same dtype,
dimensions never reduced; power, SNR or SER stay with the metrics,
applied to the observation afterwards. Declare with
`Sequential([...], observations=["tx"])` (or `chain.observations =
[...]`), read back with `chain.observation("tx")` after the run. No
new naming system: observation names are the block names already used
by `set_params`, `wiring` and the profile table. Renamed with it:
`tapped_` -> `observed_`, the `taps` key of `graph()` and of the JSON
export -> `observations`, and the `tapped` mermaid class -> `observed`.
An empty `observations` list retains nothing, so large batches and
fibre propagations pay only for what they declare. `taps=`/`tap()` are
gone (one breaking-change window, no compatibility layer); the FIR
sense of the word -- filter taps, channel taps, `TappedDelayLine` -- is
untouched.

### Fixed — a full-library audit: 37 findings, 37 fixed

Five independent reviews swept the whole library -- core signal path,
Expand Down
34 changes: 22 additions & 12 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,30 +96,38 @@ by realization inequality, `REFUSES` by the raised error) or exempted
with a written reason. A new block cannot merge without declaring what
a batch means for it.

## Observing signals: taps, not blocks
## Observing signals: observations, not blocks

A chain describes the communication system. Nothing else goes into
`module_list` — no recorder, logger, scope or debugger block. To observe
an intermediate signal, name the block and declare it as a tap:
an intermediate signal, name the block and declare it as an observation.
An observation is simply the output of a named mechanism, retained for
experimental analysis: it performs no computation and does not alter the
signal — same shape, same dtype, dimensions never reduced.

```python
chain = Sequential([SymbolGenerator(16, name="tx"), SymbolMapper(alphabet),
AWGN(snr_dB=15, name="awgn")], taps=["tx", "awgn"])
AWGN(snr_dB=15, name="awgn")],
observations=["tx", "awgn"])
y = chain(1000)
x_tx = chain.tap("tx") # signal recorded during the last run
plot_iq(chain.tap("awgn")) # plotting is a function, not a block
x_tx = chain.observation("tx") # signal retained during the last run
plot_iq(chain.observation("awgn")) # plotting is a function, not a block
```

Consequences of this rule:

* `repr`, `summary()`, `to_mermaid()`, the JSON export and the block
indices describe the communication system only.
* A tap costs one dictionary store of a **reference** per tapped block
(no copy). This relies on the library-wide invariant that **a block
* Power, SNR, SER and the like stay with the metrics: an observation is
data, a metric is a computation applied to it.
* An observation costs one dictionary store of a **reference** per
observed block (no copy) — an empty `observations` list retains
nothing, so large batches and fibre propagations pay only for what
they declare. This relies on the library-wide invariant that **a block
never mutates its input in place**: `forward()` returns a freshly
allocated array (or the input untouched). Custom blocks must honour it
— an in-place block would corrupt earlier taps *and* break chain
re-entrancy.
— an in-place block would corrupt earlier observations *and* break
chain re-entrancy.
* Blocks never hold a live reference to another block. A data-aided
block takes its reference as a plain array (`reference=x_tx`) when
the reference is known in advance — a preamble, a training sequence.
Expand Down Expand Up @@ -147,13 +155,15 @@ chain = Sequential([
Before `comp` runs, the chain assigns it the signal `ref` produced in the
same pass. Rules:

* the source is tapped automatically — no need to list it in `taps`;
* the source is observed automatically — no need to list it in
`observations`;
* the source must run **before** the target; a backward edge raises,
because it would serve the previous run's value;
* the wiring feeds *data*, not structure: the target's `__post_init__` is
not re-run (use `set_params` for parameters that need a re-precompute);
* like taps, the edge is chain metadata — `module_list` is unchanged, and
`taps`/`wiring` are carried through the JSON export.
* like observations, the edge is chain metadata — `module_list` is
unchanged, and `observations`/`wiring` are carried through the JSON
export.

This is the honest, bounded version of the `inputs` field of decision
D31: a second input to a block, declared by the chain. A general DAG is
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ chain = Sequential([
SymbolMapper(alphabet),
AWGN(snr_dB=15, seed=123),
SymbolDemapper(alphabet),
], taps=["tx"])
], observations=["tx"])

# Transmit 10,000 symbols and evaluate performance
detected = chain(10_000)
print(f"SER = {compute_ser(chain.tap('tx'), detected)}") # SER = 0.016
print(f"SER = {compute_ser(chain.observation('tx'), detected)}") # SER = 0.016
```

## Installation
Expand Down
10 changes: 5 additions & 5 deletions docs/documentation/core/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ Observing signals inside a chain

A chain describes the communication system and nothing else: there are no
recorder, logger or scope blocks to insert between the processors. To
observe a signal, name the block and declare it as a *tap*::
observe a signal, name the block and declare it as an *observation*::

chain = Sequential([SymbolGenerator(16, name="tx"), SymbolMapper(alphabet),
AWGN(snr_dB=15, name="awgn")], taps=["tx", "awgn"])
AWGN(snr_dB=15, name="awgn")], observations=["tx", "awgn"])
y = chain(1000)
plot_iq(chain.tap("awgn"))
plot_iq(chain.observation("awgn"))

``taps`` is chain metadata: each tapped block costs one dictionary store of
a reference (no copy), and :meth:`~comnumpy.core.generics.Sequential.tap`
``observations`` is chain metadata: each observed block costs one dictionary store of
a reference (no copy), and :meth:`~comnumpy.core.generics.Sequential.observation`
returns the recorded array afterwards. Plotting and reporting are plain
functions applied to the extracted arrays -- see :doc:`visualizers` and
``comnumpy.core.metrics.signal_report``.
Expand Down
10 changes: 5 additions & 5 deletions docs/getting_started/first_simulation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ and shows what the second version has that the first does not.
- What a one-shot AWGN simulation is, written out by hand.
- Which parts of it are conventions you have to keep consistent yourself.
- How to assemble the same thing as a ``Sequential`` chain, and how to look
inside it with a tap.
inside it with an observation.
- What a ``Constellation`` knows about itself, and why that matters more than
it looks.

Expand Down Expand Up @@ -82,7 +82,7 @@ The Same Thing, as a Chain

The diagram is not drawn by hand: it is what the chain says about itself,
``chain.to_mermaid()``, so the block names are the ones the code uses and a
dashed outline marks a tapped block.
dashed outline marks an observed block.

Four processors, and each one is worth a sentence -- these four come back in
every tutorial that follows:
Expand Down Expand Up @@ -114,9 +114,9 @@ than in the caller's head.

**Nothing in the chain records, displays or measures.** A chain contains
communication blocks only. To see a signal in the middle of it, give the
block a name and declare that name as a **tap** -- ``taps=["tx", "awgn"]``
block a name and declare that name as an **observation** -- ``observations=["tx", "awgn"]``
above -- and the chain keeps what that block produced. One habit to take
right away: *run the chain first, read the tap after*. A tap holds the last
right away: *run the chain first, read the observation after*. An observation holds the last
run, so reading it before calling the chain raises rather than returning
stale data.

Expand Down Expand Up @@ -176,7 +176,7 @@ It also draws itself, next to what the channel did to it:
:align: center

The chain answers the same way:
:meth:`~comnumpy.core.generics.Sequential.tap` for a signal inside it,
:meth:`~comnumpy.core.generics.Sequential.observation` for a signal inside it,
``seed`` for reproducibility,
:meth:`~comnumpy.core.generics.Sequential.set_params` to reconfigure a named
block after construction, ``elapsed_`` for the time the last pass took, and
Expand Down
4 changes: 2 additions & 2 deletions docs/getting_started/mermaid/first_simulation.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ flowchart LR
tx --> symbol_mapper
symbol_mapper --> awgn
awgn --> symbol_demapper
classDef tapped stroke-dasharray: 4 2
class tx,awgn tapped
classDef observed stroke-dasharray: 4 2
class tx,awgn observed
Loading
Loading