zero_engine_client/stat.rs
1//! `Stat<T>` — the honesty primitive.
2//!
3//! Every numeric value the TUI renders passes through this type. The
4//! renderer refuses to display a `Stat` whose freshness violates the
5//! configured threshold, and it always shows `n` and `source` when
6//! relevant.
7//!
8//! See spec §3.1 ("honesty is render-native") and ADR-003.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13/// Where a value came from. Used in rendering for source attribution
14/// and in debugging to trace drift.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Source {
18 /// HTTP REST response from the engine.
19 Http,
20 /// WebSocket push from the engine bus poller.
21 Ws,
22 /// MCP tool call response.
23 Mcp,
24 /// Derived on CLI side from other `Stat`s (presentation only).
25 Derived,
26 /// Fixture or mock — never rendered in production.
27 Mock,
28}
29
30/// A value with the metadata required to render honestly.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Stat<T> {
33 /// The actual value.
34 pub value: T,
35 /// When the engine produced this reading.
36 pub as_of: DateTime<Utc>,
37 /// Sample size, when the value is a summary statistic. `None` when
38 /// the value is a live reading (price, position size, etc.).
39 pub n: Option<u64>,
40 /// Where it came from.
41 pub source: Source,
42}
43
44impl<T> Stat<T> {
45 pub fn new(value: T, source: Source) -> Self {
46 Self {
47 value,
48 as_of: Utc::now(),
49 n: None,
50 source,
51 }
52 }
53
54 #[must_use]
55 pub fn with_n(mut self, n: u64) -> Self {
56 self.n = Some(n);
57 self
58 }
59
60 #[must_use]
61 pub fn with_as_of(mut self, as_of: DateTime<Utc>) -> Self {
62 self.as_of = as_of;
63 self
64 }
65
66 /// Age of the reading at the given instant.
67 #[must_use]
68 pub fn age(&self, now: DateTime<Utc>) -> chrono::Duration {
69 now.signed_duration_since(self.as_of)
70 }
71
72 #[must_use]
73 pub fn is_stale(&self, now: DateTime<Utc>, threshold: chrono::Duration) -> bool {
74 self.age(now) > threshold
75 }
76}