Skip to main content

waggle_core/
event.rs

1//! Events: payload-free by construction (invariant I-1) and fixed-width by
2//! consequence — which is what makes the `SoA` log's torn-read safety
3//! provable (design docs `02 §2`, `15 §2`).
4
5use serde::{Deserialize, Serialize};
6
7use crate::context::{ConsumerKind, ResolverContext};
8use crate::slug::Stage;
9use crate::time::Timestamp;
10use crate::token::Token;
11
12/// Per-token monotonic sequence number, assigned by the store at append
13/// (contract C-3). Identity of a record is `(token, seq)` — the dedup key
14/// (C-4).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct Seq(pub u32);
18
19/// Coarse model-family class — the *maximum* granularity events may hold
20/// (I-7). Families, never versions or instance identifiers.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum FamilyClass {
24    /// Not declared.
25    None,
26    /// Anthropic Claude family.
27    Claude,
28    /// `OpenAI` GPT family.
29    Gpt,
30    /// Google Gemini family.
31    Gemini,
32    /// Any other declared family.
33    Other,
34}
35
36/// Coarse harness class (same discipline as [`FamilyClass`]).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum HarnessClass {
40    /// Not declared.
41    None,
42    /// Claude Code.
43    ClaudeCode,
44    /// `OpenAI` Codex.
45    Codex,
46    /// Any other declared harness.
47    Other,
48}
49
50/// The actor dimensions an event may carry — three coarse classes, packed
51/// into one byte in the analytical log.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ActorClass {
54    /// Bot, human, terminal, or agent.
55    pub kind: ConsumerKind,
56    /// Coarse model family (agents only; `None` otherwise).
57    pub family: FamilyClass,
58    /// Coarse harness (agents only; `None` otherwise).
59    pub harness: HarnessClass,
60}
61
62impl ActorClass {
63    /// Classify a resolver context — the lossy, deliberate downgrade from
64    /// context to analytics dimension (I-7 enforced at the boundary).
65    #[must_use]
66    pub fn from_context(ctx: &ResolverContext) -> Self {
67        let family = match ctx
68            .model_family
69            .as_deref()
70            .map(str::to_ascii_lowercase)
71            .as_deref()
72        {
73            None => FamilyClass::None,
74            Some("claude") => FamilyClass::Claude,
75            Some("gpt") => FamilyClass::Gpt,
76            Some("gemini") => FamilyClass::Gemini,
77            Some(_) => FamilyClass::Other,
78        };
79        let harness = match ctx
80            .harness
81            .as_deref()
82            .map(str::to_ascii_lowercase)
83            .as_deref()
84        {
85            None => HarnessClass::None,
86            Some("claude-code") => HarnessClass::ClaudeCode,
87            Some("codex") => HarnessClass::Codex,
88            Some(_) => HarnessClass::Other,
89        };
90        Self {
91            kind: ctx.kind,
92            family,
93            harness,
94        }
95    }
96
97    /// Pack into one byte: kind (2 bits) | family (3 bits) | harness (3
98    /// bits) — the `SoA` column encoding (doc `03 §4`).
99    #[must_use]
100    pub fn code(self) -> u8 {
101        let k = match self.kind {
102            ConsumerKind::Bot => 0u8,
103            ConsumerKind::Human => 1,
104            ConsumerKind::Terminal => 2,
105            ConsumerKind::Agent => 3,
106        };
107        let f = match self.family {
108            FamilyClass::None => 0u8,
109            FamilyClass::Claude => 1,
110            FamilyClass::Gpt => 2,
111            FamilyClass::Gemini => 3,
112            FamilyClass::Other => 4,
113        };
114        let h = match self.harness {
115            HarnessClass::None => 0u8,
116            HarnessClass::ClaudeCode => 1,
117            HarnessClass::Codex => 2,
118            HarnessClass::Other => 3,
119        };
120        k | (f << 2) | (h << 5)
121    }
122}
123
124/// One thing that happened to a token. **No payload field exists** — the
125/// type system, not policy, keeps recipient data out of analytics (I-1).
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct Event {
128    /// The token the event applies to.
129    pub token: Token,
130    /// The funnel stage.
131    pub stage: Stage,
132    /// Coarse actor dimensions.
133    pub actor: ActorClass,
134    /// When it happened.
135    pub at: Timestamp,
136    /// Per-token monotonic sequence (store-assigned).
137    pub seq: Seq,
138    /// Which manifest variant served a resolve, if this event is one —
139    /// manifest-referencing, so I-1-compatible (doc `02`).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub variant: Option<u8>,
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn actor_codes_are_distinct_and_stable() {
150        let mut seen = std::collections::BTreeSet::new();
151        for kind in [
152            ConsumerKind::Bot,
153            ConsumerKind::Human,
154            ConsumerKind::Terminal,
155            ConsumerKind::Agent,
156        ] {
157            for family in [
158                FamilyClass::None,
159                FamilyClass::Claude,
160                FamilyClass::Gpt,
161                FamilyClass::Gemini,
162                FamilyClass::Other,
163            ] {
164                for harness in [
165                    HarnessClass::None,
166                    HarnessClass::ClaudeCode,
167                    HarnessClass::Codex,
168                    HarnessClass::Other,
169                ] {
170                    assert!(seen.insert(
171                        ActorClass {
172                            kind,
173                            family,
174                            harness
175                        }
176                        .code()
177                    ));
178                }
179            }
180        }
181        assert_eq!(seen.len(), 4 * 5 * 4);
182    }
183
184    #[test]
185    fn classification_is_coarse_by_construction() {
186        let mut ctx = ResolverContext::anonymous_agent();
187        ctx.model_family = Some("claude-fable-5.1-preview".into()); // a version string
188        let actor = ActorClass::from_context(&ctx);
189        assert_eq!(
190            actor.family,
191            FamilyClass::Other,
192            "unknown strings bucket to Other — versions never survive"
193        );
194        ctx.model_family = Some("Claude".into());
195        assert_eq!(ActorClass::from_context(&ctx).family, FamilyClass::Claude);
196    }
197
198    #[test]
199    fn event_serde_roundtrip() {
200        let e = Event {
201            token: Token::parse("abc123").unwrap(),
202            stage: Stage::resolve(),
203            actor: ActorClass::from_context(&ResolverContext::human()),
204            at: Timestamp::from_unix_ms(9),
205            seq: Seq(4),
206            variant: Some(2),
207        };
208        let json = serde_json::to_string(&e).unwrap();
209        let back: Event = serde_json::from_str(&json).unwrap();
210        assert_eq!(back, e);
211    }
212}