semantic-typescript 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,1442 +1,1442 @@
1
- # **Semantic-TypeScript**
2
-
3
- **Flow, Indexed.** Your data, under precise control.
4
-
5
- English · [简体中文](./readme.cn.md) · [繁體中文](./readme.tw.md) · [日本語](./readme.jp.md) · [한국어](./readme.kr.md) · [Deutsch](./readme.de.md) · [Français](./readme.fr.md) · [Español](./readme.es.md) · [Русский](./readme.ru.md)
6
-
7
- A type-safe stream processing library that unifies **JavaScript generators**, **Java Stream** and
8
- **index-based data addressing**. Every element carries a `bigint` index, every pipeline is lazy, and
9
- every terminal operation is an explicit opt-in.
10
-
11
- ```bash
12
- npm install semantic-typescript
13
- ```
14
-
15
- - Zero runtime dependencies, pure ESM, ships its own types.
16
- - 684 regression tests: `npm test`.
17
- - Full change history: [CHANGELOG.md](./CHANGELOG.md).
18
-
19
- ---
20
-
21
- ## Table of Contents
22
-
23
- 1. [Design Philosophy](#1-design-philosophy)
24
- 2. [Architecture](#2-architecture)
25
- 3. [Quick Start](#3-quick-start)
26
- 4. [Factory Functions](#4-factory-functions)
27
- 5. [Transformation Operators](#5-transformation-operators)
28
- 6. [Collectables and Terminal Operations](#6-collectables-and-terminal-operations)
29
- 7. [Statistics and Windows](#7-statistics-and-windows)
30
- 8. [The Index Model](#8-the-index-model)
31
- 9. [The Asynchronous Model](#9-the-asynchronous-model)
32
- 10. [Periodic Analysis: Rings, Fields and Scores](#10-periodic-analysis-rings-fields-and-scores)
33
- 11. [Utility Helpers](#11-utility-helpers)
34
- 12. [Performance and Pitfalls](#12-performance-and-pitfalls)
35
- 13. [Phase Rings: An Exact Cycle of Positions](#13-phase-rings)
36
- 14. [Signal Analysis: Windows, Spectra and Time](#14-signal-analysis)
37
- 15. [License](#15-license)
38
-
39
- ---
40
-
41
- <a id="1-design-philosophy"></a>
42
- ## 1. Design Philosophy
43
-
44
- Three ideas, one pipeline:
45
-
46
- **Index is data.** Every element is addressed by a `bigint`. Reversal, rotation, shuffling and
47
- partitioning are index arithmetic, not buffer operations. Nothing is materialised until a
48
- collectable asks for it.
49
-
50
- **Laziness is the default.** A factory wraps a generator; an operator wraps another generator. No
51
- element is requested until a terminal operation runs, so an infinite source is perfectly legal as
52
- long as something downstream stops it.
53
-
54
- **Terminals are explicit.** `map` returns a `Semantic`, not an array. Values appear only when you
55
- name a collectable — `toUnordered()`, `toOrdered()`, `toNumericStatistics()`, `toWindow()` — and each
56
- one states what it costs and what order it produces.
57
-
58
- The same discipline applies to the mathematical half of the library. A `Complex`, an `Angle` or a
59
- `Frequency` is an immutable value whose invariants are checked at construction; an `Optional`
60
- replaces `null`; and a phase ring states exactly how many elements it holds, what period it has and
61
- how balanced it is.
62
-
63
- ---
64
-
65
- <a id="2-architecture"></a>
66
- ## 2. Architecture
67
-
68
- ```
69
- factory ──▶ Semantic ──▶ Semantic ──▶ Collectable ──▶ result
70
- (source) (map) (filter) (terminal)
71
- ▲
72
- └── every operator returns a new Semantic wrapping a generator
73
- ```
74
-
75
- | Layer | Module | Responsibility |
76
- | :--- | :--- | :--- |
77
- | Sources | `synchronous/collectors`, `asynchronous/collectors` | Wrap an iterable, a generator, a promise, a blob, an event target |
78
- | Pipelines | `synchronous/semantic`, `asynchronous/semantic` | Lazy operators and index rewriting |
79
- | Collectables | `synchronous/semantic`, `asynchronous/semantic` | Ordered, unordered, statistics, window and custom terminals |
80
- | Collectors | `synchronous/collector`, `asynchronous/collector` | The fold primitives every terminal is built from |
81
- | Mathematics | `mathematics/*` | Angles, complexes, frequencies, spectra, windows, estimators, epicycles, phase rings |
82
- | Structures | `structure/*` | Lists, sets, maps, trees, forests, rings, fields |
83
- | Errors | `structure/error` | The typed `SemanticError` hierarchy and its brand guards |
84
-
85
- Synchronous and asynchronous families share one vocabulary: the same operator names, the same index
86
- semantics, the same collectables. The asynchronous variants simply return promises, and they queue
87
- writes so that a read issued after a write always observes it.
88
-
89
- ### 2.1 What this release moved and broke
90
-
91
- Three modules changed location or shape, and a set of defaults changed meaning. Every old behaviour
92
- is still reachable through an explicit option; nothing was removed without a replacement.
93
-
94
- | Change | Before | Now |
95
- | :--- | :--- | :--- |
96
- | Forests | `structure/tree/rootedForest`, `structure/tree/unionFindForest` | `structure/forest/rootedForest`, `structure/forest/unionFindForest`; `tree/index.ts` no longer re-exports them |
97
- | Errors | ad-hoc native errors | a fifteen-class hierarchy in `structure/error.ts`, with one brand guard per class and the `SemanticRangeError` alias |
98
- | Estimator options | fixed definitions | `mathematics/quantile.ts` exports `QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator` and `VarianceOptions` |
99
- | `quantile` | Hyndman–Fan type 1 | type 7 — R `quantile`, NumPy `linear`, Excel `PERCENTILE.INC` |
100
- | `skewness` / `kurtosis` | `g1` / `g2` | the bias-corrected `G1` / `G2` |
101
- | `variance` / `standardDeviation` | `ddof = 0` | `ddof = 1`, wrapped in an `Optional` |
102
- | `decibel` | one `20·log10` function | removed; `amplitudeDecibel` (20·log10) and `powerDecibel` (10·log10) |
103
- | `Spectrum.flatness()` | magnitudes over both halves | powers over the one-sided half |
104
- | `Spectrum.bandEnergy()` | raw two-sided sum | one-sided by default, folding the conjugate half |
105
- | `shortTimeFourierTransform` / `spectrogram` | symmetric Hann, complete frames | periodic Hann, `boundary: "zeros"`, `padded: true` |
106
-
107
- Chapters 6, 7 and 10 document the replacements; the measured consequences are in
108
- [CHANGELOG.md](./CHANGELOG.md).
109
-
110
- ---
111
-
112
- <a id="3-quick-start"></a>
113
- ## 3. Quick Start
114
-
115
- ```typescript
116
- import {
117
- useOf, useFrom, useRange, useText, useStringify, usePromise
118
- } from "semantic-typescript";
119
-
120
- // Numeric aggregation
121
- const sum: number = useOf(10, 20, 30, 40)
122
- .map(n => n * 2)
123
- .toNumericStatistics()
124
- .summate(); // 200
125
-
126
- // BigInt aggregation
127
- const bigSum: bigint = useOf(10n, 20n, 30n, 40n)
128
- .map(n => n * 2n)
129
- .toBigintStatistics()
130
- .summate(); // 200n
131
-
132
- // Reordering is index arithmetic — collect with toOrdered() for it to take effect
133
- const reversed: number[] = useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
134
- const rotated: number[] = useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3]
135
-
136
- // Lazy evaluation over a large range
137
- const multiples: bigint[] = useRange(0n, 1_000_000n)
138
- .filter(n => n % 17n === 0n)
139
- .limit(10n)
140
- .toUnordered()
141
- .toArray(); // [0n,17n,…,153n]
142
-
143
- // Unicode-safe string processing (code points, not UTF-16 units)
144
- useText("semantic 👍").toUnordered().forEach(character => console.log(character));
145
-
146
- // Circular-safe serialisation
147
- const cyclic: any = { a: 1 }; cyclic.self = cyclic;
148
- useStringify(cyclic); // '{"a":1}'
149
-
150
- // Terminals hand the elements to real structures, not only to arrays
151
- useOf(3, 1, 4, 1, 5).toCollectable().toOrderedSet().toArray(); // [1,3,4,5]
152
- useFrom([3, 1, 4, 1, 5]).toCollectable().toBinarySearchTree().size(); // 5n
153
-
154
- // Async sources
155
- const total = await usePromise(Promise.resolve(7)).toNumericStatistics().summate(); // 7
156
- ```
157
-
158
- ---
159
-
160
- <a id="4-factory-functions"></a>
161
- ## 4. Factory Functions
162
-
163
- Construction is always O(1) — a factory only wraps a generator. The table states the cost of
164
- *consuming* `n` elements, plus the extra space the source itself needs.
165
-
166
- | Factory | Signature (result) | Time (consume n) | Space (source) | Notes |
167
- | :--- | :--- | :--- | :--- | :--- |
168
- | `useOf` | `useOf<E>(...elements) → SynchronousSemantic<E>` | O(n) | O(n) | Indices are 0..n−1; the spread argument is already materialised |
169
- | `useFrom` | `useFrom<E>(Iterable<E>) → SynchronousSemantic<E>`<br>`useFrom<E>(AsyncIterable<E>) → AsynchronousSemantic<E>` | O(n) | O(1) | Wraps any iterable; iterated once on collect |
170
- | `useRange` | `useRange<N>(start, end, step?) → SynchronousSemantic<N>` | O(n) | O(1) | `end` is exclusive; `step` must be non-zero; `number` and `bigint` both work |
171
- | `useFill` | `useFill<E>(element \| supplier, count) → SynchronousSemantic<E>` | O(n) | O(1) | A supplier is called once per element |
172
- | `useEmpty` | `useEmpty<E>() → SynchronousSemantic<E>` | O(1) | O(1) | Identity element for `concat` |
173
- | `useText` | `useText(text, delimiter?)` | O(n) | O(n) for the text | Iterates **code points**, so emoji and CJK stay single elements |
174
- | `useAttribute` | `useAttribute<T>(object)` | O(v + e) | O(d + v) | Recursively walks nested objects with cycle protection |
175
- | `useGenerate` | `useGenerate<E>(supplier, interrupt)` | O(n) × supplier cost | O(1) | **Must** have a stopping `interrupt` or a downstream `limit` |
176
- | `useIterate` | `useIterate<E>(Iterable<E>)` / `(AsyncIterable<E>)` | O(n) per collection | O(1) | Replays the iterator on each collection |
177
- | `useInterval` | `useInterval(period, delay?) → AsynchronousSemantic<number>` | n × period (real time) | O(1) | Emits elapsed time |
178
- | `usePromise` | `usePromise<T>(promise) → AsynchronousSemantic<T>` | O(1) | O(1) | A single element at index `0n` |
179
- | `useBlob` | `useBlob(blob, chunk?) → AsynchronousSemantic<Uint8Array>` | O(blob size) | O(chunk) per copy | Default chunk is 64 KiB |
180
- | `useSubscription` | `useSubscription(target, subscriber, event) → AsynchronousSemantic<Event>` | one element per event | O(1) | `limit()` / `takeWhile()` define the unsubscribe boundary |
181
-
182
- ```typescript
183
- useOf(1, 2, 3).toUnordered().toArray(); // [1,2,3]
184
- useFrom(new Set([1, 2, 2])).toUnordered().toArray(); // [1,2]
185
- useRange(0, 5).toUnordered().toArray(); // [0,1,2,3,4]
186
- useRange(10n, 0n, -5n).toUnordered().toArray(); // [10n,5n]
187
- useFill("x", 3n).toUnordered().toArray(); // ["x","x","x"]
188
- useText("A😊B").toUnordered().toArray(); // ["A","😊","B"]
189
- useGenerate(() => 7, (_e, index) => index >= 3n).toUnordered().toArray(); // [7,7,7]
190
- ```
191
-
192
- ---
193
-
194
- <a id="5-transformation-operators"></a>
195
- ## 5. Transformation Operators
196
-
197
- Every operator returns a new `Semantic`; none touches an element until a collectable runs. Time is
198
- the extra cost **per element**; space is what the operator keeps while the pipeline runs.
199
-
200
- | Operator | Purpose | Index effect | Time | Space |
201
- | :--- | :--- | :--- | :--- | :--- |
202
- | `map(mapper)` | 1:1 transform | forwarded | O(1) | O(1) |
203
- | `filter(predicate)` | keep matching elements | forwarded | O(1) | O(1) |
204
- | `flat(mapper)` / `flatMap(mapper)` | 1:N flatten | re-indexed from 0 | O(1) per emit | O(1) |
205
- | `distinct(keyExtractor?)` | drop repeated keys, keep the first | forwarded | O(1) average | O(n) |
206
- | `peek(consumer)` | side effect, element passes through | forwarded | O(1) | O(1) |
207
- | `limit(count)` | take the first *n* | re-indexed from 0 | O(1) | O(1) |
208
- | `skip(count)` | drop the first *n* | forwarded | O(1) | O(1) |
209
- | `sub(start, end)` | positional slice `[start, end)` | forwarded | O(1) | O(1) |
210
- | `takeWhile(predicate)` | take while the predicate holds | forwarded | O(1) | O(1) |
211
- | `dropWhile(predicate)` | drop while the predicate holds | re-indexed from 0 | O(1) | O(1) |
212
- | `concat(other)` | append a semantic or iterable | continues the index | O(1) | O(1) |
213
- | `redirect(redirector)` | compute a new index however you like | **yours** | O(1) | O(1) |
214
- | `reverse()` | `redirect((_e, i) => -i - 1n)` | negated and shifted | O(1) | O(1) |
215
- | `translate(offset \| mapper)` | shift every index | `i + offset` | O(1) | O(1) |
216
- | `shuffle(mapper?)` | randomise the index | random | O(1) | O(1) |
217
- | `pipe(conversion)` | hand the generator to a custom function | up to you | yours | yours |
218
- | `semantic()` | lift a collectable back into a semantic | unchanged | O(1) | O(1) |
219
-
220
- **`reverse`, `translate` and `shuffle` only rewrite indices.** Nothing is buffered or rotated until
221
- `toOrdered()` normalises the indices modulo the element count:
222
-
223
- ```typescript
224
- useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
225
- useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3] (rotation)
226
- useFrom([1, 2, 3, 4, 5]).translate(7).toOrdered().toArray(); // [4,5,1,2,3] (7 ≡ 2 mod 5)
227
- useFrom([1, 2, 3]).shuffle((_e, i) => -i - 1n).toOrdered().toArray(); // [3,2,1]
228
- ```
229
-
230
- ---
231
-
232
- <a id="6-collectables-and-terminal-operations"></a>
233
- ## 6. Collectables and Terminal Operations
234
-
235
- The collectable owns both the **order** and the **index bookkeeping**. `n` is the element count.
236
-
237
- ### 6.1 Collectables
238
-
239
- | Collectable | Structure | Time | Order | Use when |
240
- | :--- | :--- | :--- | :--- | :--- |
241
- | `toUnordered()` | `Map<bigint, E>` keyed by index | O(n) | insertion | maximum throughput; raw indices preserved untouched |
242
- | `toOrdered()` | array sorted by element index | O(n log n) | by normalised index | reversal/rotation must take effect |
243
- | `toOrdered(comparator)` | sorted by your comparator | O(n log n) | by your comparator | sorted views |
244
- | `toNumericStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `number` |
245
- | `toBigintStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `bigint` |
246
- | `toWindow()` | value-sorted buffer | O(n log n) | by value | `slide` / `tumble` |
247
- | `toCollectable()` | the collectable itself, wired to the generator | O(1) | — | the cheapest way to reach a terminal |
248
- | `toCollectable(mapper)` | whatever the mapper returns | — | — | custom collectables |
249
-
250
- ### 6.2 Terminals
251
-
252
- Every terminal lives on the **collectable**, not on the `Semantic`. That is what "terminals are
253
- explicit" means in practice: a pipeline produces nothing until a collectable is named, and
254
- `toCollectable()` with no mapper is the cheapest one to name.
255
-
256
- | Terminal | Returns | Time | Notes |
257
- | :--- | :--- | :--- | :--- |
258
- | `toArray()` / `toSet()` | `Array<E>` / `Set<E>` | O(n) | Drains the buffer; `toSet` deduplicates by identity |
259
- | `toMap(key, value?)` | `Map<K, V>` | O(n) average | Last write wins |
260
- | `count()` / `isEmpty()` | `bigint` / `boolean` | O(1) | The buffer already knows |
261
- | `forEach(action)` | `void` | O(n) | `action(element, index)` |
262
- | `join(…)` | `string` | O(n) | `join()`, `join(delimiter)`, `join(prefix, delimiter, suffix)` |
263
- | `reduce(…)` | `Optional<E>` / `E` / `R` | O(n) | Empty stream → empty `Optional` |
264
- | `group(classifier)` / `groupBy(key, value)` | `Map<K, E[]>` | O(n) average | — |
265
- | `partition(count)` | `Array<Array<E>>` | O(n) | Round-robin by index |
266
- | `partitionBy(classifier)` | `Array<Array<E>>` | O(n) | The bucket number comes from the classifier |
267
- | `anyMatch` / `allMatch` / `nonMatch` | `boolean` | O(n) worst, short-circuits | — |
268
- | `findAny` / `findFirst` / `findLast` | `Optional<E>` | O(1)–O(n) | Positional in materialised order |
269
- | `findAt(k)` / `findNegativeAt(k)` | `Optional<E>` | O(k) / O(n−k) | Counts from the front / the end |
270
- | `findMaximum(cmp?)` / `findMinimum(cmp?)` | `Optional<E>` | O(n) | Defaults to a structural comparison |
271
- | `frequency()` | `Map<E, bigint>` | O(n) average | Statistics collectables only |
272
- | `write(stream, accumulator?)` | `WritableStream<S>` | O(n) | IO-like terminal |
273
- | `collect(collector)` | `R` | O(n) | The generic fold behind every terminal above |
274
-
275
- ### 6.3 Structure collectors
276
-
277
- `toArray`, `toSet` and `toMap` end a pipeline in a JavaScript container. Twelve more terminals end it
278
- in one of the library's own structures, so the result carries the invariants that structure
279
- guarantees rather than the ones an array happens to have. They are available on every collectable,
280
- including the bare one from `toCollectable()`.
281
-
282
- | Terminal | Returns | Order | Cost |
283
- | :--- | :--- | :--- | :--- |
284
- | `toArray()` | `Array<E>` | traversal | O(n) |
285
- | `toSet()` | `Set<E>` | insertion, SameValueZero | O(n) average |
286
- | `toMap(key, value?)` | `Map<K, V>` | insertion; the last write wins | O(n) average |
287
- | `toOrderedSet(comparator?)` | `SynchronousOrderedSet<E>` | comparator-ascending | O(n²) worst case |
288
- | `toUnorderedSet()` | `SynchronousUnorderedSet<E>` | unspecified (hash buckets) | O(n) expected |
289
- | `toOrderedMap(key, value?)` | `SynchronousOrderedMap<K, V>` | insertion; the last write wins | O(n) expected |
290
- | `toUnorderedMap(key, value?)` | `SynchronousUnorderedMap<K, V>` | unspecified | O(n) expected |
291
- | `toList()` | `SynchronousList<E>` | traversal, duplicates kept | O(n) |
292
- | `toRing(span?)` | `SynchronousRing<E>` | traversal fixes ring positions | O(n) |
293
- | `toLRUSet(capacity)` | `SynchronousUnorderedLRUSet<E>` | recency; the last `capacity` survive | O(n²) worst case |
294
- | `toLRUMap(capacity, key, value?)` | `SynchronousUnorderedLRUMap<K, V>` | recency | O(n) expected |
295
- | `toSkipSet(comparator?)` | `SynchronousSkipSet<E>` | comparator-ascending, navigable | O(n²) worst case |
296
- | `toSkipMap(key, value?)` | `SynchronousSkipMap<K, V>` | key comparator, navigable | O(n) expected |
297
- | `toBinarySearchTree(comparator?)` | `SynchronousBinarySearchTree<E>` | comparator-ascending, height-balanced | O(n log n) |
298
- | `toRootedForest()` | `SynchronousRootedForest<E>` | encounter order becomes structure | O(n) |
299
-
300
- The result type follows the collectable's own element type, so no terminal needs a type argument: a
301
- `SynchronousCollectable<E>` hands you `Array<E>`, `SynchronousList<E>` or `SynchronousOrderedSet<E>`
302
- directly. Only the business arguments remain — the comparator of `toOrderedSet` / `toSkipSet` /
303
- `toBinarySearchTree`, the capacity of the LRU pair, the span of `toRing`, and the key and value
304
- extractors of the map family.
305
-
306
- A ring is a set, so repeated elements keep their first position; every other structure above keeps
307
- duplicates unless its own contract says otherwise (`toOrderedSet`, `toUnorderedSet`, `toSkipSet` are
308
- sets; `toBinarySearchTree` and `toList` are not).
309
-
310
- ```typescript
311
- const numbers = useOf(3, 1, 4, 1, 5).toCollectable();
312
-
313
- numbers.toOrderedSet().toArray(); // [1,3,4,5]
314
- numbers.toUnorderedSet().size(); // 4n — the duplicate 1 collapses
315
- numbers.toList().toArray(); // [3,1,4,1,5]
316
- numbers.toSkipSet().first().get(); // 1 — navigable by comparator
317
- numbers.toBinarySearchTree().size(); // 5n — duplicates retained
318
- numbers.toRootedForest().getRoots().length; // 1
319
- useOf(1, 2, 3).toCollectable().toRing().phaseAt(3n).get().toDegreeValue(); // 0
320
- useOf(3, 1, 3).toCollectable().toOrderedMap(n => n, n => `v${n}`).get(3).get(); // "v3"
321
- ```
322
-
323
- The asynchronous collectables expose the same terminals and resolve each in a promise, so the call
324
- shape only gains an `await`.
325
-
326
- ---
327
-
328
- <a id="7-statistics-and-windows"></a>
329
- ## 7. Statistics and Windows
330
-
331
- ### 7.1 The statistics surface
332
-
333
- ```typescript
334
- useOf(3, 1, 4, 1, 5).toNumericStatistics().summate(); // 14
335
- useOf(3, 1, 4, 1, 5).toNumericStatistics().average(); // 2.8
336
- useOf(3, 1, 4, 1, 5).toNumericStatistics().median(); // 3
337
- useOf(3, 1, 4, 1, 5).toNumericStatistics().mode(); // 1
338
- useOf(3, 1, 4, 1, 5).toNumericStatistics().variance().get(); // 3.2 (n − 1)
339
- useOf(3, 1, 4, 1, 5).toNumericStatistics().standardDeviation().get(); // 1.7889 (n − 1)
340
- ```
341
-
342
- Both statistics families expose the same surface — `summate`, `average`, `median`, `mode`,
343
- `variance`, `standardDeviation`, `frequency`, `findMaximum`, `findMinimum` — with `bigint` arithmetic
344
- for `toBigintStatistics()`. The dispersion measures and the quantile family return `Optional`,
345
- because a sample can be too small for them to be defined; unwrap them with `.get()`, `.orElse(…)` or
346
- `.isPresent()`.
347
-
348
- ### 7.2 Estimators and defaults
349
-
350
- The defaults are the definitions R, NumPy, pandas and Excel use, so a value computed here can be
351
- compared with those tools without a correction factor.
352
-
353
- | Statistic | Default | Previous | Restore the old value with |
354
- | :--- | :--- | :--- | :--- |
355
- | `quantile(probability)` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
356
- | `interquartileRange()` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
357
- | `skewness()` | bias-corrected `G1` | `g1` | `{ estimator: "g1" }` |
358
- | `kurtosis()` | bias-corrected excess `G2` | `g2` | `{ estimator: "g2" }` |
359
- | `variance()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
360
- | `standardDeviation()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
361
-
362
- All nine Hyndman–Fan types are available as `"type-1"` … `"type-9"`, and `{ excess: false }` adds
363
- three back to a kurtosis, reporting the non-excess fourth standardised moment. The option types —
364
- `QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator`, `VarianceOptions` —
365
- are exported from `mathematics/quantile.ts`.
366
-
367
- ```typescript
368
- const sample = useOf(1, 2, 3, 4).toNumericStatistics();
369
-
370
- sample.variance().get(); // 1.6667 (5/3, ddof = 1)
371
- sample.variance({ ddof: 0 }).get(); // 1.25
372
- sample.standardDeviation().get(); // 1.2910
373
- sample.skewness().get(); // 0
374
- sample.kurtosis().get(); // -1.2 (G2)
375
- sample.kurtosis({ estimator: "g2" }).get(); // -1.36
376
- sample.kurtosis({ excess: false }).get(); // 1.8
377
-
378
- const quartiles = useOf(1, 2, 3, 4, 5, 6, 7, 8).toNumericStatistics();
379
- quartiles.quantile(0.75).get(); // 6.25 (type 7)
380
- quartiles.quantile(0.75, { method: "type-1" }).get(); // 6 (nearest rank)
381
- quartiles.interquartileRange().get(); // 3.5
382
- ```
383
-
384
- `G1` and `G2` are undefined below three and four observations, and the sample variance and standard
385
- deviation are undefined below two under `ddof = 1`. That is reported as an empty `Optional` rather
386
- than as a misleading zero:
387
-
388
- ```typescript
389
- useOf(1, 2, 3).toNumericStatistics().kurtosis().isEmpty(); // true (G2 needs four observations)
390
- useOf(1, 2).toNumericStatistics().skewness().isEmpty(); // true (G1 needs three)
391
- useOf(1).toNumericStatistics().variance().isEmpty(); // true (ddof = 1 needs two)
392
- useOf(1).toNumericStatistics().variance({ ddof: 0 }).get(); // 0
393
- ```
394
-
395
- The bigint views accept the same options and round every result back to a whole `bigint`.
396
-
397
- ### 7.3 Windows
398
-
399
- Windows re-expose the buffered elements as a pipeline of pipelines, so a window can be consumed
400
- without materialising it into an array first. Both forms include a final partial window.
401
-
402
- ```typescript
403
- useFrom([1, 2, 3, 4]).toWindow().slide(2n, 1n).toUnordered().toArray()
404
- .map(window => window.toUnordered().toArray()); // [[1,2],[2,3],[3,4],[4]]
405
-
406
- useFrom([1, 2, 3, 4]).toWindow().tumble(2n).toUnordered().toArray()
407
- .map(window => window.toUnordered().toArray()); // [[1,2],[3,4]]
408
- ```
409
-
410
- ---
411
-
412
- <a id="8-the-index-model"></a>
413
- ## 8. The Index Model
414
-
415
- An index is a `bigint` attached to every element. Operators either **forward** it, **re-index** it
416
- from zero, or let you **compute** it.
417
-
418
- Ordered collection normalises each index modulo the element count, then sorts by the result. That is
419
- why `translate(7)` on five elements equals `translate(2)`: `7 ≡ 2 (mod 5)`.
420
-
421
- Unordered collection keeps whatever index the pipeline produced and returns elements in arrival
422
- order. Negative indices are legal and simply sort before non-negative ones once normalised.
423
-
424
- ---
425
-
426
- <a id="9-the-asynchronous-model"></a>
427
- ## 9. The Asynchronous Model
428
-
429
- `AsynchronousSemantic` mirrors the synchronous surface, returning promises. Writes are queued, so a
430
- read issued after a write always observes it.
431
-
432
- ```typescript
433
- const ring = new AsynchronousRing([1, 2]);
434
- const added = ring.add(3); // queued
435
- const sampled = ring.elementAt(2n); // observes the write above
436
- await added;
437
- (await sampled).get().element; // 3
438
- ```
439
-
440
- Factories that are asynchronous by nature — `usePromise`, `useBlob`, `useInterval`,
441
- `useSubscription` — return `AsynchronousSemantic` directly, and `useFrom` / `useIterate` upgrade
442
- themselves when handed an `AsyncIterable`.
443
-
444
- ---
445
-
446
- <a id="10-periodic-analysis-rings-fields-and-scores"></a>
447
- ## 10. Periodic Analysis: Rings, Fields and Scores
448
-
449
- The mathematical half of the library treats a cycle as a first-class object rather than a lookup
450
- table. Three constructions work together.
451
-
452
- ### 10.1 A ring is one integer
453
-
454
- A `Ring` is an **n-fold cycle**: the element count, the positional period and the number of distinct
455
- phases are the same integer `n`. The phase of a position is computed from its exact residue, so the
456
- ring is periodic to the bit:
457
-
458
- ```typescript
459
- import { SynchronousRing } from "semantic-typescript";
460
-
461
- const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
462
- ring.phaseAt(0n).get().toDegreeValue(); // 0
463
- ring.phaseAt(1n).get().toDegreeValue(); // 60
464
- ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true, exactly
465
- ring.purity(); // 1
466
- ring.isBalanced(); // true
467
- ring.sequences().map(z => z.magnitude()); // [0, 6, 0, 0, 0, 0]
468
- ```
469
-
470
- `frequency` is the **ambient** rotation rate, not a phase step between positions: the environment
471
- moves every phasor by the same angle, so it can never break the intrinsic pattern. `span` fixes how
472
- many turns the ring occupies.
473
-
474
- `purity()` is the share of energy carried by the fundamental mode,
475
- `max(|c₁|², |c_{n−1}|²) / (n · Σ|z|²)`. It is `1` exactly when the ring is the `n`-th roots of unity
476
- up to rotation, and it costs `O(n)` because only two bins are needed. Both directions are tested
477
- because a ring may traverse the circle either way.
478
-
479
- ### 10.2 A field is a bounded score
480
-
481
- A `PhaseField` maps a relative phase to a score in `[-1, 1]`:
482
-
483
- ```typescript
484
- import { useField, useRadian } from "semantic-typescript";
485
-
486
- const cosine = useField([1]); // cos(2πΔ): +1 aligned, −1 opposed
487
- const mixed = useField([0.4, 0.3, 0.3]); // first three harmonics
488
-
489
- cosine.sample(useRadian(0)); // 1
490
- cosine.sample(useRadian(Math.PI)); // -1
491
- ```
492
-
493
- The score is a truncated cosine series, `a₀ + Σ wₘ·cos(2πmΔ)`, and the constructor enforces
494
- `|a₀| + Σ|wₘ| ≤ 1`. By the triangle inequality the score can never leave `[-1, 1]` — the bound is a
495
- **consequence of the construction**, not a clamp applied afterwards.
496
-
497
- The reason the field is a series rather than a table: an `n`-point table reproduces its own samples
498
- exactly, but its trigonometric interpolant overshoots between them. A five-point table scored
499
- `[+1, +0.5, −1, −0.5, 0]` interpolates over `[−1.0428, 1.0041]`, which already breaks the promise.
500
- A bounded series cannot.
501
-
502
- ### 10.3 Analysis measures data against every element
503
-
504
- Each ring element is a reference phase. `analyse` locates every observation back on the ring and
505
- measures it against each reference:
506
-
507
- ```typescript
508
- const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
509
- const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
510
-
511
- report.matched; // 5
512
- report.missed; // 1 ('zz' is not on the ring)
513
- report.references.map(r => r.count); // [2, 0, 2, 0, 1]
514
- ```
515
-
516
- Each reference row reports `count`, the mean relative phase `mean`, and the mean resultant length
517
- `resultant`. One identity is worth remembering: **`resultant` is the same for every reference**,
518
- because changing the baseline only rotates all relative phases by a constant. What differs per
519
- reference is the count and the mean.
520
-
521
- ```typescript
522
- const ring = new SynchronousRing(["a", "b", "c"]);
523
- ring.scoresAt(0, cosine); // one score per position, in [-1, 1]
524
- ```
525
-
526
- ### 10.4 Related instruments
527
-
528
- | Call | Answers | Cost |
529
- | :--- | :--- | :--- |
530
- | `purity()` | is the ring still the `n`-th roots of unity? | O(n) |
531
- | `resultant(t?)` | how concentrated are the phasors right now? | O(n) |
532
- | `analyse(data)` | how does this data sit against every element? | O(n · m) |
533
- | `toSpectrum()` | the ring as a spectrum, wavenumber on the frequency axis | O(n²) |
534
- | `useAngularSpectrum(ring, instants, plan)` | the `(ω, n)` plane over time | O(n² · frames) |
535
-
536
- Two more primitives round out the model: `combineRings(a, b)` joins two rings on their least common
537
- multiple (a pullback, not a product — the pairs that cannot share an instant are simply absent), and
538
- `rate()` gives each element its own intrinsic frequency so that a ring can drift against the
539
- environment. `jetAt`, `windingAt` and `errorIntegralAt` expose the differential state of that drift.
540
-
541
- ### 10.5 Spectral windows
542
-
543
- The ring constructions are one half of the analytic surface; the other half works on sampled
544
- signals. Its most reused primitive is the window, and every shape here has two forms. The symmetric
545
- form divides the frame by `count − 1`; the periodic (DFT-even) form divides it by `count`, one
546
- sample short of a full period, so the taper is continuous under the periodic extension the DFT
547
- assumes.
548
-
549
- ```typescript
550
- useHannWindow(1, 4); // 0.75 symmetric
551
- useHannPeriodicWindow(1, 4); // 0.5 periodic
552
- coherentGain(useHannPeriodicWindow, 16); // 0.5
553
- equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
554
- ```
555
-
556
- | Shape | Symmetric | Periodic | Notes |
557
- | :--- | :--- | :--- | :--- |
558
- | Rectangular | `useRectangularWindow` | — | no taper; no periodic distinction |
559
- | Hann | `useHannWindow` | `useHannPeriodicWindow` | the short-time default |
560
- | Hamming | `useHammingWindow` | `useHammingPeriodicWindow` | — |
561
- | Blackman | `useBlackmanWindow` | `useBlackmanPeriodicWindow` | — |
562
- | Nuttall | `useNuttallWindow` | `useNuttallPeriodicWindow` | four-term Blackman–Harris |
563
- | Flat-top | `useFlatTopWindow` | `useFlatTopPeriodicWindow` | amplitude accuracy over resolution |
564
- | Kaiser | `useKaiserWindow(beta)` | `useKaiserPeriodicWindow(beta)` | `beta` sets the main-lobe width |
565
-
566
- Use the periodic form for analysis and overlap-add: at a half-frame hop the periodic Hann window
567
- sums to `1.000000` at every length, while the symmetric form ripples by `0.841 dB` at `N = 16` and
568
- still by `0.013 dB` at `N = 1024`. Keep the symmetric form for filter design, where the frame is not
569
- one period of the taper.
570
-
571
- The flat-top windows return **negative weights** near the ends of the frame, because that is what a
572
- flat-top window is. They are returned unchanged rather than clamped, because clamping perturbs the
573
- main lobe and destroys the low scalloping loss the window exists for:
574
-
575
- ```typescript
576
- useFlatTopWindow(0, 1024); // -0.000421 — negative by definition
577
- Array.from({ length: 1024 }, (_unused, index) => useFlatTopWindow(index, 1024))
578
- .filter(weight => weight < 0).length; // 578
579
- ```
580
-
581
- ### 10.6 Levels, flatness and band energy
582
-
583
- `decibel` is gone as a single function, because an amplitude and a power do not share a factor.
584
- `amplitudeDecibel` applies `20·log10` to a voltage-like ratio; `powerDecibel` applies `10·log10` to a
585
- power, an energy, a mean square or a power spectral density:
586
-
587
- ```typescript
588
- amplitudeDecibel(0.25); // -12.0412
589
- powerDecibel(0.25); // -6.0206
590
- ```
591
-
592
- `Bin.decibel` and `Spectrum.decibelOf` are amplitude levels; `Spectrum.powerDecibelOf` is the
593
- matching power level. Feeding a power to the amplitude form reports `20·log10(power)` and doubles
594
- the level in decibels.
595
-
596
- Two spectrum measurements changed reading:
597
-
598
- ```typescript
599
- const twoBins = new Spectrum(new Sample(2, 1), [new Complex(2, 0), new Complex(1, 0)]);
600
- twoBins.flatness(); // 0.8 — geometric over arithmetic mean of power
601
-
602
- const shape = new Spectrum(new Sample(4, 1), [
603
- new Complex(1, 0), new Complex(2, 0), new Complex(0, 0), new Complex(0, 0),
604
- ]);
605
- shape.bandEnergy(new Frequency(0), new Frequency(2)); // 9
606
- shape.bandEnergy(new Frequency(0), new Frequency(2), "two-sided"); // 5
607
- ```
608
-
609
- `flatness()` compares the geometric and arithmetic means of `power()` over the one-sided half of the
610
- spectrum, not of magnitude over both halves, and the display-only amplitude doubling of
611
- `oneSided()` is deliberately not applied. `bandEnergy()` defaults to the one-sided reading, folding
612
- the conjugate half so that DC and Nyquist count once and every interior bin counts twice; a band
613
- that covers the whole axis then satisfies Parseval, `Σ|X|² = N·Σx²`. Pass `"two-sided"` for a
614
- complex spectrum, whose bins are already independent.
615
-
616
- ### 10.7 Short-time analysis
617
-
618
- `Sampler.shortTimeFourierTransform` and `Sampler.spectrogram` follow the SciPy `stft` conventions:
619
- the periodic Hann window, a zero-filled boundary at both ends and a zero-filled tail.
620
-
621
- ```typescript
622
- const sampler = new Sampler(new Sample(64, 1));
623
- const spectrogram = sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8);
624
-
625
- spectrogram.size(); // 9 frames
626
- spectrogram.getBoundary(); // "zeros"
627
- spectrogram.getTime(6); // 0.75 — frame centres, not frame starts
628
- sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8, useHannWindow,
629
- { boundary: "none", padded: false }).size(); // 7 — complete frames only
630
- ```
631
-
632
- The extension centres the first frame on the first sample, and the tail padding keeps the last
633
- samples reachable: 64 samples at frame 16 and hop 8 produce 9 frames instead of 7, and 1000 samples
634
- at frame 256 and hop 128 produce 9 instead of 6. Frame times are centres — `(k·hop + (frameSize mod
635
- 2)/2)/rate` for an extended view, `(k·hop + frameSize/2)/rate` for an unextended one — and
636
- `toFrame()` trims the extension before inverting.
637
-
638
- ---
639
-
640
- <a id="11-utility-helpers"></a>
641
- ## 11. Utility Helpers
642
-
643
- ### 11.1 The error hierarchy
644
-
645
- `structure/error.ts` exports a fifteen-class hierarchy for the errors raised *around* a pipeline — by
646
- your own code, a custom collector or an adapter:
647
-
648
- ```text
649
- SemanticError
650
- ├── IllegalOperationError
651
- ├── ValidationError
652
- │ ├── IndexError
653
- │ ├── RangeError (also exported as SemanticRangeError)
654
- │ └── ConfigurationError
655
- ├── CapacityError
656
- ├── StateError
657
- │ └── IllegalStateError
658
- ├── ConcurrencyError
659
- ├── CollectionError
660
- ├── SerializationError
661
- ├── NumericError
662
- │ └── ArithmeticError
663
- └── PhaseError
664
- ```
665
-
666
- Each class fixes `name`, forwards the native `cause`, carries an optional `context` bag and
667
- serialises through `toJSON()`; each has a brand guard named after it.
668
-
669
- ```typescript
670
- const error = new CapacityError("LRU capacity exhausted", { context: { capacity: 0 } });
671
-
672
- error.name; // "CapacityError"
673
- isCapacityError(error); // true
674
- isValidationError(error); // false — a capacity failure is not a validation failure
675
- isSemanticError(error); // true
676
- error.toJSON(); // { name: "CapacityError", message: "…", context: { capacity: 0 } }
677
- ```
678
-
679
- `SemanticRangeError` is the same class as the exported `RangeError`, so `instanceof` holds across
680
- both names; importing `RangeError` shadows the global constructor for the whole module, which is why
681
- the alias exists. Neither class is thrown by the library's own argument checks, which keep raising
682
- native `TypeError` and `RangeError` — the hierarchy is the vocabulary for the layer above.
683
-
684
- ### 11.2 Utility table
685
-
686
- | Helper | Purpose |
687
- | :--- | :--- |
688
- | `useCompare(a, b)` | Total ordering across mixed primitives |
689
- | `useToNumber` / `useToBigInt` | Lossless, range-checked numeric conversion |
690
- | `useRandom` | Seeded or unseeded random helpers |
691
- | `useTraverse` | Structural walk of nested objects and arrays |
692
- | `useStringify` | Circular-safe JSON serialisation |
693
- | `useHash` / `maskOf` / `register` / `unregister` | Structural hashing and the hash strategy registry |
694
- | `invalidate` / `validate` | Guard clauses that raise a consistent error shape |
695
- | `Optional<T>` | The library's `null` replacement: `Optional.of(x)` / `Optional.empty()` |
696
-
697
- ### 11.3 `useTraverse`
698
-
699
- `useTraverse(target, callback)` flattens any object into its **non-null leaves**. It descends nested
700
- objects, walks iterable values by position, skips `null` and `undefined`, and never re-enters an
701
- object it has already visited — so an object graph with a cycle terminates instead of overflowing
702
- the stack.
703
-
704
- ```typescript
705
- const source = { id: 7, tags: ["a", "b"], owner: { name: "Ada", note: null } };
706
- const leaves: string[] = [];
707
-
708
- useTraverse(source, (key, value, path) => {
709
- leaves.push(`${path.join(".")} = ${String(value)}`);
710
- return true;
711
- });
712
-
713
- leaves; // ["id = 7", "tags.0 = a", "tags.1 = b", "owner.name = Ada"]
714
- ```
715
-
716
- The callback runs once per leaf and receives the leaf's own key (its index when the leaf came from
717
- an iterable), the value, and the path that reached it. Returning `false` abandons the **whole**
718
- traversal, not just the current branch:
719
-
720
- ```typescript
721
- let visited = 0;
722
- useTraverse({ a: 1, b: 2, c: 3 }, () => {
723
- visited += 1;
724
- return false;
725
- });
726
- visited; // 1 — the first leaf already stops the walk
727
- ```
728
-
729
- A cycle is safe for the same reason:
730
-
731
- ```typescript
732
- const cyclic: any = { name: "root", child: { name: "leaf" } };
733
- cyclic.child.parent = cyclic;
734
-
735
- const names: string[] = [];
736
- useTraverse(cyclic, (_key, value) => {
737
- if (typeof value === "string") names.push(value);
738
- return true;
739
- });
740
- names; // ["root", "leaf"] — the back edge to `cyclic` is not revisited
741
- ```
742
-
743
- **Each callback receives its own path snapshot.** The walk keeps a single working array internally and, at the moment a leaf is handed over, copies it into a fresh array frozen with `Object.freeze`. The snapshot never changes afterwards, so it can be stored as it is, two callbacks never share one, and re-running the traversal does not disturb snapshots already handed out. Its type is `ReadonlyArray`, so writing to it is a compile error, and the frozen array also throws a `TypeError` at runtime. The safety costs one `O(depth)` allocation per reported leaf, and a branch that reports nothing allocates nothing extra.
744
-
745
- ```typescript
746
- const paths: Array<ReadonlyArray<PropertyKey>> = [];
747
- useTraverse({ a: { b: 1 }, c: 2 }, (_key, _value, path) => {
748
- paths.push(path); // safe to keep as-is
749
- return true;
750
- });
751
- paths[0] === paths[1]; // false — one array per call
752
- paths.map(path => path.join(".")); // ["a.b", "c"]
753
- ```
754
-
755
- `useAttribute` is this walk turned into a factory: it reports the same leaves as `{ key, value }`
756
- pairs, so the rules — and the cost — are identical.
757
-
758
- ```typescript
759
- useAttribute({ a: 1, b: { c: 2 } }).toUnordered().toArray();
760
- // [{ key: "a", value: 1 }, { key: "c", value: 2 }]
761
- ```
762
-
763
- ---
764
-
765
- <a id="12-performance-and-pitfalls"></a>
766
- ## 12. Performance and Pitfalls
767
-
768
- **Laziness has a price per stage.** Each operator adds a generator hop. For tight numeric loops over
769
- in-memory arrays, a plain `for` loop will beat any pipeline; the pipeline wins when the source is
770
- lazy, infinite, or the pipeline is long enough for the index discipline to pay off.
771
-
772
- **`toOrdered()` sorts; `toUnordered()` does not.** If a reversal seems to have "not worked", the
773
- collector was unordered — index rewriting only becomes an order when indices are normalised.
774
-
775
- **`useGenerate` without an `interrupt` never ends.** Always supply a stopping predicate or a
776
- downstream `limit` / `takeWhile`.
777
-
778
- **`Ring` is an insertion-ordered set.** Elements are unique and keep the order in which they were
779
- first added. A duplicate `add` returns `false`, and a `set` that would introduce one throws. This is
780
- what lets a phase be a function of the *element* rather than of whichever position it happens to
781
- occupy.
782
-
783
- **`Spectrum` assumes a real signal in its one-sided helpers.** `oneSided()`, `getNyquist()` and
784
- `centroid()` assume conjugate symmetry. The spatial spectrum of a phase ring is complex and in
785
- general not symmetric — a ring that traverses the circle backwards puts all of its energy in bin
786
- `n − 1`, beyond Nyquist. Read the bins directly.
787
-
788
- **Structure collectors are terminals, not views.** Each one drains the pipeline and then builds a
789
- full structure, so it costs O(n) time and O(n) space and cannot run on an infinite source. Reach for
790
- one at the end of a pipeline, never inside a loop over one.
791
-
792
- **`variance()` and friends return an `Optional`.** `variance().get()` on a sample that is too small
793
- throws; use `.orElse(0)` or check `.isPresent()` when the sample size is not under your control.
794
- The same applies to `standardDeviation`, `quantile`, `interquartileRange`, `skewness` and
795
- `kurtosis`.
796
-
797
- **The periodic windows are the analysis windows.** `useHannWindow` and the other symmetric forms are
798
- for filter design; a short-time transform wants `useHannPeriodicWindow` and its siblings, or the
799
- overlap-add sum will ripple wherever the frames meet.
800
-
801
- ---
802
-
803
- <a id="13-phase-rings"></a>
804
- ## 13. Phase Rings: An Exact Cycle of Positions
805
-
806
- Chapter 10 introduced the ring as the library's model of a cycle and used it to measure a data
807
- set against every phase. This chapter is the complete surface: how a position becomes an angle,
808
- what the ambient frequency does and does not change, and the instruments that read the ring
809
- against time, against data and against another ring.
810
-
811
- A `SynchronousRing` is a positional list backed by a circular doubly linked ring whose head and
812
- tail are joined. It is also an insertion-ordered *set*: no element appears twice, and a rejected
813
- write leaves both the links and the samples untouched. What makes it a ring rather than a list is
814
- that the phase of a position is a pure function of its residue modulo the current length, so the
815
- ring is periodic to the bit and its balance cannot drift.
816
-
817
- ### 13.1 A ring is one integer, computed exactly
818
-
819
- A ring carries `n` elements, and the element count, the positional period and the number of
820
- distinct phases are the same integer. Position `i` carries
821
-
822
- `phase + span · 2π · (((i mod n) + n) mod n) / n`
823
-
824
- with the residue reduced in `bigint` before it ever becomes a `number`. That is the whole reason
825
- `phaseAt(i)` and `phaseAt(i + n)` are *identical* rather than merely close: the second call
826
- reduces to the same integer, evaluates the same product and produces the same double. An index
827
- far beyond the exact integer range of `number` still lands on the right position, and a negative
828
- index walks the ring backwards.
829
-
830
- | Call | Returns | Meaning |
831
- | :--- | :--- | :--- |
832
- | `new SynchronousRing(source, options?)` | ring | `source` is any iterable, duplicates dropped |
833
- | `getPhase()` / `getFrequency()` / `getSpan()` | `Angle`, `Frequency`, `number` | the three construction parameters |
834
- | `size()` | `bigint` | the `n` that is also the period |
835
- | `phaseAt(index)` | `Optional<Angle>` | intrinsic phase only; ignores time, rate and frequency |
836
- | `elementAt(index)` | `Optional<RingElement>` | element, intrinsic phase, ambient frequency, phasor |
837
- | `frequencyAt(index)` | `Optional<Frequency>` | the ambient rate, empty for an empty ring |
838
- | `toComplex()` / `toComplex(index)` | `Complex[]` / `Optional<Complex>` | intrinsic phasors |
839
-
840
- `RingOptions` adds two per-element functions: `magnitude(element, index)` gives the phasor length
841
- and defaults to one, and `rate(element, index)` gives the element its own intrinsic frequency and
842
- defaults to zero, a fixed marker.
843
-
844
- ```typescript
845
- import { SynchronousRing } from "semantic-typescript";
846
-
847
- const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
848
-
849
- ring.size(); // 6n
850
- ring.phaseAt(0n).get().toDegreeValue(); // 0
851
- ring.phaseAt(1n).get().toDegreeValue(); // 60
852
- ring.phaseAt(-1n).get().toDegreeValue(); // 300
853
- ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true
854
- ring.phaseAt(10n ** 20n).get().toDegreeValue(); // 240
855
- ```
856
-
857
- `phaseAt(-1n)` is `300` on a six-element ring, and `10n ** 20n` reduces to `4` modulo six, so it
858
- reads `240`. Neither case accumulates a floating-point product, which is what keeps the identity
859
- exact under a change of index representation.
860
-
861
- ### 13.2 The ambient frequency and the state at an instant
862
-
863
- `frequency` is the **ambient rotation rate** of the environment the ring is embedded in, not a
864
- phase step between positions. The environment advances the phase of every element by the same
865
- angle, so it cannot change the intrinsic pattern, cannot break the balance and cannot change any
866
- `phaseAt` result. It defaults to `0 Hz`.
867
-
868
- `relativeAt(index, time)` subtracts that common angle, leaving the coordinate a `PhaseField` is
869
- scored against. `viewAt` and `viewsAt` return the full state at an instant, including the
870
- element's own `rate`, and the differential accessors expose the drift:
871
-
872
- | Call | Formula | Reads |
873
- | :--- | :--- | :--- |
874
- | `relativeAt(index, time)` | `intrinsic + ownRate · t − ambient · t` | the phase the environment sees |
875
- | `viewAt(index, time)` / `viewsAt(time)` | the same, plus element, index, time, rate, phasor | the full state |
876
- | `jetAt(index, time)` | phase at `t`, own rate, `slip = ownRate − ambient` | the differential state |
877
- | `windingAt(index, time)` | `trunc(slip · t)` | whole turns slipped, truncated toward zero |
878
- | `errorIntegralAt(index, time, origin?)` | `∫ relative phase dt`, in turns · seconds | how long an element has been ahead |
879
- | `resultant(time?)` | `abs(Σ w · e^{iθ}) / Σ w` | phase concentration, unaffected by ambient rotation |
880
- | `analyse(data, time?)` | counts and means per reference | data measured against every element |
881
- | `scoreAt(index, time, field)` / `scoresAt(time, field)` | the field sampled at the relative phase | a bounded score per position |
882
-
883
- ```typescript
884
- import { SynchronousRing, useHertz } from "semantic-typescript";
885
-
886
- const moving = new SynchronousRing(["a", "b", "c", "d", "e", "f"], {
887
- frequency: useHertz(1),
888
- });
889
-
890
- moving.getFrequency().getHertz(); // 1
891
- moving.phaseAt(1n).get().toDegreeValue(); // 60 — intrinsic
892
- moving.relativeAt(1n, 0.25).get().toDegreeValue(); // 330 — 60 − 90
893
- moving.viewAt(1n, 0.25).get().relative.toDegreeValue(); // 330
894
- moving.jetAt(1n, 0.25).get().slip; // -1
895
- moving.purity(); // 1 — rotation cannot break it
896
- ```
897
-
898
- At `1 Hz` the environment advances a quarter turn in `0.25 s`, so the relative phase of position
899
- one is `60 − 90 = 330` degrees while its intrinsic phase stays `60`. The jet reports `slip` of
900
- `-1` hertz because the element itself does not move, and `purity()` is still `1`: rotating every
901
- phasor by the same angle is not a deformation.
902
-
903
- ### 13.3 Purity and balance
904
-
905
- `purity()` is the share of the ring's energy carried by the fundamental mode:
906
-
907
- `max(abs(c₁)², abs(c_{n−1})²) / (n · Σ abs(z)²)`
908
-
909
- Both fundamental bins are tested because a ring may traverse the circle in either direction. A
910
- ring built with `span: 5` over six elements steps by `300` degrees, which is `−60` degrees: it
911
- runs backwards, puts all of its energy in `c₅`, and would be reported as impure by a formula that
912
- only looked at `c₁`. The cost stays `O(n)` because only those two bins and the total energy are
913
- needed.
914
-
915
- The edge cases are part of the contract. A ring of fewer than two elements has no fundamental
916
- mode and reports `purity() === 0` and `isBalanced() === false`, whatever its content. A ring whose
917
- phasors all have zero length has no energy and also reports zero. `isBalanced(epsilon?)` is
918
- `1 − purity() <= epsilon`, with `1E-9` as the default tolerance.
919
-
920
- ```typescript
921
- import { SynchronousRing } from "semantic-typescript";
922
-
923
- const forward = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
924
- const backward = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 5 });
925
- const weighted = new SynchronousRing(["a", "b", "c", "d"], {
926
- magnitude: element => element === "a" ? 1 : 0,
927
- });
928
-
929
- forward.purity(); // 1
930
- backward.phaseAt(1n).get().toDegreeValue(); // 300 — it runs backwards
931
- backward.purity(); // 1 — the energy sits in c₅
932
- new SynchronousRing(["only"]).purity(); // 0
933
- new SynchronousRing(["only"]).isBalanced(); // false
934
- weighted.purity(); // 0.25
935
- ```
936
-
937
- ### 13.4 Resultant and the symmetric components
938
-
939
- `resultant(time?)` is the mean resultant length: the magnitude of the phasor sum divided by the
940
- sum of the magnitudes. Unlike `purity`, which asks whether the ring is still the `n`-th roots of
941
- unity, `resultant` asks how concentrated the phasors are right now. A balanced ring reports `0`;
942
- a ring whose elements all share one angle would report `1`, but the set semantics forbid duplicate
943
- elements, so in practice `1` appears when `magnitude` zeroes all but one element.
944
-
945
- `zeroSequence()` is the mean phasor, that is `c₀ / n`. `sequences()` returns all `n` symmetric
946
- components, lowest order first, by a direct transform. `toSpectrum()` wraps them in a `Spectrum`
947
- whose bin `j` reports `j` cycles per ring revolution, so the frequency axis is the wavenumber
948
- axis.
949
-
950
- ```typescript
951
- import { SynchronousRing } from "semantic-typescript";
952
-
953
- const round = (value: number): number => Number(value.toFixed(4));
954
- const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
955
-
956
- round(ring.resultant().get()); // 0
957
- round(ring.zeroSequence().get().magnitude()); // 0
958
- ring.sequences().map(component => round(component.magnitude())); // [0, 6, 0, 0, 0, 0]
959
- ring.toSpectrum().get().size(); // 6
960
- ring.toSpectrum().get().getResolution().getHertz(); // 1
961
- ```
962
-
963
- The components are complex and in general **not conjugate-symmetric**: a balanced ring puts all
964
- of its energy in bin one, and a backwards ring puts it in bin `n − 1`. The one-sided helpers of
965
- `Spectrum` assume a real signal, so read the bins directly instead of folding them.
966
-
967
- ### 13.5 Analysing data against every element
968
-
969
- `analyse(data, time?)` matches every observation back to the ring by identity and measures it
970
- against each element as a reference phase. Observations the ring does not hold are counted as
971
- `missed` rather than guessed at, because the ring is the only source of phases. Each reference
972
- row reports `count`, the mean relative phase `mean`, and the mean resultant length `resultant`.
973
-
974
- One identity is worth remembering: **`resultant` is the same for every reference**. Changing the
975
- baseline rotates all relative phases by a constant, and a rotation cannot change a resultant
976
- length. What differs per reference is the count and the mean.
977
-
978
- ```typescript
979
- import { SynchronousRing } from "semantic-typescript";
980
-
981
- const round = (value: number): number => Number(value.toFixed(4));
982
- const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
983
- const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
984
-
985
- report.matched; // 5
986
- report.missed; // 1
987
- report.references.map(row => row.count); // [2, 0, 2, 0, 1]
988
- report.references.map(row => round(row.resultant)); // [0.1453, 0.1453, 0.1453, 0.1453, 0.1453]
989
- ```
990
-
991
- Scoring is the other side of the same coin. A `PhaseField` is a bounded cosine series (chapter
992
- 10.2), and `scoreAt`/`scoresAt` sample it at the relative phase, so the same ring can be scored
993
- against alignment, against a harmonic pattern, or against any other field without changing the
994
- ring.
995
-
996
- ```typescript
997
- import { SynchronousRing, useField } from "semantic-typescript";
998
-
999
- const round = (value: number): number => Number(value.toFixed(4));
1000
- const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
1001
- const cosine = useField([1]);
1002
-
1003
- round(ring.scoreAt(0n, 0, cosine).get()); // 1
1004
- round(ring.scoreAt(1n, 0, cosine).get()); // 0.5
1005
- ring.scoresAt(0, cosine).map(score => round(score)); // [1, 0.5, -0.5, -1, -0.5, 0.5]
1006
- ```
1007
-
1008
- ### 13.6 Combining rings and the angular spectrum
1009
-
1010
- `combineRings(left, right)` joins two rings on their least common multiple, not their product.
1011
- Instant `i` of the joint ring reads `i mod m` of the left ring and `i mod n` of the right ring,
1012
- so the joint ring closes after `lcm(m, n)` instants. The pairs that can never co-occur — those
1013
- whose positions disagree modulo `gcd(m, n)` — are simply absent, which is why the result is a
1014
- pullback rather than a product; `aligns(left, right)` reports whether a given pair is
1015
- representable.
1016
-
1017
- `useAngularSpectrum(ring, instants, plan)` builds the `(ω, n)` plane: each instant contributes a
1018
- frame of phasors, frame `k` of mode `j` is the `j`-th symmetric component of that frame, and
1019
- `cells[frame][order]` is the coefficient. Taking one column gives the time series of a single
1020
- spatial mode, and `spectrum(order)` is its temporal spectrum.
1021
-
1022
- ```typescript
1023
- import { Sample, SynchronousRing, combineRings, useAngularSpectrum } from "semantic-typescript";
1024
-
1025
- const left = new SynchronousRing(["a", "b", "c", "d"]);
1026
- const right = new SynchronousRing(["x", "y", "z", "w", "v", "u"]);
1027
- const joined = combineRings(left, right);
1028
-
1029
- joined.period; // 12n
1030
- joined.divisor; // 2n
1031
- joined.ring.size(); // 12n
1032
- joined.aligns(0n, 0n); // true
1033
- joined.aligns(1n, 0n); // false
1034
- joined.ring.get(0n).join("-"); // a-x
1035
-
1036
- const angular = useAngularSpectrum(left, [0, 0.25, 0.5, 0.75], new Sample(4, 1));
1037
- angular.order; // 4
1038
- angular.frameCount; // 4
1039
- angular.cells[0][1].magnitude(); // 4
1040
- angular.spectrum(1).get().size(); // 4
1041
- ```
1042
-
1043
- ### 13.7 Pitfalls
1044
-
1045
- **Exactness is exact.** `phaseAt(i)` and `phaseAt(i + n)` are the same double, not two values
1046
- within a tolerance. A ring built by accumulating `2π / n` per element would fail this; this one
1047
- reduces the index in `bigint` first.
1048
-
1049
- **`frequency` is not a phase step.** It is the ambient rotation rate, it defaults to zero, and
1050
- rotating every phasor by the same angle cannot break the intrinsic pattern. If you want positions
1051
- to advance relative to each other, use `rate`, not `frequency`.
1052
-
1053
- **`purity()` must look at both fundamental bins.** A backwards ring is still the `n`-th roots of
1054
- unity; only `c₁` would call it impure.
1055
-
1056
- **`n < 2` is a defined edge case.** `purity()` returns `0` and `isBalanced()` returns `false`; an
1057
- empty ring returns empty optionals from the sampling methods.
1058
-
1059
- **A span that shares a factor with `n` collapses the orbit.** Six elements with `span: 2` visit
1060
- only three distinct phases, and the ring is no longer balanced.
1061
-
1062
- **`resultant` is rotation-invariant, not time-invariant.** Ambient rotation leaves it unchanged;
1063
- per-element `rate`s change it as the ring de-phases.
1064
-
1065
- **The ring is a set, and `analyse` matches by identity.** Duplicate elements are dropped at
1066
- construction, and an object that is structurally equal to a ring element but not the same
1067
- reference is counted as missed.
1068
-
1069
- **`toSpectrum()` is complex.** Its bins are not conjugate-symmetric, so the one-sided helpers of
1070
- `Spectrum` do not apply. Read the bins.
1071
-
1072
- **`windingAt` truncates toward zero, and `errorIntegralAt` integrates phase.** Half a turn of slip
1073
- reports `0n`; the error integral is in turns · seconds, not a rate.
1074
-
1075
- ```typescript
1076
- import { SynchronousRing, useHertz } from "semantic-typescript";
1077
-
1078
- const collapsed = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 2 });
1079
- collapsed.phaseAt(1n).get().toDegreeValue(); // 120
1080
- collapsed.phaseAt(3n).get().toDegreeValue(); // 0
1081
- Number(collapsed.purity().toFixed(4)); // 0
1082
-
1083
- const falling = new SynchronousRing(["a", "b", "c", "d"], {
1084
- frequency: useHertz(2),
1085
- rate: () => useHertz(0.5),
1086
- });
1087
- falling.jetAt(0n, 2).get().slip; // -1.5
1088
- falling.windingAt(0n, 1).get(); // -1n
1089
- falling.windingAt(0n, 0.5).get(); // 0n
1090
-
1091
- const objects = new SynchronousRing([{ key: 1 }, { key: 2 }]);
1092
- objects.analyse([{ key: 1 }]).missed; // 1 — identity, not structure
1093
- ```
1094
-
1095
- ---
1096
-
1097
- <a id="14-signal-analysis"></a>
1098
- ## 14. Signal Analysis: Windows, Spectra and Time
1099
-
1100
- Chapter 10 treated a ring as a cycle of phases. This chapter is the temporal counterpart: a
1101
- real-valued record that carries, besides its samples, the two pieces of context every spectral
1102
- formula needs — the sampling plan that turns a bin index into hertz, and the taper that fixes both
1103
- the coherent gain and the noise bandwidth.
1104
-
1105
- The value object is `SynchronousSignalCollectable`, with `AsynchronousSignalCollectable` as its
1106
- queued twin. Both expose the same `SignalCollectable` surface: twenty-three methods, each delegating
1107
- to exactly one `useSynchronous<Method>` factory in `synchronous/collectors`. The split is
1108
- deliberate. The class is a documented facade, the factory owns the accumulation, and the constructor
1109
- caches `sum(w) / count` and `count * sum(w^2) / sum(w)^2` once, at this length. A caller who has to
1110
- remember "divide by the coherent gain" will eventually forget, and the resulting 6.02 decibels look
1111
- like a plausible measurement.
1112
-
1113
- ### 14.1 A signal carries its own calibration
1114
-
1115
- A signal is built from an iterable and a rate, from an existing `Frame`, or from the constructor
1116
- with an explicit `Sample`. The default taper is the periodic Hann window; every analysis that does
1117
- not name a window uses it, and every analysis accepts an override. The metadata accessors are
1118
- constant-time reads of state fixed at construction, so a signal can be handed around without the
1119
- context drifting.
1120
-
1121
- | Call | Returns | Meaning |
1122
- | :--- | :--- | :--- |
1123
- | `SynchronousSignalCollectable.useRate(values, rate, window?)` | signal | duration `count / rate` |
1124
- | `SynchronousSignalCollectable.useFrame(frame, window?)` | signal | keeps the frame's plan |
1125
- | `new SynchronousSignalCollectable(plan, values, window?)` | signal | plan count must equal the sample count |
1126
- | `getSample()` / `getValues()` / `getWindow()` | plan, samples, taper | the frozen context |
1127
- | `getRate()` | `number` | samples per second |
1128
- | `getNyquist()` / `getResolution()` | `Frequency` | `rate / 2` and `rate / count` |
1129
- | `getCoherentGain()` / `getNoiseBandwidth()` | `number` | recomputed at this exact length |
1130
- | `size()` / `at(index)` / `toFrame()` / `equals(other)` / `toString()` | — | value semantics |
1131
-
1132
- ```typescript
1133
- import { SynchronousSignalCollectable } from "semantic-typescript";
1134
-
1135
- const signal = SynchronousSignalCollectable.useRate(
1136
- Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 64)),
1137
- 64,
1138
- );
1139
-
1140
- signal.size(); // 64n
1141
- signal.getRate(); // 64
1142
- signal.getNyquist().getHertz(); // 32
1143
- signal.getResolution().getHertz(); // 1
1144
- signal.getCoherentGain(); // 0.5
1145
- signal.getNoiseBandwidth(); // 1.5
1146
- ```
1147
-
1148
- `getResolution()` is the bin spacing, not the resolving power: it is `rate / count` because the
1149
- transform has `count` bins, and padding the transform changes the spacing without changing what the
1150
- window can separate (section 14.8).
1151
-
1152
- ### 14.2 The twenty-three methods
1153
-
1154
- The methods fall into six families. Seven are spectral readings, two are time–frequency transforms,
1155
- four are analytic quantities, three are sequence statistics, five are shape descriptors, and two
1156
- return another signal.
1157
-
1158
- | Method | Returns | Convention |
1159
- | :--- | :--- | :--- |
1160
- | `spectrum(size?, window?)` | `Spectrum` | raw and unnormalised, full complex grid |
1161
- | `oneSided(size?, window?)` | `Bin[]` | conjugate half folded, DC and Nyquist not doubled |
1162
- | `amplitude(window?)` | `number[]` | `2·abs(X) / (count · CG)`, once at DC and Nyquist |
1163
- | `power(window?)` | `number[]` | `A² / 2` per interior bin, `A²` at DC and Nyquist |
1164
- | `decibel(window?)` | `number[]` | `20·log10(amplitude)` |
1165
- | `powerDecibel(window?)` | `number[]` | `10·log10(power)` |
1166
- | `powerSpectralDensity(frameSize?, hopSize?, window?)` | `SpectralDensity` | Welch average, `abs(X)² / (rate · Σw²)` |
1167
- | `shortTimeFourierTransform(frameSize, hopSize, window?)` | `Spectrum[]` | extended boundaries, centre times |
1168
- | `spectrogram(frameSize, hopSize, window?)` | `Spectrogram` | the same columns with a time axis |
1169
- | `analyticSignal()` | `Complex[]` | the Hilbert pair `x + i·H(x)` |
1170
- | `instantaneousPhase()` | `Angle[]` | folded into `[0, 2π)` |
1171
- | `instantaneousFrequency(rate?, gate?)` | `number[]` | unwrapped phase difference, gated on the envelope |
1172
- | `envelope()` | `number[]` | `abs(z[n])`, circular at the edges |
1173
- | `zeroCrossingRate(perSecond?)` | `number` | crossings per adjacent sample pair |
1174
- | `spectralFlux(frameSize?, hopSize?, window?)` | `number[]` | unit-norm magnitude distance between frames |
1175
- | `autocorrelation(maximumLag?, normalization?)` | `number[]` | coefficient by default, `ρ[0] = 1` |
1176
- | `spectralCentroid(window?)` | `Optional<Frequency>` | power-weighted mean frequency |
1177
- | `spectralBandwidth(window?)` | `Optional<Frequency>` | power-weighted standard deviation |
1178
- | `spectralRolloff(ratio?, window?)` | `Optional<Frequency>` | first bin reaching `ratio` of the power |
1179
- | `spectralFlatness(window?)` | `number` | geometric over arithmetic mean of power |
1180
- | `signalToNoiseRatio(frameSize?, hopSize?, window?, floorQuantile?)` | `Optional<number>` | median noise floor, `10·log10` |
1181
- | `detrended(mode?)` | signal | `"none"`, `"constant"` (default), `"linear"` |
1182
- | `windowed(window?)` | signal | applies a taper and adopts it |
1183
-
1184
- The optional arguments are not decoration. `frameSize` defaults to `min(256, size)`, the hop to
1185
- half a frame rounded down, `instantaneousFrequency` gates at `1E-3` of the peak envelope, and
1186
- `spectralRolloff` uses `0.85`. Each default is the value a reference implementation picks when the
1187
- parameter is named rather than supplied.
1188
-
1189
- ### 14.3 Amplitude, power and density
1190
-
1191
- Three normalisations are needed because three different questions are being asked.
1192
-
1193
- `amplitude()` answers "how large is the sinusoid on this bin": `2·abs(X[k]) / (count · CG)` for an
1194
- interior bin and `abs(X[k]) / (count · CG)` at DC and Nyquist. The factor of two folds the
1195
- conjugate half; the coherent gain removes the taper. Omit either and a unit tone reads `0.5`,
1196
- which is minus 6.02 decibels.
1197
-
1198
- `power()` answers "what mean square would a sinusoid on this bin have": `A² / 2` for an interior
1199
- bin and `A²` at DC and Nyquist, because those two do not oscillate. This is the peak convention,
1200
- and it deliberately does not satisfy Parseval: a window spreads each line over its main lobe, so
1201
- the sum of every bin exceeds the mean square by the equivalent noise bandwidth — `1.5` for Hann.
1202
-
1203
- `powerSpectralDensity()` answers "how much power per hertz": `abs(X)² / (rate · Σw²)`, averaged
1204
- over overlapping segments. Only the sum of the squared weights keeps a white-noise floor unbiased;
1205
- dividing by `count` reads `0.375` for unit noise and dividing by `count · CG²` reads `1.5`.
1206
-
1207
- ```typescript
1208
- import { SynchronousSignalCollectable } from "semantic-typescript";
1209
-
1210
- const round = (value: number): number => Number(value.toFixed(4));
1211
- const tone = SynchronousSignalCollectable.useRate(
1212
- Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
1213
- 256,
1214
- );
1215
-
1216
- round(tone.amplitude()[32]); // 1
1217
- round(tone.power()[32]); // 0.5
1218
- round(tone.power().reduce((sum, power) => sum + power, 0)); // 0.75
1219
- round(tone.powerSpectralDensity(64n, 32n).totalPower()); // 0.5
1220
- tone.powerSpectralDensity(64n, 32n).getSegments(); // 7n
1221
- ```
1222
-
1223
- The pair of totals is the check worth remembering: the peak-convention sum is `0.75` while the
1224
- density integrates to `0.5`, and the ratio is exactly the noise bandwidth. The density is also the
1225
- only one of the three that survives a change of frame length.
1226
-
1227
- ### 14.4 Levels: the two decibel conventions
1228
-
1229
- An amplitude and a power do not share a factor, so they do not share a logarithm. `amplitudeDecibel`
1230
- applies `20·log10` and is correct for a voltage, a displacement or a sound pressure; `powerDecibel`
1231
- applies `10·log10` and is correct for a power, an energy, a mean square or a density. The method
1232
- names on the signal follow the same split.
1233
-
1234
- ```typescript
1235
- import { amplitudeDecibel, powerDecibel, SynchronousSignalCollectable } from "semantic-typescript";
1236
-
1237
- const round = (value: number): number => Number(value.toFixed(4));
1238
- const tone = SynchronousSignalCollectable.useRate(
1239
- Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
1240
- 256,
1241
- );
1242
-
1243
- amplitudeDecibel(0.25); // -12.0412
1244
- powerDecibel(0.25); // -6.0206
1245
- round(tone.decibel()[32]); // 0
1246
- round(tone.powerDecibel()[32]); // -3.0103
1247
- ```
1248
-
1249
- Both readings of the same tone are correct: its amplitude is one (zero decibels), and its mean
1250
- square is one half (minus 3.01 decibels). Feeding a power to the amplitude convention would double
1251
- the level in decibels, turning the correct minus 6.02 into minus 12.04.
1252
-
1253
- ### 14.5 The one-sided fold
1254
-
1255
- A real signal satisfies `X[count − k] = conj(X[k])`, so the negative-frequency half carries no
1256
- independent information. The fold doubles every interior bin and keeps the two self-conjugate bins
1257
- — DC and, for an even length, Nyquist — exactly as the transform produced them. Those two are their
1258
- own mirror image; doubling them invents energy and reports exactly 6.02 decibels too high.
1259
-
1260
- ```typescript
1261
- import { SynchronousSignalCollectable } from "semantic-typescript";
1262
-
1263
- const round = (value: number): number => Number(value.toFixed(4));
1264
- const edge = SynchronousSignalCollectable.useRate(
1265
- Array.from({ length: 16 }, (_unused, index) => 2 + Math.cos(Math.PI * index)),
1266
- 16,
1267
- );
1268
- const bins = edge.oneSided();
1269
-
1270
- bins.length; // 9
1271
- round(bins[0].magnitude()); // 16
1272
- round(bins[8].magnitude()); // 8
1273
- round(edge.amplitude()[0]); // 2
1274
- round(edge.amplitude()[8]); // 1
1275
- ```
1276
-
1277
- The raw magnitudes are `16` and `8` because the taper's weights sum to eight; dividing by
1278
- `count · CG = 8` turns them into the calibrated amplitudes `2` and `1`. Doubling the edge bins
1279
- would read `4` and `2`, and the error would never raise an exception.
1280
-
1281
- ### 14.6 Time–frequency views
1282
-
1283
- `shortTimeFourierTransform` and `spectrogram` frame the record the way the reference short-time
1284
- transform does: half a frame of zeros at each end, then a zero-padded tail so that a whole number
1285
- of hops remains. The first frame is centred on the first sample, the last frame reaches the last
1286
- sample, and frame `m` is centred at `m · hop / rate` seconds. Retaining only complete frames would
1287
- discard up to `frameSize − 1` samples — for 1000 samples at frame 256 and hop 128, that is 104
1288
- samples, about a tenth of the record.
1289
-
1290
- ```typescript
1291
- import { SynchronousSignalCollectable } from "semantic-typescript";
1292
-
1293
- const tone = SynchronousSignalCollectable.useRate(
1294
- Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 4 * index / 64)),
1295
- 64,
1296
- );
1297
- const spectrogram = tone.spectrogram(16n, 8n);
1298
-
1299
- spectrogram.size(); // 9
1300
- spectrogram.getFrameSize(); // 16
1301
- spectrogram.getBoundary(); // "zeros"
1302
- spectrogram.getTime(0); // 0
1303
- spectrogram.getTime(6); // 0.75
1304
- tone.shortTimeFourierTransform(16n, 8n).length; // 9
1305
- tone.spectralFlux(16n, 8n).length; // 8
1306
- ```
1307
-
1308
- Every column is a `Spectrum` over `frameSize` bins, exactly as `spectrum()` would produce for the
1309
- same frame, and `getTime()` reports frame centres rather than frame starts.
1310
-
1311
- ### 14.7 Windows, coherent gain and COLA
1312
-
1313
- Every shape has two forms. The symmetric form divides the frame by `count − 1`; the periodic
1314
- (DFT-even) form divides it by `count`, one sample short of a full period, so the taper is continuous
1315
- under the periodic extension the DFT assumes. Analysis and overlap-add need the periodic form;
1316
- filter design keeps the symmetric one.
1317
-
1318
- The difference is measurable and never raises an error. At fifty per cent overlap the periodic Hann
1319
- window sums to one at every position; the symmetric form ripples, by `0.84` decibels at sixteen
1320
- samples, `0.21` at sixty-four and still `0.013` at one thousand and twenty-four. The class exposes
1321
- the coherent gain and the equivalent noise bandwidth of whichever taper it holds, so the correction
1322
- is never hard-coded: a symmetric Hann reads `0.4375` at eight samples, not `0.5`.
1323
-
1324
- ```typescript
1325
- import {
1326
- coherentGain, equivalentNoiseBandwidth, useFlatTopWindow,
1327
- useHannPeriodicWindow, useHannWindow,
1328
- } from "semantic-typescript";
1329
-
1330
- const round = (value: number): number => Number(value.toFixed(4));
1331
-
1332
- round(useHannWindow(1, 4)); // 0.75
1333
- round(useHannPeriodicWindow(1, 4)); // 0.5
1334
- coherentGain(useHannPeriodicWindow, 16); // 0.5
1335
- equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
1336
- useFlatTopWindow(0, 1024); // -0.000421
1337
- Array.from({ length: 1024 }, (_unused, i) => useFlatTopWindow(i, 1024)).filter(w => w < 0).length; // 578
1338
- ```
1339
-
1340
- The flat-top row is not a mistake in the table. A flat-top window is partially negative by
1341
- definition — 578 of 1024 weights here — and clamping those weights to zero would perturb the main
1342
- lobe and destroy the low scalloping loss the window exists for. The library returns the series
1343
- unchanged.
1344
-
1345
- ### 14.8 Pitfalls
1346
-
1347
- **Zero padding interpolates, it never resolves.** A transform length above the sample count samples
1348
- the same underlying DTFT more densely: the peak *location* becomes finer and the bin spacing
1349
- becomes `rate / size`, but the ability to separate two components is still governed by the window
1350
- length and shape. A tone exactly between two bins reads `82.1093` and `80.8598` on the unpadded
1351
- grid, and `128` at its own frequency on the padded grid.
1352
-
1353
- ```typescript
1354
- import { SynchronousSignalCollectable, useRectangularWindow } from "semantic-typescript";
1355
-
1356
- const round = (value: number): number => Number(value.toFixed(4));
1357
- const tone = SynchronousSignalCollectable.useRate(
1358
- Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 100.5 * index / 256)),
1359
- 256,
1360
- );
1361
- const flat = tone.spectrum(256n, useRectangularWindow);
1362
- const padded = tone.spectrum(1024n, useRectangularWindow);
1363
-
1364
- flat.getResolution().getHertz(); // 1
1365
- padded.getResolution().getHertz(); // 0.25
1366
- round(flat.magnitudeAt(100).get()); // 82.1093
1367
- round(flat.magnitudeAt(101).get()); // 80.8598
1368
- round(padded.magnitudeAt(402).get()); // 128
1369
- padded.frequencyAt(402).get().getHertz(); // 100.5
1370
- ```
1371
-
1372
- Two equal tones `0.4` bins apart stay merged at any padding: with a 256-point record and a Hann
1373
- window, padding to 8192 produces a single local maximum, not two.
1374
-
1375
- **The Hilbert transform is circular.** `analyticSignal`, `envelope` and `instantaneousPhase` treat
1376
- the first and last samples as neighbours, so a record whose period does not divide its length is
1377
- contaminated at both ends. The middle of the record is trustworthy; the ends are not.
1378
-
1379
- ```typescript
1380
- import { SynchronousSignalCollectable } from "semantic-typescript";
1381
-
1382
- const round = (value: number): number => Number(value.toFixed(4));
1383
- const edge = SynchronousSignalCollectable.useRate(
1384
- Array.from({ length: 512 }, (_unused, index) => Math.cos(2 * Math.PI * 8.3 * index / 512)),
1385
- 512,
1386
- );
1387
- const envelope = edge.envelope();
1388
-
1389
- round(envelope.slice(8, -8).reduce((sum, value) => sum + value, 0) / 496); // 1.0025
1390
- round(Math.max(...envelope.slice(0, 8))); // 1.2592
1391
- round(Math.min(...envelope.slice(-8))); // 0.1314
1392
- ```
1393
-
1394
- **A vanishing envelope does not define a phase.** Where the envelope collapses, the difference of
1395
- two rounding-noise angles can be half a turn, which reads as half the sampling rate. The gate is
1396
- what keeps the number meaningful, and the default is `1E-3` of the peak envelope.
1397
-
1398
- ```typescript
1399
- import { SynchronousSignalCollectable } from "semantic-typescript";
1400
-
1401
- const round = (value: number): number => Number(value.toFixed(4));
1402
- const beat = SynchronousSignalCollectable.useRate(
1403
- Array.from({ length: 4096 }, (_unused, index) =>
1404
- Math.cos(2 * Math.PI * 100 * index / 2048) + Math.cos(2 * Math.PI * 102 * index / 2048)),
1405
- 2048,
1406
- );
1407
-
1408
- round(Math.max(...beat.instantaneousFrequency(2048, 0).map(Math.abs))); // 920.218
1409
- round(Math.max(...beat.instantaneousFrequency().map(Math.abs))); // 101
1410
- ```
1411
-
1412
- **An unnormalised correlation is not a similarity.** The raw sum `r[k]` grows with the record
1413
- length, so two lengths cannot be compared. The default `"coefficient"` rule divides by `r[0]` and
1414
- keeps every value in `[-1, 1]`; `"unbiased"` divides by `count − k` and pays with a variance that
1415
- grows with the lag.
1416
-
1417
- ```typescript
1418
- import { SynchronousSignalCollectable } from "semantic-typescript";
1419
-
1420
- const round = (value: number): number => Number(value.toFixed(4));
1421
- const periodic = SynchronousSignalCollectable.useRate(
1422
- Array.from({ length: 1024 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 1024)),
1423
- 1024,
1424
- );
1425
- const rho = periodic.autocorrelation(200n);
1426
-
1427
- round(rho[0]); // 1
1428
- round(rho[128]); // 0.875
1429
- round(rho[64]); // -0.9375
1430
- round(periodic.autocorrelation(200n, "none")[0]); // 512
1431
- ```
1432
-
1433
- One more reading deserves a warning: `spectralFlux` normalises each frame before comparing, so a
1434
- level change alone produces zero flux. Its first and last values are edge artefacts of the zero
1435
- boundary, not onsets.
1436
-
1437
- ---
1438
-
1439
- <a id="15-license"></a>
1440
- ## 15. License
1441
-
1442
- MIT © Eloy Kim
1
+ # **Semantic-TypeScript**
2
+
3
+ **Flow, Indexed.** Your data, under precise control.
4
+
5
+ English · [简体中文](./readme.cn.md) · [繁體中文](./readme.tw.md) · [日本語](./readme.jp.md) · [한국어](./readme.kr.md) · [Deutsch](./readme.de.md) · [Français](./readme.fr.md) · [Español](./readme.es.md) · [Русский](./readme.ru.md)
6
+
7
+ A type-safe stream processing library that unifies **JavaScript generators**, **Java Stream** and
8
+ **index-based data addressing**. Every element carries a `bigint` index, every pipeline is lazy, and
9
+ every terminal operation is an explicit opt-in.
10
+
11
+ ```bash
12
+ npm install semantic-typescript
13
+ ```
14
+
15
+ - Zero runtime dependencies, pure ESM, ships its own types.
16
+ - 684 regression tests: `npm test`.
17
+ - Full change history: [CHANGELOG.md](./CHANGELOG.md).
18
+
19
+ ---
20
+
21
+ ## Table of Contents
22
+
23
+ 1. [Design Philosophy](#1-design-philosophy)
24
+ 2. [Architecture](#2-architecture)
25
+ 3. [Quick Start](#3-quick-start)
26
+ 4. [Factory Functions](#4-factory-functions)
27
+ 5. [Transformation Operators](#5-transformation-operators)
28
+ 6. [Collectables and Terminal Operations](#6-collectables-and-terminal-operations)
29
+ 7. [Statistics and Windows](#7-statistics-and-windows)
30
+ 8. [The Index Model](#8-the-index-model)
31
+ 9. [The Asynchronous Model](#9-the-asynchronous-model)
32
+ 10. [Periodic Analysis: Rings, Fields and Scores](#10-periodic-analysis-rings-fields-and-scores)
33
+ 11. [Utility Helpers](#11-utility-helpers)
34
+ 12. [Performance and Pitfalls](#12-performance-and-pitfalls)
35
+ 13. [Phase Rings: An Exact Cycle of Positions](#13-phase-rings)
36
+ 14. [Signal Analysis: Windows, Spectra and Time](#14-signal-analysis)
37
+ 15. [License](#15-license)
38
+
39
+ ---
40
+
41
+ <a id="1-design-philosophy"></a>
42
+ ## 1. Design Philosophy
43
+
44
+ Three ideas, one pipeline:
45
+
46
+ **Index is data.** Every element is addressed by a `bigint`. Reversal, rotation, shuffling and
47
+ partitioning are index arithmetic, not buffer operations. Nothing is materialised until a
48
+ collectable asks for it.
49
+
50
+ **Laziness is the default.** A factory wraps a generator; an operator wraps another generator. No
51
+ element is requested until a terminal operation runs, so an infinite source is perfectly legal as
52
+ long as something downstream stops it.
53
+
54
+ **Terminals are explicit.** `map` returns a `Semantic`, not an array. Values appear only when you
55
+ name a collectable — `toUnordered()`, `toOrdered()`, `toNumericStatistics()`, `toWindow()` — and each
56
+ one states what it costs and what order it produces.
57
+
58
+ The same discipline applies to the mathematical half of the library. A `Complex`, an `Angle` or a
59
+ `Frequency` is an immutable value whose invariants are checked at construction; an `Optional`
60
+ replaces `null`; and a phase ring states exactly how many elements it holds, what period it has and
61
+ how balanced it is.
62
+
63
+ ---
64
+
65
+ <a id="2-architecture"></a>
66
+ ## 2. Architecture
67
+
68
+ ```
69
+ factory ──▶ Semantic ──▶ Semantic ──▶ Collectable ──▶ result
70
+ (source) (map) (filter) (terminal)
71
+ ▲
72
+ └── every operator returns a new Semantic wrapping a generator
73
+ ```
74
+
75
+ | Layer | Module | Responsibility |
76
+ | :--- | :--- | :--- |
77
+ | Sources | `synchronous/collectors`, `asynchronous/collectors` | Wrap an iterable, a generator, a promise, a blob, an event target |
78
+ | Pipelines | `synchronous/semantic`, `asynchronous/semantic` | Lazy operators and index rewriting |
79
+ | Collectables | `synchronous/semantic`, `asynchronous/semantic` | Ordered, unordered, statistics, window and custom terminals |
80
+ | Collectors | `synchronous/collector`, `asynchronous/collector` | The fold primitives every terminal is built from |
81
+ | Mathematics | `mathematics/*` | Angles, complexes, frequencies, spectra, windows, estimators, epicycles, phase rings |
82
+ | Structures | `structure/*` | Lists, sets, maps, trees, forests, rings, fields |
83
+ | Errors | `structure/error` | The typed `SemanticError` hierarchy and its brand guards |
84
+
85
+ Synchronous and asynchronous families share one vocabulary: the same operator names, the same index
86
+ semantics, the same collectables. The asynchronous variants simply return promises, and they queue
87
+ writes so that a read issued after a write always observes it.
88
+
89
+ ### 2.1 What this release moved and broke
90
+
91
+ Three modules changed location or shape, and a set of defaults changed meaning. Every old behaviour
92
+ is still reachable through an explicit option; nothing was removed without a replacement.
93
+
94
+ | Change | Before | Now |
95
+ | :--- | :--- | :--- |
96
+ | Forests | `structure/tree/rootedForest`, `structure/tree/unionFindForest` | `structure/forest/rootedForest`, `structure/forest/unionFindForest`; `tree/index.ts` no longer re-exports them |
97
+ | Errors | ad-hoc native errors | a fifteen-class hierarchy in `structure/error.ts`, with one brand guard per class and the `SemanticRangeError` alias |
98
+ | Estimator options | fixed definitions | `mathematics/quantile.ts` exports `QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator` and `VarianceOptions` |
99
+ | `quantile` | Hyndman–Fan type 1 | type 7 — R `quantile`, NumPy `linear`, Excel `PERCENTILE.INC` |
100
+ | `skewness` / `kurtosis` | `g1` / `g2` | the bias-corrected `G1` / `G2` |
101
+ | `variance` / `standardDeviation` | `ddof = 0` | `ddof = 1`, wrapped in an `Optional` |
102
+ | `decibel` | one `20·log10` function | removed; `amplitudeDecibel` (20·log10) and `powerDecibel` (10·log10) |
103
+ | `Spectrum.flatness()` | magnitudes over both halves | powers over the one-sided half |
104
+ | `Spectrum.bandEnergy()` | raw two-sided sum | one-sided by default, folding the conjugate half |
105
+ | `shortTimeFourierTransform` / `spectrogram` | symmetric Hann, complete frames | periodic Hann, `boundary: "zeros"`, `padded: true` |
106
+
107
+ Chapters 6, 7 and 10 document the replacements; the measured consequences are in
108
+ [CHANGELOG.md](./CHANGELOG.md).
109
+
110
+ ---
111
+
112
+ <a id="3-quick-start"></a>
113
+ ## 3. Quick Start
114
+
115
+ ```typescript
116
+ import {
117
+ useOf, useFrom, useRange, useText, useStringify, usePromise
118
+ } from "semantic-typescript";
119
+
120
+ // Numeric aggregation
121
+ const sum: number = useOf(10, 20, 30, 40)
122
+ .map(n => n * 2)
123
+ .toNumericStatistics()
124
+ .summate(); // 200
125
+
126
+ // BigInt aggregation
127
+ const bigSum: bigint = useOf(10n, 20n, 30n, 40n)
128
+ .map(n => n * 2n)
129
+ .toBigintStatistics()
130
+ .summate(); // 200n
131
+
132
+ // Reordering is index arithmetic — collect with toOrdered() for it to take effect
133
+ const reversed: number[] = useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
134
+ const rotated: number[] = useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3]
135
+
136
+ // Lazy evaluation over a large range
137
+ const multiples: bigint[] = useRange(0n, 1_000_000n)
138
+ .filter(n => n % 17n === 0n)
139
+ .limit(10n)
140
+ .toUnordered()
141
+ .toArray(); // [0n,17n,…,153n]
142
+
143
+ // Unicode-safe string processing (code points, not UTF-16 units)
144
+ useText("semantic 👍").toUnordered().forEach(character => console.log(character));
145
+
146
+ // Circular-safe serialisation
147
+ const cyclic: any = { a: 1 }; cyclic.self = cyclic;
148
+ useStringify(cyclic); // '{"a":1}'
149
+
150
+ // Terminals hand the elements to real structures, not only to arrays
151
+ useOf(3, 1, 4, 1, 5).toCollectable().toOrderedSet().toArray(); // [1,3,4,5]
152
+ useFrom([3, 1, 4, 1, 5]).toCollectable().toBinarySearchTree().size(); // 5n
153
+
154
+ // Async sources
155
+ const total = await usePromise(Promise.resolve(7)).toNumericStatistics().summate(); // 7
156
+ ```
157
+
158
+ ---
159
+
160
+ <a id="4-factory-functions"></a>
161
+ ## 4. Factory Functions
162
+
163
+ Construction is always O(1) — a factory only wraps a generator. The table states the cost of
164
+ *consuming* `n` elements, plus the extra space the source itself needs.
165
+
166
+ | Factory | Signature (result) | Time (consume n) | Space (source) | Notes |
167
+ | :--- | :--- | :--- | :--- | :--- |
168
+ | `useOf` | `useOf<E>(...elements) → SynchronousSemantic<E>` | O(n) | O(n) | Indices are 0..n−1; the spread argument is already materialised |
169
+ | `useFrom` | `useFrom<E>(Iterable<E>) → SynchronousSemantic<E>`<br>`useFrom<E>(AsyncIterable<E>) → AsynchronousSemantic<E>` | O(n) | O(1) | Wraps any iterable; iterated once on collect |
170
+ | `useRange` | `useRange<N>(start, end, step?) → SynchronousSemantic<N>` | O(n) | O(1) | `end` is exclusive; `step` must be non-zero; `number` and `bigint` both work |
171
+ | `useFill` | `useFill<E>(element \| supplier, count) → SynchronousSemantic<E>` | O(n) | O(1) | A supplier is called once per element |
172
+ | `useEmpty` | `useEmpty<E>() → SynchronousSemantic<E>` | O(1) | O(1) | Identity element for `concat` |
173
+ | `useText` | `useText(text, delimiter?)` | O(n) | O(n) for the text | Iterates **code points**, so emoji and CJK stay single elements |
174
+ | `useAttribute` | `useAttribute<T>(object)` | O(v + e) | O(d + v) | Recursively walks nested objects with cycle protection |
175
+ | `useGenerate` | `useGenerate<E>(supplier, interrupt)` | O(n) × supplier cost | O(1) | **Must** have a stopping `interrupt` or a downstream `limit` |
176
+ | `useIterate` | `useIterate<E>(Iterable<E>)` / `(AsyncIterable<E>)` | O(n) per collection | O(1) | Replays the iterator on each collection |
177
+ | `useInterval` | `useInterval(period, delay?) → AsynchronousSemantic<number>` | n × period (real time) | O(1) | Emits elapsed time |
178
+ | `usePromise` | `usePromise<T>(promise) → AsynchronousSemantic<T>` | O(1) | O(1) | A single element at index `0n` |
179
+ | `useBlob` | `useBlob(blob, chunk?) → AsynchronousSemantic<Uint8Array>` | O(blob size) | O(chunk) per copy | Default chunk is 64 KiB |
180
+ | `useSubscription` | `useSubscription(target, subscriber, event) → AsynchronousSemantic<Event>` | one element per event | O(1) | `limit()` / `takeWhile()` define the unsubscribe boundary |
181
+
182
+ ```typescript
183
+ useOf(1, 2, 3).toUnordered().toArray(); // [1,2,3]
184
+ useFrom(new Set([1, 2, 2])).toUnordered().toArray(); // [1,2]
185
+ useRange(0, 5).toUnordered().toArray(); // [0,1,2,3,4]
186
+ useRange(10n, 0n, -5n).toUnordered().toArray(); // [10n,5n]
187
+ useFill("x", 3n).toUnordered().toArray(); // ["x","x","x"]
188
+ useText("A😊B").toUnordered().toArray(); // ["A","😊","B"]
189
+ useGenerate(() => 7, (_e, index) => index >= 3n).toUnordered().toArray(); // [7,7,7]
190
+ ```
191
+
192
+ ---
193
+
194
+ <a id="5-transformation-operators"></a>
195
+ ## 5. Transformation Operators
196
+
197
+ Every operator returns a new `Semantic`; none touches an element until a collectable runs. Time is
198
+ the extra cost **per element**; space is what the operator keeps while the pipeline runs.
199
+
200
+ | Operator | Purpose | Index effect | Time | Space |
201
+ | :--- | :--- | :--- | :--- | :--- |
202
+ | `map(mapper)` | 1:1 transform | forwarded | O(1) | O(1) |
203
+ | `filter(predicate)` | keep matching elements | forwarded | O(1) | O(1) |
204
+ | `flat(mapper)` / `flatMap(mapper)` | 1:N flatten | re-indexed from 0 | O(1) per emit | O(1) |
205
+ | `distinct(keyExtractor?)` | drop repeated keys, keep the first | forwarded | O(1) average | O(n) |
206
+ | `peek(consumer)` | side effect, element passes through | forwarded | O(1) | O(1) |
207
+ | `limit(count)` | take the first *n* | re-indexed from 0 | O(1) | O(1) |
208
+ | `skip(count)` | drop the first *n* | forwarded | O(1) | O(1) |
209
+ | `sub(start, end)` | positional slice `[start, end)` | forwarded | O(1) | O(1) |
210
+ | `takeWhile(predicate)` | take while the predicate holds | forwarded | O(1) | O(1) |
211
+ | `dropWhile(predicate)` | drop while the predicate holds | re-indexed from 0 | O(1) | O(1) |
212
+ | `concat(other)` | append a semantic or iterable | continues the index | O(1) | O(1) |
213
+ | `redirect(redirector)` | compute a new index however you like | **yours** | O(1) | O(1) |
214
+ | `reverse()` | `redirect((_e, i) => -i - 1n)` | negated and shifted | O(1) | O(1) |
215
+ | `translate(offset \| mapper)` | shift every index | `i + offset` | O(1) | O(1) |
216
+ | `shuffle(mapper?)` | randomise the index | random | O(1) | O(1) |
217
+ | `pipe(conversion)` | hand the generator to a custom function | up to you | yours | yours |
218
+ | `semantic()` | lift a collectable back into a semantic | unchanged | O(1) | O(1) |
219
+
220
+ **`reverse`, `translate` and `shuffle` only rewrite indices.** Nothing is buffered or rotated until
221
+ `toOrdered()` normalises the indices modulo the element count:
222
+
223
+ ```typescript
224
+ useFrom([1, 2, 3, 4, 5]).reverse().toOrdered().toArray(); // [5,4,3,2,1]
225
+ useFrom([1, 2, 3, 4, 5]).translate(2).toOrdered().toArray(); // [4,5,1,2,3] (rotation)
226
+ useFrom([1, 2, 3, 4, 5]).translate(7).toOrdered().toArray(); // [4,5,1,2,3] (7 ≡ 2 mod 5)
227
+ useFrom([1, 2, 3]).shuffle((_e, i) => -i - 1n).toOrdered().toArray(); // [3,2,1]
228
+ ```
229
+
230
+ ---
231
+
232
+ <a id="6-collectables-and-terminal-operations"></a>
233
+ ## 6. Collectables and Terminal Operations
234
+
235
+ The collectable owns both the **order** and the **index bookkeeping**. `n` is the element count.
236
+
237
+ ### 6.1 Collectables
238
+
239
+ | Collectable | Structure | Time | Order | Use when |
240
+ | :--- | :--- | :--- | :--- | :--- |
241
+ | `toUnordered()` | `Map<bigint, E>` keyed by index | O(n) | insertion | maximum throughput; raw indices preserved untouched |
242
+ | `toOrdered()` | array sorted by element index | O(n log n) | by normalised index | reversal/rotation must take effect |
243
+ | `toOrdered(comparator)` | sorted by your comparator | O(n log n) | by your comparator | sorted views |
244
+ | `toNumericStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `number` |
245
+ | `toBigintStatistics()` | value-sorted buffer | O(n log n) | by value | statistics over `bigint` |
246
+ | `toWindow()` | value-sorted buffer | O(n log n) | by value | `slide` / `tumble` |
247
+ | `toCollectable()` | the collectable itself, wired to the generator | O(1) | — | the cheapest way to reach a terminal |
248
+ | `toCollectable(mapper)` | whatever the mapper returns | — | — | custom collectables |
249
+
250
+ ### 6.2 Terminals
251
+
252
+ Every terminal lives on the **collectable**, not on the `Semantic`. That is what "terminals are
253
+ explicit" means in practice: a pipeline produces nothing until a collectable is named, and
254
+ `toCollectable()` with no mapper is the cheapest one to name.
255
+
256
+ | Terminal | Returns | Time | Notes |
257
+ | :--- | :--- | :--- | :--- |
258
+ | `toArray()` / `toSet()` | `Array<E>` / `Set<E>` | O(n) | Drains the buffer; `toSet` deduplicates by identity |
259
+ | `toMap(key, value?)` | `Map<K, V>` | O(n) average | Last write wins |
260
+ | `count()` / `isEmpty()` | `bigint` / `boolean` | O(1) | The buffer already knows |
261
+ | `forEach(action)` | `void` | O(n) | `action(element, index)` |
262
+ | `join(…)` | `string` | O(n) | `join()`, `join(delimiter)`, `join(prefix, delimiter, suffix)` |
263
+ | `reduce(…)` | `Optional<E>` / `E` / `R` | O(n) | Empty stream → empty `Optional` |
264
+ | `group(classifier)` / `groupBy(key, value)` | `Map<K, E[]>` | O(n) average | — |
265
+ | `partition(count)` | `Array<Array<E>>` | O(n) | Round-robin by index |
266
+ | `partitionBy(classifier)` | `Array<Array<E>>` | O(n) | The bucket number comes from the classifier |
267
+ | `anyMatch` / `allMatch` / `nonMatch` | `boolean` | O(n) worst, short-circuits | — |
268
+ | `findAny` / `findFirst` / `findLast` | `Optional<E>` | O(1)–O(n) | Positional in materialised order |
269
+ | `findAt(k)` / `findNegativeAt(k)` | `Optional<E>` | O(k) / O(n−k) | Counts from the front / the end |
270
+ | `findMaximum(cmp?)` / `findMinimum(cmp?)` | `Optional<E>` | O(n) | Defaults to a structural comparison |
271
+ | `frequency()` | `Map<E, bigint>` | O(n) average | Statistics collectables only |
272
+ | `write(stream, accumulator?)` | `WritableStream<S>` | O(n) | IO-like terminal |
273
+ | `collect(collector)` | `R` | O(n) | The generic fold behind every terminal above |
274
+
275
+ ### 6.3 Structure collectors
276
+
277
+ `toArray`, `toSet` and `toMap` end a pipeline in a JavaScript container. Twelve more terminals end it
278
+ in one of the library's own structures, so the result carries the invariants that structure
279
+ guarantees rather than the ones an array happens to have. They are available on every collectable,
280
+ including the bare one from `toCollectable()`.
281
+
282
+ | Terminal | Returns | Order | Cost |
283
+ | :--- | :--- | :--- | :--- |
284
+ | `toArray()` | `Array<E>` | traversal | O(n) |
285
+ | `toSet()` | `Set<E>` | insertion, SameValueZero | O(n) average |
286
+ | `toMap(key, value?)` | `Map<K, V>` | insertion; the last write wins | O(n) average |
287
+ | `toOrderedSet(comparator?)` | `SynchronousOrderedSet<E>` | comparator-ascending | O(n²) worst case |
288
+ | `toUnorderedSet()` | `SynchronousUnorderedSet<E>` | unspecified (hash buckets) | O(n) expected |
289
+ | `toOrderedMap(key, value?)` | `SynchronousOrderedMap<K, V>` | insertion; the last write wins | O(n) expected |
290
+ | `toUnorderedMap(key, value?)` | `SynchronousUnorderedMap<K, V>` | unspecified | O(n) expected |
291
+ | `toList()` | `SynchronousList<E>` | traversal, duplicates kept | O(n) |
292
+ | `toRing(span?)` | `SynchronousRing<E>` | traversal fixes ring positions | O(n) |
293
+ | `toLRUSet(capacity)` | `SynchronousUnorderedLRUSet<E>` | recency; the last `capacity` survive | O(n²) worst case |
294
+ | `toLRUMap(capacity, key, value?)` | `SynchronousUnorderedLRUMap<K, V>` | recency | O(n) expected |
295
+ | `toSkipSet(comparator?)` | `SynchronousSkipSet<E>` | comparator-ascending, navigable | O(n²) worst case |
296
+ | `toSkipMap(key, value?)` | `SynchronousSkipMap<K, V>` | key comparator, navigable | O(n) expected |
297
+ | `toBinarySearchTree(comparator?)` | `SynchronousBinarySearchTree<E>` | comparator-ascending, height-balanced | O(n log n) |
298
+ | `toRootedForest()` | `SynchronousRootedForest<E>` | encounter order becomes structure | O(n) |
299
+
300
+ The result type follows the collectable's own element type, so no terminal needs a type argument: a
301
+ `SynchronousCollectable<E>` hands you `Array<E>`, `SynchronousList<E>` or `SynchronousOrderedSet<E>`
302
+ directly. Only the business arguments remain — the comparator of `toOrderedSet` / `toSkipSet` /
303
+ `toBinarySearchTree`, the capacity of the LRU pair, the span of `toRing`, and the key and value
304
+ extractors of the map family.
305
+
306
+ A ring is a set, so repeated elements keep their first position; every other structure above keeps
307
+ duplicates unless its own contract says otherwise (`toOrderedSet`, `toUnorderedSet`, `toSkipSet` are
308
+ sets; `toBinarySearchTree` and `toList` are not).
309
+
310
+ ```typescript
311
+ const numbers = useOf(3, 1, 4, 1, 5).toCollectable();
312
+
313
+ numbers.toOrderedSet().toArray(); // [1,3,4,5]
314
+ numbers.toUnorderedSet().size(); // 4n — the duplicate 1 collapses
315
+ numbers.toList().toArray(); // [3,1,4,1,5]
316
+ numbers.toSkipSet().first().get(); // 1 — navigable by comparator
317
+ numbers.toBinarySearchTree().size(); // 5n — duplicates retained
318
+ numbers.toRootedForest().getRoots().length; // 1
319
+ useOf(1, 2, 3).toCollectable().toRing().phaseAt(3n).get().toDegreeValue(); // 0
320
+ useOf(3, 1, 3).toCollectable().toOrderedMap(n => n, n => `v${n}`).get(3).get(); // "v3"
321
+ ```
322
+
323
+ The asynchronous collectables expose the same terminals and resolve each in a promise, so the call
324
+ shape only gains an `await`.
325
+
326
+ ---
327
+
328
+ <a id="7-statistics-and-windows"></a>
329
+ ## 7. Statistics and Windows
330
+
331
+ ### 7.1 The statistics surface
332
+
333
+ ```typescript
334
+ useOf(3, 1, 4, 1, 5).toNumericStatistics().summate(); // 14
335
+ useOf(3, 1, 4, 1, 5).toNumericStatistics().average(); // 2.8
336
+ useOf(3, 1, 4, 1, 5).toNumericStatistics().median(); // 3
337
+ useOf(3, 1, 4, 1, 5).toNumericStatistics().mode(); // 1
338
+ useOf(3, 1, 4, 1, 5).toNumericStatistics().variance().get(); // 3.2 (n − 1)
339
+ useOf(3, 1, 4, 1, 5).toNumericStatistics().standardDeviation().get(); // 1.7889 (n − 1)
340
+ ```
341
+
342
+ Both statistics families expose the same surface — `summate`, `average`, `median`, `mode`,
343
+ `variance`, `standardDeviation`, `frequency`, `findMaximum`, `findMinimum` — with `bigint` arithmetic
344
+ for `toBigintStatistics()`. The dispersion measures and the quantile family return `Optional`,
345
+ because a sample can be too small for them to be defined; unwrap them with `.get()`, `.orElse(…)` or
346
+ `.isPresent()`.
347
+
348
+ ### 7.2 Estimators and defaults
349
+
350
+ The defaults are the definitions R, NumPy, pandas and Excel use, so a value computed here can be
351
+ compared with those tools without a correction factor.
352
+
353
+ | Statistic | Default | Previous | Restore the old value with |
354
+ | :--- | :--- | :--- | :--- |
355
+ | `quantile(probability)` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
356
+ | `interquartileRange()` | Hyndman–Fan type 7 | type 1 | `{ method: "type-1" }` |
357
+ | `skewness()` | bias-corrected `G1` | `g1` | `{ estimator: "g1" }` |
358
+ | `kurtosis()` | bias-corrected excess `G2` | `g2` | `{ estimator: "g2" }` |
359
+ | `variance()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
360
+ | `standardDeviation()` | `ddof = 1` | `ddof = 0` | `{ ddof: 0 }` |
361
+
362
+ All nine Hyndman–Fan types are available as `"type-1"` … `"type-9"`, and `{ excess: false }` adds
363
+ three back to a kurtosis, reporting the non-excess fourth standardised moment. The option types —
364
+ `QuantileMethod`, `QuantileOptions`, `SkewnessEstimator`, `KurtosisEstimator`, `VarianceOptions` —
365
+ are exported from `mathematics/quantile.ts`.
366
+
367
+ ```typescript
368
+ const sample = useOf(1, 2, 3, 4).toNumericStatistics();
369
+
370
+ sample.variance().get(); // 1.6667 (5/3, ddof = 1)
371
+ sample.variance({ ddof: 0 }).get(); // 1.25
372
+ sample.standardDeviation().get(); // 1.2910
373
+ sample.skewness().get(); // 0
374
+ sample.kurtosis().get(); // -1.2 (G2)
375
+ sample.kurtosis({ estimator: "g2" }).get(); // -1.36
376
+ sample.kurtosis({ excess: false }).get(); // 1.8
377
+
378
+ const quartiles = useOf(1, 2, 3, 4, 5, 6, 7, 8).toNumericStatistics();
379
+ quartiles.quantile(0.75).get(); // 6.25 (type 7)
380
+ quartiles.quantile(0.75, { method: "type-1" }).get(); // 6 (nearest rank)
381
+ quartiles.interquartileRange().get(); // 3.5
382
+ ```
383
+
384
+ `G1` and `G2` are undefined below three and four observations, and the sample variance and standard
385
+ deviation are undefined below two under `ddof = 1`. That is reported as an empty `Optional` rather
386
+ than as a misleading zero:
387
+
388
+ ```typescript
389
+ useOf(1, 2, 3).toNumericStatistics().kurtosis().isEmpty(); // true (G2 needs four observations)
390
+ useOf(1, 2).toNumericStatistics().skewness().isEmpty(); // true (G1 needs three)
391
+ useOf(1).toNumericStatistics().variance().isEmpty(); // true (ddof = 1 needs two)
392
+ useOf(1).toNumericStatistics().variance({ ddof: 0 }).get(); // 0
393
+ ```
394
+
395
+ The bigint views accept the same options and round every result back to a whole `bigint`.
396
+
397
+ ### 7.3 Windows
398
+
399
+ Windows re-expose the buffered elements as a pipeline of pipelines, so a window can be consumed
400
+ without materialising it into an array first. Both forms include a final partial window.
401
+
402
+ ```typescript
403
+ useFrom([1, 2, 3, 4]).toWindow().slide(2n, 1n).toUnordered().toArray()
404
+ .map(window => window.toUnordered().toArray()); // [[1,2],[2,3],[3,4],[4]]
405
+
406
+ useFrom([1, 2, 3, 4]).toWindow().tumble(2n).toUnordered().toArray()
407
+ .map(window => window.toUnordered().toArray()); // [[1,2],[3,4]]
408
+ ```
409
+
410
+ ---
411
+
412
+ <a id="8-the-index-model"></a>
413
+ ## 8. The Index Model
414
+
415
+ An index is a `bigint` attached to every element. Operators either **forward** it, **re-index** it
416
+ from zero, or let you **compute** it.
417
+
418
+ Ordered collection normalises each index modulo the element count, then sorts by the result. That is
419
+ why `translate(7)` on five elements equals `translate(2)`: `7 ≡ 2 (mod 5)`.
420
+
421
+ Unordered collection keeps whatever index the pipeline produced and returns elements in arrival
422
+ order. Negative indices are legal and simply sort before non-negative ones once normalised.
423
+
424
+ ---
425
+
426
+ <a id="9-the-asynchronous-model"></a>
427
+ ## 9. The Asynchronous Model
428
+
429
+ `AsynchronousSemantic` mirrors the synchronous surface, returning promises. Writes are queued, so a
430
+ read issued after a write always observes it.
431
+
432
+ ```typescript
433
+ const ring = new AsynchronousRing([1, 2]);
434
+ const added = ring.add(3); // queued
435
+ const sampled = ring.elementAt(2n); // observes the write above
436
+ await added;
437
+ (await sampled).get().element; // 3
438
+ ```
439
+
440
+ Factories that are asynchronous by nature — `usePromise`, `useBlob`, `useInterval`,
441
+ `useSubscription` — return `AsynchronousSemantic` directly, and `useFrom` / `useIterate` upgrade
442
+ themselves when handed an `AsyncIterable`.
443
+
444
+ ---
445
+
446
+ <a id="10-periodic-analysis-rings-fields-and-scores"></a>
447
+ ## 10. Periodic Analysis: Rings, Fields and Scores
448
+
449
+ The mathematical half of the library treats a cycle as a first-class object rather than a lookup
450
+ table. Three constructions work together.
451
+
452
+ ### 10.1 A ring is one integer
453
+
454
+ A `Ring` is an **n-fold cycle**: the element count, the positional period and the number of distinct
455
+ phases are the same integer `n`. The phase of a position is computed from its exact residue, so the
456
+ ring is periodic to the bit:
457
+
458
+ ```typescript
459
+ import { SynchronousRing } from "semantic-typescript";
460
+
461
+ const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
462
+ ring.phaseAt(0n).get().toDegreeValue(); // 0
463
+ ring.phaseAt(1n).get().toDegreeValue(); // 60
464
+ ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true, exactly
465
+ ring.purity(); // 1
466
+ ring.isBalanced(); // true
467
+ ring.sequences().map(z => z.magnitude()); // [0, 6, 0, 0, 0, 0]
468
+ ```
469
+
470
+ `frequency` is the **ambient** rotation rate, not a phase step between positions: the environment
471
+ moves every phasor by the same angle, so it can never break the intrinsic pattern. `span` fixes how
472
+ many turns the ring occupies.
473
+
474
+ `purity()` is the share of energy carried by the fundamental mode,
475
+ `max(|c₁|², |c_{n−1}|²) / (n · Σ|z|²)`. It is `1` exactly when the ring is the `n`-th roots of unity
476
+ up to rotation, and it costs `O(n)` because only two bins are needed. Both directions are tested
477
+ because a ring may traverse the circle either way.
478
+
479
+ ### 10.2 A field is a bounded score
480
+
481
+ A `PhaseField` maps a relative phase to a score in `[-1, 1]`:
482
+
483
+ ```typescript
484
+ import { useField, useRadian } from "semantic-typescript";
485
+
486
+ const cosine = useField([1]); // cos(2πΔ): +1 aligned, −1 opposed
487
+ const mixed = useField([0.4, 0.3, 0.3]); // first three harmonics
488
+
489
+ cosine.sample(useRadian(0)); // 1
490
+ cosine.sample(useRadian(Math.PI)); // -1
491
+ ```
492
+
493
+ The score is a truncated cosine series, `a₀ + Σ wₘ·cos(2πmΔ)`, and the constructor enforces
494
+ `|a₀| + Σ|wₘ| ≤ 1`. By the triangle inequality the score can never leave `[-1, 1]` — the bound is a
495
+ **consequence of the construction**, not a clamp applied afterwards.
496
+
497
+ The reason the field is a series rather than a table: an `n`-point table reproduces its own samples
498
+ exactly, but its trigonometric interpolant overshoots between them. A five-point table scored
499
+ `[+1, +0.5, −1, −0.5, 0]` interpolates over `[−1.0428, 1.0041]`, which already breaks the promise.
500
+ A bounded series cannot.
501
+
502
+ ### 10.3 Analysis measures data against every element
503
+
504
+ Each ring element is a reference phase. `analyse` locates every observation back on the ring and
505
+ measures it against each reference:
506
+
507
+ ```typescript
508
+ const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
509
+ const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
510
+
511
+ report.matched; // 5
512
+ report.missed; // 1 ('zz' is not on the ring)
513
+ report.references.map(r => r.count); // [2, 0, 2, 0, 1]
514
+ ```
515
+
516
+ Each reference row reports `count`, the mean relative phase `mean`, and the mean resultant length
517
+ `resultant`. One identity is worth remembering: **`resultant` is the same for every reference**,
518
+ because changing the baseline only rotates all relative phases by a constant. What differs per
519
+ reference is the count and the mean.
520
+
521
+ ```typescript
522
+ const ring = new SynchronousRing(["a", "b", "c"]);
523
+ ring.scoresAt(0, cosine); // one score per position, in [-1, 1]
524
+ ```
525
+
526
+ ### 10.4 Related instruments
527
+
528
+ | Call | Answers | Cost |
529
+ | :--- | :--- | :--- |
530
+ | `purity()` | is the ring still the `n`-th roots of unity? | O(n) |
531
+ | `resultant(t?)` | how concentrated are the phasors right now? | O(n) |
532
+ | `analyse(data)` | how does this data sit against every element? | O(n · m) |
533
+ | `toSpectrum()` | the ring as a spectrum, wavenumber on the frequency axis | O(n²) |
534
+ | `useAngularSpectrum(ring, instants, plan)` | the `(ω, n)` plane over time | O(n² · frames) |
535
+
536
+ Two more primitives round out the model: `combineRings(a, b)` joins two rings on their least common
537
+ multiple (a pullback, not a product — the pairs that cannot share an instant are simply absent), and
538
+ `rate()` gives each element its own intrinsic frequency so that a ring can drift against the
539
+ environment. `jetAt`, `windingAt` and `errorIntegralAt` expose the differential state of that drift.
540
+
541
+ ### 10.5 Spectral windows
542
+
543
+ The ring constructions are one half of the analytic surface; the other half works on sampled
544
+ signals. Its most reused primitive is the window, and every shape here has two forms. The symmetric
545
+ form divides the frame by `count − 1`; the periodic (DFT-even) form divides it by `count`, one
546
+ sample short of a full period, so the taper is continuous under the periodic extension the DFT
547
+ assumes.
548
+
549
+ ```typescript
550
+ useHannWindow(1, 4); // 0.75 symmetric
551
+ useHannPeriodicWindow(1, 4); // 0.5 periodic
552
+ coherentGain(useHannPeriodicWindow, 16); // 0.5
553
+ equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
554
+ ```
555
+
556
+ | Shape | Symmetric | Periodic | Notes |
557
+ | :--- | :--- | :--- | :--- |
558
+ | Rectangular | `useRectangularWindow` | — | no taper; no periodic distinction |
559
+ | Hann | `useHannWindow` | `useHannPeriodicWindow` | the short-time default |
560
+ | Hamming | `useHammingWindow` | `useHammingPeriodicWindow` | — |
561
+ | Blackman | `useBlackmanWindow` | `useBlackmanPeriodicWindow` | — |
562
+ | Nuttall | `useNuttallWindow` | `useNuttallPeriodicWindow` | four-term Blackman–Harris |
563
+ | Flat-top | `useFlatTopWindow` | `useFlatTopPeriodicWindow` | amplitude accuracy over resolution |
564
+ | Kaiser | `useKaiserWindow(beta)` | `useKaiserPeriodicWindow(beta)` | `beta` sets the main-lobe width |
565
+
566
+ Use the periodic form for analysis and overlap-add: at a half-frame hop the periodic Hann window
567
+ sums to `1.000000` at every length, while the symmetric form ripples by `0.841 dB` at `N = 16` and
568
+ still by `0.013 dB` at `N = 1024`. Keep the symmetric form for filter design, where the frame is not
569
+ one period of the taper.
570
+
571
+ The flat-top windows return **negative weights** near the ends of the frame, because that is what a
572
+ flat-top window is. They are returned unchanged rather than clamped, because clamping perturbs the
573
+ main lobe and destroys the low scalloping loss the window exists for:
574
+
575
+ ```typescript
576
+ useFlatTopWindow(0, 1024); // -0.000421 — negative by definition
577
+ Array.from({ length: 1024 }, (_unused, index) => useFlatTopWindow(index, 1024))
578
+ .filter(weight => weight < 0).length; // 578
579
+ ```
580
+
581
+ ### 10.6 Levels, flatness and band energy
582
+
583
+ `decibel` is gone as a single function, because an amplitude and a power do not share a factor.
584
+ `amplitudeDecibel` applies `20·log10` to a voltage-like ratio; `powerDecibel` applies `10·log10` to a
585
+ power, an energy, a mean square or a power spectral density:
586
+
587
+ ```typescript
588
+ amplitudeDecibel(0.25); // -12.0412
589
+ powerDecibel(0.25); // -6.0206
590
+ ```
591
+
592
+ `Bin.decibel` and `Spectrum.decibelOf` are amplitude levels; `Spectrum.powerDecibelOf` is the
593
+ matching power level. Feeding a power to the amplitude form reports `20·log10(power)` and doubles
594
+ the level in decibels.
595
+
596
+ Two spectrum measurements changed reading:
597
+
598
+ ```typescript
599
+ const twoBins = new Spectrum(new Sample(2, 1), [new Complex(2, 0), new Complex(1, 0)]);
600
+ twoBins.flatness(); // 0.8 — geometric over arithmetic mean of power
601
+
602
+ const shape = new Spectrum(new Sample(4, 1), [
603
+ new Complex(1, 0), new Complex(2, 0), new Complex(0, 0), new Complex(0, 0),
604
+ ]);
605
+ shape.bandEnergy(new Frequency(0), new Frequency(2)); // 9
606
+ shape.bandEnergy(new Frequency(0), new Frequency(2), "two-sided"); // 5
607
+ ```
608
+
609
+ `flatness()` compares the geometric and arithmetic means of `power()` over the one-sided half of the
610
+ spectrum, not of magnitude over both halves, and the display-only amplitude doubling of
611
+ `oneSided()` is deliberately not applied. `bandEnergy()` defaults to the one-sided reading, folding
612
+ the conjugate half so that DC and Nyquist count once and every interior bin counts twice; a band
613
+ that covers the whole axis then satisfies Parseval, `Σ|X|² = N·Σx²`. Pass `"two-sided"` for a
614
+ complex spectrum, whose bins are already independent.
615
+
616
+ ### 10.7 Short-time analysis
617
+
618
+ `Sampler.shortTimeFourierTransform` and `Sampler.spectrogram` follow the SciPy `stft` conventions:
619
+ the periodic Hann window, a zero-filled boundary at both ends and a zero-filled tail.
620
+
621
+ ```typescript
622
+ const sampler = new Sampler(new Sample(64, 1));
623
+ const spectrogram = sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8);
624
+
625
+ spectrogram.size(); // 9 frames
626
+ spectrogram.getBoundary(); // "zeros"
627
+ spectrogram.getTime(6); // 0.75 — frame centres, not frame starts
628
+ sampler.spectrogram(time => Math.cos(2 * Math.PI * 4 * time), 16, 8, useHannWindow,
629
+ { boundary: "none", padded: false }).size(); // 7 — complete frames only
630
+ ```
631
+
632
+ The extension centres the first frame on the first sample, and the tail padding keeps the last
633
+ samples reachable: 64 samples at frame 16 and hop 8 produce 9 frames instead of 7, and 1000 samples
634
+ at frame 256 and hop 128 produce 9 instead of 6. Frame times are centres — `(k·hop + (frameSize mod
635
+ 2)/2)/rate` for an extended view, `(k·hop + frameSize/2)/rate` for an unextended one — and
636
+ `toFrame()` trims the extension before inverting.
637
+
638
+ ---
639
+
640
+ <a id="11-utility-helpers"></a>
641
+ ## 11. Utility Helpers
642
+
643
+ ### 11.1 The error hierarchy
644
+
645
+ `structure/error.ts` exports a fifteen-class hierarchy for the errors raised *around* a pipeline — by
646
+ your own code, a custom collector or an adapter:
647
+
648
+ ```text
649
+ SemanticError
650
+ ├── IllegalOperationError
651
+ ├── ValidationError
652
+ │ ├── IndexError
653
+ │ ├── RangeError (also exported as SemanticRangeError)
654
+ │ └── ConfigurationError
655
+ ├── CapacityError
656
+ ├── StateError
657
+ │ └── IllegalStateError
658
+ ├── ConcurrencyError
659
+ ├── CollectionError
660
+ ├── SerializationError
661
+ ├── NumericError
662
+ │ └── ArithmeticError
663
+ └── PhaseError
664
+ ```
665
+
666
+ Each class fixes `name`, forwards the native `cause`, carries an optional `context` bag and
667
+ serialises through `toJSON()`; each has a brand guard named after it.
668
+
669
+ ```typescript
670
+ const error = new CapacityError("LRU capacity exhausted", { context: { capacity: 0 } });
671
+
672
+ error.name; // "CapacityError"
673
+ isCapacityError(error); // true
674
+ isValidationError(error); // false — a capacity failure is not a validation failure
675
+ isSemanticError(error); // true
676
+ error.toJSON(); // { name: "CapacityError", message: "…", context: { capacity: 0 } }
677
+ ```
678
+
679
+ `SemanticRangeError` is the same class as the exported `RangeError`, so `instanceof` holds across
680
+ both names; importing `RangeError` shadows the global constructor for the whole module, which is why
681
+ the alias exists. Neither class is thrown by the library's own argument checks, which keep raising
682
+ native `TypeError` and `RangeError` — the hierarchy is the vocabulary for the layer above.
683
+
684
+ ### 11.2 Utility table
685
+
686
+ | Helper | Purpose |
687
+ | :--- | :--- |
688
+ | `useCompare(a, b)` | Total ordering across mixed primitives |
689
+ | `useToNumber` / `useToBigInt` | Lossless, range-checked numeric conversion |
690
+ | `useRandom` | Seeded or unseeded random helpers |
691
+ | `useTraverse` | Structural walk of nested objects and arrays |
692
+ | `useStringify` | Circular-safe JSON serialisation |
693
+ | `useHash` / `maskOf` / `register` / `unregister` | Structural hashing and the hash strategy registry |
694
+ | `invalidate` / `validate` | Guard clauses that raise a consistent error shape |
695
+ | `Optional<T>` | The library's `null` replacement: `Optional.of(x)` / `Optional.empty()` |
696
+
697
+ ### 11.3 `useTraverse`
698
+
699
+ `useTraverse(target, callback)` flattens any object into its **non-null leaves**. It descends nested
700
+ objects, walks iterable values by position, skips `null` and `undefined`, and never re-enters an
701
+ object it has already visited — so an object graph with a cycle terminates instead of overflowing
702
+ the stack.
703
+
704
+ ```typescript
705
+ const source = { id: 7, tags: ["a", "b"], owner: { name: "Ada", note: null } };
706
+ const leaves: string[] = [];
707
+
708
+ useTraverse(source, (key, value, path) => {
709
+ leaves.push(`${path.join(".")} = ${String(value)}`);
710
+ return true;
711
+ });
712
+
713
+ leaves; // ["id = 7", "tags.0 = a", "tags.1 = b", "owner.name = Ada"]
714
+ ```
715
+
716
+ The callback runs once per leaf and receives the leaf's own key (its index when the leaf came from
717
+ an iterable), the value, and the path that reached it. Returning `false` abandons the **whole**
718
+ traversal, not just the current branch:
719
+
720
+ ```typescript
721
+ let visited = 0;
722
+ useTraverse({ a: 1, b: 2, c: 3 }, () => {
723
+ visited += 1;
724
+ return false;
725
+ });
726
+ visited; // 1 — the first leaf already stops the walk
727
+ ```
728
+
729
+ A cycle is safe for the same reason:
730
+
731
+ ```typescript
732
+ const cyclic: any = { name: "root", child: { name: "leaf" } };
733
+ cyclic.child.parent = cyclic;
734
+
735
+ const names: string[] = [];
736
+ useTraverse(cyclic, (_key, value) => {
737
+ if (typeof value === "string") names.push(value);
738
+ return true;
739
+ });
740
+ names; // ["root", "leaf"] — the back edge to `cyclic` is not revisited
741
+ ```
742
+
743
+ **Each callback receives its own path snapshot.** The walk keeps a single working array internally and, at the moment a leaf is handed over, copies it into a fresh array frozen with `Object.freeze`. The snapshot never changes afterwards, so it can be stored as it is, two callbacks never share one, and re-running the traversal does not disturb snapshots already handed out. Its type is `ReadonlyArray`, so writing to it is a compile error, and the frozen array also throws a `TypeError` at runtime. The safety costs one `O(depth)` allocation per reported leaf, and a branch that reports nothing allocates nothing extra.
744
+
745
+ ```typescript
746
+ const paths: Array<ReadonlyArray<PropertyKey>> = [];
747
+ useTraverse({ a: { b: 1 }, c: 2 }, (_key, _value, path) => {
748
+ paths.push(path); // safe to keep as-is
749
+ return true;
750
+ });
751
+ paths[0] === paths[1]; // false — one array per call
752
+ paths.map(path => path.join(".")); // ["a.b", "c"]
753
+ ```
754
+
755
+ `useAttribute` is this walk turned into a factory: it reports the same leaves as `{ key, value }`
756
+ pairs, so the rules — and the cost — are identical.
757
+
758
+ ```typescript
759
+ useAttribute({ a: 1, b: { c: 2 } }).toUnordered().toArray();
760
+ // [{ key: "a", value: 1 }, { key: "c", value: 2 }]
761
+ ```
762
+
763
+ ---
764
+
765
+ <a id="12-performance-and-pitfalls"></a>
766
+ ## 12. Performance and Pitfalls
767
+
768
+ **Laziness has a price per stage.** Each operator adds a generator hop. For tight numeric loops over
769
+ in-memory arrays, a plain `for` loop will beat any pipeline; the pipeline wins when the source is
770
+ lazy, infinite, or the pipeline is long enough for the index discipline to pay off.
771
+
772
+ **`toOrdered()` sorts; `toUnordered()` does not.** If a reversal seems to have "not worked", the
773
+ collector was unordered — index rewriting only becomes an order when indices are normalised.
774
+
775
+ **`useGenerate` without an `interrupt` never ends.** Always supply a stopping predicate or a
776
+ downstream `limit` / `takeWhile`.
777
+
778
+ **`Ring` is an insertion-ordered set.** Elements are unique and keep the order in which they were
779
+ first added. A duplicate `add` returns `false`, and a `set` that would introduce one throws. This is
780
+ what lets a phase be a function of the *element* rather than of whichever position it happens to
781
+ occupy.
782
+
783
+ **`Spectrum` assumes a real signal in its one-sided helpers.** `oneSided()`, `getNyquist()` and
784
+ `centroid()` assume conjugate symmetry. The spatial spectrum of a phase ring is complex and in
785
+ general not symmetric — a ring that traverses the circle backwards puts all of its energy in bin
786
+ `n − 1`, beyond Nyquist. Read the bins directly.
787
+
788
+ **Structure collectors are terminals, not views.** Each one drains the pipeline and then builds a
789
+ full structure, so it costs O(n) time and O(n) space and cannot run on an infinite source. Reach for
790
+ one at the end of a pipeline, never inside a loop over one.
791
+
792
+ **`variance()` and friends return an `Optional`.** `variance().get()` on a sample that is too small
793
+ throws; use `.orElse(0)` or check `.isPresent()` when the sample size is not under your control.
794
+ The same applies to `standardDeviation`, `quantile`, `interquartileRange`, `skewness` and
795
+ `kurtosis`.
796
+
797
+ **The periodic windows are the analysis windows.** `useHannWindow` and the other symmetric forms are
798
+ for filter design; a short-time transform wants `useHannPeriodicWindow` and its siblings, or the
799
+ overlap-add sum will ripple wherever the frames meet.
800
+
801
+ ---
802
+
803
+ <a id="13-phase-rings"></a>
804
+ ## 13. Phase Rings: An Exact Cycle of Positions
805
+
806
+ Chapter 10 introduced the ring as the library's model of a cycle and used it to measure a data
807
+ set against every phase. This chapter is the complete surface: how a position becomes an angle,
808
+ what the ambient frequency does and does not change, and the instruments that read the ring
809
+ against time, against data and against another ring.
810
+
811
+ A `SynchronousRing` is a positional list backed by a circular doubly linked ring whose head and
812
+ tail are joined. It is also an insertion-ordered *set*: no element appears twice, and a rejected
813
+ write leaves both the links and the samples untouched. What makes it a ring rather than a list is
814
+ that the phase of a position is a pure function of its residue modulo the current length, so the
815
+ ring is periodic to the bit and its balance cannot drift.
816
+
817
+ ### 13.1 A ring is one integer, computed exactly
818
+
819
+ A ring carries `n` elements, and the element count, the positional period and the number of
820
+ distinct phases are the same integer. Position `i` carries
821
+
822
+ `phase + span · 2π · (((i mod n) + n) mod n) / n`
823
+
824
+ with the residue reduced in `bigint` before it ever becomes a `number`. That is the whole reason
825
+ `phaseAt(i)` and `phaseAt(i + n)` are *identical* rather than merely close: the second call
826
+ reduces to the same integer, evaluates the same product and produces the same double. An index
827
+ far beyond the exact integer range of `number` still lands on the right position, and a negative
828
+ index walks the ring backwards.
829
+
830
+ | Call | Returns | Meaning |
831
+ | :--- | :--- | :--- |
832
+ | `new SynchronousRing(source, options?)` | ring | `source` is any iterable, duplicates dropped |
833
+ | `getPhase()` / `getFrequency()` / `getSpan()` | `Angle`, `Frequency`, `number` | the three construction parameters |
834
+ | `size()` | `bigint` | the `n` that is also the period |
835
+ | `phaseAt(index)` | `Optional<Angle>` | intrinsic phase only; ignores time, rate and frequency |
836
+ | `elementAt(index)` | `Optional<RingElement>` | element, intrinsic phase, ambient frequency, phasor |
837
+ | `frequencyAt(index)` | `Optional<Frequency>` | the ambient rate, empty for an empty ring |
838
+ | `toComplex()` / `toComplex(index)` | `Complex[]` / `Optional<Complex>` | intrinsic phasors |
839
+
840
+ `RingOptions` adds two per-element functions: `magnitude(element, index)` gives the phasor length
841
+ and defaults to one, and `rate(element, index)` gives the element its own intrinsic frequency and
842
+ defaults to zero, a fixed marker.
843
+
844
+ ```typescript
845
+ import { SynchronousRing } from "semantic-typescript";
846
+
847
+ const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
848
+
849
+ ring.size(); // 6n
850
+ ring.phaseAt(0n).get().toDegreeValue(); // 0
851
+ ring.phaseAt(1n).get().toDegreeValue(); // 60
852
+ ring.phaseAt(-1n).get().toDegreeValue(); // 300
853
+ ring.phaseAt(6n).get().getValue() === ring.phaseAt(0n).get().getValue(); // true
854
+ ring.phaseAt(10n ** 20n).get().toDegreeValue(); // 240
855
+ ```
856
+
857
+ `phaseAt(-1n)` is `300` on a six-element ring, and `10n ** 20n` reduces to `4` modulo six, so it
858
+ reads `240`. Neither case accumulates a floating-point product, which is what keeps the identity
859
+ exact under a change of index representation.
860
+
861
+ ### 13.2 The ambient frequency and the state at an instant
862
+
863
+ `frequency` is the **ambient rotation rate** of the environment the ring is embedded in, not a
864
+ phase step between positions. The environment advances the phase of every element by the same
865
+ angle, so it cannot change the intrinsic pattern, cannot break the balance and cannot change any
866
+ `phaseAt` result. It defaults to `0 Hz`.
867
+
868
+ `relativeAt(index, time)` subtracts that common angle, leaving the coordinate a `PhaseField` is
869
+ scored against. `viewAt` and `viewsAt` return the full state at an instant, including the
870
+ element's own `rate`, and the differential accessors expose the drift:
871
+
872
+ | Call | Formula | Reads |
873
+ | :--- | :--- | :--- |
874
+ | `relativeAt(index, time)` | `intrinsic + ownRate · t − ambient · t` | the phase the environment sees |
875
+ | `viewAt(index, time)` / `viewsAt(time)` | the same, plus element, index, time, rate, phasor | the full state |
876
+ | `jetAt(index, time)` | phase at `t`, own rate, `slip = ownRate − ambient` | the differential state |
877
+ | `windingAt(index, time)` | `trunc(slip · t)` | whole turns slipped, truncated toward zero |
878
+ | `errorIntegralAt(index, time, origin?)` | `∫ relative phase dt`, in turns · seconds | how long an element has been ahead |
879
+ | `resultant(time?)` | `abs(Σ w · e^{iθ}) / Σ w` | phase concentration, unaffected by ambient rotation |
880
+ | `analyse(data, time?)` | counts and means per reference | data measured against every element |
881
+ | `scoreAt(index, time, field)` / `scoresAt(time, field)` | the field sampled at the relative phase | a bounded score per position |
882
+
883
+ ```typescript
884
+ import { SynchronousRing, useHertz } from "semantic-typescript";
885
+
886
+ const moving = new SynchronousRing(["a", "b", "c", "d", "e", "f"], {
887
+ frequency: useHertz(1),
888
+ });
889
+
890
+ moving.getFrequency().getHertz(); // 1
891
+ moving.phaseAt(1n).get().toDegreeValue(); // 60 — intrinsic
892
+ moving.relativeAt(1n, 0.25).get().toDegreeValue(); // 330 — 60 − 90
893
+ moving.viewAt(1n, 0.25).get().relative.toDegreeValue(); // 330
894
+ moving.jetAt(1n, 0.25).get().slip; // -1
895
+ moving.purity(); // 1 — rotation cannot break it
896
+ ```
897
+
898
+ At `1 Hz` the environment advances a quarter turn in `0.25 s`, so the relative phase of position
899
+ one is `60 − 90 = 330` degrees while its intrinsic phase stays `60`. The jet reports `slip` of
900
+ `-1` hertz because the element itself does not move, and `purity()` is still `1`: rotating every
901
+ phasor by the same angle is not a deformation.
902
+
903
+ ### 13.3 Purity and balance
904
+
905
+ `purity()` is the share of the ring's energy carried by the fundamental mode:
906
+
907
+ `max(abs(c₁)², abs(c_{n−1})²) / (n · Σ abs(z)²)`
908
+
909
+ Both fundamental bins are tested because a ring may traverse the circle in either direction. A
910
+ ring built with `span: 5` over six elements steps by `300` degrees, which is `−60` degrees: it
911
+ runs backwards, puts all of its energy in `c₅`, and would be reported as impure by a formula that
912
+ only looked at `c₁`. The cost stays `O(n)` because only those two bins and the total energy are
913
+ needed.
914
+
915
+ The edge cases are part of the contract. A ring of fewer than two elements has no fundamental
916
+ mode and reports `purity() === 0` and `isBalanced() === false`, whatever its content. A ring whose
917
+ phasors all have zero length has no energy and also reports zero. `isBalanced(epsilon?)` is
918
+ `1 − purity() <= epsilon`, with `1E-9` as the default tolerance.
919
+
920
+ ```typescript
921
+ import { SynchronousRing } from "semantic-typescript";
922
+
923
+ const forward = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
924
+ const backward = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 5 });
925
+ const weighted = new SynchronousRing(["a", "b", "c", "d"], {
926
+ magnitude: element => element === "a" ? 1 : 0,
927
+ });
928
+
929
+ forward.purity(); // 1
930
+ backward.phaseAt(1n).get().toDegreeValue(); // 300 — it runs backwards
931
+ backward.purity(); // 1 — the energy sits in c₅
932
+ new SynchronousRing(["only"]).purity(); // 0
933
+ new SynchronousRing(["only"]).isBalanced(); // false
934
+ weighted.purity(); // 0.25
935
+ ```
936
+
937
+ ### 13.4 Resultant and the symmetric components
938
+
939
+ `resultant(time?)` is the mean resultant length: the magnitude of the phasor sum divided by the
940
+ sum of the magnitudes. Unlike `purity`, which asks whether the ring is still the `n`-th roots of
941
+ unity, `resultant` asks how concentrated the phasors are right now. A balanced ring reports `0`;
942
+ a ring whose elements all share one angle would report `1`, but the set semantics forbid duplicate
943
+ elements, so in practice `1` appears when `magnitude` zeroes all but one element.
944
+
945
+ `zeroSequence()` is the mean phasor, that is `c₀ / n`. `sequences()` returns all `n` symmetric
946
+ components, lowest order first, by a direct transform. `toSpectrum()` wraps them in a `Spectrum`
947
+ whose bin `j` reports `j` cycles per ring revolution, so the frequency axis is the wavenumber
948
+ axis.
949
+
950
+ ```typescript
951
+ import { SynchronousRing } from "semantic-typescript";
952
+
953
+ const round = (value: number): number => Number(value.toFixed(4));
954
+ const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
955
+
956
+ round(ring.resultant().get()); // 0
957
+ round(ring.zeroSequence().get().magnitude()); // 0
958
+ ring.sequences().map(component => round(component.magnitude())); // [0, 6, 0, 0, 0, 0]
959
+ ring.toSpectrum().get().size(); // 6
960
+ ring.toSpectrum().get().getResolution().getHertz(); // 1
961
+ ```
962
+
963
+ The components are complex and in general **not conjugate-symmetric**: a balanced ring puts all
964
+ of its energy in bin one, and a backwards ring puts it in bin `n − 1`. The one-sided helpers of
965
+ `Spectrum` assume a real signal, so read the bins directly instead of folding them.
966
+
967
+ ### 13.5 Analysing data against every element
968
+
969
+ `analyse(data, time?)` matches every observation back to the ring by identity and measures it
970
+ against each element as a reference phase. Observations the ring does not hold are counted as
971
+ `missed` rather than guessed at, because the ring is the only source of phases. Each reference
972
+ row reports `count`, the mean relative phase `mean`, and the mean resultant length `resultant`.
973
+
974
+ One identity is worth remembering: **`resultant` is the same for every reference**. Changing the
975
+ baseline rotates all relative phases by a constant, and a rotation cannot change a resultant
976
+ length. What differs per reference is the count and the mean.
977
+
978
+ ```typescript
979
+ import { SynchronousRing } from "semantic-typescript";
980
+
981
+ const round = (value: number): number => Number(value.toFixed(4));
982
+ const ring = new SynchronousRing(["a", "b", "c", "d", "e"]);
983
+ const report = ring.analyse(["a", "c", "c", "a", "zz", "e"]);
984
+
985
+ report.matched; // 5
986
+ report.missed; // 1
987
+ report.references.map(row => row.count); // [2, 0, 2, 0, 1]
988
+ report.references.map(row => round(row.resultant)); // [0.1453, 0.1453, 0.1453, 0.1453, 0.1453]
989
+ ```
990
+
991
+ Scoring is the other side of the same coin. A `PhaseField` is a bounded cosine series (chapter
992
+ 10.2), and `scoreAt`/`scoresAt` sample it at the relative phase, so the same ring can be scored
993
+ against alignment, against a harmonic pattern, or against any other field without changing the
994
+ ring.
995
+
996
+ ```typescript
997
+ import { SynchronousRing, useField } from "semantic-typescript";
998
+
999
+ const round = (value: number): number => Number(value.toFixed(4));
1000
+ const ring = new SynchronousRing(["a", "b", "c", "d", "e", "f"]);
1001
+ const cosine = useField([1]);
1002
+
1003
+ round(ring.scoreAt(0n, 0, cosine).get()); // 1
1004
+ round(ring.scoreAt(1n, 0, cosine).get()); // 0.5
1005
+ ring.scoresAt(0, cosine).map(score => round(score)); // [1, 0.5, -0.5, -1, -0.5, 0.5]
1006
+ ```
1007
+
1008
+ ### 13.6 Combining rings and the angular spectrum
1009
+
1010
+ `combineRings(left, right)` joins two rings on their least common multiple, not their product.
1011
+ Instant `i` of the joint ring reads `i mod m` of the left ring and `i mod n` of the right ring,
1012
+ so the joint ring closes after `lcm(m, n)` instants. The pairs that can never co-occur — those
1013
+ whose positions disagree modulo `gcd(m, n)` — are simply absent, which is why the result is a
1014
+ pullback rather than a product; `aligns(left, right)` reports whether a given pair is
1015
+ representable.
1016
+
1017
+ `useAngularSpectrum(ring, instants, plan)` builds the `(ω, n)` plane: each instant contributes a
1018
+ frame of phasors, frame `k` of mode `j` is the `j`-th symmetric component of that frame, and
1019
+ `cells[frame][order]` is the coefficient. Taking one column gives the time series of a single
1020
+ spatial mode, and `spectrum(order)` is its temporal spectrum.
1021
+
1022
+ ```typescript
1023
+ import { Sample, SynchronousRing, combineRings, useAngularSpectrum } from "semantic-typescript";
1024
+
1025
+ const left = new SynchronousRing(["a", "b", "c", "d"]);
1026
+ const right = new SynchronousRing(["x", "y", "z", "w", "v", "u"]);
1027
+ const joined = combineRings(left, right);
1028
+
1029
+ joined.period; // 12n
1030
+ joined.divisor; // 2n
1031
+ joined.ring.size(); // 12n
1032
+ joined.aligns(0n, 0n); // true
1033
+ joined.aligns(1n, 0n); // false
1034
+ joined.ring.get(0n).join("-"); // a-x
1035
+
1036
+ const angular = useAngularSpectrum(left, [0, 0.25, 0.5, 0.75], new Sample(4, 1));
1037
+ angular.order; // 4
1038
+ angular.frameCount; // 4
1039
+ angular.cells[0][1].magnitude(); // 4
1040
+ angular.spectrum(1).get().size(); // 4
1041
+ ```
1042
+
1043
+ ### 13.7 Pitfalls
1044
+
1045
+ **Exactness is exact.** `phaseAt(i)` and `phaseAt(i + n)` are the same double, not two values
1046
+ within a tolerance. A ring built by accumulating `2π / n` per element would fail this; this one
1047
+ reduces the index in `bigint` first.
1048
+
1049
+ **`frequency` is not a phase step.** It is the ambient rotation rate, it defaults to zero, and
1050
+ rotating every phasor by the same angle cannot break the intrinsic pattern. If you want positions
1051
+ to advance relative to each other, use `rate`, not `frequency`.
1052
+
1053
+ **`purity()` must look at both fundamental bins.** A backwards ring is still the `n`-th roots of
1054
+ unity; only `c₁` would call it impure.
1055
+
1056
+ **`n < 2` is a defined edge case.** `purity()` returns `0` and `isBalanced()` returns `false`; an
1057
+ empty ring returns empty optionals from the sampling methods.
1058
+
1059
+ **A span that shares a factor with `n` collapses the orbit.** Six elements with `span: 2` visit
1060
+ only three distinct phases, and the ring is no longer balanced.
1061
+
1062
+ **`resultant` is rotation-invariant, not time-invariant.** Ambient rotation leaves it unchanged;
1063
+ per-element `rate`s change it as the ring de-phases.
1064
+
1065
+ **The ring is a set, and `analyse` matches by identity.** Duplicate elements are dropped at
1066
+ construction, and an object that is structurally equal to a ring element but not the same
1067
+ reference is counted as missed.
1068
+
1069
+ **`toSpectrum()` is complex.** Its bins are not conjugate-symmetric, so the one-sided helpers of
1070
+ `Spectrum` do not apply. Read the bins.
1071
+
1072
+ **`windingAt` truncates toward zero, and `errorIntegralAt` integrates phase.** Half a turn of slip
1073
+ reports `0n`; the error integral is in turns · seconds, not a rate.
1074
+
1075
+ ```typescript
1076
+ import { SynchronousRing, useHertz } from "semantic-typescript";
1077
+
1078
+ const collapsed = new SynchronousRing(["a", "b", "c", "d", "e", "f"], { span: 2 });
1079
+ collapsed.phaseAt(1n).get().toDegreeValue(); // 120
1080
+ collapsed.phaseAt(3n).get().toDegreeValue(); // 0
1081
+ Number(collapsed.purity().toFixed(4)); // 0
1082
+
1083
+ const falling = new SynchronousRing(["a", "b", "c", "d"], {
1084
+ frequency: useHertz(2),
1085
+ rate: () => useHertz(0.5),
1086
+ });
1087
+ falling.jetAt(0n, 2).get().slip; // -1.5
1088
+ falling.windingAt(0n, 1).get(); // -1n
1089
+ falling.windingAt(0n, 0.5).get(); // 0n
1090
+
1091
+ const objects = new SynchronousRing([{ key: 1 }, { key: 2 }]);
1092
+ objects.analyse([{ key: 1 }]).missed; // 1 — identity, not structure
1093
+ ```
1094
+
1095
+ ---
1096
+
1097
+ <a id="14-signal-analysis"></a>
1098
+ ## 14. Signal Analysis: Windows, Spectra and Time
1099
+
1100
+ Chapter 10 treated a ring as a cycle of phases. This chapter is the temporal counterpart: a
1101
+ real-valued record that carries, besides its samples, the two pieces of context every spectral
1102
+ formula needs — the sampling plan that turns a bin index into hertz, and the taper that fixes both
1103
+ the coherent gain and the noise bandwidth.
1104
+
1105
+ The value object is `SynchronousSignalCollectable`, with `AsynchronousSignalCollectable` as its
1106
+ queued twin. Both expose the same `SignalCollectable` surface: twenty-three methods, each delegating
1107
+ to exactly one `useSynchronous<Method>` factory in `synchronous/collectors`. The split is
1108
+ deliberate. The class is a documented facade, the factory owns the accumulation, and the constructor
1109
+ caches `sum(w) / count` and `count * sum(w^2) / sum(w)^2` once, at this length. A caller who has to
1110
+ remember "divide by the coherent gain" will eventually forget, and the resulting 6.02 decibels look
1111
+ like a plausible measurement.
1112
+
1113
+ ### 14.1 A signal carries its own calibration
1114
+
1115
+ A signal is built from an iterable and a rate, from an existing `Frame`, or from the constructor
1116
+ with an explicit `Sample`. The default taper is the periodic Hann window; every analysis that does
1117
+ not name a window uses it, and every analysis accepts an override. The metadata accessors are
1118
+ constant-time reads of state fixed at construction, so a signal can be handed around without the
1119
+ context drifting.
1120
+
1121
+ | Call | Returns | Meaning |
1122
+ | :--- | :--- | :--- |
1123
+ | `SynchronousSignalCollectable.useRate(values, rate, window?)` | signal | duration `count / rate` |
1124
+ | `SynchronousSignalCollectable.useFrame(frame, window?)` | signal | keeps the frame's plan |
1125
+ | `new SynchronousSignalCollectable(plan, values, window?)` | signal | plan count must equal the sample count |
1126
+ | `getSample()` / `getValues()` / `getWindow()` | plan, samples, taper | the frozen context |
1127
+ | `getRate()` | `number` | samples per second |
1128
+ | `getNyquist()` / `getResolution()` | `Frequency` | `rate / 2` and `rate / count` |
1129
+ | `getCoherentGain()` / `getNoiseBandwidth()` | `number` | recomputed at this exact length |
1130
+ | `size()` / `at(index)` / `toFrame()` / `equals(other)` / `toString()` | — | value semantics |
1131
+
1132
+ ```typescript
1133
+ import { SynchronousSignalCollectable } from "semantic-typescript";
1134
+
1135
+ const signal = SynchronousSignalCollectable.useRate(
1136
+ Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 64)),
1137
+ 64,
1138
+ );
1139
+
1140
+ signal.size(); // 64n
1141
+ signal.getRate(); // 64
1142
+ signal.getNyquist().getHertz(); // 32
1143
+ signal.getResolution().getHertz(); // 1
1144
+ signal.getCoherentGain(); // 0.5
1145
+ signal.getNoiseBandwidth(); // 1.5
1146
+ ```
1147
+
1148
+ `getResolution()` is the bin spacing, not the resolving power: it is `rate / count` because the
1149
+ transform has `count` bins, and padding the transform changes the spacing without changing what the
1150
+ window can separate (section 14.8).
1151
+
1152
+ ### 14.2 The twenty-three methods
1153
+
1154
+ The methods fall into six families. Seven are spectral readings, two are time–frequency transforms,
1155
+ four are analytic quantities, three are sequence statistics, five are shape descriptors, and two
1156
+ return another signal.
1157
+
1158
+ | Method | Returns | Convention |
1159
+ | :--- | :--- | :--- |
1160
+ | `spectrum(size?, window?)` | `Spectrum` | raw and unnormalised, full complex grid |
1161
+ | `oneSided(size?, window?)` | `Bin[]` | conjugate half folded, DC and Nyquist not doubled |
1162
+ | `amplitude(window?)` | `number[]` | `2·abs(X) / (count · CG)`, once at DC and Nyquist |
1163
+ | `power(window?)` | `number[]` | `A² / 2` per interior bin, `A²` at DC and Nyquist |
1164
+ | `decibel(window?)` | `number[]` | `20·log10(amplitude)` |
1165
+ | `powerDecibel(window?)` | `number[]` | `10·log10(power)` |
1166
+ | `powerSpectralDensity(frameSize?, hopSize?, window?)` | `SpectralDensity` | Welch average, `abs(X)² / (rate · Σw²)` |
1167
+ | `shortTimeFourierTransform(frameSize, hopSize, window?)` | `Spectrum[]` | extended boundaries, centre times |
1168
+ | `spectrogram(frameSize, hopSize, window?)` | `Spectrogram` | the same columns with a time axis |
1169
+ | `analyticSignal()` | `Complex[]` | the Hilbert pair `x + i·H(x)` |
1170
+ | `instantaneousPhase()` | `Angle[]` | folded into `[0, 2π)` |
1171
+ | `instantaneousFrequency(rate?, gate?)` | `number[]` | unwrapped phase difference, gated on the envelope |
1172
+ | `envelope()` | `number[]` | `abs(z[n])`, circular at the edges |
1173
+ | `zeroCrossingRate(perSecond?)` | `number` | crossings per adjacent sample pair |
1174
+ | `spectralFlux(frameSize?, hopSize?, window?)` | `number[]` | unit-norm magnitude distance between frames |
1175
+ | `autocorrelation(maximumLag?, normalization?)` | `number[]` | coefficient by default, `ρ[0] = 1` |
1176
+ | `spectralCentroid(window?)` | `Optional<Frequency>` | power-weighted mean frequency |
1177
+ | `spectralBandwidth(window?)` | `Optional<Frequency>` | power-weighted standard deviation |
1178
+ | `spectralRolloff(ratio?, window?)` | `Optional<Frequency>` | first bin reaching `ratio` of the power |
1179
+ | `spectralFlatness(window?)` | `number` | geometric over arithmetic mean of power |
1180
+ | `signalToNoiseRatio(frameSize?, hopSize?, window?, floorQuantile?)` | `Optional<number>` | median noise floor, `10·log10` |
1181
+ | `detrended(mode?)` | signal | `"none"`, `"constant"` (default), `"linear"` |
1182
+ | `windowed(window?)` | signal | applies a taper and adopts it |
1183
+
1184
+ The optional arguments are not decoration. `frameSize` defaults to `min(256, size)`, the hop to
1185
+ half a frame rounded down, `instantaneousFrequency` gates at `1E-3` of the peak envelope, and
1186
+ `spectralRolloff` uses `0.85`. Each default is the value a reference implementation picks when the
1187
+ parameter is named rather than supplied.
1188
+
1189
+ ### 14.3 Amplitude, power and density
1190
+
1191
+ Three normalisations are needed because three different questions are being asked.
1192
+
1193
+ `amplitude()` answers "how large is the sinusoid on this bin": `2·abs(X[k]) / (count · CG)` for an
1194
+ interior bin and `abs(X[k]) / (count · CG)` at DC and Nyquist. The factor of two folds the
1195
+ conjugate half; the coherent gain removes the taper. Omit either and a unit tone reads `0.5`,
1196
+ which is minus 6.02 decibels.
1197
+
1198
+ `power()` answers "what mean square would a sinusoid on this bin have": `A² / 2` for an interior
1199
+ bin and `A²` at DC and Nyquist, because those two do not oscillate. This is the peak convention,
1200
+ and it deliberately does not satisfy Parseval: a window spreads each line over its main lobe, so
1201
+ the sum of every bin exceeds the mean square by the equivalent noise bandwidth — `1.5` for Hann.
1202
+
1203
+ `powerSpectralDensity()` answers "how much power per hertz": `abs(X)² / (rate · Σw²)`, averaged
1204
+ over overlapping segments. Only the sum of the squared weights keeps a white-noise floor unbiased;
1205
+ dividing by `count` reads `0.375` for unit noise and dividing by `count · CG²` reads `1.5`.
1206
+
1207
+ ```typescript
1208
+ import { SynchronousSignalCollectable } from "semantic-typescript";
1209
+
1210
+ const round = (value: number): number => Number(value.toFixed(4));
1211
+ const tone = SynchronousSignalCollectable.useRate(
1212
+ Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
1213
+ 256,
1214
+ );
1215
+
1216
+ round(tone.amplitude()[32]); // 1
1217
+ round(tone.power()[32]); // 0.5
1218
+ round(tone.power().reduce((sum, power) => sum + power, 0)); // 0.75
1219
+ round(tone.powerSpectralDensity(64n, 32n).totalPower()); // 0.5
1220
+ tone.powerSpectralDensity(64n, 32n).getSegments(); // 7n
1221
+ ```
1222
+
1223
+ The pair of totals is the check worth remembering: the peak-convention sum is `0.75` while the
1224
+ density integrates to `0.5`, and the ratio is exactly the noise bandwidth. The density is also the
1225
+ only one of the three that survives a change of frame length.
1226
+
1227
+ ### 14.4 Levels: the two decibel conventions
1228
+
1229
+ An amplitude and a power do not share a factor, so they do not share a logarithm. `amplitudeDecibel`
1230
+ applies `20·log10` and is correct for a voltage, a displacement or a sound pressure; `powerDecibel`
1231
+ applies `10·log10` and is correct for a power, an energy, a mean square or a density. The method
1232
+ names on the signal follow the same split.
1233
+
1234
+ ```typescript
1235
+ import { amplitudeDecibel, powerDecibel, SynchronousSignalCollectable } from "semantic-typescript";
1236
+
1237
+ const round = (value: number): number => Number(value.toFixed(4));
1238
+ const tone = SynchronousSignalCollectable.useRate(
1239
+ Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 32 * index / 256)),
1240
+ 256,
1241
+ );
1242
+
1243
+ amplitudeDecibel(0.25); // -12.0412
1244
+ powerDecibel(0.25); // -6.0206
1245
+ round(tone.decibel()[32]); // 0
1246
+ round(tone.powerDecibel()[32]); // -3.0103
1247
+ ```
1248
+
1249
+ Both readings of the same tone are correct: its amplitude is one (zero decibels), and its mean
1250
+ square is one half (minus 3.01 decibels). Feeding a power to the amplitude convention would double
1251
+ the level in decibels, turning the correct minus 6.02 into minus 12.04.
1252
+
1253
+ ### 14.5 The one-sided fold
1254
+
1255
+ A real signal satisfies `X[count − k] = conj(X[k])`, so the negative-frequency half carries no
1256
+ independent information. The fold doubles every interior bin and keeps the two self-conjugate bins
1257
+ — DC and, for an even length, Nyquist — exactly as the transform produced them. Those two are their
1258
+ own mirror image; doubling them invents energy and reports exactly 6.02 decibels too high.
1259
+
1260
+ ```typescript
1261
+ import { SynchronousSignalCollectable } from "semantic-typescript";
1262
+
1263
+ const round = (value: number): number => Number(value.toFixed(4));
1264
+ const edge = SynchronousSignalCollectable.useRate(
1265
+ Array.from({ length: 16 }, (_unused, index) => 2 + Math.cos(Math.PI * index)),
1266
+ 16,
1267
+ );
1268
+ const bins = edge.oneSided();
1269
+
1270
+ bins.length; // 9
1271
+ round(bins[0].magnitude()); // 16
1272
+ round(bins[8].magnitude()); // 8
1273
+ round(edge.amplitude()[0]); // 2
1274
+ round(edge.amplitude()[8]); // 1
1275
+ ```
1276
+
1277
+ The raw magnitudes are `16` and `8` because the taper's weights sum to eight; dividing by
1278
+ `count · CG = 8` turns them into the calibrated amplitudes `2` and `1`. Doubling the edge bins
1279
+ would read `4` and `2`, and the error would never raise an exception.
1280
+
1281
+ ### 14.6 Time–frequency views
1282
+
1283
+ `shortTimeFourierTransform` and `spectrogram` frame the record the way the reference short-time
1284
+ transform does: half a frame of zeros at each end, then a zero-padded tail so that a whole number
1285
+ of hops remains. The first frame is centred on the first sample, the last frame reaches the last
1286
+ sample, and frame `m` is centred at `m · hop / rate` seconds. Retaining only complete frames would
1287
+ discard up to `frameSize − 1` samples — for 1000 samples at frame 256 and hop 128, that is 104
1288
+ samples, about a tenth of the record.
1289
+
1290
+ ```typescript
1291
+ import { SynchronousSignalCollectable } from "semantic-typescript";
1292
+
1293
+ const tone = SynchronousSignalCollectable.useRate(
1294
+ Array.from({ length: 64 }, (_unused, index) => Math.cos(2 * Math.PI * 4 * index / 64)),
1295
+ 64,
1296
+ );
1297
+ const spectrogram = tone.spectrogram(16n, 8n);
1298
+
1299
+ spectrogram.size(); // 9
1300
+ spectrogram.getFrameSize(); // 16
1301
+ spectrogram.getBoundary(); // "zeros"
1302
+ spectrogram.getTime(0); // 0
1303
+ spectrogram.getTime(6); // 0.75
1304
+ tone.shortTimeFourierTransform(16n, 8n).length; // 9
1305
+ tone.spectralFlux(16n, 8n).length; // 8
1306
+ ```
1307
+
1308
+ Every column is a `Spectrum` over `frameSize` bins, exactly as `spectrum()` would produce for the
1309
+ same frame, and `getTime()` reports frame centres rather than frame starts.
1310
+
1311
+ ### 14.7 Windows, coherent gain and COLA
1312
+
1313
+ Every shape has two forms. The symmetric form divides the frame by `count − 1`; the periodic
1314
+ (DFT-even) form divides it by `count`, one sample short of a full period, so the taper is continuous
1315
+ under the periodic extension the DFT assumes. Analysis and overlap-add need the periodic form;
1316
+ filter design keeps the symmetric one.
1317
+
1318
+ The difference is measurable and never raises an error. At fifty per cent overlap the periodic Hann
1319
+ window sums to one at every position; the symmetric form ripples, by `0.84` decibels at sixteen
1320
+ samples, `0.21` at sixty-four and still `0.013` at one thousand and twenty-four. The class exposes
1321
+ the coherent gain and the equivalent noise bandwidth of whichever taper it holds, so the correction
1322
+ is never hard-coded: a symmetric Hann reads `0.4375` at eight samples, not `0.5`.
1323
+
1324
+ ```typescript
1325
+ import {
1326
+ coherentGain, equivalentNoiseBandwidth, useFlatTopWindow,
1327
+ useHannPeriodicWindow, useHannWindow,
1328
+ } from "semantic-typescript";
1329
+
1330
+ const round = (value: number): number => Number(value.toFixed(4));
1331
+
1332
+ round(useHannWindow(1, 4)); // 0.75
1333
+ round(useHannPeriodicWindow(1, 4)); // 0.5
1334
+ coherentGain(useHannPeriodicWindow, 16); // 0.5
1335
+ equivalentNoiseBandwidth(useHannPeriodicWindow, 16); // 1.5
1336
+ useFlatTopWindow(0, 1024); // -0.000421
1337
+ Array.from({ length: 1024 }, (_unused, i) => useFlatTopWindow(i, 1024)).filter(w => w < 0).length; // 578
1338
+ ```
1339
+
1340
+ The flat-top row is not a mistake in the table. A flat-top window is partially negative by
1341
+ definition — 578 of 1024 weights here — and clamping those weights to zero would perturb the main
1342
+ lobe and destroy the low scalloping loss the window exists for. The library returns the series
1343
+ unchanged.
1344
+
1345
+ ### 14.8 Pitfalls
1346
+
1347
+ **Zero padding interpolates, it never resolves.** A transform length above the sample count samples
1348
+ the same underlying DTFT more densely: the peak *location* becomes finer and the bin spacing
1349
+ becomes `rate / size`, but the ability to separate two components is still governed by the window
1350
+ length and shape. A tone exactly between two bins reads `82.1093` and `80.8598` on the unpadded
1351
+ grid, and `128` at its own frequency on the padded grid.
1352
+
1353
+ ```typescript
1354
+ import { SynchronousSignalCollectable, useRectangularWindow } from "semantic-typescript";
1355
+
1356
+ const round = (value: number): number => Number(value.toFixed(4));
1357
+ const tone = SynchronousSignalCollectable.useRate(
1358
+ Array.from({ length: 256 }, (_unused, index) => Math.cos(2 * Math.PI * 100.5 * index / 256)),
1359
+ 256,
1360
+ );
1361
+ const flat = tone.spectrum(256n, useRectangularWindow);
1362
+ const padded = tone.spectrum(1024n, useRectangularWindow);
1363
+
1364
+ flat.getResolution().getHertz(); // 1
1365
+ padded.getResolution().getHertz(); // 0.25
1366
+ round(flat.magnitudeAt(100).get()); // 82.1093
1367
+ round(flat.magnitudeAt(101).get()); // 80.8598
1368
+ round(padded.magnitudeAt(402).get()); // 128
1369
+ padded.frequencyAt(402).get().getHertz(); // 100.5
1370
+ ```
1371
+
1372
+ Two equal tones `0.4` bins apart stay merged at any padding: with a 256-point record and a Hann
1373
+ window, padding to 8192 produces a single local maximum, not two.
1374
+
1375
+ **The Hilbert transform is circular.** `analyticSignal`, `envelope` and `instantaneousPhase` treat
1376
+ the first and last samples as neighbours, so a record whose period does not divide its length is
1377
+ contaminated at both ends. The middle of the record is trustworthy; the ends are not.
1378
+
1379
+ ```typescript
1380
+ import { SynchronousSignalCollectable } from "semantic-typescript";
1381
+
1382
+ const round = (value: number): number => Number(value.toFixed(4));
1383
+ const edge = SynchronousSignalCollectable.useRate(
1384
+ Array.from({ length: 512 }, (_unused, index) => Math.cos(2 * Math.PI * 8.3 * index / 512)),
1385
+ 512,
1386
+ );
1387
+ const envelope = edge.envelope();
1388
+
1389
+ round(envelope.slice(8, -8).reduce((sum, value) => sum + value, 0) / 496); // 1.0025
1390
+ round(Math.max(...envelope.slice(0, 8))); // 1.2592
1391
+ round(Math.min(...envelope.slice(-8))); // 0.1314
1392
+ ```
1393
+
1394
+ **A vanishing envelope does not define a phase.** Where the envelope collapses, the difference of
1395
+ two rounding-noise angles can be half a turn, which reads as half the sampling rate. The gate is
1396
+ what keeps the number meaningful, and the default is `1E-3` of the peak envelope.
1397
+
1398
+ ```typescript
1399
+ import { SynchronousSignalCollectable } from "semantic-typescript";
1400
+
1401
+ const round = (value: number): number => Number(value.toFixed(4));
1402
+ const beat = SynchronousSignalCollectable.useRate(
1403
+ Array.from({ length: 4096 }, (_unused, index) =>
1404
+ Math.cos(2 * Math.PI * 100 * index / 2048) + Math.cos(2 * Math.PI * 102 * index / 2048)),
1405
+ 2048,
1406
+ );
1407
+
1408
+ round(Math.max(...beat.instantaneousFrequency(2048, 0).map(Math.abs))); // 920.218
1409
+ round(Math.max(...beat.instantaneousFrequency().map(Math.abs))); // 101
1410
+ ```
1411
+
1412
+ **An unnormalised correlation is not a similarity.** The raw sum `r[k]` grows with the record
1413
+ length, so two lengths cannot be compared. The default `"coefficient"` rule divides by `r[0]` and
1414
+ keeps every value in `[-1, 1]`; `"unbiased"` divides by `count − k` and pays with a variance that
1415
+ grows with the lag.
1416
+
1417
+ ```typescript
1418
+ import { SynchronousSignalCollectable } from "semantic-typescript";
1419
+
1420
+ const round = (value: number): number => Number(value.toFixed(4));
1421
+ const periodic = SynchronousSignalCollectable.useRate(
1422
+ Array.from({ length: 1024 }, (_unused, index) => Math.cos(2 * Math.PI * 8 * index / 1024)),
1423
+ 1024,
1424
+ );
1425
+ const rho = periodic.autocorrelation(200n);
1426
+
1427
+ round(rho[0]); // 1
1428
+ round(rho[128]); // 0.875
1429
+ round(rho[64]); // -0.9375
1430
+ round(periodic.autocorrelation(200n, "none")[0]); // 512
1431
+ ```
1432
+
1433
+ One more reading deserves a warning: `spectralFlux` normalises each frame before comparing, so a
1434
+ level change alone produces zero flux. Its first and last values are edge artefacts of the zero
1435
+ boundary, not onsets.
1436
+
1437
+ ---
1438
+
1439
+ <a id="15-license"></a>
1440
+ ## 15. License
1441
+
1442
+ MIT © Eloy Kim