Skip to main content

chio_kernel/
compliance_score.rs

1//! Compliance scoring built on top of `ComplianceReport`.
2//!
3//! Projects a [`crate::operator_report::ComplianceReport`]
4//! into a user-facing 0..=1000 score with weighted factors:
5//!
6//! | Factor                   | Max points | Signal                                   |
7//! |--------------------------|-----------:|------------------------------------------|
8//! | Deny rate                |        300 | denies / total_observed                  |
9//! | Revocation rate          |        300 | revoked_caps / observed_caps (or flag)   |
10//! | Velocity anomaly rate    |        150 | anomaly_windows / total_windows          |
11//! | Policy coverage          |        150 | lineage + checkpoint rates (averaged)    |
12//! | Attestation freshness    |        100 | age of latest attestation vs. staleness  |
13//!
14//! Weights sum to 1000. Each factor produces a 0..=max deduction; the
15//! final score is `1000 - total_deductions`, clamped to `[0, 1000]`.
16//!
17//! Scoring consumes a [`ComplianceReport`] without modifying its fields.
18//! Callers who already materialize a compliance report reuse its figures
19//! verbatim.
20
21use serde::{Deserialize, Serialize};
22
23use chio_core::underwriting::{
24    UnderwritingComplianceEvidence, UNDERWRITING_COMPLIANCE_EVIDENCE_SCHEMA,
25};
26
27use crate::operator_report::ComplianceReport;
28
29/// Maximum possible compliance score.
30pub const COMPLIANCE_SCORE_MAX: u32 = 1000;
31
32/// Weight (maximum-deducted points) for the deny-rate factor.
33pub const WEIGHT_DENY_RATE: u32 = 300;
34/// Weight for the revocation factor.
35pub const WEIGHT_REVOCATION: u32 = 300;
36/// Weight for the velocity-anomaly factor.
37pub const WEIGHT_VELOCITY_ANOMALY: u32 = 150;
38/// Weight for the policy-coverage factor.
39pub const WEIGHT_POLICY_COVERAGE: u32 = 150;
40/// Weight for the attestation-freshness factor.
41pub const WEIGHT_ATTESTATION_FRESHNESS: u32 = 100;
42
43/// Default staleness threshold (seconds) beyond which the attestation
44/// freshness factor is fully deducted. Ninety days mirrors the default
45/// receipt-retention window in [`crate::receipt_store::RetentionConfig`].
46pub const DEFAULT_ATTESTATION_STALENESS_SECS: u64 = 7_776_000;
47
48/// Observed compliance inputs that are not carried by `ComplianceReport`.
49///
50/// The raw `ComplianceReport` tracks lineage and checkpoint coverage
51/// but does not carry deny counts, revocation state, or velocity
52/// anomaly counts. `ComplianceScoreInputs` is the additive surface that
53/// callers populate from adjacent stores (receipt analytics,
54/// revocation store, velocity guard telemetry).
55#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "camelCase")]
57pub struct ComplianceScoreInputs {
58    /// Total receipts observed in the scoring window.
59    pub total_receipts: u64,
60    /// Receipts with a deny decision in the scoring window.
61    pub deny_receipts: u64,
62    /// Number of capabilities exercised (or observed) in the window.
63    pub observed_capabilities: u64,
64    /// Number of those capabilities that are currently revoked.
65    pub revoked_capabilities: u64,
66    /// Whether any capability exercised by this agent is currently revoked.
67    /// Fast-path fallback when per-capability counts aren't available.
68    pub any_revoked: bool,
69    /// Number of velocity windows evaluated.
70    pub velocity_windows: u64,
71    /// Windows flagged as anomalous by velocity / behavioral guards.
72    pub anomalous_velocity_windows: u64,
73    /// Age (in seconds) of the most recent kernel-signed attestation
74    /// (checkpoint, receipt, or dpop nonce) at scoring time. When
75    /// `None`, freshness is treated as maximally stale.
76    pub attestation_age_secs: Option<u64>,
77}
78
79impl ComplianceScoreInputs {
80    /// Build an inputs struct from a `ComplianceReport` plus the
81    /// ambient inputs the report does not track.
82    ///
83    /// This helper keeps callers from duplicating the "zero checkpoint
84    /// coverage still counts" logic when no receipts are observed.
85    #[must_use]
86    pub fn new(
87        total_receipts: u64,
88        deny_receipts: u64,
89        observed_capabilities: u64,
90        revoked_capabilities: u64,
91        velocity_windows: u64,
92        anomalous_velocity_windows: u64,
93        attestation_age_secs: Option<u64>,
94    ) -> Self {
95        let any_revoked = revoked_capabilities > 0;
96        Self {
97            total_receipts,
98            deny_receipts,
99            observed_capabilities,
100            revoked_capabilities,
101            any_revoked,
102            velocity_windows,
103            anomalous_velocity_windows,
104            attestation_age_secs,
105        }
106    }
107}
108
109/// Per-factor deduction detail (0..=max points).
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111#[serde(rename_all = "camelCase")]
112pub struct ComplianceFactor {
113    /// Human-readable factor name.
114    pub name: String,
115    /// Weight (maximum deduction) assigned to this factor.
116    pub weight: u32,
117    /// Deduction applied (0..=weight).
118    pub deduction: u32,
119    /// Points awarded (weight - deduction).
120    pub points: u32,
121    /// Raw rate / ratio that drove the deduction (0.0..=1.0).
122    pub rate: f64,
123}
124
125impl ComplianceFactor {
126    fn from_rate(name: &str, weight: u32, rate: f64) -> Self {
127        let clamped = rate.clamp(0.0, 1.0);
128        // Round half-away-from-zero is not necessary; floor is enough
129        // because all weights are small integers.
130        let raw = (clamped * f64::from(weight)).round();
131        let deduction = raw.clamp(0.0, f64::from(weight)) as u32;
132        let points = weight.saturating_sub(deduction);
133        Self {
134            name: name.to_string(),
135            weight,
136            deduction,
137            points,
138            rate: clamped,
139        }
140    }
141}
142
143/// Full factor breakdown for a compliance score.
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145#[serde(rename_all = "camelCase")]
146pub struct ComplianceFactorBreakdown {
147    pub deny_rate: ComplianceFactor,
148    pub revocation: ComplianceFactor,
149    pub velocity_anomaly: ComplianceFactor,
150    pub policy_coverage: ComplianceFactor,
151    pub attestation_freshness: ComplianceFactor,
152}
153
154impl ComplianceFactorBreakdown {
155    #[must_use]
156    pub fn total_deductions(&self) -> u32 {
157        self.deny_rate.deduction
158            + self.revocation.deduction
159            + self.velocity_anomaly.deduction
160            + self.policy_coverage.deduction
161            + self.attestation_freshness.deduction
162    }
163
164    #[must_use]
165    pub fn total_points(&self) -> u32 {
166        self.deny_rate.points
167            + self.revocation.points
168            + self.velocity_anomaly.points
169            + self.policy_coverage.points
170            + self.attestation_freshness.points
171    }
172}
173
174/// Final compliance score for an agent over a window.
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176#[serde(rename_all = "camelCase")]
177pub struct ComplianceScore {
178    /// Agent subject the score applies to.
179    pub agent_id: String,
180    /// 0..=1000 score (1000 = perfect).
181    pub score: u32,
182    /// Factor-by-factor breakdown.
183    pub factor_breakdown: ComplianceFactorBreakdown,
184    /// Unix timestamp (seconds) at which the score was computed.
185    pub generated_at: u64,
186    /// Snapshot of the inputs used to compute the score.
187    pub inputs: ComplianceScoreInputs,
188}
189
190impl ComplianceScore {
191    #[must_use]
192    pub fn as_underwriting_evidence(&self) -> UnderwritingComplianceEvidence {
193        UnderwritingComplianceEvidence {
194            schema: UNDERWRITING_COMPLIANCE_EVIDENCE_SCHEMA.to_string(),
195            agent_id: self.agent_id.clone(),
196            score: self.score,
197            generated_at: self.generated_at,
198            total_receipts: self.inputs.total_receipts,
199            deny_receipts: self.inputs.deny_receipts,
200            observed_capabilities: self.inputs.observed_capabilities,
201            revoked_capabilities: self.inputs.revoked_capabilities,
202            attestation_age_secs: self.inputs.attestation_age_secs,
203        }
204    }
205}
206
207/// Options controlling scoring thresholds. Defaults yield zero denies in
208/// 1000 calls -> >900; any revoked cap -> <500.
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
210#[serde(rename_all = "camelCase")]
211pub struct ComplianceScoreConfig {
212    /// Attestation age (seconds) at which freshness is fully deducted.
213    pub attestation_staleness_secs: u64,
214    /// When `true`, a single `any_revoked == true` flag fully deducts
215    /// the revocation factor even if `observed_capabilities` is zero.
216    pub treat_any_revocation_as_full: bool,
217    /// Ceiling (exclusive) on the final score when any observed
218    /// capability is revoked. Defaults to 500 so that a revoked
219    /// capability yields a score <500 regardless of the raw factor math.
220    pub revocation_ceiling: u32,
221}
222
223impl Default for ComplianceScoreConfig {
224    fn default() -> Self {
225        Self {
226            attestation_staleness_secs: DEFAULT_ATTESTATION_STALENESS_SECS,
227            treat_any_revocation_as_full: true,
228            revocation_ceiling: 499,
229        }
230    }
231}
232
233/// Compute the weighted compliance score for an agent.
234///
235/// * `report` -- previously-materialized compliance report (for
236///   lineage and checkpoint coverage).
237/// * `inputs` -- ambient inputs the report does not carry (deny rate,
238///   revocations, velocity anomalies, attestation age).
239/// * `config` -- scoring thresholds.
240/// * `agent_id` -- scored agent (echoed into the output).
241/// * `now` -- Unix timestamp to stamp on the score.
242#[must_use]
243pub fn compliance_score(
244    report: &ComplianceReport,
245    inputs: &ComplianceScoreInputs,
246    config: &ComplianceScoreConfig,
247    agent_id: &str,
248    now: u64,
249) -> ComplianceScore {
250    let breakdown = compliance_factor_breakdown(report, inputs, config);
251    let raw_score = COMPLIANCE_SCORE_MAX.saturating_sub(breakdown.total_deductions());
252    // Revocation ceiling: once the regulatory system has revoked any
253    // capability exercised by this agent, the score is capped below
254    // `revocation_ceiling` regardless of the raw factor math. This
255    // ensures a revoked capability yields a score <500 even when other
256    // factors look healthy.
257    let score = if inputs.any_revoked || inputs.revoked_capabilities > 0 {
258        raw_score.min(config.revocation_ceiling)
259    } else {
260        raw_score
261    };
262
263    ComplianceScore {
264        agent_id: agent_id.to_string(),
265        score,
266        factor_breakdown: breakdown,
267        generated_at: now,
268        inputs: inputs.clone(),
269    }
270}
271
272/// Build the per-factor breakdown without wrapping it in a score.
273///
274/// Exposed for callers that want to surface individual factor deltas
275/// (dashboards) without collapsing to a single number.
276#[must_use]
277pub fn compliance_factor_breakdown(
278    report: &ComplianceReport,
279    inputs: &ComplianceScoreInputs,
280    config: &ComplianceScoreConfig,
281) -> ComplianceFactorBreakdown {
282    // --- Deny rate ----------------------------------------------------
283    let deny_rate = if inputs.total_receipts == 0 {
284        0.0
285    } else {
286        inputs.deny_receipts as f64 / inputs.total_receipts as f64
287    };
288
289    // --- Revocation ---------------------------------------------------
290    let revocation_rate = if inputs.observed_capabilities == 0 {
291        if config.treat_any_revocation_as_full && inputs.any_revoked {
292            1.0
293        } else {
294            0.0
295        }
296    } else {
297        let raw = inputs.revoked_capabilities as f64 / inputs.observed_capabilities as f64;
298        // If `any_revoked` is set the agent has *at least one* revoked
299        // capability: floor the rate so any revocation deducts below 500
300        // even when the denominator is large.
301        if config.treat_any_revocation_as_full && inputs.any_revoked {
302            raw.max(1.0)
303        } else {
304            raw
305        }
306    };
307
308    // --- Velocity anomaly --------------------------------------------
309    let velocity_rate = if inputs.velocity_windows == 0 {
310        0.0
311    } else {
312        inputs.anomalous_velocity_windows as f64 / inputs.velocity_windows as f64
313    };
314
315    // --- Policy coverage --------------------------------------------
316    //
317    // Deduct when coverage is *missing*. We average the checkpoint
318    // coverage and lineage coverage gap, then clamp to [0, 1]. If the
319    // report has no receipts, coverage is treated as "unknown good" (no
320    // deduction) so that a brand-new agent with no activity still
321    // scores perfectly on this factor.
322    let policy_coverage_gap = if report.matching_receipts == 0 {
323        0.0
324    } else {
325        let checkpoint_coverage = report.checkpoint_coverage_rate.unwrap_or_else(|| {
326            if report.matching_receipts == 0 {
327                1.0
328            } else {
329                report.evidence_ready_receipts as f64 / report.matching_receipts as f64
330            }
331        });
332        let lineage_coverage = report.lineage_coverage_rate.unwrap_or_else(|| {
333            if report.matching_receipts == 0 {
334                1.0
335            } else {
336                report.lineage_covered_receipts as f64 / report.matching_receipts as f64
337            }
338        });
339        let avg_coverage = ((checkpoint_coverage + lineage_coverage) / 2.0).clamp(0.0, 1.0);
340        1.0 - avg_coverage
341    };
342
343    // --- Attestation freshness ---------------------------------------
344    let freshness_rate = match inputs.attestation_age_secs {
345        None => 1.0,
346        Some(age) => {
347            if config.attestation_staleness_secs == 0 {
348                0.0
349            } else {
350                (age as f64 / config.attestation_staleness_secs as f64).clamp(0.0, 1.0)
351            }
352        }
353    };
354
355    ComplianceFactorBreakdown {
356        deny_rate: ComplianceFactor::from_rate("deny_rate", WEIGHT_DENY_RATE, deny_rate),
357        revocation: ComplianceFactor::from_rate("revocation", WEIGHT_REVOCATION, revocation_rate),
358        velocity_anomaly: ComplianceFactor::from_rate(
359            "velocity_anomaly",
360            WEIGHT_VELOCITY_ANOMALY,
361            velocity_rate,
362        ),
363        policy_coverage: ComplianceFactor::from_rate(
364            "policy_coverage",
365            WEIGHT_POLICY_COVERAGE,
366            policy_coverage_gap,
367        ),
368        attestation_freshness: ComplianceFactor::from_rate(
369            "attestation_freshness",
370            WEIGHT_ATTESTATION_FRESHNESS,
371            freshness_rate,
372        ),
373    }
374}
375
376#[cfg(test)]
377#[allow(clippy::unwrap_used, clippy::expect_used)]
378mod tests {
379    use super::*;
380    use crate::evidence_export::{EvidenceChildReceiptScope, EvidenceExportQuery};
381
382    fn perfect_report() -> ComplianceReport {
383        ComplianceReport {
384            matching_receipts: 1000,
385            evidence_ready_receipts: 1000,
386            uncheckpointed_receipts: 0,
387            checkpoint_coverage_rate: Some(1.0),
388            lineage_covered_receipts: 1000,
389            lineage_gap_receipts: 0,
390            lineage_coverage_rate: Some(1.0),
391            pending_settlement_receipts: 0,
392            failed_settlement_receipts: 0,
393            direct_evidence_export_supported: true,
394            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
395            proofs_complete: true,
396            export_query: EvidenceExportQuery::default(),
397            export_scope_note: None,
398        }
399    }
400
401    #[test]
402    fn clean_agent_scores_above_900() {
403        let inputs = ComplianceScoreInputs::new(1000, 0, 1, 0, 0, 0, Some(0));
404        let score = compliance_score(
405            &perfect_report(),
406            &inputs,
407            &ComplianceScoreConfig::default(),
408            "agent-1",
409            0,
410        );
411        assert!(
412            score.score > 900,
413            "clean agent should score >900, got {}",
414            score.score
415        );
416    }
417
418    #[test]
419    fn revocation_flag_drives_score_below_500() {
420        let mut inputs = ComplianceScoreInputs::new(1000, 0, 1, 1, 0, 0, Some(0));
421        inputs.any_revoked = true;
422        let score = compliance_score(
423            &perfect_report(),
424            &inputs,
425            &ComplianceScoreConfig::default(),
426            "agent-2",
427            0,
428        );
429        assert!(
430            score.score < 500,
431            "revoked agent should score <500, got {}",
432            score.score
433        );
434    }
435
436    #[test]
437    fn empty_report_scores_perfectly_on_coverage() {
438        let report = ComplianceReport {
439            matching_receipts: 0,
440            evidence_ready_receipts: 0,
441            uncheckpointed_receipts: 0,
442            checkpoint_coverage_rate: None,
443            lineage_covered_receipts: 0,
444            lineage_gap_receipts: 0,
445            lineage_coverage_rate: None,
446            pending_settlement_receipts: 0,
447            failed_settlement_receipts: 0,
448            direct_evidence_export_supported: true,
449            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
450            proofs_complete: true,
451            export_query: EvidenceExportQuery::default(),
452            export_scope_note: None,
453        };
454        let inputs = ComplianceScoreInputs::new(0, 0, 0, 0, 0, 0, Some(0));
455        let breakdown =
456            compliance_factor_breakdown(&report, &inputs, &ComplianceScoreConfig::default());
457        assert_eq!(breakdown.policy_coverage.deduction, 0);
458        assert_eq!(breakdown.deny_rate.deduction, 0);
459    }
460
461    #[test]
462    fn stale_attestation_deducts_freshness_factor() {
463        let inputs = ComplianceScoreInputs::new(
464            100,
465            0,
466            1,
467            0,
468            0,
469            0,
470            Some(DEFAULT_ATTESTATION_STALENESS_SECS),
471        );
472        let breakdown = compliance_factor_breakdown(
473            &perfect_report(),
474            &inputs,
475            &ComplianceScoreConfig::default(),
476        );
477        assert_eq!(
478            breakdown.attestation_freshness.deduction, WEIGHT_ATTESTATION_FRESHNESS,
479            "fully stale attestation should deduct the full weight"
480        );
481    }
482
483    #[test]
484    fn weights_sum_to_maximum() {
485        assert_eq!(
486            WEIGHT_DENY_RATE
487                + WEIGHT_REVOCATION
488                + WEIGHT_VELOCITY_ANOMALY
489                + WEIGHT_POLICY_COVERAGE
490                + WEIGHT_ATTESTATION_FRESHNESS,
491            COMPLIANCE_SCORE_MAX
492        );
493    }
494}