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 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub entry: Option<u32>,
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn actor_codes_are_distinct_and_stable() {
165 let mut seen = std::collections::BTreeSet::new();
166 for kind in [
167 ConsumerKind::Bot,
168 ConsumerKind::Human,
169 ConsumerKind::Terminal,
170 ConsumerKind::Agent,
171 ] {
172 for family in [
173 FamilyClass::None,
174 FamilyClass::Claude,
175 FamilyClass::Gpt,
176 FamilyClass::Gemini,
177 FamilyClass::Other,
178 ] {
179 for harness in [
180 HarnessClass::None,
181 HarnessClass::ClaudeCode,
182 HarnessClass::Codex,
183 HarnessClass::Other,
184 ] {
185 assert!(seen.insert(
186 ActorClass {
187 kind,
188 family,
189 harness
190 }
191 .code()
192 ));
193 }
194 }
195 }
196 assert_eq!(seen.len(), 4 * 5 * 4);
197 }
198
199 #[test]
200 fn classification_is_coarse_by_construction() {
201 let mut ctx = ResolverContext::anonymous_agent();
202 ctx.model_family = Some("claude-fable-5.1-preview".into()); let actor = ActorClass::from_context(&ctx);
204 assert_eq!(
205 actor.family,
206 FamilyClass::Other,
207 "unknown strings bucket to Other — versions never survive"
208 );
209 ctx.model_family = Some("Claude".into());
210 assert_eq!(ActorClass::from_context(&ctx).family, FamilyClass::Claude);
211 }
212
213 #[test]
214 fn event_serde_roundtrip() {
215 let e = Event {
216 token: Token::parse("abc123").unwrap(),
217 stage: Stage::resolve(),
218 actor: ActorClass::from_context(&ResolverContext::human()),
219 at: Timestamp::from_unix_ms(9),
220 seq: Seq(4),
221 variant: Some(2),
222 regions: Some(0b101),
223 entry: Some(7),
224 };
225 let json = serde_json::to_string(&e).unwrap();
226 let back: Event = serde_json::from_str(&json).unwrap();
227 assert_eq!(back, e);
228 }
229}