Skip to main content

trex/
flow.rs

1//! The flow axis - trex's dynamics substrate (direction + rate of change).
2//!
3//! The other axes read a STATE at each token: its scale (`magnitude`), its
4//! structural load (`stress`), its texture (`spectral`), its form (`shape`).
5//! Flow reads the dynamics - which way the stream is heading and how fast. It
6//! is the directional generalisation of `magnitude`'s `dm/dt`: where that is
7//! one signal's derivative, flow is the windowed slope, direction, momentum,
8//! and reversal structure of any scalar signal.
9//!
10//! That is what makes it a meta axis: `analyze_signal` takes a precomputed
11//! per-token scalar and reads its flow, so the same dynamics machinery composes
12//! over the magnitude field, the stress depth, or the raw token length - "the
13//! flow of any axis." Change-points (spectral / shape) find where a signal
14//! shifts; flow finds which way and how strongly, and where the trend reverses.
15//!
16//! ## The analytic reading, and what it cost to keep time pointing forward
17//!
18//! [`analytic_signal`] is the complex counterpart of the same meta-axis idea:
19//! for a real signal `s` it reads the instantaneous amplitude and phase, so
20//! where the flow reading gives the slope over a window, this gives the local
21//! envelope, the position inside the oscillation, and the rate the
22//! oscillation itself is running at. The envelope is the part a slope cannot
23//! reach: a signal swinging with a growing amplitude has a slope that turns
24//! around at every peak while the envelope only climbs.
25//!
26//! The transform behind it, the Hilbert transform, is not causal in its
27//! standard form - it wants the whole signal, and [`crate::spectral`] states
28//! an arrow of time the rest of the crate holds to. Three routes were
29//! available: approximate it causally, build a one-sided filter from the
30//! resonator bank, or declare the reading dependent on the whole input and
31//! let a chunked scanner defer it.
32//!
33//! It approximates. The kernel is truncated and shifted so it reads only
34//! backwards, which costs a fixed delay of half the filter length and band
35//! limits the result, and buys a reading that a streaming scanner can emit as
36//! it goes. Appending input never changes a value already emitted with full
37//! support, which a test pins. The alternative that keeps the transform exact
38//! would have made this the only axis that cannot stream, and the delay is
39//! the cheaper price.
40//!
41//! Documented in `wiki/content/docs/reference/axes/flow.md`.
42
43use crate::token::Token;
44
45/// Which scalar signal the flow is read over.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Signal {
48    /// The `magnitude` field's order-of-magnitude per token.
49    Magnitude,
50    /// The `stress` field's nesting depth per token.
51    StressDepth,
52    /// The token's byte length (self-contained, no other axis).
53    Length,
54}
55
56impl Signal {
57    /// Parse a `--over` argument.
58    #[must_use]
59    pub fn parse(name: &str) -> Option<Signal> {
60        match name {
61            "magnitude" => Some(Signal::Magnitude),
62            "stress" => Some(Signal::StressDepth),
63            "length" => Some(Signal::Length),
64            _ => None,
65        }
66    }
67
68    /// A short, stable label.
69    #[must_use]
70    pub fn label(self) -> &'static str {
71        match self {
72            Signal::Magnitude => "magnitude",
73            Signal::StressDepth => "stress",
74            Signal::Length => "length",
75        }
76    }
77}
78
79/// Knobs for the flow reader.
80#[derive(Clone, Copy, Debug)]
81pub struct FlowConfig {
82    /// Trailing token window the slope is averaged over.
83    pub window: usize,
84    /// `|slope|` at or below this reads as steady (no trend).
85    pub steady_band: f32,
86}
87
88impl Default for FlowConfig {
89    fn default() -> Self {
90        Self {
91            window: 4,
92            steady_band: 0.05,
93        }
94    }
95}
96
97/// One token's reading on the flow axis.
98#[derive(Clone, Copy, Debug, Default)]
99pub struct FlowFrame {
100    /// Windowed rate of change: average per-step slope over the trailing window.
101    pub slope: f32,
102    /// Trend direction: `-1` falling, `0` steady, `+1` rising.
103    pub direction: i8,
104    /// Trend persistence: consecutive tokens holding the same non-steady
105    /// direction.
106    pub momentum: u16,
107}
108
109/// The dynamics side table, keyed by byte offset.
110#[derive(Clone, Debug, Default)]
111pub struct FlowField {
112    /// Token count.
113    pub n_tokens: usize,
114    /// Byte span per token - the byte-offset key.
115    pub spans: Vec<(usize, usize)>,
116    /// One frame per token.
117    pub frames: Vec<FlowFrame>,
118    /// Reversal byte offsets: where the trend flips between rising and falling.
119    pub reversals: Vec<usize>,
120}
121
122impl FlowField {
123    /// The token index covering `byte`, or `None` if the field is empty.
124    fn token_at(&self, byte: usize) -> Option<usize> {
125        if self.spans.is_empty() {
126            return None;
127        }
128        let i = self.spans.partition_point(|&(s, _)| s <= byte);
129        Some(i.saturating_sub(1))
130    }
131
132    /// The flow slope at `byte`.
133    #[must_use]
134    pub fn slope_at(&self, byte: usize) -> f32 {
135        self.token_at(byte)
136            .and_then(|i| self.frames.get(i))
137            .map_or(0.0, |f| f.slope)
138    }
139
140    /// The trend direction at `byte` (`-1` / `0` / `+1`).
141    #[must_use]
142    pub fn direction_at(&self, byte: usize) -> i8 {
143        self.token_at(byte)
144            .and_then(|i| self.frames.get(i))
145            .map_or(0, |f| f.direction)
146    }
147
148    /// The trend persistence at `byte`: consecutive tokens up to it holding the
149    /// same non-steady direction. Counts back past the caller's span, because
150    /// it is a property of the position rather than of any window over it.
151    #[must_use]
152    pub fn momentum_at(&self, byte: usize) -> u16 {
153        self.token_at(byte)
154            .and_then(|i| self.frames.get(i))
155            .map_or(0, |f| f.momentum)
156    }
157}
158
159/// The per-token scalar signal a `Signal` selects.
160#[must_use]
161pub fn signal_values(tokens: &[Token], bytes: &[u8], signal: Signal) -> Vec<f32> {
162    match signal {
163        Signal::Magnitude => crate::magnitude::analyze(tokens, bytes)
164            .frames
165            .iter()
166            .map(|f| f.magnitude)
167            .collect(),
168        Signal::StressDepth => crate::stress::analyze(tokens, bytes)
169            .frames
170            .iter()
171            .map(|f| f32::from(f.depth))
172            .collect(),
173        Signal::Length => tokens
174            .iter()
175            .map(|t| (t.end - t.start) as f32)
176            .collect(),
177    }
178}
179
180/// Read the flow of a `Signal` over a token stream (default config).
181#[must_use]
182pub fn analyze(tokens: &[Token], bytes: &[u8], signal: Signal) -> FlowField {
183    let sig = signal_values(tokens, bytes, signal);
184    analyze_signal(tokens, &sig, &FlowConfig::default())
185}
186
187/// Tokenize `bytes` and read the flow of a `Signal` - the byte-only path.
188#[must_use]
189pub fn analyze_bytes(bytes: &[u8], signal: Signal) -> FlowField {
190    let toks = crate::tokutil::lex_sig(bytes);
191    analyze(&toks, bytes, signal)
192}
193
194/// The core: read the flow (slope, direction, momentum, reversals) of an
195/// arbitrary precomputed per-token scalar `signal`. The meta-axis entry point -
196/// every other axis exposes a signal this composes over.
197#[must_use]
198pub fn analyze_signal(tokens: &[Token], signal: &[f32], cfg: &FlowConfig) -> FlowField {
199    let spans: Vec<(usize, usize)> = tokens.iter().map(|t| (t.start(), t.end())).collect();
200    analyze_spans(&spans, signal, cfg)
201}
202
203/// The same reading over units of any size, given their byte spans.
204///
205/// The spans are all this needs of the layer it is running over, so one
206/// implementation serves tokens and supertokens rather than each grain
207/// carrying a copy. What changes with the grain is what a step means: over
208/// tokens the slope is per token, over supertokens it is per construct, and
209/// the second is not derivable from the first.
210#[must_use]
211pub fn analyze_spans(spans: &[(usize, usize)], signal: &[f32], cfg: &FlowConfig) -> FlowField {
212    let n = spans.len();
213    let mut field = FlowField {
214        n_tokens: n,
215        spans: Vec::with_capacity(n),
216        frames: Vec::with_capacity(n),
217        reversals: Vec::new(),
218    };
219    if n == 0 {
220        return field;
221    }
222    field.spans.extend_from_slice(spans);
223
224    let mut frames: Vec<FlowFrame> = Vec::with_capacity(n);
225    let mut momentum: u16 = 0;
226    let mut prev_dir: i8 = 0;
227    for i in 0..n.min(signal.len()) {
228        // slope: average per-step change over the trailing window.
229        let lo = i.saturating_sub(cfg.window);
230        let span = (i - lo).max(1) as f32;
231        let slope = (signal[i] - signal[lo]) / span;
232
233        let direction = if slope > cfg.steady_band {
234            1
235        } else if slope < -cfg.steady_band {
236            -1
237        } else {
238            0
239        };
240
241        // momentum: run length of the same non-steady direction.
242        if direction != 0 && direction == prev_dir {
243            momentum = momentum.saturating_add(1);
244        } else {
245            momentum = u16::from(direction != 0);
246        }
247
248        // reversal: a flip between rising and falling (steady does not count).
249        if direction != 0 && prev_dir != 0 && direction != prev_dir {
250            field.reversals.push(spans[i].0);
251        }
252        if direction != 0 {
253            prev_dir = direction;
254        }
255
256        frames.push(FlowFrame {
257            slope,
258            direction,
259            momentum,
260        });
261    }
262    // pad if the signal was short (defensive; signal is normally per-token).
263    while frames.len() < n {
264        frames.push(FlowFrame::default());
265    }
266    field.frames = frames;
267    field
268}
269
270/// Knobs for the analytic reader.
271#[derive(Clone, Copy, Debug)]
272pub struct AnalyticConfig {
273    /// Filter length in tokens. Odd; the group delay is half of it.
274    pub taps: usize,
275    /// Trailing window the running mean is taken over before the transform.
276    pub detrend: usize,
277}
278
279impl Default for AnalyticConfig {
280    fn default() -> Self {
281        Self { taps: 31, detrend: 16 }
282    }
283}
284
285/// The analytic reading of a scalar signal: its envelope and where inside
286/// that envelope it currently sits.
287///
288/// [`FlowField`] answers which way a signal is heading. This answers how
289/// widely it is swinging and how fast it is swinging, which a slope cannot
290/// separate: a signal oscillating with a growing envelope has a slope that
291/// keeps changing sign while the envelope only rises.
292#[derive(Clone, Debug, Default)]
293pub struct AnalyticField {
294    /// Instantaneous amplitude, the local envelope of the signal.
295    pub amplitude: Vec<f32>,
296    /// Instantaneous phase in `(-pi, pi]`, position within the oscillation.
297    pub phase: Vec<f32>,
298    /// Instantaneous frequency in radians per token, the phase advance.
299    ///
300    /// Unlike the resonator bank's, this is not prescribed: the bank rotates
301    /// at the frequency it was tuned to whatever the input, while this is
302    /// read off the signal and changes with it.
303    pub frequency: Vec<f32>,
304    /// Tokens of group delay. Index `i` reads the signal around `i`, using
305    /// input up to `i + latency`, so the reading is causal with latency
306    /// rather than centred on unseen input.
307    pub latency: usize,
308}
309
310impl AnalyticField {
311    /// Envelope at a token index, or `0.0` past the end.
312    #[must_use]
313    pub fn amplitude_at(&self, i: usize) -> f32 {
314        self.amplitude.get(i).copied().unwrap_or(0.0)
315    }
316
317    /// Instantaneous frequency at a token index, or `0.0` past the end.
318    #[must_use]
319    pub fn frequency_at(&self, i: usize) -> f32 {
320        self.frequency.get(i).copied().unwrap_or(0.0)
321    }
322}
323
324/// The windowed Hilbert kernel, causal: `h[k]` weights `signal[i - k]`.
325///
326/// The ideal transform is `2 / (pi * n)` on odd offsets from the center and
327/// zero on even ones, over an infinite two-sided support. Truncating it to
328/// `taps` and shifting so the center lands at `taps / 2` is what makes it
329/// causal, at the cost of a delay of exactly that much. The Hamming window
330/// is there because a bare truncation rings: the sharp cutoff puts ripple
331/// across the passband and the envelope inherits it.
332fn hilbert_kernel(taps: usize) -> Vec<f32> {
333    let n = taps | 1;
334    let mid = (n / 2) as isize;
335    (0..n)
336        .map(|k| {
337            let d = k as isize - mid;
338            if d == 0 || d % 2 == 0 {
339                return 0.0;
340            }
341            let ideal = 2.0 / (std::f32::consts::PI * d as f32);
342            let w = 0.54
343                - 0.46
344                    * (std::f32::consts::TAU * k as f32 / (n - 1) as f32).cos();
345            ideal * w
346        })
347        .collect()
348}
349
350/// Read the analytic signal of an arbitrary per-token scalar.
351///
352/// Composes the same way [`analyze_signal`] does, over any axis that can
353/// produce a scalar per token.
354///
355/// The transform is applied causally. A Hilbert transform is not causal in
356/// its standard form, and [`crate::spectral`] states an arrow of time this
357/// crate holds to, so the kernel is truncated and delayed rather than
358/// centred: the value at index `i` uses input through `i + latency` only.
359/// What that costs is stated on [`AnalyticField::latency`], and the last
360/// `latency` tokens have no full-support reading.
361///
362/// The signal is detrended first. The transform of a constant is zero, so a
363/// signal sitting on a large offset would report an envelope that is mostly
364/// that offset and a phase pinned near zero; subtracting a trailing mean
365/// leaves the part that actually oscillates, and keeps the pass causal.
366#[must_use]
367pub fn analytic_signal(signal: &[f32], cfg: &AnalyticConfig) -> AnalyticField {
368    let n = signal.len();
369    let taps = cfg.taps.max(3) | 1;
370    let latency = taps / 2;
371    let mut out = AnalyticField {
372        amplitude: vec![0.0; n],
373        phase: vec![0.0; n],
374        frequency: vec![0.0; n],
375        latency,
376    };
377    if n == 0 {
378        return out;
379    }
380
381    let win = cfg.detrend.max(1);
382    let detrended: Vec<f32> = {
383        let mut acc = 0.0f32;
384        signal
385            .iter()
386            .enumerate()
387            .map(|(i, &x)| {
388                acc += x;
389                if i >= win {
390                    acc -= signal[i - win];
391                }
392                x - acc / (i + 1).min(win) as f32
393            })
394            .collect()
395    };
396
397    let h = hilbert_kernel(taps);
398    for i in 0..n {
399        // The reading is placed at `at`, using input up to `i`: the delay is
400        // what buys causality.
401        let Some(at) = i.checked_sub(latency) else { continue };
402        let mut im = 0.0f32;
403        for (k, &hk) in h.iter().enumerate() {
404            if hk == 0.0 {
405                continue;
406            }
407            let Some(j) = i.checked_sub(k) else { break };
408            im += hk * detrended[j];
409        }
410        let re = detrended[at];
411        out.amplitude[at] = (re * re + im * im).sqrt();
412        out.phase[at] = im.atan2(re);
413    }
414
415    for i in 1..n {
416        let mut d = out.phase[i] - out.phase[i - 1];
417        while d > std::f32::consts::PI {
418            d -= std::f32::consts::TAU;
419        }
420        while d < -std::f32::consts::PI {
421            d += std::f32::consts::TAU;
422        }
423        out.frequency[i] = d;
424    }
425    out
426}
427
428/// Read the analytic signal of a named [`Signal`] over a token stream.
429#[must_use]
430pub fn analytic(tokens: &[Token], bytes: &[u8], signal: Signal) -> AnalyticField {
431    let sig = signal_values(tokens, bytes, signal);
432    analytic_signal(&sig, &AnalyticConfig::default())
433}
434
435/// One value per supertoken, and the spans they occupy.
436///
437/// The value is a fold: a unit's scalar is its tokens' combined, through the
438/// [`crate::profile`] monoid, so it is determined by the layer below. What is
439/// not determined is what the reading over these values says, because a step
440/// is now a construct rather than a token - the sequence being read is a
441/// different sequence, not a coarser view of the same one.
442fn supertoken_signal(
443    toks: &[Token],
444    bytes: &[u8],
445    signal: Signal,
446) -> (Vec<(usize, usize)>, Vec<f32>) {
447    let stress = crate::stress::analyze(toks, bytes);
448    let units = crate::supertoken::supertokens_from(toks, bytes);
449
450    let mut spans = Vec::with_capacity(units.len());
451    let mut values = Vec::with_capacity(units.len());
452    let mut cursor = 0usize;
453    for u in &units {
454        // Unit starts are non-decreasing, so one cursor walks the left ends.
455        // The right end is found by search rather than by scanning from the
456        // left one: a nested unit ends before its parent, so a span walk costs
457        // the sum of every unit's span rather than the token count.
458        while cursor < toks.len() && toks[cursor].start() < u.start {
459            cursor += 1;
460        }
461        let lo = cursor;
462        let hi = lo + toks[lo..].partition_point(|t| t.end() <= u.end);
463        spans.push((u.start, u.end));
464        // Only the one scalar the signal reads is taken over the span. Folding
465        // a whole `Profile` here would build the free monoid in `shape` for
466        // every unit, and its combine returns a fresh sequence, so a span of
467        // `L` costs `L(L+1)/2` copies and `L` allocations. Over a stream whose
468        // units are few and long - which is what the significant-token stream
469        // gives on machine-generated input - that is the sum of each span
470        // squared rather than the sum of the spans.
471        //
472        // The depth is read by index because the field was built from these
473        // tokens and their starts strictly increase, so the byte-offset lookup
474        // resolves to frame `i`.
475        values.push(match signal {
476            Signal::Magnitude => {
477                if hi > lo {
478                    let sum: f32 = toks[lo..hi]
479                        .iter()
480                        .map(|t| crate::magnitude::token_magnitude(t.kind, &bytes[t.span()]))
481                        .sum();
482                    sum / (hi - lo) as f32
483                } else {
484                    0.0
485                }
486            }
487            Signal::StressDepth => {
488                stress.frames[lo..hi].iter().map(|f| f.depth).max().map_or(0.0, f32::from)
489            }
490            Signal::Length => (u.end - u.start) as f32,
491        });
492    }
493    (spans, values)
494}
495
496/// Read the flow of a `Signal` over the supertoken stream.
497///
498/// A step here is one construct, so this reads how a document moves from
499/// construct to construct rather than from token to token. The two are
500/// different readings of the same input and neither follows from the other.
501#[must_use]
502pub fn analyze_supertokens(toks: &[Token], bytes: &[u8], signal: Signal) -> FlowField {
503    let (spans, values) = supertoken_signal(toks, bytes, signal);
504    analyze_spans(&spans, &values, &FlowConfig::default())
505}
506
507/// Read the analytic signal over the supertoken stream.
508#[must_use]
509pub fn analytic_supertokens(toks: &[Token], bytes: &[u8], signal: Signal) -> AnalyticField {
510    let (_, values) = supertoken_signal(toks, bytes, signal);
511    analytic_signal(&values, &AnalyticConfig::default())
512}
513
514#[cfg(test)]
515mod grain_tests {
516    use super::*;
517
518    #[test]
519    fn the_supertoken_signal_reads_what_the_folded_profile_reads() {
520        // Reading one scalar over each span replaces folding a whole profile
521        // over it, so what it must equal is that fold, signal by signal and
522        // unit by unit. Both readings are taken left to right over the same
523        // tokens, so the bar is bit equality rather than a tolerance.
524        use crate::profile::{AxisCtx, Profile, fold_tokens};
525        for src in [
526            &b"{module, [{a, [1, 2, {b, [3, 4]}]}, {c, [5, {d, [6, 7]}]}]}. {x, [{y, 8}]}."[..],
527            &b"alpha beta gamma delta"[..],
528            &b"f(x) g(yy) h(zzz) i(wwww)"[..],
529        ] {
530            let toks = crate::lexer::lex(src);
531            let stress = crate::stress::analyze(&toks, src);
532            let ctx = AxisCtx { stress: Some(&stress), ..AxisCtx::new(src) };
533            let units = crate::supertoken::supertokens_from(&toks, src);
534
535            for signal in [Signal::Magnitude, Signal::StressDepth, Signal::Length] {
536                let (spans, values) = supertoken_signal(&toks, src, signal);
537                assert_eq!(spans.len(), units.len(), "one span per unit, {signal:?} {src:?}");
538
539                let mut cursor = 0usize;
540                for (k, u) in units.iter().enumerate() {
541                    while cursor < toks.len() && toks[cursor].start() < u.start {
542                        cursor += 1;
543                    }
544                    let lo = cursor;
545                    let mut hi = cursor;
546                    while hi < toks.len() && toks[hi].end() <= u.end {
547                        hi += 1;
548                    }
549                    let p: Profile = fold_tokens(lo, &toks[lo..hi], &ctx);
550                    let want = match signal {
551                        Signal::Magnitude => p.magnitude.mean(),
552                        Signal::StressDepth => f32::from(p.stress.max_depth),
553                        Signal::Length => (u.end - u.start) as f32,
554                    };
555                    assert_eq!(
556                        values[k].to_bits(),
557                        want.to_bits(),
558                        "unit {k} under {signal:?} reads {} against the fold's {want}, {src:?}",
559                        values[k]
560                    );
561                }
562            }
563        }
564    }
565
566    #[test]
567    fn the_supertoken_reading_is_not_the_token_reading_coarsened() {
568        // Constructs whose scale rises across the document while the tokens
569        // inside each one keep the same shape. Over tokens the reading keeps
570        // turning around at every construct boundary; over constructs it is a
571        // trend, because a step there is a whole unit.
572        let src = b"a = 1;\nb = 22;\nc = 333;\nd = 4444;\ne = 55555;\nf = 666666;\n";
573        let toks = crate::lexer::lex(src);
574        let by_token = analyze(&toks, src, Signal::Magnitude);
575        let by_unit = analyze_supertokens(&toks, src, Signal::Magnitude);
576        assert!(!by_unit.frames.is_empty(), "there are constructs to read");
577        assert!(by_unit.frames.len() < by_token.frames.len(), "fewer units than tokens");
578
579        // The unit reading rises, because each construct carries a larger
580        // number than the one before.
581        let rising = by_unit.frames.iter().filter(|f| f.direction > 0).count();
582        let falling = by_unit.frames.iter().filter(|f| f.direction < 0).count();
583        assert!(rising > falling, "the construct sequence trends up: {rising} vs {falling}");
584    }
585
586    #[test]
587    fn the_analytic_reading_lifts_the_same_way() {
588        let src = b"a = 1;\nb = 22;\nc = 333;\nd = 4444;\ne = 55555;\nf = 666666;\n";
589        let toks = crate::lexer::lex(src);
590        let a = analytic_supertokens(&toks, src, Signal::Length);
591        assert!(!a.amplitude.is_empty(), "an envelope over constructs");
592    }
593
594    #[test]
595    fn spans_are_all_the_reading_needs_of_its_layer() {
596        // The token entry and the span entry are the same computation, which
597        // is what lets one implementation serve both grains.
598        let src = b"a 1 bb 22 ccc 333";
599        let toks = crate::tokutil::lex_sig(src);
600        let sig: Vec<f32> = toks.iter().map(|t| t.len() as f32).collect();
601        let spans: Vec<(usize, usize)> = toks.iter().map(|t| (t.start(), t.end())).collect();
602        let cfg = FlowConfig::default();
603        assert_eq!(
604            analyze_signal(&toks, &sig, &cfg).frames.len(),
605            analyze_spans(&spans, &sig, &cfg).frames.len()
606        );
607    }
608}
609
610#[cfg(test)]
611mod analytic_tests {
612    use super::*;
613
614    fn cfg() -> AnalyticConfig {
615        AnalyticConfig::default()
616    }
617
618    /// Mean of a slice, ignoring the filter's warm-up and tail.
619    fn mid_mean(v: &[f32], lat: usize) -> f32 {
620        let lo = lat * 2;
621        let hi = v.len().saturating_sub(lat);
622        if hi <= lo {
623            return 0.0;
624        }
625        v[lo..hi].iter().sum::<f32>() / (hi - lo) as f32
626    }
627
628    #[test]
629    fn a_constant_amplitude_sinusoid_reads_its_amplitude_and_frequency() {
630        // The case where the answer is known exactly: amplitude 3, period 8,
631        // so the envelope should read 3 and the frequency 2*pi/8.
632        let w = std::f32::consts::TAU / 8.0;
633        let s: Vec<f32> = (0..400).map(|i| 3.0 * (w * i as f32).sin()).collect();
634        let a = analytic_signal(&s, &cfg());
635        let env = mid_mean(&a.amplitude, a.latency);
636        assert!((env - 3.0).abs() < 0.3, "envelope reads the amplitude: {env}");
637        let f = mid_mean(&a.frequency, a.latency);
638        assert!((f - w).abs() < 0.05, "frequency reads the rate: {f} vs {w}");
639    }
640
641    #[test]
642    fn the_envelope_rises_where_the_slope_only_keeps_changing_sign() {
643        // What the analytic reading separates that a slope cannot. The signal
644        // oscillates the whole way with a linearly growing amplitude: its
645        // slope reverses every few tokens throughout, so flow reports the
646        // same churn at the start as at the end, while the envelope rises
647        // monotonically because that is the thing actually changing.
648        let w = std::f32::consts::TAU / 8.0;
649        let s: Vec<f32> =
650            (0..400).map(|i| (0.5 + i as f32 * 0.02) * (w * i as f32).sin()).collect();
651        let a = analytic_signal(&s, &cfg());
652        let lat = a.latency;
653        let early = a.amplitude[lat * 2..100].iter().sum::<f32>() / (100 - lat * 2) as f32;
654        let late = a.amplitude[300..380].iter().sum::<f32>() / 80.0;
655        assert!(late > early * 3.0, "the envelope tracks the growth: {early} -> {late}");
656
657        // The envelope climbs almost without turning back, because growth is
658        // the only thing happening to it.
659        let env_flips = (lat * 2 + 1..380)
660            .filter(|&i| {
661                (a.amplitude[i] - a.amplitude[i - 1]) * (a.amplitude[i - 1] - a.amplitude[i - 2])
662                    < 0.0
663            })
664            .count();
665
666        // Flow over the same signal, with a real token stream behind it so
667        // this is a comparison and not an empty one. Its slope turns around
668        // constantly, because at every peak of the oscillation the signal is
669        // momentarily flat and then heads the other way. Both readings are
670        // correct; they answer different questions, and only one of them
671        // recovers the growth.
672        let src: Vec<u8> = std::iter::repeat_n(b"a ", s.len()).flatten().copied().collect();
673        let toks = crate::tokutil::lex_sig(&src);
674        assert_eq!(toks.len(), s.len(), "one token per sample");
675        let f = analyze_signal(&toks, &s, &FlowConfig::default());
676        let slope_flips = (lat * 2 + 1..380)
677            .filter(|&i| f.frames[i].slope * f.frames[i - 1].slope < 0.0)
678            .count();
679
680        assert!(
681            slope_flips > env_flips * 4,
682            "the slope keeps reversing where the envelope does not: {slope_flips} vs {env_flips}"
683        );
684    }
685
686    #[test]
687    fn a_chirp_is_read_as_a_rising_frequency() {
688        // Instantaneous frequency is read off the signal rather than
689        // prescribed, so a signal that speeds up reads as speeding up. This
690        // is the property the resonator bank cannot have: its phase advances
691        // at whatever theta it was tuned to, whatever the input does.
692        let s: Vec<f32> = (0..600)
693            .map(|i| {
694                let t = i as f32;
695                let phase = 0.15 * t + 0.0004 * t * t;
696                phase.sin()
697            })
698            .collect();
699        let a = analytic_signal(&s, &cfg());
700        let lat = a.latency;
701        let early = a.frequency[lat * 2..200].iter().sum::<f32>() / (200 - lat * 2) as f32;
702        let late = a.frequency[400..560].iter().sum::<f32>() / 160.0;
703        assert!(late > early + 0.05, "the frequency rises with the chirp: {early} -> {late}");
704    }
705
706    #[test]
707    fn the_reading_is_causal_with_latency() {
708        // The contract that keeps this axis compatible with the crate's
709        // arrow of time: appending input must not change any reading already
710        // emitted with full support.
711        let w = std::f32::consts::TAU / 7.0;
712        let long: Vec<f32> = (0..300).map(|i| (w * i as f32).sin()).collect();
713        let short = &long[..200];
714        let a = analytic_signal(&long, &cfg());
715        let b = analytic_signal(short, &cfg());
716        let lat = a.latency;
717        // Everything the short read with full support must survive unchanged.
718        for i in lat..(200 - lat) {
719            assert!(
720                (a.amplitude[i] - b.amplitude[i]).abs() < 1e-4,
721                "index {i} moved when later input arrived: {} vs {}",
722                b.amplitude[i],
723                a.amplitude[i]
724            );
725        }
726    }
727
728    #[test]
729    fn a_constant_signal_has_no_envelope() {
730        // Detrending is what makes this true: without it the envelope would
731        // report the offset the signal happens to sit on.
732        let s = vec![7.5f32; 200];
733        let a = analytic_signal(&s, &cfg());
734        assert!(mid_mean(&a.amplitude, a.latency) < 0.05, "a flat signal does not oscillate");
735    }
736
737    #[test]
738    fn degenerate_inputs_are_safe() {
739        assert!(analytic_signal(&[], &cfg()).amplitude.is_empty());
740        let one = analytic_signal(&[1.0], &cfg());
741        assert_eq!(one.amplitude.len(), 1);
742        assert_eq!(one.frequency_at(99), 0.0);
743        assert_eq!(one.amplitude_at(99), 0.0);
744    }
745
746    #[test]
747    fn it_composes_over_a_named_axis_the_way_flow_does() {
748        let src = b"a 1 bb 22 ccc 333 dddd 4444 eeeee 55555 f 6 gg 77 hhh 888";
749        let toks = crate::tokutil::lex_sig(src);
750        let a = analytic(&toks, src, Signal::Length);
751        assert_eq!(a.amplitude.len(), toks.len());
752        assert!(a.amplitude.iter().any(|&x| x > 0.0), "the length signal swings");
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn ramp_flows_upward_with_momentum() {
762        // Orders of magnitude climbing: flow direction is +1 and momentum builds.
763        let f = analyze_bytes(b"1 10 100 1000 10000 100000", Signal::Magnitude);
764        let last = f.frames.last().copied().unwrap_or_default();
765        assert_eq!(last.direction, 1, "a rising ramp flows upward");
766        assert!(last.momentum >= 3, "sustained trend builds momentum, got {}", last.momentum);
767        assert!(f.reversals.is_empty(), "a monotone ramp has no reversal");
768    }
769
770    #[test]
771    fn valley_has_a_reversal_at_the_bottom() {
772        // Magnitude falls then rises - a turning point at the trough.
773        let f = analyze_bytes(b"100000 1000 10 1 10 1000 100000", Signal::Magnitude);
774        assert!(
775            !f.reversals.is_empty(),
776            "a fall-then-rise should register a reversal"
777        );
778    }
779
780    #[test]
781    fn flow_composes_over_stress_depth() {
782        // Flow over the STRESS signal: nesting deepens (rising) then releases.
783        let f = analyze_bytes(b"a(b(c(d(e))))", Signal::StressDepth);
784        assert!(f.frames.iter().any(|fr| fr.direction == 1), "deepening rises");
785        assert!(
786            f.frames.iter().any(|fr| fr.direction == -1),
787            "the release falls"
788        );
789        assert!(!f.reversals.is_empty(), "deepen-then-release reverses");
790    }
791
792    #[test]
793    fn flat_signal_is_steady() {
794        let f = analyze_bytes(b"a b c d e f g", Signal::Length);
795        assert!(f.frames.iter().all(|fr| fr.direction == 0), "equal lengths are steady");
796        assert!(f.reversals.is_empty());
797    }
798
799    #[test]
800    fn empty_is_safe() {
801        let f = analyze_bytes(b"", Signal::Magnitude);
802        assert_eq!(f.n_tokens, 0);
803        assert!(f.frames.is_empty());
804        assert!(f.reversals.is_empty());
805        assert_eq!(f.direction_at(0), 0);
806    }
807}