Skip to main content

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}