Skip to main content

waggle_core/
soa.rs

1//! The analytical event log: struct-of-arrays with interned identifiers
2//! (design doc `03 §4`). Six primitive columns, 1:1 with the Parquet
3//! archive schema — a million-event funnel fold is a sequential scan over
4//! 2-byte stage ids. This is the *accelerator* representation; the
5//! `LogRecord` stream stays the truth (doc `04`).
6
7use std::collections::HashMap;
8
9use crate::event::Event;
10use crate::slug::Stage;
11use crate::token::Token;
12
13/// Dense stage identifier (interned).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct StageId(pub u16);
16
17/// Dense token identifier (interned, per-store).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct TokenId(pub u32);
20
21/// Append-only intern tables. Extension is committer-owned in stores
22/// (G-1); in-process users own their instance, so `&mut` is the guard.
23#[derive(Debug, Default)]
24pub struct InternTables {
25    stages: Vec<Stage>,
26    stage_index: HashMap<Stage, StageId>,
27    tokens: Vec<Token>,
28    token_index: HashMap<Token, TokenId>,
29}
30
31impl InternTables {
32    /// Intern a stage (idempotent).
33    pub fn stage(&mut self, stage: &Stage) -> StageId {
34        if let Some(id) = self.stage_index.get(stage) {
35            return *id;
36        }
37        #[allow(clippy::cast_possible_truncation)] // stage vocabulary is small by design
38        let id = StageId(self.stages.len() as u16);
39        self.stages.push(stage.clone());
40        self.stage_index.insert(stage.clone(), id);
41        id
42    }
43
44    /// Intern a token (idempotent).
45    pub fn token(&mut self, token: Token) -> TokenId {
46        if let Some(id) = self.token_index.get(&token) {
47            return *id;
48        }
49        #[allow(clippy::cast_possible_truncation)] // u32 tokens per store is the design bound
50        let id = TokenId(self.tokens.len() as u32);
51        self.tokens.push(token);
52        self.token_index.insert(token, id);
53        id
54    }
55
56    /// Resolve a stage id back to its slug.
57    #[must_use]
58    pub fn stage_name(&self, id: StageId) -> Option<&Stage> {
59        self.stages.get(id.0 as usize)
60    }
61
62    /// Look a token's dense id up without interning.
63    #[must_use]
64    pub fn token_id(&self, token: Token) -> Option<TokenId> {
65        self.token_index.get(&token).copied()
66    }
67}
68
69/// Sentinel for "no variant recorded" in the packed column.
70const NO_VARIANT: u8 = u8::MAX;
71
72/// The seven-column `SoA` log. Fixed-width rows — a consequence of I-1
73/// (events carry no payload), and the load-bearing fact behind lock-free
74/// tail reads in stores (doc `15 §2`). The `regions` column packs the
75/// contract touch bitmask; `0` doubles as "none" because an empty mask
76/// carries no information (unlike `variant`, where index 0 is real).
77#[derive(Debug, Default)]
78pub struct EventLog {
79    token_ids: Vec<TokenId>,
80    stage_ids: Vec<StageId>,
81    actors: Vec<u8>,
82    variants: Vec<u8>,
83    regions: Vec<u8>,
84    at_ms: Vec<u64>,
85    seqs: Vec<u32>,
86}
87
88impl EventLog {
89    /// Append one event, interning through `tables`.
90    pub fn push(&mut self, event: &Event, tables: &mut InternTables) {
91        self.token_ids.push(tables.token(event.token));
92        self.stage_ids.push(tables.stage(&event.stage));
93        self.actors.push(event.actor.code());
94        self.variants.push(event.variant.unwrap_or(NO_VARIANT));
95        self.regions.push(event.regions.unwrap_or(0));
96        self.at_ms.push(event.at.as_unix_ms());
97        self.seqs.push(event.seq.0);
98    }
99
100    /// Number of rows.
101    #[must_use]
102    pub fn len(&self) -> usize {
103        self.token_ids.len()
104    }
105
106    /// True when empty.
107    #[must_use]
108    pub fn is_empty(&self) -> bool {
109        self.token_ids.is_empty()
110    }
111
112    /// Stage counts for one token — the hot funnel fold: one sequential
113    /// pass over two narrow columns, counts into a dense array.
114    #[must_use]
115    pub fn stage_counts(&self, token: TokenId, tables: &InternTables) -> Vec<(Stage, u64)> {
116        let mut counts = vec![0u64; tables.stages.len()];
117        for (t, s) in self.token_ids.iter().zip(&self.stage_ids) {
118            if *t == token {
119                counts[s.0 as usize] += 1;
120            }
121        }
122        counts
123            .into_iter()
124            .enumerate()
125            .filter(|(_, c)| *c > 0)
126            .filter_map(|(i, c)| {
127                #[allow(clippy::cast_possible_truncation)] // i < stages.len() <= u16::MAX
128                tables.stage_name(StageId(i as u16)).map(|s| (s.clone(), c))
129            })
130            .collect()
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::event::{ActorClass, Seq};
138    use crate::{ResolverContext, Timestamp};
139
140    fn ev(token: Token, stage: Stage, seq: u32) -> Event {
141        Event {
142            token,
143            stage,
144            actor: ActorClass::from_context(&ResolverContext::human()),
145            at: Timestamp::from_unix_ms(u64::from(seq)),
146            seq: Seq(seq),
147            variant: if seq % 2 == 0 { Some(1) } else { None },
148            regions: None,
149        }
150    }
151
152    #[test]
153    fn interning_is_idempotent_and_dense() {
154        let mut tables = InternTables::default();
155        let a = tables.stage(&Stage::resolve());
156        let b = tables.stage(&Stage::resolve());
157        assert_eq!(a, b);
158        let c = tables.stage(&Stage::run());
159        assert_eq!(c.0, a.0 + 1, "dense, append-only ids");
160    }
161
162    #[test]
163    fn soa_counts_agree_with_naive_counting() {
164        let mut tables = InternTables::default();
165        let mut log = EventLog::default();
166        let t1 = Token::parse("one").unwrap();
167        let t2 = Token::parse("two").unwrap();
168        for i in 0..10 {
169            log.push(&ev(t1, Stage::resolve(), i), &mut tables);
170        }
171        for i in 0..3 {
172            log.push(&ev(t2, Stage::run(), i), &mut tables);
173            log.push(&ev(t1, Stage::run(), 100 + i), &mut tables);
174        }
175        let id1 = tables.token_id(t1).unwrap();
176        let counts = log.stage_counts(id1, &tables);
177        assert!(counts.contains(&(Stage::resolve(), 10)));
178        assert!(counts.contains(&(Stage::run(), 3)));
179        assert_eq!(log.len(), 16);
180    }
181}