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    /// Which declared contract regions this access touched, as a bitmask
143    /// indexing the manifest's [`crate::Contract`] (doc `19 §4.2`) —
144    /// manifest-referencing exactly like `variant`, so I-1-compatible:
145    /// positions into a signed declaration, never bytes. Absent on
146    /// contract-free tokens and on pre-contract logs.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub regions: Option<u8>,
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn actor_codes_are_distinct_and_stable() {
157        let mut seen = std::collections::BTreeSet::new();
158        for kind in [
159            ConsumerKind::Bot,
160            ConsumerKind::Human,
161            ConsumerKind::Terminal,
162            ConsumerKind::Agent,
163        ] {
164            for family in [
165                FamilyClass::None,
166                FamilyClass::Claude,
167                FamilyClass::Gpt,
168                FamilyClass::Gemini,
169                FamilyClass::Other,
170            ] {
171                for harness in [
172                    HarnessClass::None,
173                    HarnessClass::ClaudeCode,
174                    HarnessClass::Codex,
175                    HarnessClass::Other,
176                ] {
177                    assert!(seen.insert(
178                        ActorClass {
179                            kind,
180                            family,
181                            harness
182                        }
183                        .code()
184                    ));
185                }
186            }
187        }
188        assert_eq!(seen.len(), 4 * 5 * 4);
189    }
190
191    #[test]
192    fn classification_is_coarse_by_construction() {
193        let mut ctx = ResolverContext::anonymous_agent();
194        ctx.model_family = Some("claude-fable-5.1-preview".into()); // a version string
195        let actor = ActorClass::from_context(&ctx);
196        assert_eq!(
197            actor.family,
198            FamilyClass::Other,
199            "unknown strings bucket to Other — versions never survive"
200        );
201        ctx.model_family = Some("Claude".into());
202        assert_eq!(ActorClass::from_context(&ctx).family, FamilyClass::Claude);
203    }
204
205    #[test]
206    fn event_serde_roundtrip() {
207        let e = Event {
208            token: Token::parse("abc123").unwrap(),
209            stage: Stage::resolve(),
210            actor: ActorClass::from_context(&ResolverContext::human()),
211            at: Timestamp::from_unix_ms(9),
212            seq: Seq(4),
213            variant: Some(2),
214            regions: Some(0b101),
215        };
216        let json = serde_json::to_string(&e).unwrap();
217        let back: Event = serde_json::from_str(&json).unwrap();
218        assert_eq!(back, e);
219    }
220}