1use serde::{Deserialize, Serialize};
22
23use chio_core::underwriting::{
24 UnderwritingComplianceEvidence, UNDERWRITING_COMPLIANCE_EVIDENCE_SCHEMA,
25};
26
27use crate::operator_report::ComplianceReport;
28
29pub const COMPLIANCE_SCORE_MAX: u32 = 1000;
31
32pub const WEIGHT_DENY_RATE: u32 = 300;
34pub const WEIGHT_REVOCATION: u32 = 300;
36pub const WEIGHT_VELOCITY_ANOMALY: u32 = 150;
38pub const WEIGHT_POLICY_COVERAGE: u32 = 150;
40pub const WEIGHT_ATTESTATION_FRESHNESS: u32 = 100;
42
43pub const DEFAULT_ATTESTATION_STALENESS_SECS: u64 = 7_776_000;
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "camelCase")]
57pub struct ComplianceScoreInputs {
58 pub total_receipts: u64,
60 pub deny_receipts: u64,
62 pub observed_capabilities: u64,
64 pub revoked_capabilities: u64,
66 pub any_revoked: bool,
69 pub velocity_windows: u64,
71 pub anomalous_velocity_windows: u64,
73 pub attestation_age_secs: Option<u64>,
77}
78
79impl ComplianceScoreInputs {
80 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111#[serde(rename_all = "camelCase")]
112pub struct ComplianceFactor {
113 pub name: String,
115 pub weight: u32,
117 pub deduction: u32,
119 pub points: u32,
121 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176#[serde(rename_all = "camelCase")]
177pub struct ComplianceScore {
178 pub agent_id: String,
180 pub score: u32,
182 pub factor_breakdown: ComplianceFactorBreakdown,
184 pub generated_at: u64,
186 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "camelCase")]
212pub struct ComplianceScoreConfig {
213 pub attestation_staleness_secs: u64,
215 pub treat_any_revocation_as_full: bool,
218 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#[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 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#[must_use]
279pub fn compliance_factor_breakdown(
280 report: &ComplianceReport,
281 inputs: &ComplianceScoreInputs,
282 config: &ComplianceScoreConfig,
283) -> ComplianceFactorBreakdown {
284 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 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 config.treat_any_revocation_as_full && inputs.any_revoked {
304 raw.max(1.0)
305 } else {
306 raw
307 }
308 };
309
310 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 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 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}