firstpass_core/features.rs
1//! The request feature vector (SPEC §9.2) — deterministic, versioned, privacy-preserving.
2//!
3//! Features are the input to routing policy. They are extracted deterministically from a
4//! request so that the same request always produces the same vector (a precondition for a
5//! re-derivable audit trail), and they are **privacy-preserving by construction**: no raw
6//! prompt text, only coarse buckets and salted hashes. The vector is versioned
7//! ([`FEATURE_VERSION`]); a change to how any feature is computed bumps the version so old
8//! traces remain interpretable.
9
10use serde::{Deserialize, Serialize};
11
12/// Version of the feature-extraction contract. Bump on any change to how a feature is
13/// computed. Recorded per trace as `features@vN`.
14pub const FEATURE_VERSION: u32 = 1;
15
16/// Coarse task classification. `Other` is the safe default when classification is uncertain.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum TaskKind {
20 /// Editing or writing code (the primary M0/M1 target).
21 CodeEdit,
22 /// Generating or repairing tests.
23 TestGen,
24 /// Read-only investigation / search / navigation.
25 Explore,
26 /// Reviewing or critiquing existing work.
27 Review,
28 /// Structured extraction / classification.
29 Extract,
30 /// Free-form conversation.
31 Chat,
32 /// Anything not confidently classified (the safe default).
33 #[default]
34 Other,
35}
36
37/// The per-request feature vector (§9.2).
38///
39/// Rolling per-bucket statistics (e.g. `prior_rung_clearance`) are intentionally **not**
40/// here — they live in the trace store, not the deterministic per-request contract.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct Features {
43 /// Feature-extraction contract version (`features@vN`).
44 pub version: u32,
45 /// Coarse task classification.
46 pub task_kind: TaskKind,
47 /// Programming language, when known (lowercased identifier, e.g. `"rust"`).
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub language: Option<String>,
50 /// Calling agent identity, when known (e.g. `"compass"`).
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub agent: Option<String>,
53 /// Calling subagent identity, when known (e.g. `"test-runner"`).
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub subagent: Option<String>,
56 /// Coarse bucket of the prompt's token count (see [`token_bucket`]) — never the raw count.
57 pub prompt_token_bucket: u32,
58 /// Number of tools/functions offered in the request.
59 pub tool_count: u32,
60 /// Whether the request carried image content.
61 pub has_images: bool,
62 /// Salted, truncated hash of the repository identity (see [`repo_fingerprint`]) — never a path.
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub repo_fingerprint: Option<String>,
65 /// How many attempts have already failed in this session (drives session promotion, §8.4).
66 pub session_failure_count: u32,
67 /// Hour-of-day bucket in UTC, `0..=23` (see [`hour_bucket`]).
68 pub hour_bucket: u8,
69}
70
71impl Features {
72 /// A minimal vector stamped with the current [`FEATURE_VERSION`] and the given task kind.
73 #[must_use]
74 pub fn new(task_kind: TaskKind) -> Self {
75 Self {
76 version: FEATURE_VERSION,
77 task_kind,
78 language: None,
79 agent: None,
80 subagent: None,
81 prompt_token_bucket: 0,
82 tool_count: 0,
83 has_images: false,
84 repo_fingerprint: None,
85 session_failure_count: 0,
86 hour_bucket: 0,
87 }
88 }
89}
90
91/// Bucket a token count into a coarse, privacy-preserving band: `floor(log2(n))`, with
92/// `0` and `1` both mapping to bucket `0`.
93///
94/// Monotonic non-decreasing in `n`, so ordering is preserved while the exact count is not
95/// recoverable. Deterministic — the same `n` always yields the same bucket.
96#[must_use]
97pub fn token_bucket(n: u64) -> u32 {
98 if n < 2 {
99 0
100 } else {
101 // 63 - leading_zeros == floor(log2(n)) for n >= 1.
102 63 - n.leading_zeros()
103 }
104}
105
106/// Salted, truncated fingerprint of a repository identity: the first 16 hex chars of
107/// `SHA-256(salt || 0x00 || repo)`.
108///
109/// The salt is a per-deployment secret; without it the fingerprint is not reversible to the
110/// repo identity, and cross-deployment correlation is prevented. Deterministic for a fixed salt.
111#[must_use]
112pub fn repo_fingerprint(salt: &str, repo: &str) -> String {
113 use sha2::{Digest, Sha256};
114 let mut h = Sha256::new();
115 h.update(salt.as_bytes());
116 h.update([0u8]); // domain separator so salt||repo can't collide with a different split
117 h.update(repo.as_bytes());
118 let digest = h.finalize();
119 hex::encode(&digest[..8]) // 8 bytes -> 16 hex chars
120}
121
122/// Hour-of-day bucket in UTC (`0..=23`) for a timestamp — a routing feature (traffic varies
123/// by hour) that leaks nothing finer than the hour.
124#[must_use]
125pub fn hour_bucket(ts: jiff::Timestamp) -> u8 {
126 // Convert to a UTC civil time; hour is 0..=23.
127 ts.to_zoned(jiff::tz::TimeZone::UTC).hour() as u8
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn token_bucket_is_monotonic_and_coarse() {
136 assert_eq!(token_bucket(0), 0);
137 assert_eq!(token_bucket(1), 0);
138 assert_eq!(token_bucket(2), 1);
139 assert_eq!(token_bucket(3), 1);
140 assert_eq!(token_bucket(4), 2);
141 assert_eq!(token_bucket(1024), 10);
142 // monotonic non-decreasing
143 let mut last = 0;
144 for n in 0..5000u64 {
145 let b = token_bucket(n);
146 assert!(b >= last);
147 last = b;
148 }
149 }
150
151 #[test]
152 fn repo_fingerprint_is_deterministic_salted_and_truncated() {
153 let a = repo_fingerprint("salt1", "github.com/acme/api");
154 assert_eq!(a, repo_fingerprint("salt1", "github.com/acme/api")); // deterministic
155 assert_ne!(a, repo_fingerprint("salt2", "github.com/acme/api")); // salt-sensitive
156 assert_ne!(a, repo_fingerprint("salt1", "github.com/acme/web")); // repo-sensitive
157 assert_eq!(a.len(), 16);
158 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
159 // domain separator: "a"+"bc" must not collide with "ab"+"c"
160 assert_ne!(repo_fingerprint("a", "bc"), repo_fingerprint("ab", "c"));
161 }
162
163 #[test]
164 fn hour_bucket_in_range() {
165 // 2026-07-08T15:04:05Z -> hour 15 UTC
166 let ts: jiff::Timestamp = "2026-07-08T15:04:05Z".parse().unwrap();
167 assert_eq!(hour_bucket(ts), 15);
168 let midnight: jiff::Timestamp = "2026-01-01T00:00:00Z".parse().unwrap();
169 assert_eq!(hour_bucket(midnight), 0);
170 }
171
172 #[test]
173 fn features_default_version_stamped() {
174 let f = Features::new(TaskKind::CodeEdit);
175 assert_eq!(f.version, FEATURE_VERSION);
176 assert_eq!(f.task_kind, TaskKind::CodeEdit);
177 }
178}