Skip to main content

trex/
shape.rs

1//! The shape axis - trex's structural substrate, the dual of `spectral`.
2//!
3//! The spectral axis (`crate::spectral`) reads the TEMPORAL character of the
4//! byte stream; the shape axis reads its structural form. Two spans have the
5//! same shape when they share a silhouette - the same sequence of token-classes
6//! and word-shapes - regardless of content. `foo(a, b)` and `bar(x, y)` are the
7//! same shape `W ( W , W )`; `1,22,3` and `444,5,66` are the same shape
8//! `N , N , N`. A silhouette IS a grammar production, so a shape token is a
9//! grammar-free grammar.
10//!
11//! The load-bearing signal is the **shape period**: the spectral period
12//! (`spectral::dominant_period`) autocorrelates bytes and so finds only
13//! fixed-width structure; a ragged table (varying field widths) defeats it
14//! because the delimiter byte-offsets shift every row. Shape autocorrelates
15//! silhouettes, where width variation has already collapsed (a field is one
16//! `Number` token whatever its width), so the row period survives. Structural
17//! periodicity is invisible to every other axis.
18//!
19//! Token-grain, byte-span-keyed: every frame carries the token's byte span, so
20//! the byte / spectral / construct layers query this field by byte offset
21//! exactly as they query the spectral field. Documented in
22//! `wiki/content/docs/reference/axes/shape.md`.
23
24use crate::token::{Token, TokenKind};
25
26/// Knobs for the shape reader. `Default` suits general token input.
27#[derive(Clone, Copy, Debug)]
28pub struct ShapeConfig {
29    /// Largest token-lag the period search considers.
30    pub max_lag: usize,
31    /// Trailing token window the period autocorrelation runs over.
32    pub period_window: usize,
33    /// Recompute the period every `period_hop` tokens (held between), so total
34    /// periodicity work stays under a fixed op budget on large inputs.
35    pub period_hop: usize,
36    /// Silhouette n-gram order for novelty / change-point.
37    pub novelty_k: usize,
38    /// Sliding window (in n-grams) the novelty count map spans.
39    pub novelty_window: usize,
40    /// A shape-period run is a template when its strength clears this.
41    pub template_strength: f32,
42    /// Minimum tokens between two change-points.
43    pub cp_min_gap: usize,
44}
45
46impl Default for ShapeConfig {
47    fn default() -> Self {
48        Self {
49            max_lag: 64,
50            period_window: 256,
51            period_hop: 8,
52            novelty_k: 3,
53            novelty_window: 4096,
54            template_strength: 0.6,
55            cp_min_gap: 4,
56        }
57    }
58}
59
60/// One token's silhouette as a comparable `u32`: the `TokenKind` code in the
61/// high bits, plus - for `Word` - the [`crate::tokutil::shape`] class, and for
62/// `Punct` - the glyph byte. Two tokens with the same code are the same shape.
63#[must_use]
64pub fn shape_class(kind: TokenKind, tok_bytes: &[u8]) -> u32 {
65    let base = kind.code() << 16;
66    match kind {
67        TokenKind::Word => {
68            let s = crate::tokutil::shape(&String::from_utf8_lossy(tok_bytes));
69            base | word_shape_code(s)
70        }
71        TokenKind::Punct => base | u32::from(tok_bytes.first().copied().unwrap_or(0)),
72        _ => base,
73    }
74}
75
76/// Map a [`crate::tokutil::shape`] class string to a small code (0..6).
77fn word_shape_code(s: &str) -> u32 {
78    match s {
79        "Pascal" => 1,
80        "snake" => 2,
81        "camel" => 3,
82        "SCREAM" => 4,
83        "short" => 5,
84        _ => 0, // "word"
85    }
86}
87
88/// One token's structural reading.
89#[derive(Clone, Copy, Debug, Default)]
90pub struct ShapeFrame {
91    /// The token's [`shape_class`] code.
92    pub class: u32,
93    /// Dominant shape-period of the neighbourhood, in tokens (`0` = none).
94    pub period: u16,
95    /// Normalised silhouette-autocorrelation peak `[0,1]`.
96    pub period_strength: f32,
97    /// Shape n-gram surprise `[0,1]` (`1` = first sighting of this template).
98    pub novelty: f32,
99}
100
101/// The structural side table, keyed by byte offset (like `SpectralField`).
102#[derive(Clone, Debug, Default)]
103pub struct ShapeField {
104    /// Token count.
105    pub n_tokens: usize,
106    /// Byte span per token - the byte-offset key.
107    pub spans: Vec<(usize, usize)>,
108    /// One frame per token.
109    pub frames: Vec<ShapeFrame>,
110    /// Shape-change-point byte offsets (silhouette-break cuts), sorted.
111    pub boundaries: Vec<usize>,
112    /// The template-strength threshold this field was built with.
113    template_strength: f32,
114}
115
116impl ShapeField {
117    /// The token index covering `byte` (the last token whose span starts at or
118    /// before `byte`), or `None` if the field is empty.
119    fn token_at(&self, byte: usize) -> Option<usize> {
120        if self.spans.is_empty() {
121            return None;
122        }
123        let i = self.spans.partition_point(|&(s, _)| s <= byte);
124        Some(i.saturating_sub(1))
125    }
126
127    /// The shape-class of the token covering `byte` (`0` if empty).
128    #[must_use]
129    pub fn class_at(&self, byte: usize) -> u32 {
130        self.token_at(byte)
131            .and_then(|i| self.frames.get(i))
132            .map_or(0, |f| f.class)
133    }
134
135    /// The dominant shape-period and its strength at `byte`.
136    #[must_use]
137    pub fn period_at(&self, byte: usize) -> (u16, f32) {
138        self.token_at(byte)
139            .and_then(|i| self.frames.get(i))
140            .map_or((0, 0.0), |f| (f.period, f.period_strength))
141    }
142
143    /// Is `byte` inside a strong shape-period (template) run?
144    #[must_use]
145    pub fn in_template(&self, byte: usize) -> bool {
146        self.period_at(byte).1 >= self.template_strength
147    }
148
149    /// The periodic / template regions as byte spans plus their period: maximal
150    /// runs of tokens whose period strength clears the template threshold. The
151    /// structural map a tabular / template consumer reads.
152    #[must_use]
153    pub fn shape_regions(&self) -> Vec<(usize, usize, u16)> {
154        let mut out: Vec<(usize, usize, u16)> = Vec::new();
155        let mut run: Option<(usize, usize, u16)> = None;
156        for (i, f) in self.frames.iter().enumerate() {
157            let (s, e) = self.spans[i];
158            if f.period_strength >= self.template_strength && f.period > 0 {
159                match run.as_mut() {
160                    Some(r) => r.1 = e,
161                    None => run = Some((s, e, f.period)),
162                }
163            } else if let Some(r) = run.take() {
164                out.push(r);
165            }
166        }
167        if let Some(r) = run.take() {
168            out.push(r);
169        }
170        out
171    }
172
173    /// [`Self::shape_regions`] with each run reaching back over the periods
174    /// before it that already repeat: a whole period is taken while every
175    /// token's kind equals the kind one period later, or one period earlier
176    /// where the later one lies past the input's end, and the period holds a
177    /// kind other than a word. The kind rather than the whole class, so two
178    /// words of different shapes in one column, `alpha` and `beta`, carry the
179    /// run back; a kind other than a word, since every word is one kind and
180    /// text of words alone repeats at any period. The reader finds a period
181    /// only once its trailing window holds a few repeats, so a run begins rows
182    /// after the repetition does, and at the input's end it may be the last
183    /// row alone. Byte spans with their period, ascending by start.
184    #[must_use]
185    pub fn template_spans(&self) -> Vec<(usize, usize, u16)> {
186        let mut out: Vec<(usize, usize, u16)> = Vec::new();
187        let mut run: Option<(usize, usize, u16)> = None;
188        let n = self.frames.len();
189        let kind = |i: usize| self.frames[i].class >> 16;
190        let repeats = |j: usize, lag: usize| {
191            if j + lag < n {
192                kind(j) == kind(j + lag)
193            } else {
194                j >= lag && kind(j) == kind(j - lag)
195            }
196        };
197        let word = TokenKind::Word.code();
198        let mut close = |(first, last, period): (usize, usize, u16)| {
199            let lag = usize::from(period);
200            let mut first = first;
201            while first > 0 {
202                let j = first - 1;
203                if !repeats(j, lag) || (j..(j + lag).min(n)).all(|i| kind(i) == word) {
204                    break;
205                }
206                first = j;
207            }
208            out.push((self.spans[first].0, self.spans[last].1, period));
209        };
210        for (i, f) in self.frames.iter().enumerate() {
211            if f.period_strength >= self.template_strength && f.period > 0 {
212                match run.as_mut() {
213                    Some(r) => r.1 = i,
214                    None => run = Some((i, i, f.period)),
215                }
216            } else if let Some(r) = run.take() {
217                close(r);
218            }
219        }
220        if let Some(r) = run.take() {
221            close(r);
222        }
223        out.sort_unstable_by_key(|&(s, _, _)| s);
224        out
225    }
226}
227
228/// Analyse a token stream with the default configuration.
229#[must_use]
230pub fn analyze(tokens: &[Token], bytes: &[u8]) -> ShapeField {
231    analyze_with(tokens, bytes, &ShapeConfig::default())
232}
233
234/// Tokenize `bytes` (the significant-token lexer) and analyse - the convenience
235/// path for consumers that hold only bytes.
236#[must_use]
237pub fn analyze_bytes(bytes: &[u8]) -> ShapeField {
238    let toks = crate::tokutil::lex_sig(bytes);
239    analyze(&toks, bytes)
240}
241
242/// The shape-class for a token measured over a CHOSEN orbit quotient: the
243/// silhouette is the token's [`crate::orbit::canonical`] form under `group`
244/// (folded with the `TokenKind`), so two tokens that are the same UP TO the
245/// group share a class. `Identity` -> the literal token (exact-repeat
246/// structure); `Case` -> case-folded (`The` = `the`); `Notation` ->
247/// notation-folded; `Shape` -> the C/V/D phonotactic pattern. This is the shape
248/// axis reading the structure of an orbit the caller picks, instead of the
249/// built-in word-shape silhouette of [`shape_class`].
250#[must_use]
251pub fn shape_class_over(kind: TokenKind, tok_bytes: &[u8], group: crate::orbit::OrbitGroup) -> u32 {
252    let base = kind.code() << 16;
253    let canon = crate::orbit::canonical(tok_bytes, group);
254    // 16-bit FNV-1a of the canonical form: same orbit -> same low bits.
255    let mut h: u32 = 2166136261;
256    for &b in canon.as_bytes() {
257        h = (h ^ u32::from(b)).wrapping_mul(16777619);
258    }
259    base | ((h ^ (h >> 16)) & 0xFFFF)
260}
261
262/// Build the field (spans, novelty, periodicity, change-points) from a
263/// precomputed silhouette-class per token - the shared core of [`analyze_with`]
264/// (built-in word-shape silhouette) and [`analyze_over_with`] (orbit-quotient
265/// silhouette).
266fn build_field(tokens: &[Token], classes: &[u32], cfg: &ShapeConfig) -> ShapeField {
267    let n = tokens.len();
268    let mut field = ShapeField {
269        n_tokens: n,
270        spans: Vec::with_capacity(n),
271        frames: Vec::with_capacity(n),
272        boundaries: Vec::new(),
273        template_strength: cfg.template_strength,
274    };
275    if n == 0 {
276        return field;
277    }
278    for t in tokens {
279        field.spans.push((t.start(), t.end()));
280    }
281
282    // 5.3 novelty + 5.4 change-point: rolling silhouette n-gram surprise.
283    let k = cfg.novelty_k.max(1);
284    let mut counts: std::collections::HashMap<u64, u32> =
285        std::collections::HashMap::with_capacity(cfg.novelty_window.min(n) + 1);
286    // VecDeque, not Vec: the sliding window evicts from the FRONT every token, and Vec::remove(0) is
287    // an O(window) shift (O(n*window) overall). push_back / pop_front are O(1); same FIFO order.
288    let mut ring: std::collections::VecDeque<u64> = std::collections::VecDeque::with_capacity(cfg.novelty_window + 1);
289    let mut last_cut: isize = -(cfg.cp_min_gap as isize);
290
291    // 5.2 periodicity: recomputed every `period_hop` tokens, held between.
292    let mut cur_period: u16 = 0;
293    let mut cur_strength: f32 = 0.0;
294
295    let mut frames: Vec<ShapeFrame> = Vec::with_capacity(n);
296    for i in 0..n {
297        // novelty of the k-gram ending at i.
298        let novelty = if i + 1 >= k {
299            let mut h: u64 = 1469598103934665603;
300            for &c in &classes[i + 1 - k..=i] {
301                h = (h ^ u64::from(c)).wrapping_mul(1099511628211);
302            }
303            let prev = *counts.get(&h).unwrap_or(&0);
304            let entry = counts.entry(h).or_insert(0);
305            *entry += 1;
306            ring.push_back(h);
307            if ring.len() > cfg.novelty_window
308                && let Some(old) = ring.pop_front()
309                && let Some(c) = counts.get_mut(&old)
310            {
311                *c = c.saturating_sub(1);
312            }
313            1.0 / (1.0 + prev as f32)
314        } else {
315            1.0
316        };
317
318        // change-point: a novelty spike past 0.5 after the min gap is a break.
319        if novelty > 0.5 && (i as isize - last_cut) >= cfg.cp_min_gap as isize && i > 0 {
320            field.boundaries.push(tokens[i].start());
321            last_cut = i as isize;
322        }
323
324        // periodicity over a trailing silhouette window, recomputed on the hop.
325        if i % cfg.period_hop == 0 || i + 1 == n {
326            let lo = i.saturating_sub(cfg.period_window);
327            let (p, s) = dominant_shape_period(&classes[lo..=i], cfg.max_lag);
328            cur_period = p;
329            cur_strength = s;
330        }
331
332        frames.push(ShapeFrame {
333            class: classes[i],
334            period: cur_period,
335            period_strength: cur_strength,
336            novelty,
337        });
338    }
339    field.frames = frames;
340    field
341}
342
343/// The full one-pass reader over the built-in word-shape silhouette.
344#[must_use]
345pub fn analyze_with(tokens: &[Token], bytes: &[u8], cfg: &ShapeConfig) -> ShapeField {
346    // 5.1 silhouette: one shape-class per token.
347    let classes: Vec<u32> = tokens
348        .iter()
349        .map(|t| shape_class(t.kind, &bytes[t.span()]))
350        .collect();
351    build_field(tokens, &classes, cfg)
352}
353
354/// The one-pass reader over a CHOSEN orbit quotient - the composition axis. The
355/// shape axis measures period / novelty / change-points of the silhouette
356/// `group` produces: `Identity` recovers exact-token structure, `Case` folds
357/// case before measuring (the structural period of the case-folded stream),
358/// `Notation` / `Shape` fold notation / phonotactic shape. Orbit is the
359/// pre-transform; shape is the measurement that composes over it.
360#[must_use]
361pub fn analyze_over_with(
362    tokens: &[Token],
363    bytes: &[u8],
364    group: crate::orbit::OrbitGroup,
365    cfg: &ShapeConfig,
366) -> ShapeField {
367    let classes: Vec<u32> = tokens
368        .iter()
369        .map(|t| shape_class_over(t.kind, &bytes[t.span()], group))
370        .collect();
371    build_field(tokens, &classes, cfg)
372}
373
374/// [`analyze_over_with`] with the default configuration.
375#[must_use]
376pub fn analyze_over(tokens: &[Token], bytes: &[u8], group: crate::orbit::OrbitGroup) -> ShapeField {
377    analyze_over_with(tokens, bytes, group, &ShapeConfig::default())
378}
379
380/// Tokenize `bytes` and analyse over a chosen orbit quotient - the convenience
381/// path for consumers that hold only bytes.
382#[must_use]
383pub fn analyze_bytes_over(bytes: &[u8], group: crate::orbit::OrbitGroup) -> ShapeField {
384    let toks = crate::tokutil::lex_sig(bytes);
385    analyze_over(&toks, bytes, group)
386}
387
388/// Categorical autocorrelation over shape-classes: for each token-lag the
389/// fraction of positions whose class equals the class `lag` back. The dominant
390/// period is the arg-max with prominence (>= 0.5 match); equality-based because
391/// shape-classes are categorical, not numeric - that is what lets a ragged
392/// table (same silhouette, different widths) still align.
393fn dominant_shape_period(win: &[u32], max_lag: usize) -> (u16, f32) {
394    let n = win.len();
395    if n < 4 {
396        return (0, 0.0);
397    }
398    let hi = max_lag.min(n / 2);
399    let mut best_lag = 0usize;
400    let mut best = 0.0f32;
401    for lag in 1..=hi {
402        // Branchless equality count over the two overlapping slices, accumulated
403        // in u32, the lane width of the data: an indexless loop with a u32
404        // accumulator vectorizes (eight lanes per AVX2 step) where a usize one
405        // widens every element and stays scalar. The window never nears
406        // u32::MAX elements.
407        let mut matches = 0u32;
408        for (a, b) in win[lag..].iter().zip(&win[..n - lag]) {
409            matches += u32::from(a == b);
410        }
411        let matches = matches as usize;
412        let frac = matches as f32 / (n - lag) as f32;
413        if frac > best {
414            best = frac;
415            best_lag = lag;
416        }
417    }
418    if best < 0.5 {
419        (0, best)
420    } else {
421        (best_lag as u16, best)
422    }
423}
424
425/// A region's classification, fusing the spectral texture (byte-grain) with the
426/// shape period (token-grain): a region carrying a strong shape period is a
427/// `Table` (a tabular / record template) regardless of byte texture; the rest
428/// take their spectral texture. The two-axis composition - shape x spectral -
429/// that names a data table with neither a delimiter nor a grammar.
430#[derive(Clone, Copy, Debug, PartialEq, Eq)]
431pub enum RegionKind {
432    /// A strong shape-period template (the `u16` is the period in tokens).
433    Table(u16),
434    /// Spectral high-entropy run (packed / base64 / encrypted).
435    Blob,
436    /// Spectral prose texture.
437    Prose,
438    /// Spectral numeric texture.
439    Numeric,
440    /// Spectral code texture, no strong period.
441    Code,
442    /// Spectral mixed texture.
443    Mixed,
444}
445
446impl RegionKind {
447    /// The kind's name, without the period a table carries.
448    #[must_use]
449    pub fn label(self) -> &'static str {
450        match self {
451            RegionKind::Table(_) => "table",
452            RegionKind::Blob => "blob",
453            RegionKind::Prose => "prose",
454            RegionKind::Numeric => "numeric",
455            RegionKind::Code => "code",
456            RegionKind::Mixed => "mixed",
457        }
458    }
459
460    /// Every kind's name, for a caller listing what it accepts.
461    pub const NAMES: [&'static str; 6] = ["table", "blob", "prose", "numeric", "code", "mixed"];
462
463    /// Whether this kind is the one `name` calls for.
464    ///
465    /// A table is named by `table` whatever its period, because the period is
466    /// a property of the table found and not of the kind asked for: a caller
467    /// keeping the tables cannot know their periods in advance, and one that
468    /// wants a particular period reads it off the region.
469    #[must_use]
470    pub fn named(self, name: &str) -> bool {
471        self.label() == name
472    }
473}
474
475/// Whether an input whose dominant kind is `kind` is kept by the texture
476/// filters `asked`, each a kind's name and whether it keeps (`true`) or
477/// drops (`false`) an input of that kind.
478///
479/// A kind named to drop drops. Where only drops are given every other input
480/// is kept, since the caller asked to remove something rather than to select
481/// something; where any keep is given the keeps are the whole of what passes.
482#[must_use]
483pub fn keeps_texture(asked: &[(String, bool)], kind: Option<RegionKind>) -> bool {
484    let named = |name: &str| kind.is_some_and(|k| k.named(name));
485    if asked.iter().any(|(name, keeps)| !keeps && named(name)) {
486        return false;
487    }
488    match asked.iter().any(|(_, keeps)| *keeps) {
489        true => asked.iter().any(|(name, keeps)| *keeps && named(name)),
490        false => true,
491    }
492}
493
494/// Classify `input` into regions by fusing the spectral region texture with the
495/// shape period: a spectral region more than half of whose bytes lie in
496/// strong shape-period templates, each reaching back over the rows its period
497/// already repeated ([`ShapeField::template_spans`]), becomes `Table` with the
498/// period of the template covering most of it; the rest keep their texture.
499/// The cross-cutting consumer of the shape axis - a tabular block is named
500/// structurally, where the byte-period alone reads only "data". `shape` calls
501/// `spectral` here (token grain over byte grain), never the reverse.
502#[must_use]
503pub fn classified_regions(input: &[u8]) -> Vec<(usize, usize, RegionKind)> {
504    let templates = analyze_bytes(input).template_spans();
505    crate::spectral::code_regions(input)
506        .into_iter()
507        .map(|(s, e, tex)| {
508            // The templates' union inside the region, and the one covering most.
509            let mut covered = 0usize;
510            let mut reach = s;
511            let mut widest: Option<(usize, u16)> = None;
512            for &(ts, te, p) in &templates {
513                let (lo, hi) = (ts.max(s), te.min(e));
514                if lo >= hi {
515                    continue;
516                }
517                covered += hi.saturating_sub(lo.max(reach));
518                reach = reach.max(hi);
519                if widest.is_none_or(|(w, _)| hi - lo > w) {
520                    widest = Some((hi - lo, p));
521                }
522            }
523            let period = widest.filter(|_| 2 * covered > e - s).map(|(_, p)| p);
524            let kind = match period {
525                Some(p) => RegionKind::Table(p),
526                None => match tex {
527                    crate::spectral::CodeTexture::Blob => RegionKind::Blob,
528                    crate::spectral::CodeTexture::Prose => RegionKind::Prose,
529                    crate::spectral::CodeTexture::Numeric => RegionKind::Numeric,
530                    crate::spectral::CodeTexture::Mixed => RegionKind::Mixed,
531                    crate::spectral::CodeTexture::Code => RegionKind::Code,
532                },
533            };
534            (s, e, kind)
535        })
536        .collect()
537}
538
539/// The kind covering the most bytes of `input`, or `None` for an input with
540/// no region at all.
541///
542/// Bytes rather than region count, because a file is named by what most of it
543/// is: a source file holding one long base64 line and forty short code
544/// regions is code by count and could be a blob by bytes, and it is the bytes
545/// a reader means when they call a file one thing. A table reports the period
546/// of the widest table in it, since the period belongs to the region rather
547/// than to the file and one had to be chosen.
548#[must_use]
549pub fn dominant_kind(input: &[u8]) -> Option<RegionKind> {
550    let regions = classified_regions(input);
551    // Held by name rather than by kind, so every table counts toward one
552    // total whatever period each carries.
553    let mut totals: Vec<(&'static str, usize, RegionKind, usize)> = Vec::new();
554    for (s, e, kind) in regions {
555        let span = e.saturating_sub(s);
556        match totals.iter_mut().find(|(name, _, _, _)| *name == kind.label()) {
557            Some((_, bytes, widest, widest_span)) => {
558                *bytes += span;
559                if span > *widest_span {
560                    *widest = kind;
561                    *widest_span = span;
562                }
563            }
564            None => totals.push((kind.label(), span, kind, span)),
565        }
566    }
567    totals.into_iter().max_by_key(|&(_, bytes, _, _)| bytes).map(|(_, _, kind, _)| kind)
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    fn field(s: &str) -> ShapeField {
575        analyze_bytes(s.as_bytes())
576    }
577
578    #[test]
579    fn ragged_csv_has_strong_shape_period() {
580        // Field widths vary, so the byte period finds nothing; the shape period
581        // (N , N , N per row) is strong.
582        let f = field("1,22,3\n444,5,66\n7,888,9\n12,3,456\n");
583        let strong = f.frames.iter().any(|fr| fr.period > 0 && fr.period_strength >= 0.6);
584        assert!(strong, "ragged CSV should show a strong shape period");
585        assert!(!f.shape_regions().is_empty(), "should report a template region");
586    }
587
588    #[test]
589    fn prose_has_no_shape_period() {
590        let f = field("the quick brown fox jumps over the lazy dog and then rests");
591        let any_template = f.frames.iter().any(|fr| fr.period_strength >= 0.8 && fr.period > 1);
592        assert!(!any_template, "free prose should not read as a strong template");
593    }
594
595    #[test]
596    fn repeated_idiom_is_low_novelty() {
597        // The repeated `self.x = x;` idiom recurs - later sightings score low.
598        let f = field("self.a = a; self.b = b; self.c = c; self.d = d;");
599        let tail: f32 = f.frames.iter().rev().take(4).map(|fr| fr.novelty).sum::<f32>() / 4.0;
600        assert!(tail < 0.6, "a repeated template should have low tail novelty, got {tail}");
601    }
602
603    #[test]
604    fn a_texture_filter_keeps_by_name_and_drops_first() {
605        let asked = |list: &[(&str, bool)]| list.iter().map(|(n, k)| ((*n).to_string(), *k)).collect::<Vec<_>>();
606        let table = Some(RegionKind::Table(7));
607        assert!(keeps_texture(&[], None));
608        assert!(keeps_texture(&asked(&[("table", true)]), table));
609        assert!(!keeps_texture(&asked(&[("prose", true)]), table));
610        assert!(!keeps_texture(&asked(&[("prose", true)]), None));
611        assert!(!keeps_texture(&asked(&[("table", false)]), table));
612        assert!(keeps_texture(&asked(&[("blob", false)]), table));
613        assert!(keeps_texture(&asked(&[("blob", false)]), None));
614        assert!(!keeps_texture(&asked(&[("table", true), ("table", false)]), table));
615    }
616
617    #[test]
618    fn empty_is_safe() {
619        let f = field("");
620        assert_eq!(f.n_tokens, 0);
621        assert!(f.frames.is_empty());
622        assert!(f.shape_regions().is_empty());
623        assert_eq!(f.class_at(0), 0);
624        assert!(!f.in_template(0));
625    }
626
627    #[test]
628    fn class_at_maps_byte_to_silhouette() {
629        let f = field("foo(a, b)");
630        // The first token is the Word `foo`; its class is non-zero and stable.
631        assert_ne!(f.class_at(0), 0);
632        // `foo` and a same-shaped word elsewhere share a class.
633        let g = field("bar(x, y)");
634        assert_eq!(f.class_at(0), g.class_at(0), "same silhouette -> same class");
635    }
636
637    #[test]
638    fn classified_regions_names_a_table() {
639        // The fused classifier (shape x spectral) names a ragged CSV `Table`,
640        // where the byte-period alone would read only "data".
641        let csv = "name,age,score\nalice,30,95\nbob,25,88\ncarol,41,73\ndan,38,91\n";
642        let regions = classified_regions(csv.as_bytes());
643        assert!(
644            regions.iter().any(|&(_, _, k)| matches!(k, RegionKind::Table(_))),
645            "ragged CSV should classify as a Table region, got {regions:?}"
646        );
647    }
648
649    #[test]
650    fn a_short_template_run_in_prose_is_no_table() {
651        let prose = "# Reading a directory\n\nThe walk reports what it found rather than what it was asked for. A filter that\nsilently drops a file reads exactly the same as a directory that never held one, and\nthe reader cannot tell the two apart afterwards.\n";
652        assert!(
653            !field(prose).shape_regions().is_empty(),
654            "the prose holds a template run, which is what the rule must not take for a table"
655        );
656        assert!(
657            !classified_regions(prose.as_bytes()).iter().any(|&(_, _, k)| matches!(k, RegionKind::Table(_))),
658            "prose with a short template run reads by its texture"
659        );
660        let words = "queue drained\nnothing to report\n";
661        assert!(!classified_regions(words.as_bytes()).iter().any(|&(_, _, k)| matches!(k, RegionKind::Table(_))));
662    }
663
664    #[test]
665    fn a_table_whose_period_is_found_at_its_last_row_is_a_table() {
666        for table in ["alpha 10\nbeta 20\ngamma 300\ndelta 4000\nepsilon 5\n", "alpha 1000B 80ms\nalpha 2000B 80ms\nalpha 4000B 80ms\nbeta 8000B 300ms\n"] {
667            assert!(
668                classified_regions(table.as_bytes()).iter().all(|&(_, _, k)| matches!(k, RegionKind::Table(_))),
669                "{table:?} reads as one table"
670            );
671        }
672    }
673
674    #[test]
675    fn orbit_case_fold_finds_the_true_period() {
676        // The composition axis: a case-varied repeated phrase "the cat sat". The
677        // The identity orbit sees the case-cycle (period 6); the case orbit folds
678        // case and recovers the true phrase period (3). Orbit is the
679        // pre-transform, shape the measurement that composes over it.
680        let s: &[u8] = b"the cat sat THE CAT SAT the cat sat THE CAT SAT";
681        let toks = crate::tokutil::lex_sig(s);
682        let id = analyze_over(&toks, s, crate::orbit::OrbitGroup::Identity);
683        let ca = analyze_over(&toks, s, crate::orbit::OrbitGroup::Case);
684        let id_p = id.frames.last().map_or(0, |f| f.period);
685        let ca_p = ca.frames.last().map_or(0, |f| f.period);
686        assert!(
687            ca_p > 0 && ca_p < id_p,
688            "case orbit should find a tighter period ({ca_p}) than identity ({id_p})"
689        );
690    }
691
692    #[test]
693    fn orbit_identity_matches_default_silhouette_periodicity() {
694        // Sanity: over the Identity orbit the field is well-formed and
695        // non-empty on structured input (the literal-token silhouette).
696        let s: &[u8] = b"a,1,b,2,a,1,b,2,a,1,b,2";
697        let toks = crate::tokutil::lex_sig(s);
698        let f = analyze_over(&toks, s, crate::orbit::OrbitGroup::Identity);
699        assert_eq!(f.n_tokens, toks.len());
700        assert!(f.frames.iter().any(|fr| fr.period > 0));
701    }
702}