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)]
210#[serde(rename_all = "camelCase")]
211pub struct ComplianceScoreConfig {
212 pub attestation_staleness_secs: u64,
214 pub treat_any_revocation_as_full: bool,
217 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#[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 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#[must_use]
277pub fn compliance_factor_breakdown(
278 report: &ComplianceReport,
279 inputs: &ComplianceScoreInputs,
280 config: &ComplianceScoreConfig,
281) -> ComplianceFactorBreakdown {
282 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 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 config.treat_any_revocation_as_full && inputs.any_revoked {
302 raw.max(1.0)
303 } else {
304 raw
305 }
306 };
307
308 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 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 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}