Skip to main content

everruns_core/
observer.rs

1// Observer domain types — online scoring of production sessions.
2//
3// Design Decision: Observers watch real production traffic instead of creating
4// synthetic sessions (that is what Evals do). An Observer holds match rules
5// (which sessions to score) and scorer configs (how to score them). Scoring is
6// asynchronous: matching enqueues pending TraceScore rows, background workers
7// claim and complete them.
8//
9// Design Decision: Scores are derived data with their own lifecycle and live in
10// their own table; they are never appended to the immutable session event log.
11//
12// See specs/online-evals.md for full specification.
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::eval::Scorer;
19use crate::typed_id::{AgentId, AgentVersionId, HarnessId, ObserverId, SessionId, TraceScoreId};
20
21#[cfg(feature = "openapi")]
22use utoipa::ToSchema;
23
24// ============================================
25// Observer status
26// ============================================
27
28/// Observer lifecycle status. `paused` keeps configuration but stops matching.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[cfg_attr(feature = "openapi", derive(ToSchema))]
31#[serde(rename_all = "lowercase")]
32pub enum ObserverStatus {
33    Active,
34    Paused,
35    Archived,
36    Deleted,
37}
38
39impl std::fmt::Display for ObserverStatus {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            ObserverStatus::Active => write!(f, "active"),
43            ObserverStatus::Paused => write!(f, "paused"),
44            ObserverStatus::Archived => write!(f, "archived"),
45            ObserverStatus::Deleted => write!(f, "deleted"),
46        }
47    }
48}
49
50impl From<&str> for ObserverStatus {
51    fn from(s: &str) -> Self {
52        match s {
53            "paused" => ObserverStatus::Paused,
54            "archived" => ObserverStatus::Archived,
55            "deleted" => ObserverStatus::Deleted,
56            _ => ObserverStatus::Active,
57        }
58    }
59}
60
61// ============================================
62// Match rules
63// ============================================
64
65/// Predicates selecting which production sessions an observer scores.
66/// All present predicates must match (AND); within a list, any entry matches (OR).
67/// An empty match block matches all org traffic. Sessions tagged `eval` are
68/// always excluded so synthetic eval-run sessions are never scored.
69#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
70#[cfg_attr(feature = "openapi", derive(ToSchema))]
71pub struct ObserverMatch {
72    /// Match sessions running any of these agents.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    #[cfg_attr(feature = "openapi", schema(value_type = Option<Vec<String>>))]
75    pub agent_ids: Option<Vec<AgentId>>,
76    /// Match sessions on any of these harnesses.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    #[cfg_attr(feature = "openapi", schema(value_type = Option<Vec<String>>))]
79    pub harness_ids: Option<Vec<HarnessId>>,
80    /// Match sessions carrying any of these tags.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub session_tags: Option<Vec<String>>,
83}
84
85impl ObserverMatch {
86    /// Evaluate predicates against session attributes.
87    pub fn matches(
88        &self,
89        agent_id: Option<AgentId>,
90        harness_id: Option<HarnessId>,
91        session_tags: &[String],
92    ) -> bool {
93        if let Some(agent_ids) = &self.agent_ids {
94            let Some(agent_id) = agent_id else {
95                return false;
96            };
97            if !agent_ids.contains(&agent_id) {
98                return false;
99            }
100        }
101        if let Some(harness_ids) = &self.harness_ids {
102            let Some(harness_id) = harness_id else {
103                return false;
104            };
105            if !harness_ids.contains(&harness_id) {
106                return false;
107            }
108        }
109        if let Some(tags) = &self.session_tags
110            && !tags.iter().any(|t| session_tags.contains(t))
111        {
112            return false;
113        }
114        true
115    }
116}
117
118// ============================================
119// Scorer config
120// ============================================
121
122/// What slice of the trace a scorer grades. Phase 1 implements `turn` only;
123/// `session` and `tool` scopes are specced in specs/online-evals.md.
124#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
125#[cfg_attr(feature = "openapi", derive(ToSchema))]
126#[serde(rename_all = "lowercase")]
127pub enum ObserverScope {
128    #[default]
129    Turn,
130}
131
132impl std::fmt::Display for ObserverScope {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            ObserverScope::Turn => write!(f, "turn"),
136        }
137    }
138}
139
140/// Default value at/above which an LLM-judge score is considered a pass.
141fn default_pass_threshold() -> f64 {
142    0.5
143}
144
145/// LLM-as-judge scoring configuration. The judge grades the scoped trace
146/// slice against `rubric` and returns a 0.0–1.0 value, an optional
147/// categorical label, and free-text reasoning.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[cfg_attr(feature = "openapi", derive(ToSchema))]
150pub struct LlmJudgeConfig {
151    /// Grading rubric shown to the judge model. Should describe what a high
152    /// vs. low score means.
153    pub rubric: String,
154    /// Org model to judge with. When `None`, the org's default model is used.
155    /// Judge calls go through the org's own providers and are billed to it.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
158    pub model_id: Option<crate::typed_id::ModelId>,
159    /// Score value at/above which `pass` is true.
160    #[serde(default = "default_pass_threshold")]
161    pub pass_threshold: f64,
162}
163
164/// How a scorer grades a trace slice: a deterministic `rule` (reusing the
165/// eval scorer vocabulary) or an `llm_judge`.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(ToSchema))]
168#[serde(tag = "method", rename_all = "snake_case")]
169pub enum ScorerMethod {
170    /// Deterministic rule. `file_contains` is rejected for observers (session
171    /// filesystems are not part of the observable trace contract).
172    Rule { rule: Scorer },
173    /// LLM-as-judge.
174    LlmJudge(LlmJudgeConfig),
175}
176
177/// One scorer inside an observer. `key` names the score series in listings
178/// and future dashboards; `scope` selects the trace slice; `method` is how
179/// it grades.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(ToSchema))]
182pub struct ObserverScorerConfig {
183    /// Stable name within the observer (score series name).
184    pub key: String,
185    /// Trace slice this scorer grades.
186    #[serde(default)]
187    pub scope: ObserverScope,
188    /// How this scorer grades (rule or llm_judge).
189    #[serde(flatten)]
190    pub method: ScorerMethod,
191}
192
193// ============================================
194// Observer entity
195// ============================================
196
197/// An observer: online scoring config over production sessions.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[cfg_attr(feature = "openapi", derive(ToSchema))]
200pub struct Observer {
201    /// External identifier (observer_<32-hex>). Shown as "id" in API.
202    #[serde(rename = "id")]
203    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "observer_01933b5a000070008000000000000001"))]
204    pub public_id: ObserverId,
205    /// Internal UUID primary key. Never exposed in API.
206    #[serde(skip, default = "Uuid::nil")]
207    pub internal_id: Uuid,
208    /// Organization ID. Internal only.
209    #[serde(skip, default)]
210    pub org_id: i64,
211    /// Display name.
212    pub name: String,
213    /// Optional description.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub description: Option<String>,
216    /// Which sessions to score.
217    #[serde(rename = "match", default)]
218    pub match_config: ObserverMatch,
219    /// Fraction of matching turns to score (0.0–1.0), applied after match.
220    pub sampling_rate: f64,
221    /// Scoring rules.
222    pub scorers: Vec<ObserverScorerConfig>,
223    /// Lifecycle status.
224    pub status: ObserverStatus,
225    pub created_at: DateTime<Utc>,
226    pub updated_at: DateTime<Utc>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub archived_at: Option<DateTime<Utc>>,
229}
230
231// ============================================
232// Trace scores
233// ============================================
234
235/// Lifecycle of one trace score. `pending` rows double as the scoring queue.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[cfg_attr(feature = "openapi", derive(ToSchema))]
238#[serde(rename_all = "lowercase")]
239pub enum TraceScoreStatus {
240    Pending,
241    Scoring,
242    Completed,
243    Errored,
244    Skipped,
245}
246
247impl std::fmt::Display for TraceScoreStatus {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        match self {
250            TraceScoreStatus::Pending => write!(f, "pending"),
251            TraceScoreStatus::Scoring => write!(f, "scoring"),
252            TraceScoreStatus::Completed => write!(f, "completed"),
253            TraceScoreStatus::Errored => write!(f, "errored"),
254            TraceScoreStatus::Skipped => write!(f, "skipped"),
255        }
256    }
257}
258
259impl From<&str> for TraceScoreStatus {
260    fn from(s: &str) -> Self {
261        match s {
262            "scoring" => TraceScoreStatus::Scoring,
263            "completed" => TraceScoreStatus::Completed,
264            "errored" => TraceScoreStatus::Errored,
265            "skipped" => TraceScoreStatus::Skipped,
266            _ => TraceScoreStatus::Pending,
267        }
268    }
269}
270
271/// One score produced by an observer scorer for one trace slice. Linked back
272/// to the exact session/turn it graded; agent/harness identifiers are
273/// denormalized at scoring time for aggregation.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "openapi", derive(ToSchema))]
276pub struct TraceScore {
277    /// External identifier (score_<32-hex>). Shown as "id" in API.
278    #[serde(rename = "id")]
279    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "score_01933b5a000070008000000000000001"))]
280    pub public_id: TraceScoreId,
281    /// Internal UUID primary key. Never exposed in API.
282    #[serde(skip, default = "Uuid::nil")]
283    pub internal_id: Uuid,
284    /// Organization ID. Internal only.
285    #[serde(skip, default)]
286    pub org_id: i64,
287    /// Observer that produced this score.
288    #[cfg_attr(feature = "openapi", schema(value_type = String))]
289    pub observer_id: ObserverId,
290    /// Scorer key within the observer.
291    pub scorer_key: String,
292    /// Session this score grades.
293    #[cfg_attr(feature = "openapi", schema(value_type = String))]
294    pub session_id: SessionId,
295    /// Turn this score grades (turn scope).
296    pub turn_id: String,
297    /// Agent active in the session at scoring time.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
300    pub agent_id: Option<AgentId>,
301    /// Agent version active in the session at scoring time.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
304    pub agent_version_id: Option<AgentVersionId>,
305    /// Harness of the session.
306    #[serde(skip_serializing_if = "Option::is_none")]
307    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
308    pub harness_id: Option<HarnessId>,
309    pub status: TraceScoreStatus,
310    /// Whether the scorer passed (set when completed).
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub pass: Option<bool>,
313    /// Score value 0.0–1.0 (set when completed).
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub value: Option<f64>,
316    /// Optional categorical label from an LLM judge (e.g. `missing_source`).
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub label: Option<String>,
319    /// Human-readable explanation (set when completed). For LLM judges this is
320    /// the judge's reasoning — retained as the raw material for the Phase 2
321    /// improvement loop.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub reason: Option<String>,
324    /// Judge LLM input tokens (llm_judge scores only).
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub judge_input_tokens: Option<u64>,
327    /// Judge LLM output tokens (llm_judge scores only).
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub judge_output_tokens: Option<u64>,
330    /// Judge call cost in USD when the provider reports it (llm_judge only).
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub judge_cost_usd: Option<f64>,
333    /// Error details if errored.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub error_message: Option<String>,
336    pub created_at: DateTime<Utc>,
337    pub updated_at: DateTime<Utc>,
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn observer_status_roundtrip() {
346        for (s, v) in [
347            ("active", ObserverStatus::Active),
348            ("paused", ObserverStatus::Paused),
349            ("archived", ObserverStatus::Archived),
350            ("deleted", ObserverStatus::Deleted),
351        ] {
352            assert_eq!(ObserverStatus::from(s), v);
353            assert_eq!(v.to_string(), s);
354        }
355        assert_eq!(ObserverStatus::from("unknown"), ObserverStatus::Active);
356    }
357
358    #[test]
359    fn trace_score_status_roundtrip() {
360        for (s, v) in [
361            ("pending", TraceScoreStatus::Pending),
362            ("scoring", TraceScoreStatus::Scoring),
363            ("completed", TraceScoreStatus::Completed),
364            ("errored", TraceScoreStatus::Errored),
365            ("skipped", TraceScoreStatus::Skipped),
366        ] {
367            assert_eq!(TraceScoreStatus::from(s), v);
368            assert_eq!(v.to_string(), s);
369        }
370        assert_eq!(TraceScoreStatus::from("unknown"), TraceScoreStatus::Pending);
371    }
372
373    #[test]
374    fn empty_match_matches_everything() {
375        let m = ObserverMatch::default();
376        assert!(m.matches(None, None, &[]));
377        assert!(m.matches(Some(AgentId::new()), Some(HarnessId::new()), &["x".into()]));
378    }
379
380    #[test]
381    fn match_agent_predicate() {
382        let agent = AgentId::new();
383        let m = ObserverMatch {
384            agent_ids: Some(vec![agent]),
385            ..Default::default()
386        };
387        assert!(m.matches(Some(agent), None, &[]));
388        assert!(!m.matches(Some(AgentId::new()), None, &[]));
389        assert!(!m.matches(None, None, &[]));
390    }
391
392    #[test]
393    fn match_harness_predicate() {
394        let harness = HarnessId::new();
395        let m = ObserverMatch {
396            harness_ids: Some(vec![harness]),
397            ..Default::default()
398        };
399        assert!(m.matches(None, Some(harness), &[]));
400        assert!(!m.matches(None, Some(HarnessId::new()), &[]));
401        assert!(!m.matches(None, None, &[]));
402    }
403
404    #[test]
405    fn match_tags_any_of() {
406        let m = ObserverMatch {
407            session_tags: Some(vec!["prod".into(), "beta".into()]),
408            ..Default::default()
409        };
410        assert!(m.matches(None, None, &["beta".into()]));
411        assert!(!m.matches(None, None, &["other".into()]));
412        assert!(!m.matches(None, None, &[]));
413    }
414
415    #[test]
416    fn match_predicates_are_anded() {
417        let agent = AgentId::new();
418        let m = ObserverMatch {
419            agent_ids: Some(vec![agent]),
420            session_tags: Some(vec!["prod".into()]),
421            ..Default::default()
422        };
423        assert!(m.matches(Some(agent), None, &["prod".into()]));
424        assert!(!m.matches(Some(agent), None, &[]));
425        assert!(!m.matches(None, None, &["prod".into()]));
426    }
427
428    #[test]
429    fn scorer_config_serde_defaults_scope() {
430        let json = serde_json::json!({
431            "key": "greeting",
432            "method": "rule",
433            "rule": { "type": "contains", "text": "hello" }
434        });
435        let config: ObserverScorerConfig = serde_json::from_value(json).unwrap();
436        assert_eq!(config.scope, ObserverScope::Turn);
437        assert_eq!(config.key, "greeting");
438        assert!(matches!(config.method, ScorerMethod::Rule { .. }));
439    }
440
441    #[test]
442    fn scorer_config_llm_judge_serde() {
443        let json = serde_json::json!({
444            "key": "completeness",
445            "scope": "turn",
446            "method": "llm_judge",
447            "rubric": "Score 1 if the answer fully addresses the question."
448        });
449        let config: ObserverScorerConfig = serde_json::from_value(json).unwrap();
450        assert_eq!(config.key, "completeness");
451        match config.method {
452            ScorerMethod::LlmJudge(j) => {
453                assert!(j.model_id.is_none());
454                assert_eq!(j.pass_threshold, 0.5);
455                assert!(j.rubric.contains("fully addresses"));
456            }
457            _ => panic!("expected llm_judge"),
458        }
459    }
460
461    #[test]
462    fn observer_serde_skips_internal_fields() {
463        let observer = Observer {
464            public_id: ObserverId::from_uuid(Uuid::nil()),
465            internal_id: Uuid::nil(),
466            org_id: 1,
467            name: "test".into(),
468            description: None,
469            match_config: ObserverMatch::default(),
470            sampling_rate: 0.1,
471            scorers: vec![],
472            status: ObserverStatus::Active,
473            created_at: Utc::now(),
474            updated_at: Utc::now(),
475            archived_at: None,
476        };
477        let json = serde_json::to_value(&observer).unwrap();
478        assert!(json.get("id").is_some());
479        assert!(json.get("match").is_some());
480        assert!(json.get("internal_id").is_none());
481        assert!(json.get("org_id").is_none());
482        assert_eq!(json["sampling_rate"], 0.1);
483    }
484}