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