use std::collections::BTreeMap;
pub type CollapseTable = BTreeMap<String, Vec<String>>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OrbitGroup {
Identity,
Case,
Notation,
Shape,
E8,
Ip,
Url,
Time,
Path,
Fold,
Numeric,
Typed(crate::typed::Relation),
}
impl OrbitGroup {
#[must_use]
pub fn parse(name: &str) -> Option<OrbitGroup> {
if let Some((base, width)) = name.split_once('/') {
if width.is_empty() || width.len() > 3 || !width.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let bits = width.bytes().fold(0u32, |a, b| a * 10 + u32::from(b - b'0'));
if bits > 128 {
return None;
}
return OrbitGroup::parse(base)?.with_prefix(bits as u8);
}
match name {
"identity" => Some(OrbitGroup::Identity),
"case" => Some(OrbitGroup::Case),
"notation" => Some(OrbitGroup::Notation),
"shape" => Some(OrbitGroup::Shape),
"e8" => Some(OrbitGroup::E8),
"ip" => Some(OrbitGroup::Ip),
"url" => Some(OrbitGroup::Url),
"time" => Some(OrbitGroup::Time),
"path" => Some(OrbitGroup::Path),
"fold" => Some(OrbitGroup::Fold),
"numeric" => Some(OrbitGroup::Numeric),
other => crate::typed::Relation::parse(other).map(OrbitGroup::Typed),
}
}
#[must_use]
pub fn with_prefix(self, bits: u8) -> Option<OrbitGroup> {
match self {
OrbitGroup::Typed(rel) => rel.with_prefix(bits).map(OrbitGroup::Typed),
_ => None,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
OrbitGroup::Identity => "identity",
OrbitGroup::Case => "case",
OrbitGroup::Notation => "notation",
OrbitGroup::Shape => "shape",
OrbitGroup::E8 => "e8",
OrbitGroup::Ip => "ip",
OrbitGroup::Url => "url",
OrbitGroup::Time => "time",
OrbitGroup::Path => "path",
OrbitGroup::Fold => "fold",
OrbitGroup::Numeric => "numeric",
OrbitGroup::Typed(rel) => rel.label(),
}
}
}
fn kind_char(b: u8) -> char {
if b.is_ascii_alphabetic() {
'A'
} else if b.is_ascii_digit() {
'D'
} else if b.is_ascii_whitespace() {
'S'
} else {
'.'
}
}
#[must_use]
pub fn shape_char(b: u8) -> char {
if b.is_ascii_digit() {
'D'
} else if matches!(b, b'a' | b'e' | b'i' | b'o' | b'u' | b'A' | b'E' | b'I' | b'O' | b'U') {
'V'
} else if b.is_ascii_alphabetic() {
'C'
} else {
'.'
}
}
#[must_use]
pub fn canonical(span: &[u8], group: OrbitGroup) -> String {
let text = String::from_utf8_lossy(span);
match group {
OrbitGroup::Identity => text.into_owned(),
OrbitGroup::Case => text.to_lowercase(),
OrbitGroup::Notation => crate::canon::canon_symbol(&text),
OrbitGroup::Shape => span.iter().map(|&b| shape_char(b)).collect(),
OrbitGroup::E8 => {
let c = crate::e8::canonicalize(&embed_e8(span));
let mut s = String::with_capacity(40);
s.push_str("E8[");
for (i, x) in c.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&x.to_string());
}
s.push(']');
s
}
OrbitGroup::Ip => match crate::typed::parse_ip(&text) {
Some(ip) => ip.to_string(),
None => text.into_owned(),
},
OrbitGroup::Url => crate::typed::canonical_url(&text),
OrbitGroup::Time => crate::typed::canonical_time(&text),
OrbitGroup::Path => text.replace('\\', "/"),
OrbitGroup::Fold => crate::typed::fold_text(&text),
OrbitGroup::Numeric => crate::typed::canonical_number(&text),
OrbitGroup::Typed(rel) => match rel.key(&text) {
Some(key) => format!("{}:{key}", rel.label()),
None => format!("?{text}"),
},
}
}
pub const E8_CHANNELS: usize = 8;
#[must_use]
pub fn embed_e8(span: &[u8]) -> crate::e8::E8Vec {
const CAP: i64 = 12;
let mut ch = [0i64; E8_CHANNELS];
for &b in span {
let i = match shape_char(b) {
'V' => 0,
'C' => 1,
'D' => 2,
_ if b == b' ' || b == b'\t' || b == b'\n' => 3,
_ if b.is_ascii_punctuation() => 4,
_ => 6,
};
ch[i] += 1;
if b.is_ascii_uppercase() {
ch[5] += 1;
}
}
ch[7] = (span.len() as f64 + 1.0).log2() as i64;
let mut v = [0i64; 8];
for i in 0..8 {
v[i] = ch[i].min(CAP) * 2;
}
v.sort_unstable_by(|a, b| b.cmp(a));
crate::e8::snap(&v)
}
#[must_use]
pub fn same_orbit(a: &[u8], b: &[u8], group: OrbitGroup) -> bool {
canonical(a, group) == canonical(b, group)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OrbitToken {
pub start: usize,
pub end: usize,
pub raw: String,
pub orbit: String,
}
#[must_use]
pub fn tokenize(input: &[u8], group: OrbitGroup) -> Vec<OrbitToken> {
crate::lexer::lex(input)
.into_iter()
.filter(crate::token::Token::is_significant)
.map(|t| {
let span = &input[t.span()];
OrbitToken {
start: t.start(),
end: t.end(),
raw: String::from_utf8_lossy(span).into_owned(),
orbit: canonical(span, group),
}
})
.collect()
}
#[must_use]
pub fn collapse(input: &[u8], group: OrbitGroup) -> CollapseTable {
let mut table: CollapseTable = BTreeMap::new();
for t in tokenize(input, group) {
let forms = table.entry(t.orbit).or_default();
if !forms.contains(&t.raw) {
forms.push(t.raw);
}
}
for forms in table.values_mut() {
forms.sort();
}
table
}
#[must_use]
pub fn collapse_stats(input: &[u8], group: OrbitGroup) -> (usize, usize) {
let toks = tokenize(input, group);
let mut raw: Vec<&str> = toks.iter().map(|t| t.raw.as_str()).collect();
raw.sort_unstable();
raw.dedup();
let mut orb: Vec<&str> = toks.iter().map(|t| t.orbit.as_str()).collect();
orb.sort_unstable();
orb.dedup();
(raw.len(), orb.len())
}
#[must_use]
pub fn shape_boundaries(input: &[u8]) -> Vec<usize> {
let mut cuts = Vec::new();
let mut prev: Option<char> = None;
for (i, &b) in input.iter().enumerate() {
let c = kind_char(b);
if prev.is_some_and(|p| p != c) {
cuts.push(i);
}
prev = Some(c);
}
cuts
}
#[must_use]
pub fn matches(input: &[u8], query: &[u8], group: OrbitGroup) -> Vec<OrbitToken> {
let key = canonical(query, group);
tokenize(input, group).into_iter().filter(|t| t.orbit == key).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn e8_orbit_is_a_stable_quotient_over_spans() {
for s in [&b"hello"[..], b"WORLD", b"a1b2c3", b" ", b"!!!", b""] {
let a = canonical(s, OrbitGroup::E8);
assert_eq!(a, canonical(s, OrbitGroup::E8), "canonical form is deterministic for {s:?}");
assert!(a.starts_with("E8["), "the representative is rendered from lattice coordinates: {a}");
let v = embed_e8(s);
assert!(crate::e8::in_e8(&v), "the embedding lands on the lattice for {s:?}: {v:?}");
assert!(crate::e8::is_dominant(&crate::e8::canonicalize(&v)), "the representative is dominant for {s:?}");
}
}
#[test]
fn e8_folds_symmetry_related_profiles_and_separates_unrelated_ones() {
let vowels = b"aei";
let consonants = b"bcd";
assert_eq!(
canonical(vowels, OrbitGroup::E8),
canonical(consonants, OrbitGroup::E8),
"channel-permuted profiles are one E8 orbit"
);
assert_ne!(
canonical(vowels, OrbitGroup::Shape),
canonical(consonants, OrbitGroup::Shape),
"the SHAPE rung keeps them apart - so E8 is adding a fold, not duplicating one"
);
assert_ne!(
canonical(vowels, OrbitGroup::E8),
canonical(b"a1! bcdefgh", OrbitGroup::E8),
"an unrelated profile stays a different token - the quotient is not collapsing everything"
);
}
#[test]
fn e8_parses_and_labels() {
assert_eq!(OrbitGroup::parse("e8"), Some(OrbitGroup::E8));
assert_eq!(OrbitGroup::E8.label(), "e8");
assert_eq!(OrbitGroup::parse(OrbitGroup::E8.label()), Some(OrbitGroup::E8), "label and parse are inverses");
}
#[test]
fn notation_orbit_collapses_encodings() {
assert!(same_orbit("\u{03B8}".as_bytes(), b"theta", OrbitGroup::Notation));
assert!(same_orbit(b"\\theta", b"Theta", OrbitGroup::Notation));
assert!(!same_orbit(b"theta", b"phi", OrbitGroup::Notation));
}
#[test]
fn shape_orbit_unifies_words_of_one_pattern() {
assert_eq!(canonical(b"cat", OrbitGroup::Shape), "CVC");
assert!(same_orbit(b"cat", b"dog", OrbitGroup::Shape));
assert!(same_orbit(b"cat", b"bat", OrbitGroup::Shape));
assert!(!same_orbit(b"cat", b"the", OrbitGroup::Shape)); }
#[test]
fn typed_rungs_fold_representations_of_one_value() {
assert!(same_orbit(b"::1", b"0:0:0:0:0:0:0:1", OrbitGroup::Ip));
assert!(same_orbit(b"2001:DB8::1", b"2001:db8:0:0:0:0:0:1", OrbitGroup::Ip));
assert!(!same_orbit(b"10.0.0.1", b"10.0.0.2", OrbitGroup::Ip));
assert!(same_orbit(b"HTTP://Example.COM:80/a", b"http://example.com/a", OrbitGroup::Url));
assert!(!same_orbit(b"http://example.com/a/", b"http://example.com/a", OrbitGroup::Url));
assert!(same_orbit(b"2026-09-15T02:00:00+02:00", b"2026-09-15 00:00:00", OrbitGroup::Time));
assert!(same_orbit(b"C:\\a\\b", b"C:/a/b", OrbitGroup::Path));
assert!(same_orbit("Caf\u{E9}".as_bytes(), b"cafe", OrbitGroup::Fold));
assert!(same_orbit(b"1,000", b"1e3", OrbitGroup::Numeric));
assert!(!same_orbit(b"1,000", b"1001", OrbitGroup::Numeric));
for name in ["ip", "url", "time", "path", "fold", "numeric"] {
let g = OrbitGroup::parse(name).unwrap_or_else(|| panic!("{name} is a rung"));
assert_eq!(g.label(), name);
}
}
#[test]
fn a_typed_relation_is_a_rung_that_folds_what_its_projection_reads_alike() {
let rung = |name: &str| OrbitGroup::parse(name).unwrap_or_else(|| panic!("{name} is a rung"));
let subnet24 = rung("subnet").with_prefix(24).unwrap_or_else(|| panic!("subnet takes one"));
assert!(same_orbit(b"10.0.0.7", b"10.0.0.201", subnet24));
assert!(!same_orbit(b"10.0.0.7", b"10.0.1.201", subnet24));
assert_eq!(canonical(b"10.0.0.7", subnet24), "subnet:10.0.0.0/24");
assert!(rung("domain").with_prefix(24).is_none());
assert!(same_orbit(b"bob@corp.example", b"amy@corp.example", rung("domain")));
assert!(!same_orbit(b"bob@corp.example", b"amy@other.example", rung("domain")));
assert!(same_orbit(b"2026-09-15T01:00:00", b"2026-09-15T23:00:00", rung("day")));
assert!(!same_orbit(b"2026-09-15T01:00:00", b"2025-09-15T23:00:00", rung("day")));
assert_eq!(canonical(b"plainword", rung("domain")), "?plainword");
assert_eq!(canonical(b"domain:corp.example", rung("domain")), "?domain:corp.example");
assert!(!same_orbit(b"domain:corp.example", b"bob@corp.example", rung("domain")));
assert!(same_orbit(b"alpha", b"alpha", rung("domain")));
assert!(!same_orbit(b"alpha", b"bravo", rung("domain")));
for name in ["subnet", "domain", "day", "host", "len", "magnitude"] {
assert_eq!(rung(name).label(), name, "a rung labels as the relation it is");
assert_eq!(OrbitGroup::parse(rung(name).label()), Some(rung(name)));
}
assert_eq!(OrbitGroup::parse("subnet/24"), Some(subnet24));
assert_eq!(OrbitGroup::parse("subnet/129"), None);
assert_eq!(OrbitGroup::parse("subnet/"), None);
assert_eq!(OrbitGroup::parse("subnet/x"), None);
assert_eq!(OrbitGroup::parse("domain/24"), None);
assert_eq!(OrbitGroup::parse("case/24"), None);
}
#[test]
fn case_orbit_folds_case_only() {
assert!(same_orbit(b"Cat", b"CAT", OrbitGroup::Case));
assert!(!same_orbit(b"cat", b"dog", OrbitGroup::Case));
}
#[test]
fn collapse_reduces_vocabulary() {
let (raw, orb) = collapse_stats(b"cat dog bat sat mat the", OrbitGroup::Shape);
assert_eq!(raw, 6, "six distinct raw words");
assert_eq!(orb, 2, "two shape orbits: CVC and CCV");
let (raw_i, orb_i) = collapse_stats(b"cat dog bat sat mat the", OrbitGroup::Identity);
assert_eq!(raw_i, orb_i, "identity is the trivial quotient");
}
#[test]
fn generalizes_to_unseen_strings() {
let table = collapse(b"cat dog", OrbitGroup::Shape);
assert!(table.contains_key("CVC"));
assert_eq!(canonical(b"vat", OrbitGroup::Shape), "CVC");
}
#[test]
fn shape_boundaries_cut_at_class_changes() {
let cuts = shape_boundaries(b"cat123dog");
assert_eq!(cuts, vec![3, 6]);
}
#[test]
fn equivariant_match_finds_the_whole_orbit() {
let hits = matches(b"the Cat and the CAT and a cat", b"cat", OrbitGroup::Case);
assert_eq!(hits.len(), 3, "Cat, CAT, cat all match up to case: {hits:?}");
}
}