1use serde::{Deserialize, Serialize};
6
7use crate::context::{ConsumerKind, ResolverContext};
8use crate::slug::Stage;
9use crate::time::Timestamp;
10use crate::token::Token;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct Seq(pub u32);
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum FamilyClass {
24 None,
26 Claude,
28 Gpt,
30 Gemini,
32 Other,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum HarnessClass {
40 None,
42 ClaudeCode,
44 Codex,
46 Other,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ActorClass {
54 pub kind: ConsumerKind,
56 pub family: FamilyClass,
58 pub harness: HarnessClass,
60}
61
62impl ActorClass {
63 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct Event {
128 pub token: Token,
130 pub stage: Stage,
132 pub actor: ActorClass,
134 pub at: Timestamp,
136 pub seq: Seq,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub variant: Option<u8>,
142 #[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()); 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}