1use serde::{Deserialize, Serialize};
6use ts_rs::TS;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
10#[ts(export)]
11pub enum VerdictKind {
12 Trusted,
13 SuspectOverclaim,
14 SuspectUnderclaim,
15 Unknown,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
20#[ts(export)]
21pub enum VerdictConfidence {
22 High,
23 Medium,
24 Low,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, TS)]
29#[ts(export)]
30pub struct Verdict {
31 pub kind: VerdictKind,
32 pub confidence: VerdictConfidence,
33 pub violated: Vec<String>,
35 pub expected_max: u64,
37 pub claimed: u64,
38 pub lcp_bytes: u64,
40 pub notes: Vec<String>,
41}
42
43impl Verdict {
44 #[must_use]
45 pub fn describe(&self) -> String {
46 format!(
47 "kind={:?} conf={:?} violated={:?} U={} claimed={} lcp={}B notes={:?}",
48 self.kind,
49 self.confidence,
50 self.violated,
51 self.expected_max,
52 self.claimed,
53 self.lcp_bytes,
54 self.notes
55 )
56 }
57}
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
62#[ts(export)]
63pub struct ProviderAuditSpec {
64 pub family: ProviderFamily,
66 pub gran: Option<u64>,
68 pub threshold: u64,
70 pub ttl_ms: u64,
72 pub model56plus: bool,
75 pub shared_ceiling_mult: Option<f64>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
83#[ts(export)]
84pub enum ProviderFamily {
85 OpenAi,
86 DeepSeek,
87 Anthropic,
88 Bedrock,
89 Gemini,
90 Vllm,
91 Generic,
92}
93
94pub mod matrix {
96 use super::{ProviderAuditSpec, ProviderFamily};
97
98 const HOUR: u64 = 3_600_000;
99
100 #[must_use]
102 pub fn openai_56() -> ProviderAuditSpec {
103 ProviderAuditSpec {
104 family: ProviderFamily::OpenAi,
105 gran: None,
106 threshold: 1024,
107 ttl_ms: HOUR,
108 model56plus: true,
109 shared_ceiling_mult: None,
110 }
111 }
112
113 #[must_use]
115 pub fn openai_legacy() -> ProviderAuditSpec {
116 ProviderAuditSpec {
117 family: ProviderFamily::OpenAi,
118 gran: Some(128),
119 threshold: 1024,
120 ttl_ms: HOUR,
121 model56plus: false,
122 shared_ceiling_mult: None,
123 }
124 }
125
126 #[must_use]
129 pub fn azure() -> ProviderAuditSpec {
130 ProviderAuditSpec {
131 family: ProviderFamily::OpenAi,
132 gran: Some(128),
133 threshold: 1024,
134 ttl_ms: HOUR,
135 model56plus: false,
136 shared_ceiling_mult: None,
137 }
138 }
139
140 #[must_use]
142 pub fn deepseek() -> ProviderAuditSpec {
143 ProviderAuditSpec {
144 family: ProviderFamily::DeepSeek,
145 gran: Some(64),
146 threshold: 64,
147 ttl_ms: 48 * HOUR,
148 model56plus: false,
149 shared_ceiling_mult: None,
150 }
151 }
152
153 #[must_use]
155 pub fn mistral() -> ProviderAuditSpec {
156 ProviderAuditSpec {
157 family: ProviderFamily::Generic,
158 gran: Some(64),
159 threshold: 64,
160 ttl_ms: HOUR,
161 model56plus: false,
162 shared_ceiling_mult: None,
163 }
164 }
165
166 #[must_use]
169 pub fn anthropic() -> ProviderAuditSpec {
170 ProviderAuditSpec {
171 family: ProviderFamily::Anthropic,
172 gran: None,
173 threshold: 512,
174 ttl_ms: HOUR,
175 model56plus: false,
176 shared_ceiling_mult: None,
177 }
178 }
179
180 #[must_use]
183 pub fn bedrock() -> ProviderAuditSpec {
184 ProviderAuditSpec {
185 family: ProviderFamily::Bedrock,
186 gran: None,
187 threshold: 0,
188 ttl_ms: 24 * HOUR,
189 model56plus: false,
190 shared_ceiling_mult: None,
191 }
192 }
193
194 #[must_use]
196 pub fn gemini() -> ProviderAuditSpec {
197 ProviderAuditSpec {
198 family: ProviderFamily::Gemini,
199 gran: None,
200 threshold: 0,
201 ttl_ms: 24 * HOUR,
202 model56plus: false,
203 shared_ceiling_mult: None,
204 }
205 }
206
207 #[must_use]
209 pub fn vllm() -> ProviderAuditSpec {
210 ProviderAuditSpec {
211 family: ProviderFamily::Vllm,
212 gran: Some(16),
213 threshold: 16,
214 ttl_ms: u64::MAX,
215 model56plus: false,
216 shared_ceiling_mult: None,
217 }
218 }
219
220 #[must_use]
223 pub fn generic() -> ProviderAuditSpec {
224 ProviderAuditSpec {
225 family: ProviderFamily::Generic,
226 gran: Some(128),
227 threshold: 1024,
228 ttl_ms: HOUR,
229 model56plus: false,
230 shared_ceiling_mult: None,
231 }
232 }
233
234 #[must_use]
236 pub fn for_provider(provider: &str, model: &str) -> ProviderAuditSpec {
237 let p = provider.to_ascii_lowercase();
238 let m = model.to_ascii_lowercase();
239 if p.contains("deepseek") {
240 return deepseek();
241 }
242 if p.contains("anthropic") {
243 return anthropic();
244 }
245 if p.contains("azure") {
246 return azure();
247 }
248 if p.contains("gemini") || p.contains("vertex") {
249 return gemini();
250 }
251 if p.contains("bedrock") {
252 return bedrock();
253 }
254 if p.contains("vllm") {
255 return vllm();
256 }
257 if p.contains("mistral") {
258 return mistral();
259 }
260 if p.contains("openai") || p.contains("openrouter") {
261 return if m.contains("gpt-5") || m.contains("o3") || m.contains("o4") {
262 openai_56()
263 } else {
264 openai_legacy()
265 };
266 }
267 generic()
268 }
269}
270
271#[derive(Debug, Clone, Copy)]
273pub struct LcpInput {
274 pub lcp_bytes: u64,
276 pub lcp_upper_bytes: u64,
280 pub same_session: bool,
282 pub matched_exists: bool,
284}
285
286#[derive(Debug, Clone, Copy, Default)]
288pub struct SessionStats {
289 pub same_session_rounds: u32,
290 pub prefix_stable: bool,
291 pub lcp_gt_1024: bool,
292}
293
294#[derive(Debug, Clone)]
296pub struct JudgmentInput {
297 pub spec: ProviderAuditSpec,
298 pub strict: bool,
300 pub first: bool,
302 pub prompt_tokens: u64,
303 pub prompt_bytes: u64,
304 pub claimed: u64,
305 pub input_no_cache: Option<u64>,
307 pub input_cache_read: Option<u64>,
308 pub input_cache_write: Option<u64>,
309 pub write: Option<u64>,
310 pub no_cache: Option<u64>,
312 pub hit: Option<u64>,
314 pub miss: Option<u64>,
315 pub usage_present: bool,
316 pub response_cache_header_hit: bool,
319 pub candidate_expired: bool,
323 pub byte_proxy: bool,
326 pub route_affinity_known: bool,
333 pub lcp: Option<LcpInput>,
334 pub system_tokens: u64,
336 pub session_stats: Option<SessionStats>,
337}
338
339#[must_use]
340pub fn quantize_down(x: u64, gran: Option<u64>) -> u64 {
341 match gran {
342 Some(g) if g > 0 => x / g * g,
343 _ => x,
344 }
345}
346
347fn tau(u: u64, gran: Option<u64>) -> u64 {
349 let pct = ((u as f64) * 0.05).round() as u64;
350 match gran {
351 Some(g) => pct.max(g),
352 None => pct,
353 }
354}
355
356fn is_w_level(rule: &str) -> bool {
357 matches!(
358 rule,
359 "R-1.1" | "R-1.1abs" | "R-1.2" | "R-1.3" | "R-1.4" | "R-1.5" | "R-1.7" | "R-1.8" | "R-3.3"
360 )
361}
362
363fn is_b_level(rule: &str) -> bool {
364 matches!(rule, "R-1.1b" | "R-1.6b" | "R-1.7b")
365}
366
367fn is_byte_proxy_rule(rule: &str) -> bool {
372 matches!(rule, "R-1.1" | "R-1.1b")
373}
374
375#[must_use]
379pub fn judge(inp: &JudgmentInput) -> Verdict {
380 let mut v = Verdict {
381 kind: VerdictKind::Trusted,
382 confidence: VerdictConfidence::High,
383 violated: Vec::new(),
384 expected_max: 0,
385 claimed: inp.claimed,
386 lcp_bytes: 0,
387 notes: Vec::new(),
388 };
389
390 if !inp.usage_present {
392 v.kind = VerdictKind::Unknown;
393 v.confidence = VerdictConfidence::Medium;
394 v.violated.push("R-5.1".into());
395 v.notes
396 .push("usage missing (stream truncated / stripped)".into());
397 return v;
398 }
399
400 if inp.response_cache_header_hit {
402 v.kind = VerdictKind::Unknown;
403 v.confidence = VerdictConfidence::Medium;
404 v.violated.push("R-4.1".into());
405 v.notes
406 .push("response-cache header hit; provider cache not audited (R-4.1)".into());
407 return v;
408 }
409
410 let prompt = inp.prompt_tokens;
411 let claimed = inp.claimed;
412
413 if claimed > prompt {
415 v.violated.push("R-1.1abs".into());
416 }
417
418 if inp.spec.family == ProviderFamily::DeepSeek
420 && let (Some(h), Some(m)) = (inp.hit, inp.miss)
421 && (h as i64 + m as i64 - prompt as i64).abs() > 1
422 {
423 v.violated.push("R-1.3".into());
424 }
425
426 if inp.spec.family == ProviderFamily::Anthropic {
431 let (nc, rd, wr) = (
432 inp.input_no_cache,
433 inp.input_cache_read,
434 inp.input_cache_write,
435 );
436 if let (Some(nc), Some(rd), Some(wr)) = (nc, rd, wr) {
437 let total = inp.prompt_tokens;
438 if (nc as i64 + rd as i64 + wr as i64 - total as i64).abs() > 1 {
439 v.violated.push("R-1.4".into());
440 }
441 }
442 if inp.first && rd.unwrap_or(0) > 0 {
444 v.violated.push("R-1.4".into());
445 }
446 }
447
448 if inp.spec.family == ProviderFamily::Bedrock {
451 let (nc, rd, wr) = (
452 inp.input_no_cache,
453 inp.input_cache_read,
454 inp.input_cache_write,
455 );
456 if let (Some(nc), Some(rd), Some(wr)) = (nc, rd, wr) {
457 let total = inp.prompt_tokens;
458 if (nc as i64 + rd as i64 + wr as i64 - total as i64).abs() > 1 {
459 v.violated.push("R-1.5".into());
460 }
461 }
462 }
463
464 if prompt < inp.spec.threshold && claimed > 0 {
466 v.violated.push("R-3.3".into());
467 }
468
469 if let Some(g) = inp.spec.gran
471 && claimed > 0
472 && !claimed.is_multiple_of(g)
473 && !inp.spec.model56plus
474 {
475 v.violated.push("R-1.6b".into());
476 }
477
478 if inp.spec.model56plus {
480 match (inp.write, inp.no_cache) {
481 (Some(w), Some(nc)) => {
482 if claimed + w + nc != prompt {
483 v.violated.push("R-1.7".into());
484 }
485 }
486 (None, _) => {
487 v.violated.push("R-1.7b".into());
490 v.notes
491 .push("5.6+ write side missing; downgraded to medium (R-1.7)".into());
492 }
493 _ => {}
494 }
495 }
496
497 if inp.first && claimed > 0 {
499 v.violated.push("R-1.2".into());
500 }
501
502 let mut lcp_present = false;
504 let mut u: u64 = 0;
505 let mut granularity_unknown = false;
508 if let Some(l) = &inp.lcp {
509 lcp_present = true;
510 v.lcp_bytes = l.lcp_bytes;
511 let sys_bytes = inp.system_tokens.saturating_mul(4);
514 let cap_bytes = if l.same_session {
515 l.lcp_upper_bytes
516 } else {
517 l.lcp_upper_bytes.min(sys_bytes)
518 };
519 if !l.same_session && claimed.saturating_mul(4) > sys_bytes {
520 v.notes
521 .push("claimed exceeds shared system segment (R-2.1)".into());
522 }
523 let upper_bytes = l.lcp_upper_bytes.min(cap_bytes).min(inp.prompt_bytes);
526 let token_upper = upper_bytes / 4;
527 u = quantize_down(token_upper, inp.spec.gran).min(prompt);
528 }
529 if lcp_present {
530 v.expected_max = u;
531 let t = tau(u, inp.spec.gran);
532 if claimed > u + t {
533 v.violated.push("R-1.1".into());
534 } else if claimed > u {
535 v.violated.push("R-1.1b".into());
536 }
537 } else if claimed > 0 && !inp.first {
538 if inp.candidate_expired {
539 v.violated.push("R-1.8".into());
541 } else {
542 v.notes.push(
547 "no block-granularity match; cannot confirm or refute (short body / sub-block growth)"
548 .into(),
549 );
550 granularity_unknown = true;
551 }
552 }
553
554 let mut suppressed_m = false;
556 if inp.spec.model56plus && claimed == 0 && v.lcp_bytes / 4 > 1024 {
557 v.notes
558 .push("5.6+ implicit breakpoint: claimed=0 legal despite large LCP (R-2.3)".into());
559 suppressed_m = true;
560 }
561
562 if !suppressed_m
571 && let Some(ss) = &inp.session_stats
572 && ss.same_session_rounds >= 3
573 && ss.prefix_stable
574 && ss.lcp_gt_1024
575 && claimed == 0
576 {
577 if inp.route_affinity_known {
578 v.violated.push("R-2.2".into());
579 } else {
580 v.notes.push(
581 "stable prefix with zero claimed hits; route affinity unknown — \
582 likely cluster routing artifact, not a broken prefix (R-2.2 suppressed)"
583 .into(),
584 );
585 }
586 }
587
588 const DOWNGRADE_IDS: [&str; 3] = ["R-1.1", "R-1.2", "R-1.8"];
591 let mut has_w = v.violated.iter().any(|r| is_w_level(r));
592 let mut has_b = v.violated.iter().any(|r| is_b_level(r));
593 if !inp.strict {
594 let only_downgrade_w = has_w
595 && v.violated
596 .iter()
597 .all(|r| !is_w_level(r) || DOWNGRADE_IDS.contains(&r.as_str()));
598 if only_downgrade_w {
599 has_w = false;
600 if lcp_present {
601 has_b = true; } else {
603 v.kind = VerdictKind::Unknown;
604 v.confidence = VerdictConfidence::Medium;
605 v.notes
606 .push("no local history source; other process may have warmed (R-5.3)".into());
607 return v;
608 }
609 }
610 }
611
612 if inp.byte_proxy {
621 let has_independent = v
622 .violated
623 .iter()
624 .any(|r| (is_w_level(r) || is_b_level(r)) && !is_byte_proxy_rule(r));
625 if !has_independent && (has_w || has_b) {
626 v.kind = VerdictKind::Unknown;
627 v.confidence = VerdictConfidence::Medium;
628 v.notes.push(
629 "byte-proxy overclaim (bytes/4 is not a safe token bound) — \
630 downgraded to Unknown (C4-2)"
631 .into(),
632 );
633 return v;
634 }
635 }
636
637 if inp.byte_proxy && has_w && !v.violated.iter().any(|r| r == "R-1.1abs") {
642 let byte_proxy_only = v
643 .violated
644 .iter()
645 .filter(|r| is_w_level(r))
646 .all(|r| r == "R-1.1");
647 if byte_proxy_only {
648 has_w = false;
649 has_b = true;
650 v.notes
651 .push("byte-proxy evidence caps at medium (RFC F2)".into());
652 }
653 }
654
655 if granularity_unknown && !has_w && !has_b && !v.violated.iter().any(|r| r == "R-2.2") {
656 v.kind = VerdictKind::Unknown;
657 v.confidence = VerdictConfidence::Medium;
658 } else if v.violated.iter().any(|r| r == "R-2.2") && !has_w {
659 v.kind = VerdictKind::SuspectUnderclaim;
660 v.confidence = VerdictConfidence::Low;
661 } else if has_w {
662 v.kind = VerdictKind::SuspectOverclaim;
663 v.confidence = VerdictConfidence::High;
664 } else if has_b {
665 v.kind = VerdictKind::SuspectOverclaim;
666 v.confidence = VerdictConfidence::Medium;
667 } else {
668 v.kind = VerdictKind::Trusted;
669 v.confidence = VerdictConfidence::High;
670 }
671 v
672}