use serde::{Deserialize, Serialize};
use crate::context::{ConsumerKind, ResolverContext};
use crate::slug::Stage;
use crate::time::Timestamp;
use crate::token::Token;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Seq(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FamilyClass {
None,
Claude,
Gpt,
Gemini,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessClass {
None,
ClaudeCode,
Codex,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActorClass {
pub kind: ConsumerKind,
pub family: FamilyClass,
pub harness: HarnessClass,
}
impl ActorClass {
#[must_use]
pub fn from_context(ctx: &ResolverContext) -> Self {
let family = match ctx
.model_family
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref()
{
None => FamilyClass::None,
Some("claude") => FamilyClass::Claude,
Some("gpt") => FamilyClass::Gpt,
Some("gemini") => FamilyClass::Gemini,
Some(_) => FamilyClass::Other,
};
let harness = match ctx
.harness
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref()
{
None => HarnessClass::None,
Some("claude-code") => HarnessClass::ClaudeCode,
Some("codex") => HarnessClass::Codex,
Some(_) => HarnessClass::Other,
};
Self {
kind: ctx.kind,
family,
harness,
}
}
#[must_use]
pub fn code(self) -> u8 {
let k = match self.kind {
ConsumerKind::Bot => 0u8,
ConsumerKind::Human => 1,
ConsumerKind::Terminal => 2,
ConsumerKind::Agent => 3,
};
let f = match self.family {
FamilyClass::None => 0u8,
FamilyClass::Claude => 1,
FamilyClass::Gpt => 2,
FamilyClass::Gemini => 3,
FamilyClass::Other => 4,
};
let h = match self.harness {
HarnessClass::None => 0u8,
HarnessClass::ClaudeCode => 1,
HarnessClass::Codex => 2,
HarnessClass::Other => 3,
};
k | (f << 2) | (h << 5)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
pub token: Token,
pub stage: Stage,
pub actor: ActorClass,
pub at: Timestamp,
pub seq: Seq,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regions: Option<u8>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actor_codes_are_distinct_and_stable() {
let mut seen = std::collections::BTreeSet::new();
for kind in [
ConsumerKind::Bot,
ConsumerKind::Human,
ConsumerKind::Terminal,
ConsumerKind::Agent,
] {
for family in [
FamilyClass::None,
FamilyClass::Claude,
FamilyClass::Gpt,
FamilyClass::Gemini,
FamilyClass::Other,
] {
for harness in [
HarnessClass::None,
HarnessClass::ClaudeCode,
HarnessClass::Codex,
HarnessClass::Other,
] {
assert!(seen.insert(
ActorClass {
kind,
family,
harness
}
.code()
));
}
}
}
assert_eq!(seen.len(), 4 * 5 * 4);
}
#[test]
fn classification_is_coarse_by_construction() {
let mut ctx = ResolverContext::anonymous_agent();
ctx.model_family = Some("claude-fable-5.1-preview".into()); let actor = ActorClass::from_context(&ctx);
assert_eq!(
actor.family,
FamilyClass::Other,
"unknown strings bucket to Other — versions never survive"
);
ctx.model_family = Some("Claude".into());
assert_eq!(ActorClass::from_context(&ctx).family, FamilyClass::Claude);
}
#[test]
fn event_serde_roundtrip() {
let e = Event {
token: Token::parse("abc123").unwrap(),
stage: Stage::resolve(),
actor: ActorClass::from_context(&ResolverContext::human()),
at: Timestamp::from_unix_ms(9),
seq: Seq(4),
variant: Some(2),
regions: Some(0b101),
};
let json = serde_json::to_string(&e).unwrap();
let back: Event = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
}