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 six-column `SoA` log. Fixed-width rows — a consequence of I-1 (events
73/// carry no payload), and the load-bearing fact behind lock-free tail reads
74/// in stores (doc `15 §2`).
75#[derive(Debug, Default)]
76pub struct EventLog {
77    token_ids: Vec<TokenId>,
78    stage_ids: Vec<StageId>,
79    actors: Vec<u8>,
80    variants: Vec<u8>,
81    at_ms: Vec<u64>,
82    seqs: Vec<u32>,
83}
84
85impl EventLog {
86    /// Append one event, interning through `tables`.
87    pub fn push(&mut self, event: &Event, tables: &mut InternTables) {
88        self.token_ids.push(tables.token(event.token));
89        self.stage_ids.push(tables.stage(&event.stage));
90        self.actors.push(event.actor.code());
91        self.variants.push(event.variant.unwrap_or(NO_VARIANT));
92        self.at_ms.push(event.at.as_unix_ms());
93        self.seqs.push(event.seq.0);
94    }
95
96    /// Number of rows.
97    #[must_use]
98    pub fn len(&self) -> usize {
99        self.token_ids.len()
100    }
101
102    /// True when empty.
103    #[must_use]
104    pub fn is_empty(&self) -> bool {
105        self.token_ids.is_empty()
106    }
107
108    /// Stage counts for one token — the hot funnel fold: one sequential
109    /// pass over two narrow columns, counts into a dense array.
110    #[must_use]
111    pub fn stage_counts(&self, token: TokenId, tables: &InternTables) -> Vec<(Stage, u64)> {
112        let mut counts = vec![0u64; tables.stages.len()];
113        for (t, s) in self.token_ids.iter().zip(&self.stage_ids) {
114            if *t == token {
115                counts[s.0 as usize] += 1;
116            }
117        }
118        counts
119            .into_iter()
120            .enumerate()
121            .filter(|(_, c)| *c > 0)
122            .filter_map(|(i, c)| {
123                #[allow(clippy::cast_possible_truncation)] // i < stages.len() <= u16::MAX
124                tables.stage_name(StageId(i as u16)).map(|s| (s.clone(), c))
125            })
126            .collect()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::event::{ActorClass, Seq};
134    use crate::{ResolverContext, Timestamp};
135
136    fn ev(token: Token, stage: Stage, seq: u32) -> Event {
137        Event {
138            token,
139            stage,
140            actor: ActorClass::from_context(&ResolverContext::human()),
141            at: Timestamp::from_unix_ms(u64::from(seq)),
142            seq: Seq(seq),
143            variant: if seq % 2 == 0 { Some(1) } else { None },
144        }
145    }
146
147    #[test]
148    fn interning_is_idempotent_and_dense() {
149        let mut tables = InternTables::default();
150        let a = tables.stage(&Stage::resolve());
151        let b = tables.stage(&Stage::resolve());
152        assert_eq!(a, b);
153        let c = tables.stage(&Stage::run());
154        assert_eq!(c.0, a.0 + 1, "dense, append-only ids");
155    }
156
157    #[test]
158    fn soa_counts_agree_with_naive_counting() {
159        let mut tables = InternTables::default();
160        let mut log = EventLog::default();
161        let t1 = Token::parse("one").unwrap();
162        let t2 = Token::parse("two").unwrap();
163        for i in 0..10 {
164            log.push(&ev(t1, Stage::resolve(), i), &mut tables);
165        }
166        for i in 0..3 {
167            log.push(&ev(t2, Stage::run(), i), &mut tables);
168            log.push(&ev(t1, Stage::run(), 100 + i), &mut tables);
169        }
170        let id1 = tables.token_id(t1).unwrap();
171        let counts = log.stage_counts(id1, &tables);
172        assert!(counts.contains(&(Stage::resolve(), 10)));
173        assert!(counts.contains(&(Stage::run(), 3)));
174        assert_eq!(log.len(), 16);
175    }
176}