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}
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()); 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}