Skip to main content

chio_kernel/
compliance_score.rs

1//! Phase 19.1 -- compliance scoring on top of `ComplianceReport`.
2//!
3//! Productizes the existing [`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//! This module is additive: it consumes a [`ComplianceReport`] without
18//! modifying its fields. Callers who already materialize a compliance
19//! report reuse its figures 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 match the
208/// roadmap's 19.1 acceptance targets (zero denies in 1000 calls -> >900;
209/// any revoked cap -> <500).
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "camelCase")]
212pub struct ComplianceScoreConfig {
213    /// Attestation age (seconds) at which freshness is fully deducted.
214    pub attestation_staleness_secs: u64,
215    /// When `true`, a single `any_revoked == true` flag fully deducts
216    /// the revocation factor even if `observed_capabilities` is zero.
217    pub treat_any_revocation_as_full: bool,
218    /// Ceiling (exclusive) on the final score when any observed
219    /// capability is revoked. Defaults to 500 so that the roadmap's
220    /// 19.1 acceptance target ("revoked capability -> score <500")
221    /// holds regardless of the raw factor math.
222    pub revocation_ceiling: u32,
223}
224
225impl Default for ComplianceScoreConfig {
226    fn default() -> Self {
227        Self {
228            attestation_staleness_secs: DEFAULT_ATTESTATION_STALENESS_SECS,
229            treat_any_revocation_as_full: true,
230            revocation_ceiling: 499,
231        }
232    }
233}
234
235/// Compute the weighted compliance score for an agent.
236///
237/// * `report` -- previously-materialized compliance report (for
238///   lineage and checkpoint coverage).
239/// * `inputs` -- ambient inputs the report does not carry (deny rate,
240///   revocations, velocity anomalies, attestation age).
241/// * `config` -- scoring thresholds.
242/// * `agent_id` -- scored agent (echoed into the output).
243/// * `now` -- Unix timestamp to stamp on the score.
244#[must_use]
245pub fn compliance_score(
246    report: &ComplianceReport,
247    inputs: &ComplianceScoreInputs,
248    config: &ComplianceScoreConfig,
249    agent_id: &str,
250    now: u64,
251) -> ComplianceScore {
252    let breakdown = compliance_factor_breakdown(report, inputs, config);
253    let raw_score = COMPLIANCE_SCORE_MAX.saturating_sub(breakdown.total_deductions());
254    // Revocation ceiling: once the regulatory system has revoked any
255    // capability exercised by this agent, the score is capped below
256    // `revocation_ceiling` regardless of the raw factor math. This
257    // ensures the 19.1 acceptance target ("revoked -> <500") holds
258    // even when other factors look healthy.
259    let score = if inputs.any_revoked || inputs.revoked_capabilities > 0 {
260        raw_score.min(config.revocation_ceiling)
261    } else {
262        raw_score
263    };
264
265    ComplianceScore {
266        agent_id: agent_id.to_string(),
267        score,
268        factor_breakdown: breakdown,
269        generated_at: now,
270        inputs: inputs.clone(),
271    }
272}
273
274/// Build the per-factor breakdown without wrapping it in a score.
275///
276/// Exposed for callers that want to surface individual factor deltas
277/// (dashboards) without collapsing to a single number.
278#[must_use]
279pub fn compliance_factor_breakdown(
280    report: &ComplianceReport,
281    inputs: &ComplianceScoreInputs,
282    config: &ComplianceScoreConfig,
283) -> ComplianceFactorBreakdown {
284    // --- Deny rate ----------------------------------------------------
285    let deny_rate = if inputs.total_receipts == 0 {
286        0.0
287    } else {
288        inputs.deny_receipts as f64 / inputs.total_receipts as f64
289    };
290
291    // --- Revocation ---------------------------------------------------
292    let revocation_rate = if inputs.observed_capabilities == 0 {
293        if config.treat_any_revocation_as_full && inputs.any_revoked {
294            1.0
295        } else {
296            0.0
297        }
298    } else {
299        let raw = inputs.revoked_capabilities as f64 / inputs.observed_capabilities as f64;
300        // If `any_revoked` is set the agent has *at least one* revoked
301        // capability: floor the rate so that acceptance (any revocation
302        // deducts below 500) is met even when the denominator is large.
303        if config.treat_any_revocation_as_full && inputs.any_revoked {
304            raw.max(1.0)
305        } else {
306            raw
307        }
308    };
309
310    // --- Velocity anomaly --------------------------------------------
311    let velocity_rate = if inputs.velocity_windows == 0 {
312        0.0
313    } else {
314        inputs.anomalous_velocity_windows as f64 / inputs.velocity_windows as f64
315    };
316
317    // --- Policy coverage --------------------------------------------
318    //
319    // Deduct when coverage is *missing*. We average the checkpoint
320    // coverage and lineage coverage gap, then clamp to [0, 1]. If the
321    // report has no receipts, coverage is treated as "unknown good" (no
322    // deduction) so that a brand-new agent with no activity still
323    // scores perfectly on this factor.
324    let policy_coverage_gap = if report.matching_receipts == 0 {
325        0.0
326    } else {
327        let checkpoint_coverage = report.checkpoint_coverage_rate.unwrap_or_else(|| {
328            if report.matching_receipts == 0 {
329                1.0
330            } else {
331                report.evidence_ready_receipts as f64 / report.matching_receipts as f64
332            }
333        });
334        let lineage_coverage = report.lineage_coverage_rate.unwrap_or_else(|| {
335            if report.matching_receipts == 0 {
336                1.0
337            } else {
338                report.lineage_covered_receipts as f64 / report.matching_receipts as f64
339            }
340        });
341        let avg_coverage = ((checkpoint_coverage + lineage_coverage) / 2.0).clamp(0.0, 1.0);
342        1.0 - avg_coverage
343    };
344
345    // --- Attestation freshness ---------------------------------------
346    let freshness_rate = match inputs.attestation_age_secs {
347        None => 1.0,
348        Some(age) => {
349            if config.attestation_staleness_secs == 0 {
350                0.0
351            } else {
352                (age as f64 / config.attestation_staleness_secs as f64).clamp(0.0, 1.0)
353            }
354        }
355    };
356
357    ComplianceFactorBreakdown {
358        deny_rate: ComplianceFactor::from_rate("deny_rate", WEIGHT_DENY_RATE, deny_rate),
359        revocation: ComplianceFactor::from_rate("revocation", WEIGHT_REVOCATION, revocation_rate),
360        velocity_anomaly: ComplianceFactor::from_rate(
361            "velocity_anomaly",
362            WEIGHT_VELOCITY_ANOMALY,
363            velocity_rate,
364        ),
365        policy_coverage: ComplianceFactor::from_rate(
366            "policy_coverage",
367            WEIGHT_POLICY_COVERAGE,
368            policy_coverage_gap,
369        ),
370        attestation_freshness: ComplianceFactor::from_rate(
371            "attestation_freshness",
372            WEIGHT_ATTESTATION_FRESHNESS,
373            freshness_rate,
374        ),
375    }
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used, clippy::expect_used)]
380mod tests {
381    use super::*;
382    use crate::evidence_export::{EvidenceChildReceiptScope, EvidenceExportQuery};
383
384    fn perfect_report() -> ComplianceReport {
385        ComplianceReport {
386            matching_receipts: 1000,
387            evidence_ready_receipts: 1000,
388            uncheckpointed_receipts: 0,
389            checkpoint_coverage_rate: Some(1.0),
390            lineage_covered_receipts: 1000,
391            lineage_gap_receipts: 0,
392            lineage_coverage_rate: Some(1.0),
393            pending_settlement_receipts: 0,
394            failed_settlement_receipts: 0,
395            direct_evidence_export_supported: true,
396            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
397            proofs_complete: true,
398            export_query: EvidenceExportQuery::default(),
399            export_scope_note: None,
400        }
401    }
402
403    #[test]
404    fn clean_agent_scores_above_900() {
405        let inputs = ComplianceScoreInputs::new(1000, 0, 1, 0, 0, 0, Some(0));
406        let score = compliance_score(
407            &perfect_report(),
408            &inputs,
409            &ComplianceScoreConfig::default(),
410            "agent-1",
411            0,
412        );
413        assert!(
414            score.score > 900,
415            "clean agent should score >900, got {}",
416            score.score
417        );
418    }
419
420    #[test]
421    fn revocation_flag_drives_score_below_500() {
422        let mut inputs = ComplianceScoreInputs::new(1000, 0, 1, 1, 0, 0, Some(0));
423        inputs.any_revoked = true;
424        let score = compliance_score(
425            &perfect_report(),
426            &inputs,
427            &ComplianceScoreConfig::default(),
428            "agent-2",
429            0,
430        );
431        assert!(
432            score.score < 500,
433            "revoked agent should score <500, got {}",
434            score.score
435        );
436    }
437
438    #[test]
439    fn empty_report_scores_perfectly_on_coverage() {
440        let report = ComplianceReport {
441            matching_receipts: 0,
442            evidence_ready_receipts: 0,
443            uncheckpointed_receipts: 0,
444            checkpoint_coverage_rate: None,
445            lineage_covered_receipts: 0,
446            lineage_gap_receipts: 0,
447            lineage_coverage_rate: None,
448            pending_settlement_receipts: 0,
449            failed_settlement_receipts: 0,
450            direct_evidence_export_supported: true,
451            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
452            proofs_complete: true,
453            export_query: EvidenceExportQuery::default(),
454            export_scope_note: None,
455        };
456        let inputs = ComplianceScoreInputs::new(0, 0, 0, 0, 0, 0, Some(0));
457        let breakdown =
458            compliance_factor_breakdown(&report, &inputs, &ComplianceScoreConfig::default());
459        assert_eq!(breakdown.policy_coverage.deduction, 0);
460        assert_eq!(breakdown.deny_rate.deduction, 0);
461    }
462
463    #[test]
464    fn stale_attestation_deducts_freshness_factor() {
465        let inputs = ComplianceScoreInputs::new(
466            100,
467            0,
468            1,
469            0,
470            0,
471            0,
472            Some(DEFAULT_ATTESTATION_STALENESS_SECS),
473        );
474        let breakdown = compliance_factor_breakdown(
475            &perfect_report(),
476            &inputs,
477            &ComplianceScoreConfig::default(),
478        );
479        assert_eq!(
480            breakdown.attestation_freshness.deduction, WEIGHT_ATTESTATION_FRESHNESS,
481            "fully stale attestation should deduct the full weight"
482        );
483    }
484
485    #[test]
486    fn weights_sum_to_maximum() {
487        assert_eq!(
488            WEIGHT_DENY_RATE
489                + WEIGHT_REVOCATION
490                + WEIGHT_VELOCITY_ANOMALY
491                + WEIGHT_POLICY_COVERAGE
492                + WEIGHT_ATTESTATION_FRESHNESS,
493            COMPLIANCE_SCORE_MAX
494        );
495    }
496}