Skip to main content

aimux_core/trace/
verdict.rs

1//! Verdict engine (RFC-0015 §4): expected-hit interval + 8 hard invariants +
2//! strict/shared dual mode. Pure, table-driven — the prototype's `judge`
3//! ported into the core, with provider parameters in a matrix (§7).
4
5use serde::{Deserialize, Serialize};
6use ts_rs::TS;
7
8/// Verdict kinds (RFC-0015 §4.1).
9#[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/// Confidence level.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
20#[ts(export)]
21pub enum VerdictConfidence {
22    High,
23    Medium,
24    Low,
25}
26
27/// A verdict for one call.
28#[derive(Debug, Clone, Serialize, Deserialize, TS)]
29#[ts(export)]
30pub struct Verdict {
31    pub kind: VerdictKind,
32    pub confidence: VerdictConfidence,
33    /// Violated rule ids (R-1.1 / R-1.2 / …).
34    pub violated: Vec<String>,
35    /// Expected upper bound U (tokens).
36    pub expected_max: u64,
37    pub claimed: u64,
38    /// Client-side LCP (bytes).
39    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/// Provider audit parameters (RFC-0015 §7 matrix — the part that lives in
60/// core; display logic belongs to the CLI).
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
62#[ts(export)]
63pub struct ProviderAuditSpec {
64    /// Provider family for family-specific invariants.
65    pub family: ProviderFamily,
66    /// Token quantization granularity; `None` = no quantization.
67    pub gran: Option<u64>,
68    /// Minimum cache threshold (tokens): below it `claimed` must be 0.
69    pub threshold: u64,
70    /// TTL_idle (ms), conservative upper bound.
71    pub ttl_ms: u64,
72    /// gpt-5.6+ semantics (no quantization, implicit-breakpoint whitelist,
73    /// write/read equality).
74    pub model56plus: bool,
75    /// Claimed-hit ceiling: above this multiple of the client upper bound
76    /// the verdict is overclaim (W) even in shared mode. `None` = use U+τ.
77    pub shared_ceiling_mult: Option<f64>,
78}
79
80/// Provider family (only families with distinct invariants need dispatch;
81/// gran/threshold/ttl live in `ProviderAuditSpec`).
82#[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
94/// The provider audit matrix (RFC-0015 §7, A/B/C tiers → parameters).
95pub mod matrix {
96    use super::{ProviderAuditSpec, ProviderFamily};
97
98    const HOUR: u64 = 3_600_000;
99
100    /// OpenAI Chat/Responses, gpt-5.6+ (byte-exact, no quantization).
101    #[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    /// OpenAI legacy (< 5.6) / compatible: 128-token quantization.
114    #[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    /// Azure: 128-token granularity across the board (differs from OpenAI
127    /// 5.6+ byte-exact — matrix-verified).
128    #[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    /// DeepSeek: 64-token granularity + equality invariant (hit+miss==prompt).
141    #[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    /// Mistral: 64-token granularity.
154    #[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    /// Anthropic: qualitative (breakpoint + 20-block lookback), no strict
167    /// quantization; conservative 1h TTL upper bound.
168    #[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    /// Bedrock Converse: usage-layer equality (total == input+read+write);
181    /// quota burndown needs raw usage and is best-effort.
182    #[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    /// Gemini/Vertex: 24h TTL, no quantization (24h conservative).
195    #[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    /// vLLM: 16-token granularity, no wall-clock TTL (LRU).
208    #[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    /// Default for providers without a matrix entry (e.g. OpenRouter passthrough
221    /// behaves like the upstream; start conservative).
222    #[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    /// Resolve by provider name (best-effort; `None` → generic).
235    #[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/// LCP result feeding the judgment (already TTL-filtered).
272#[derive(Debug, Clone, Copy)]
273pub struct LcpInput {
274    /// Block-granularity LCP lower bound (matched_blocks × block_size).
275    pub lcp_bytes: u64,
276    /// Block upper bound: `(matched_blocks + 1) × block_size` — the audit
277    /// upper bound per RFC F3 (`(j+1)·B`), capped at prompt_bytes by the
278    /// caller when computing U.
279    pub lcp_upper_bytes: u64,
280    /// The matched history record belongs to the same session.
281    pub same_session: bool,
282    /// Whether any live history source matched.
283    pub matched_exists: bool,
284}
285
286/// Session-level stats for the low-hit warning (R-2.2).
287#[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/// All evidence needed to judge one call.
295#[derive(Debug, Clone)]
296pub struct JudgmentInput {
297    pub spec: ProviderAuditSpec,
298    /// strict (self-hosted single instance) → W; shared → B/U downgrade.
299    pub strict: bool,
300    /// First request in this scope window.
301    pub first: bool,
302    pub prompt_tokens: u64,
303    pub prompt_bytes: u64,
304    pub claimed: u64,
305    /// Input-side breakdown (unified usage) for provider-equality rules.
306    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    /// OpenAI 5.6+ delta: no_cache = prompt − claimed − write.
311    pub no_cache: Option<u64>,
312    /// DeepSeek: hit / miss tokens.
313    pub hit: Option<u64>,
314    pub miss: Option<u64>,
315    pub usage_present: bool,
316    /// Response-side cache header present (e.g. OpenRouter HIT) — reported
317    /// zeros are legal, skip audit (R-4.1).
318    pub response_cache_header_hit: bool,
319    /// A same-scope candidate existed at block 0 but failed the TTL check
320    /// (true timing violation) — vs. no candidate at all (block-granularity
321    /// floor, conservative UNKNOWN).
322    pub candidate_expired: bool,
323    /// Token upper comes from the byte proxy (len/4) — no tokenizer. Per
324    /// RFC F2, byte-proxy evidence caps R-1.1 at B(Medium), never W(High).
325    pub byte_proxy: bool,
326    /// Whether the deployment guarantees cache-affine routing (single node,
327    /// sticky sessions, or a shared/global cache like DeepSeek's disk cache).
328    /// When UNKNOWN (default — models are typically served by clusters behind
329    /// load balancers), R-2.2's low-hit warning is suppressed to a note:
330    /// a stable prefix with zero claimed hits is then plausibly a routing
331    /// artifact, not a broken prefix.
332    pub route_affinity_known: bool,
333    pub lcp: Option<LcpInput>,
334    /// System/tools segment tokens (cross-session expectation, R-2.1).
335    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
347/// Upper tolerance τ = max(5%·U, 1 gran block).
348fn 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
367/// R-1.1 / R-1.1b are the only rules whose overclaim signal is derived from
368/// the byte-proxy token bound (`u = upper_bytes/4`). Every other W/B rule
369/// (R-1.1abs / R-1.2 / equality / threshold / TTL / quantization) is independent
370/// of that conversion.
371fn is_byte_proxy_rule(rule: &str) -> bool {
372    matches!(rule, "R-1.1" | "R-1.1b")
373}
374
375/// The built-in rule auditor (RFC-0015 §4.2: 8 hard invariants + diagnosis).
376///
377/// Pure function over evidence; no state, no IO.
378#[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    // R-5.1: usage missing → Unknown.
391    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    // R-4.1: response-side cache hit (gateway) — reported zeros are legal.
401    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    // Absolute ceiling — impossible under any view.
414    if claimed > prompt {
415        v.violated.push("R-1.1abs".into());
416    }
417
418    // R-1.3: DeepSeek equality prompt == hit + miss (±1 token).
419    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    // R-1.4: Anthropic three-field sum — input == read + creation +
427    // input_tokens(no_cache). The provider reports input_tokens excluding
428    // cache; our unified total = no_cache + read + write. History-write and
429    // TTL-tier checks degrade to skip when the data is unavailable.
430    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        // First request in the scope window must report zero reads.
443        if inp.first && rd.unwrap_or(0) > 0 {
444            v.violated.push("R-1.4".into());
445        }
446    }
447
448    // R-1.5: Bedrock equality — total == input + read + write. Quota
449    // burndown needs raw usage fields; degrade to skip when absent.
450    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    // R-3.3: below threshold, claimed must be 0.
465    if prompt < inp.spec.threshold && claimed > 0 {
466        v.violated.push("R-3.3".into());
467    }
468
469    // R-1.6b: quantization invariant (OpenAI<5.6: %128; DeepSeek/Mistral: %64).
470    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    // R-1.7: OpenAI 5.6+ write/read equality.
479    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                // Write side missing → B(medium) per RFC (Azure reports no
488                // write side; responses-API naming differs — B, not W).
489                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    // R-1.2: first request in the scope window must have zero hits.
498    if inp.first && claimed > 0 {
499        v.violated.push("R-1.2".into());
500    }
501
502    // View-dependent: U and the prefix-containment invariant (R-1.1).
503    let mut lcp_present = false;
504    let mut u: u64 = 0;
505    // Granularity floor flag (no block-level candidate; set below, used in
506    // classification).
507    let mut granularity_unknown = false;
508    if let Some(l) = &inp.lcp {
509        lcp_present = true;
510        v.lcp_bytes = l.lcp_bytes;
511        // R-2.1: cross-session only the system/tools segment counts;
512        // same-session append-only chains may claim the whole prefix.
513        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        // Byte proxy: token_upper = bytes/4 (no tokenizer attached). The
524        // block UPPER bound ((j+1)·B, RFC F3) is the audit ceiling.
525        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            // A candidate existed but its TTL window expired → timing violation.
540            v.violated.push("R-1.8".into());
541        } else {
542            // No block-granularity candidate at all: short bodies / sub-block
543            // growth cannot be matched at block level. Conservative UNKNOWN —
544            // never a false accusation. Hard invariants (R-1.3 / R-3.3 /
545            // R-1.1abs) still take precedence in classification.
546            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    // R-2.3: 5.6+ implicit breakpoint — large LCP with claimed=0 is legal.
555    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    // R-2.2: low-hit warning M (same session ≥3 rounds + stable prefix +
563    // LCP>1024 + claimed=0). This is a DIAGNOSTIC (SuspectUnderclaim, Low) —
564    // never an overclaim accusation. Cluster deployments (load-balanced
565    // multi-node, no sticky routing) legitimately produce exactly this
566    // signature: the prefix is stable but the request landed on a node
567    // without the KV cache. Without route-affinity evidence the warning is
568    // suppressed to a note; only deployments KNOWN to be cache-affine
569    // (single node / sticky / global cache) get the diagnostic.
570    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    // Granularity floor: UNKNOWN unless a hard invariant fired.
589    // Classification with strict/shared downgrade (RFC-0015 §4.3).
590    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; // local source, over limit → B (medium)
602            } 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    // C4-2: without a tokenizer (byte_proxy), `token_upper = upper_bytes/4` is
613    // NOT a safe upper bound — ASCII-heavy corpora have ~1 byte/token, so
614    // bytes/4 underestimates tokens → `u` is too small → real cache hits get
615    // falsely accused as overclaim (R-1.1 / R-1.1b). When the overclaim rests
616    // SOLELY on this conversion (no independent invariant — R-1.1abs / R-1.2 /
617    // equality / threshold / TTL / quantization — fires), downgrade to UNKNOWN
618    // rather than risk a false accusation. Tokenizer evidence
619    // (byte_proxy = false) keeps the original logic.
620    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    // Byte-proxy cap (RFC F2): without a tokenizer, R-1.1 evidence can
638    // never rise above Medium — byte length overestimates token sharing for
639    // non-4-bytes/token corpora. Only reaches here when independent evidence
640    // also fired (C4-2 above already handled the byte-proxy-only case).
641    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}