1use crate::token::{Token, TokenKind};
25
26#[derive(Clone, Copy, Debug)]
28pub struct ShapeConfig {
29 pub max_lag: usize,
31 pub period_window: usize,
33 pub period_hop: usize,
36 pub novelty_k: usize,
38 pub novelty_window: usize,
40 pub template_strength: f32,
42 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#[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
76fn 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, }
86}
87
88#[derive(Clone, Copy, Debug, Default)]
90pub struct ShapeFrame {
91 pub class: u32,
93 pub period: u16,
95 pub period_strength: f32,
97 pub novelty: f32,
99}
100
101#[derive(Clone, Debug, Default)]
103pub struct ShapeField {
104 pub n_tokens: usize,
106 pub spans: Vec<(usize, usize)>,
108 pub frames: Vec<ShapeFrame>,
110 pub boundaries: Vec<usize>,
112 template_strength: f32,
114}
115
116impl ShapeField {
117 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 #[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 #[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 #[must_use]
145 pub fn in_template(&self, byte: usize) -> bool {
146 self.period_at(byte).1 >= self.template_strength
147 }
148
149 #[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 #[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#[must_use]
230pub fn analyze(tokens: &[Token], bytes: &[u8]) -> ShapeField {
231 analyze_with(tokens, bytes, &ShapeConfig::default())
232}
233
234#[must_use]
237pub fn analyze_bytes(bytes: &[u8]) -> ShapeField {
238 let toks = crate::tokutil::lex_sig(bytes);
239 analyze(&toks, bytes)
240}
241
242#[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 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
262fn 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 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 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 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 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 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 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#[must_use]
345pub fn analyze_with(tokens: &[Token], bytes: &[u8], cfg: &ShapeConfig) -> ShapeField {
346 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#[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#[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#[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
388fn 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
431pub enum RegionKind {
432 Table(u16),
434 Blob,
436 Prose,
438 Numeric,
440 Code,
442 Mixed,
444}
445
446impl RegionKind {
447 #[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 pub const NAMES: [&'static str; 6] = ["table", "blob", "prose", "numeric", "code", "mixed"];
462
463 #[must_use]
470 pub fn named(self, name: &str) -> bool {
471 self.label() == name
472 }
473}
474
475#[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#[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 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#[must_use]
549pub fn dominant_kind(input: &[u8]) -> Option<RegionKind> {
550 let regions = classified_regions(input);
551 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 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 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 assert_ne!(f.class_at(0), 0);
632 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 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 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 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}