Skip to main content

chio_kernel/operator_report/
behavioral_analysis.rs

1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4#[serde(rename_all = "camelCase")]
5pub struct BehavioralFeedPrivacyBoundary {
6    pub matching_receipts: u64,
7    pub returned_receipts: u64,
8    pub direct_evidence_export_supported: bool,
9    pub child_receipt_scope: EvidenceChildReceiptScope,
10    pub proofs_complete: bool,
11    pub export_query: EvidenceExportQuery,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub export_scope_note: Option<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct BehavioralFeedDecisionSummary {
19    pub allow_count: u64,
20    pub deny_count: u64,
21    pub cancelled_count: u64,
22    pub incomplete_count: u64,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct BehavioralFeedSettlementSummary {
28    pub pending_receipts: u64,
29    pub settled_receipts: u64,
30    pub failed_receipts: u64,
31    pub not_applicable_receipts: u64,
32    pub actionable_receipts: u64,
33    pub reconciled_receipts: u64,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub struct BehavioralFeedGovernedActionSummary {
39    pub governed_receipts: u64,
40    pub approval_receipts: u64,
41    pub approved_receipts: u64,
42    pub commerce_receipts: u64,
43    pub max_amount_receipts: u64,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "camelCase")]
48pub struct BehavioralFeedMeteredBillingSummary {
49    pub metered_receipts: u64,
50    pub evidence_attached_receipts: u64,
51    pub missing_evidence_receipts: u64,
52    pub over_quoted_units_receipts: u64,
53    pub over_max_billed_units_receipts: u64,
54    pub over_quoted_cost_receipts: u64,
55    pub financial_mismatch_receipts: u64,
56    pub actionable_receipts: u64,
57    pub reconciled_receipts: u64,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61#[serde(rename_all = "camelCase")]
62pub struct BehavioralFeedReputationSummary {
63    pub subject_key: String,
64    pub effective_score: f64,
65    pub probationary: bool,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub resolved_tier: Option<String>,
68    pub imported_signal_count: usize,
69    pub accepted_imported_signal_count: usize,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[serde(rename_all = "camelCase")]
74pub struct BehavioralFeedReceiptRow {
75    pub receipt_id: String,
76    pub timestamp: u64,
77    pub capability_id: String,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub subject_key: Option<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub issuer_key: Option<String>,
82    pub tool_server: String,
83    pub tool_name: String,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub decision: Option<Decision>,
86    #[serde(default)]
87    pub authorized: bool,
88    pub settlement_status: SettlementStatus,
89    pub reconciliation_state: SettlementReconciliationState,
90    pub action_required: bool,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub cost_charged: Option<u64>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub attempted_cost: Option<u64>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub currency: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub budget_authority: Option<FinancialBudgetAuthorityReceiptMetadata>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub governed: Option<GovernedTransactionReceiptMetadata>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub governed_transaction_diagnostics: Option<GovernedTransactionDiagnostics>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub metered_reconciliation: Option<BehavioralFeedMeteredBillingRow>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108#[serde(rename_all = "camelCase")]
109pub struct BehavioralFeedMeteredBillingRow {
110    pub reconciliation_state: MeteredBillingReconciliationState,
111    pub action_required: bool,
112    pub evidence_missing: bool,
113    pub exceeds_quoted_units: bool,
114    pub exceeds_max_billed_units: bool,
115    pub exceeds_quoted_cost: bool,
116    pub financial_mismatch: bool,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub evidence: Option<MeteredBillingEvidenceRecord>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122#[serde(rename_all = "camelCase")]
123pub struct BehavioralFeedReceiptSelection {
124    pub matching_receipts: u64,
125    pub receipts: Vec<BehavioralFeedReceiptRow>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129#[serde(rename_all = "camelCase")]
130pub struct BehavioralFeedReport {
131    pub schema: String,
132    pub generated_at: u64,
133    pub filters: BehavioralFeedQuery,
134    pub privacy: BehavioralFeedPrivacyBoundary,
135    pub decisions: BehavioralFeedDecisionSummary,
136    pub settlements: BehavioralFeedSettlementSummary,
137    pub governed_actions: BehavioralFeedGovernedActionSummary,
138    pub metered_billing: BehavioralFeedMeteredBillingSummary,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub reputation: Option<BehavioralFeedReputationSummary>,
141    pub shared_evidence: SharedEvidenceReferenceSummary,
142    pub receipts: Vec<BehavioralFeedReceiptRow>,
143}
144
145pub type SignedBehavioralFeed = SignedExportEnvelope<BehavioralFeedReport>;
146
147// ===========================================================================
148// Scoring and advisory signals over ComplianceReport and BehavioralFeedReport.
149// ===========================================================================
150
151/// EMA (exponentially-weighted moving average) baseline state for a
152/// single (agent, metric) pair. Used by behavioral profiling to detect
153/// z-score anomalies without storing every historical sample.
154///
155/// The baseline uses Welford-style incremental tracking of mean and
156/// variance so callers can compute a z-score for any new sample
157/// without re-reading history.
158#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
159#[serde(rename_all = "camelCase")]
160pub struct EmaBaselineState {
161    /// Number of samples folded into the baseline.
162    pub sample_count: u64,
163    /// Exponentially-weighted mean.
164    pub ema_mean: f64,
165    /// Exponentially-weighted variance.
166    pub ema_variance: f64,
167    /// Last update timestamp (unix seconds).
168    pub last_update: u64,
169}
170
171impl EmaBaselineState {
172    /// Fold a new sample into the baseline with the provided smoothing
173    /// factor `alpha` (0.0..=1.0). Higher alpha weighs recent samples
174    /// more heavily.
175    ///
176    /// `alpha` is clamped to `(0.0, 1.0]`. `now` is recorded as
177    /// `last_update`.
178    pub fn update(&mut self, sample: f64, alpha: f64, now: u64) {
179        let alpha = alpha.clamp(f64::MIN_POSITIVE, 1.0);
180        if self.sample_count == 0 {
181            self.ema_mean = sample;
182            self.ema_variance = 0.0;
183        } else {
184            let prev_mean = self.ema_mean;
185            self.ema_mean = prev_mean + alpha * (sample - prev_mean);
186            // Incremental EWMA variance, following West (1979) / Welford.
187            let diff = sample - prev_mean;
188            self.ema_variance = (1.0 - alpha) * (self.ema_variance + alpha * diff * diff);
189        }
190        self.sample_count = self.sample_count.saturating_add(1);
191        self.last_update = now;
192    }
193
194    /// Standard deviation (sqrt of EWMA variance).
195    #[must_use]
196    pub fn stddev(&self) -> f64 {
197        self.ema_variance.max(0.0).sqrt()
198    }
199
200    /// Z-score for a new sample. Returns `None` when the baseline has
201    /// fewer than two samples or zero variance (no meaningful signal).
202    #[must_use]
203    pub fn z_score(&self, sample: f64) -> Option<f64> {
204        if self.sample_count < 2 {
205            return None;
206        }
207        let stddev = self.stddev();
208        if stddev <= f64::EPSILON {
209            return None;
210        }
211        Some((sample - self.ema_mean) / stddev)
212    }
213}
214
215/// Summary of behavioral-anomaly signals derived from receipts over a
216/// window. Used by `BehavioralProfileGuard` and surfaced in operator
217/// UIs.
218#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
219#[serde(rename_all = "camelCase")]
220pub struct BehavioralAnomalyScore {
221    /// Agent subject this anomaly score applies to.
222    pub agent_id: String,
223    /// Baseline statistic the z-score is computed against.
224    pub baseline: EmaBaselineState,
225    /// Current-window sample value (e.g. call count per window).
226    pub current_sample: f64,
227    /// Computed z-score, or `None` when baseline is too small.
228    pub z_score: Option<f64>,
229    /// Threshold above which an advisory signal is raised.
230    pub sigma_threshold: f64,
231    /// Whether the current sample crossed the threshold.
232    pub anomaly: bool,
233    /// Unix timestamp (seconds) at which the score was computed.
234    pub generated_at: u64,
235}
236
237/// Compute a behavioral-anomaly score from a pre-existing baseline plus
238/// a current-window sample. Exposes the same math the guard uses so
239/// callers can surface anomaly scores in dashboards without rerunning
240/// the guard.
241#[must_use]
242pub fn behavioral_anomaly_score(
243    agent_id: &str,
244    baseline: &EmaBaselineState,
245    current_sample: f64,
246    sigma_threshold: f64,
247    now: u64,
248) -> BehavioralAnomalyScore {
249    let z_score = baseline.z_score(current_sample);
250    let anomaly = z_score.is_some_and(|z| z.abs() > sigma_threshold);
251    BehavioralAnomalyScore {
252        agent_id: agent_id.to_string(),
253        baseline: baseline.clone(),
254        current_sample,
255        z_score,
256        sigma_threshold,
257        anomaly,
258        generated_at: now,
259    }
260}