use crate::merge::{self, Sequence, TieBreak};
use crate::store::{Store, TokenId};
use crate::surprise::{classify_surprise, compute_surprise_score, s_static, SurpriseVerdict};
use std::collections::HashMap;
pub const REVOLUTION_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TurnReport {
pub revolution: u32,
pub s_static: f64,
pub s_growth: f64,
pub surprise_score: f64,
pub verdict: SurpriseVerdict,
pub new_tokens_count: usize,
pub sequence_len: usize,
}
pub struct WheelState {
pub store: Store,
pub sequence: Sequence,
pub revolution: u32,
pub last_touched_revolution: HashMap<TokenId, u32>,
}
impl WheelState {
pub fn new() -> Self {
let mut store = Store::new();
store.init_factory();
Self::with_store(store)
}
pub fn with_store(store: Store) -> Self {
WheelState {
store,
sequence: Sequence { ids: Vec::new(), levels: Vec::new() },
revolution: 0,
last_touched_revolution: HashMap::new(),
}
}
pub fn turn(&mut self, chunk: &[u8], threshold: u64, tie_break: TieBreak) -> TurnReport {
let s_static = s_static(&self.store, chunk);
let vocab_before = self.store.len();
let mut before_counts: HashMap<TokenId, u64> = HashMap::new();
for &id in &self.sequence.ids {
before_counts.entry(id).or_insert_with(|| self.store.count_of(id));
}
let mut ids = std::mem::take(&mut self.sequence.ids);
ids.reserve(chunk.len());
for &b in chunk {
let id = self.store.intern(0, &[b as u32]);
self.store.touch(id);
ids.push(id);
}
let seq = Sequence::from_ids(&self.store, ids);
let out = merge::run(&mut self.store, seq, threshold, tie_break);
self.revolution += 1;
for &id in &out.ids {
let touched_this_turn = match before_counts.get(&id) {
Some(&old_count) => self.store.count_of(id) > old_count,
None => true, };
if touched_this_turn {
self.last_touched_revolution.insert(id, self.revolution);
}
}
let mut final_ids = out.ids;
if final_ids.len() > 100_000 {
let excess = final_ids.len() - 100_000;
final_ids.drain(0..excess);
}
let final_levels = final_ids.iter().map(|&id| self.store.level_of(id)).collect();
self.sequence = Sequence { ids: final_ids, levels: final_levels };
let vocab_after = self.store.len();
let new_tokens_count = vocab_after.saturating_sub(vocab_before);
let s_growth = if new_tokens_count > 0 {
(new_tokens_count as f64) * (vocab_after as f64).log2()
} else {
0.0
};
let surprise_score = compute_surprise_score(s_static, s_growth);
let verdict = classify_surprise(s_static, s_growth);
TurnReport {
revolution: self.revolution,
s_static,
s_growth,
surprise_score,
verdict,
new_tokens_count,
sequence_len: self.sequence.ids.len(),
}
}
pub fn run_corpus(&mut self, bytes: &[u8], threshold: u64, tie_break: TieBreak) -> u32 {
for chunk in bytes.chunks(REVOLUTION_CHUNK_BYTES) {
self.turn(chunk, threshold, tie_break);
}
self.revolution
}
}
impl Default for WheelState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn revolution_count_matches_chunking() {
let mut wheel = WheelState::new();
let bytes = vec![b'a'; REVOLUTION_CHUNK_BYTES * 2 + 1];
let revolutions = wheel.run_corpus(&bytes, 2, TieBreak::BirthOrder);
assert_eq!(revolutions, 3);
assert_eq!(wheel.revolution, 3);
}
#[test]
fn gita_calibration_is_approximately_44_revolutions() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let corpus_path = std::path::Path::new(manifest_dir).join("../docs/corpus/bhagavad_gita.txt");
let bytes = std::fs::read(&corpus_path).expect("bhagavad_gita.txt");
let expected = bytes.len().div_ceil(REVOLUTION_CHUNK_BYTES);
assert!((43..=44).contains(&expected), "калибровка плана 3: 2.8 МБ / 64 КБ ≈ 44 оборота, получили {expected}");
}
#[test]
fn sequence_grows_and_collapses_across_revolutions() {
let mut wheel = WheelState::new();
wheel.turn(b"ab ab cd cd ab cd ab cd ", 2, TieBreak::BirthOrder);
let after_first = wheel.sequence.ids.len();
assert!(after_first > 0);
wheel.turn(b"ab ab cd cd ab cd ab cd ", 2, TieBreak::BirthOrder);
assert!(wheel.sequence.ids.len() < after_first + 24, "вторая порция обязана частично слиться, не просто дописаться");
}
#[test]
fn last_touched_revolution_tracks_direct_fold_presence() {
let mut wheel = WheelState::new();
wheel.turn(b"xyz", 2, TieBreak::BirthOrder);
assert_eq!(wheel.revolution, 1);
for &id in &wheel.sequence.ids {
assert_eq!(wheel.last_touched_revolution.get(&id), Some(&1));
}
}
#[test]
fn stale_token_sitting_unchanged_does_not_get_timestamp_refreshed() {
let mut wheel = WheelState::new();
wheel.turn(b"ab ab", 2, TieBreak::BirthOrder);
let ab = wheel
.sequence
.ids
.iter()
.copied()
.find(|&id| wheel.store.bytes_of(id) == b"ab")
.expect("'ab' должен был слиться в композит");
assert_eq!(wheel.last_touched_revolution.get(&ab), Some(&1));
wheel.turn(b"zzz", 2, TieBreak::BirthOrder);
wheel.turn(b"zzz", 2, TieBreak::BirthOrder);
assert!(
wheel.sequence.ids.contains(&ab),
"'ab' обязан физически оставаться в последовательности (она не забывает старое)"
);
assert_eq!(
wheel.last_touched_revolution.get(&ab),
Some(&1),
"штамп времени не должен переписываться, пока токен просто лежит нетронутым"
);
}
}