Skip to main content

pi/
extension_scoring.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
6#[serde(rename_all = "camelCase")]
7pub struct ScoringReport {
8    pub schema: String,
9    pub generated_at: String,
10    pub as_of: String,
11    pub summary: ScoringSummary,
12    pub items: Vec<ScoredCandidate>,
13}
14
15#[derive(Debug, Clone, Deserialize, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ScoringSummary {
18    pub histogram: Vec<ScoreHistogramBucket>,
19    pub top_overall: Vec<RankedEntry>,
20    pub top_by_source_tier: BTreeMap<String, Vec<RankedEntry>>,
21    pub manual_overrides: Vec<ManualOverrideEntry>,
22}
23
24#[derive(Debug, Clone, Deserialize, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct ScoreHistogramBucket {
27    pub range: String,
28    pub count: u32,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct RankedEntry {
34    pub id: String,
35    pub score: u32,
36    pub tier: String,
37    pub rank: u32,
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct ManualOverrideEntry {
43    pub id: String,
44    pub reason: String,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub tier: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct CandidateInput {
52    pub id: String,
53    #[serde(default)]
54    pub name: Option<String>,
55    #[serde(default)]
56    pub source_tier: Option<String>,
57    #[serde(default)]
58    pub signals: Signals,
59    #[serde(default)]
60    pub tags: Tags,
61    #[serde(default)]
62    pub recency: Recency,
63    #[serde(default)]
64    pub compat: Compatibility,
65    #[serde(default)]
66    pub license: LicenseInfo,
67    #[serde(default)]
68    pub gates: Gates,
69    #[serde(default)]
70    pub risk: RiskInfo,
71    #[serde(default)]
72    pub manual_override: Option<ManualOverride>,
73}
74
75#[derive(Debug, Clone, Default, Deserialize, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Signals {
78    #[serde(default)]
79    pub official_listing: Option<bool>,
80    #[serde(default)]
81    pub pi_mono_example: Option<bool>,
82    #[serde(default)]
83    pub badlogic_gist: Option<bool>,
84    #[serde(default)]
85    pub github_stars: Option<u64>,
86    #[serde(default)]
87    pub github_forks: Option<u64>,
88    #[serde(default)]
89    pub npm_downloads_month: Option<u64>,
90    #[serde(default)]
91    pub references: Vec<String>,
92    #[serde(default)]
93    pub marketplace: Option<MarketplaceSignals>,
94}
95
96#[derive(Debug, Clone, Default, Deserialize, Serialize)]
97#[serde(rename_all = "camelCase")]
98pub struct MarketplaceSignals {
99    #[serde(default)]
100    pub rank: Option<u32>,
101    #[serde(default)]
102    pub installs_month: Option<u64>,
103    #[serde(default)]
104    pub featured: Option<bool>,
105}
106
107#[derive(Debug, Clone, Default, Deserialize, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct Tags {
110    #[serde(default)]
111    pub runtime: Option<String>,
112    #[serde(default)]
113    pub interaction: Vec<String>,
114    #[serde(default)]
115    pub capabilities: Vec<String>,
116}
117
118#[derive(Debug, Clone, Default, Deserialize, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Recency {
121    #[serde(default)]
122    pub updated_at: Option<String>,
123}
124
125#[derive(Debug, Clone, Default, Deserialize, Serialize)]
126#[serde(rename_all = "camelCase")]
127pub struct Compatibility {
128    #[serde(default)]
129    pub status: Option<CompatStatus>,
130    #[serde(default)]
131    pub blocked_reasons: Vec<String>,
132    #[serde(default)]
133    pub required_shims: Vec<String>,
134    #[serde(default)]
135    pub adjustment: Option<i8>,
136}
137
138#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum CompatStatus {
141    Unmodified,
142    RequiresShims,
143    RuntimeGap,
144    Blocked,
145    #[default]
146    Unknown,
147}
148
149#[derive(Debug, Clone, Default, Deserialize, Serialize)]
150#[serde(rename_all = "camelCase")]
151pub struct LicenseInfo {
152    #[serde(default)]
153    pub spdx: Option<String>,
154    #[serde(default)]
155    pub redistribution: Option<Redistribution>,
156    #[serde(default)]
157    pub notes: Option<String>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
161#[serde(rename_all = "snake_case")]
162pub enum Redistribution {
163    Ok,
164    Restricted,
165    Exclude,
166    Unknown,
167}
168
169#[derive(Debug, Clone, Default, Deserialize, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct Gates {
172    #[serde(default)]
173    pub provenance_pinned: Option<bool>,
174    #[serde(default)]
175    pub deterministic: Option<bool>,
176}
177
178#[derive(Debug, Clone, Default, Deserialize, Serialize)]
179#[serde(rename_all = "camelCase")]
180pub struct RiskInfo {
181    #[serde(default)]
182    pub level: Option<RiskLevel>,
183    #[serde(default)]
184    pub penalty: Option<u8>,
185    #[serde(default)]
186    pub flags: Vec<String>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
190#[serde(rename_all = "snake_case")]
191pub enum RiskLevel {
192    Low,
193    Moderate,
194    High,
195    Critical,
196}
197
198#[derive(Debug, Clone, Deserialize, Serialize)]
199#[serde(rename_all = "camelCase")]
200pub struct ManualOverride {
201    pub reason: String,
202    #[serde(default)]
203    pub tier: Option<String>,
204}
205
206#[derive(Debug, Clone, Deserialize, Serialize)]
207#[serde(rename_all = "camelCase")]
208pub struct ScoredCandidate {
209    pub id: String,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub name: Option<String>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub source_tier: Option<String>,
214    pub score: ScoreBreakdown,
215    pub tier: String,
216    pub rank: u32,
217    pub gates: GateStatus,
218    #[serde(skip_serializing_if = "Vec::is_empty", default)]
219    pub missing_signals: Vec<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub manual_override: Option<ManualOverride>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
225#[serde(rename_all = "camelCase")]
226pub struct ScoreBreakdown {
227    pub popularity: u32,
228    pub adoption: u32,
229    pub coverage: u32,
230    pub activity: u32,
231    pub compatibility: u32,
232    pub risk_penalty: u32,
233    pub base_total: u32,
234    pub final_total: u32,
235    pub components: ScoreComponents,
236}
237
238#[derive(Debug, Clone, Deserialize, Serialize, Default)]
239#[serde(rename_all = "camelCase")]
240pub struct ScoreComponents {
241    pub popularity: PopularityComponents,
242    pub adoption: AdoptionComponents,
243    pub coverage: CoverageComponents,
244}
245
246#[derive(Debug, Clone, Deserialize, Serialize, Default)]
247#[serde(rename_all = "camelCase")]
248pub struct PopularityComponents {
249    pub official_visibility: u32,
250    pub github_stars: u32,
251    pub marketplace_visibility: u32,
252    pub references: u32,
253}
254
255#[derive(Debug, Clone, Deserialize, Serialize, Default)]
256#[serde(rename_all = "camelCase")]
257pub struct AdoptionComponents {
258    pub npm_downloads: u32,
259    pub marketplace_installs: u32,
260    pub forks: u32,
261}
262
263#[derive(Debug, Clone, Deserialize, Serialize, Default)]
264#[serde(rename_all = "camelCase")]
265pub struct CoverageComponents {
266    pub runtime_tier: u32,
267    pub interaction: u32,
268    pub hostcalls: u32,
269}
270
271#[allow(clippy::struct_excessive_bools)]
272#[derive(Debug, Clone, Deserialize, Serialize)]
273#[serde(rename_all = "camelCase")]
274pub struct GateStatus {
275    pub provenance_pinned: bool,
276    pub license_ok: bool,
277    pub deterministic: bool,
278    pub unmodified: bool,
279    pub passes: bool,
280}
281
282pub fn score_candidates(
283    inputs: &[CandidateInput],
284    as_of: DateTime<Utc>,
285    generated_at: DateTime<Utc>,
286    top_n: usize,
287) -> ScoringReport {
288    let mut scored = inputs
289        .iter()
290        .map(|candidate| score_candidate(candidate, as_of))
291        .collect::<Vec<_>>();
292
293    scored.sort_by(|left, right| compare_scored(right, left));
294    for (idx, item) in scored.iter_mut().enumerate() {
295        #[allow(clippy::cast_possible_truncation)]
296        {
297            item.rank = (idx + 1) as u32;
298        }
299    }
300
301    let summary = build_summary(&scored, top_n);
302
303    ScoringReport {
304        schema: "pi.ext.scoring.v1".to_string(),
305        generated_at: generated_at.to_rfc3339(),
306        as_of: as_of.to_rfc3339(),
307        summary,
308        items: scored,
309    }
310}
311
312fn compare_scored(left: &ScoredCandidate, right: &ScoredCandidate) -> std::cmp::Ordering {
313    left.score
314        .final_total
315        .cmp(&right.score.final_total)
316        .then_with(|| left.score.coverage.cmp(&right.score.coverage))
317        .then_with(|| left.score.popularity.cmp(&right.score.popularity))
318        .then_with(|| left.score.activity.cmp(&right.score.activity))
319        .then_with(|| left.id.cmp(&right.id))
320}
321
322fn score_candidate(candidate: &CandidateInput, as_of: DateTime<Utc>) -> ScoredCandidate {
323    let mut missing = BTreeSet::new();
324    let (popularity, popularity_components) = score_popularity(&candidate.signals, &mut missing);
325    let (adoption, adoption_components) = score_adoption(&candidate.signals, &mut missing);
326    let (coverage, coverage_components) = score_coverage(&candidate.tags);
327    let activity = score_activity(&candidate.recency, as_of, &mut missing);
328    let compatibility = score_compatibility(&candidate.compat);
329    let risk_penalty = score_risk(&candidate.risk);
330    let base_total = popularity + adoption + coverage + activity + compatibility;
331    let final_total = base_total.saturating_sub(risk_penalty);
332    let gates = compute_gates(candidate);
333    let tier = compute_tier(candidate, &gates, final_total);
334    let missing_signals = missing.into_iter().collect::<Vec<_>>();
335    let components = ScoreComponents {
336        popularity: popularity_components,
337        adoption: adoption_components,
338        coverage: coverage_components,
339    };
340    let score = ScoreBreakdown {
341        popularity,
342        adoption,
343        coverage,
344        activity,
345        compatibility,
346        risk_penalty,
347        base_total,
348        final_total,
349        components,
350    };
351    let manual_override = candidate.manual_override.clone();
352    ScoredCandidate {
353        id: candidate.id.clone(),
354        name: candidate.name.clone(),
355        source_tier: candidate.source_tier.clone(),
356        score,
357        tier,
358        rank: 0,
359        gates,
360        missing_signals,
361        manual_override,
362    }
363}
364
365fn score_popularity(
366    signals: &Signals,
367    missing: &mut BTreeSet<String>,
368) -> (u32, PopularityComponents) {
369    let official_visibility = score_official_visibility(signals, missing);
370    let github_stars = score_github_stars(signals, missing);
371    let marketplace_visibility = score_marketplace_visibility(signals, missing);
372    let references = score_references(signals);
373    let total = (official_visibility + github_stars + marketplace_visibility + references).min(30);
374    (
375        total,
376        PopularityComponents {
377            official_visibility,
378            github_stars,
379            marketplace_visibility,
380            references,
381        },
382    )
383}
384
385fn score_adoption(signals: &Signals, missing: &mut BTreeSet<String>) -> (u32, AdoptionComponents) {
386    let npm_downloads = score_npm_downloads(signals, missing);
387    let marketplace_installs = score_marketplace_installs(signals, missing);
388    let forks = score_forks(signals, missing);
389    let total = (npm_downloads + marketplace_installs + forks).min(15);
390    (
391        total,
392        AdoptionComponents {
393            npm_downloads,
394            marketplace_installs,
395            forks,
396        },
397    )
398}
399
400fn score_coverage(tags: &Tags) -> (u32, CoverageComponents) {
401    let runtime_tier = score_runtime_tier(tags);
402    let interaction = score_interaction(tags);
403    let hostcalls = score_hostcalls(tags);
404    let total = (runtime_tier + interaction + hostcalls).min(20);
405    (
406        total,
407        CoverageComponents {
408            runtime_tier,
409            interaction,
410            hostcalls,
411        },
412    )
413}
414
415fn score_official_visibility(signals: &Signals, missing: &mut BTreeSet<String>) -> u32 {
416    let listing = signals.official_listing;
417    let example = signals.pi_mono_example;
418    let gist = signals.badlogic_gist;
419    if listing.is_none() && example.is_none() && gist.is_none() {
420        missing.insert("signals.official_visibility".to_string());
421    }
422    if listing.unwrap_or(false) {
423        10
424    } else if example.unwrap_or(false) {
425        8
426    } else if gist.unwrap_or(false) {
427        6
428    } else {
429        0
430    }
431}
432
433fn score_github_stars(signals: &Signals, missing: &mut BTreeSet<String>) -> u32 {
434    let Some(stars) = signals.github_stars else {
435        missing.insert("signals.github_stars".to_string());
436        return 0;
437    };
438    // Log-linear: 10 * ln(1 + stars) / ln(1 + 5000), clamped to [0, 10]
439    #[allow(clippy::cast_precision_loss)]
440    let score = 10.0 * (stars as f64).ln_1p() / 5000_f64.ln_1p();
441    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
442    {
443        (score.round() as u32).min(10)
444    }
445}
446
447fn score_marketplace_visibility(signals: &Signals, missing: &mut BTreeSet<String>) -> u32 {
448    let Some(marketplace) = signals.marketplace.as_ref() else {
449        missing.insert("signals.marketplace.rank".to_string());
450        missing.insert("signals.marketplace.featured".to_string());
451        return 0;
452    };
453    let rank_points = match marketplace.rank {
454        Some(rank) if rank <= 10 => 6,
455        Some(rank) if rank <= 50 => 4,
456        Some(rank) if rank <= 100 => 2,
457        Some(_) => 0,
458        None => {
459            missing.insert("signals.marketplace.rank".to_string());
460            0
461        }
462    };
463    let featured_points = if marketplace.featured.unwrap_or(false) {
464        2
465    } else {
466        0
467    };
468    (rank_points + featured_points).min(6)
469}
470
471fn score_references(signals: &Signals) -> u32 {
472    let unique = signals
473        .references
474        .iter()
475        .map(|entry| entry.trim())
476        .filter(|entry| !entry.is_empty())
477        .collect::<BTreeSet<_>>()
478        .len();
479    match unique {
480        n if n >= 10 => 4,
481        n if n >= 5 => 3,
482        n if n >= 2 => 2,
483        _ => 0,
484    }
485}
486
487fn score_npm_downloads(signals: &Signals, missing: &mut BTreeSet<String>) -> u32 {
488    let Some(downloads) = signals.npm_downloads_month else {
489        missing.insert("signals.npm_downloads_month".to_string());
490        return 0;
491    };
492    // Log-linear: 8 * ln(1 + downloads) / ln(1 + 50_000), clamped to [0, 8]
493    #[allow(clippy::cast_precision_loss)]
494    let score = 8.0 * (downloads as f64).ln_1p() / 50_000_f64.ln_1p();
495    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
496    {
497        (score.round() as u32).min(8)
498    }
499}
500
501fn score_marketplace_installs(signals: &Signals, missing: &mut BTreeSet<String>) -> u32 {
502    let Some(marketplace) = signals.marketplace.as_ref() else {
503        missing.insert("signals.marketplace.installs_month".to_string());
504        return 0;
505    };
506    let Some(installs) = marketplace.installs_month else {
507        missing.insert("signals.marketplace.installs_month".to_string());
508        return 0;
509    };
510    match installs {
511        d if d >= 10_000 => 5,
512        d if d >= 2_000 => 4,
513        d if d >= 500 => 2,
514        d if d >= 100 => 1,
515        _ => 0,
516    }
517}
518
519fn score_forks(signals: &Signals, missing: &mut BTreeSet<String>) -> u32 {
520    let Some(forks) = signals.github_forks else {
521        missing.insert("signals.github_forks".to_string());
522        return 0;
523    };
524    match forks {
525        f if f >= 500 => 2,
526        f if f >= 200 => 1,
527        f if f >= 50 => 1,
528        _ => 0,
529    }
530}
531
532fn score_runtime_tier(tags: &Tags) -> u32 {
533    let Some(runtime) = tags.runtime.as_deref() else {
534        return 0;
535    };
536    match runtime {
537        "pkg-with-deps" | "provider-ext" => 6,
538        "multi-file" => 4,
539        "legacy-js" => 2,
540        _ => 0,
541    }
542}
543
544fn score_interaction(tags: &Tags) -> u32 {
545    let mut score = 0;
546    if tags.interaction.iter().any(|tag| tag == "provider") {
547        score += 3;
548    }
549    if tags.interaction.iter().any(|tag| tag == "ui_integration") {
550        score += 2;
551    }
552    if tags.interaction.iter().any(|tag| tag == "event_hook") {
553        score += 2;
554    }
555    if tags.interaction.iter().any(|tag| tag == "slash_command") {
556        score += 1;
557    }
558    if tags.interaction.iter().any(|tag| tag == "tool_only") {
559        score += 1;
560    }
561    score.min(8)
562}
563
564fn score_hostcalls(tags: &Tags) -> u32 {
565    let mut score = 0;
566    if tags.capabilities.iter().any(|cap| cap == "exec") {
567        score += 2;
568    }
569    if tags.capabilities.iter().any(|cap| cap == "http") {
570        score += 2;
571    }
572    if tags
573        .capabilities
574        .iter()
575        .any(|cap| matches!(cap.as_str(), "read" | "write" | "edit"))
576    {
577        score += 1;
578    }
579    if tags.capabilities.iter().any(|cap| cap == "ui") {
580        score += 1;
581    }
582    if tags.capabilities.iter().any(|cap| cap == "session") {
583        score += 1;
584    }
585    score.min(6)
586}
587
588fn score_activity(recency: &Recency, as_of: DateTime<Utc>, missing: &mut BTreeSet<String>) -> u32 {
589    let Some(updated_at) = recency.updated_at.as_deref() else {
590        missing.insert("recency.updated_at".to_string());
591        return 0;
592    };
593    let Ok(parsed) = DateTime::parse_from_rfc3339(updated_at) else {
594        missing.insert("recency.updated_at".to_string());
595        return 0;
596    };
597    let updated_at = parsed.with_timezone(&Utc);
598    #[allow(clippy::cast_precision_loss)]
599    let days = (as_of - updated_at).num_days().max(0) as f64;
600    // Exponential decay: 15 * exp(-ln(2) * days / half_life), half_life = 180 days
601    let half_life = 180.0_f64;
602    let score = 15.0 * (-std::f64::consts::LN_2 * days / half_life).exp();
603    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
604    {
605        (score.round() as u32).min(15)
606    }
607}
608
609fn score_compatibility(compat: &Compatibility) -> u32 {
610    let base = match compat.status.unwrap_or_default() {
611        CompatStatus::Unmodified => 20,
612        CompatStatus::RequiresShims => 15,
613        CompatStatus::RuntimeGap => 10,
614        CompatStatus::Blocked | CompatStatus::Unknown => 0,
615    };
616    let adjustment = compat.adjustment.unwrap_or(0);
617    let adjusted = base + i32::from(adjustment);
618    #[allow(clippy::cast_sign_loss)]
619    {
620        adjusted.clamp(0, 20) as u32
621    }
622}
623
624fn score_risk(risk: &RiskInfo) -> u32 {
625    if let Some(penalty) = risk.penalty {
626        return u32::from(penalty.min(15));
627    }
628    match risk.level {
629        Some(RiskLevel::Low) | None => 0,
630        Some(RiskLevel::Moderate) => 5,
631        Some(RiskLevel::High) => 10,
632        Some(RiskLevel::Critical) => 15,
633    }
634}
635
636fn compute_gates(candidate: &CandidateInput) -> GateStatus {
637    let provenance_pinned = candidate.gates.provenance_pinned.unwrap_or(false);
638    let deterministic = candidate.gates.deterministic.unwrap_or(false);
639    let license_ok = matches!(
640        candidate.license.redistribution,
641        Some(Redistribution::Ok | Redistribution::Restricted)
642    );
643    let unmodified = matches!(
644        candidate.compat.status.unwrap_or_default(),
645        CompatStatus::Unmodified | CompatStatus::RequiresShims | CompatStatus::RuntimeGap
646    );
647    let passes = provenance_pinned && deterministic && license_ok && unmodified;
648    GateStatus {
649        provenance_pinned,
650        license_ok,
651        deterministic,
652        unmodified,
653        passes,
654    }
655}
656
657fn compute_tier(candidate: &CandidateInput, gates: &GateStatus, final_total: u32) -> String {
658    let is_official = candidate.signals.pi_mono_example.unwrap_or(false)
659        || matches!(candidate.source_tier.as_deref(), Some("official-pi-mono"));
660    if is_official {
661        if let Some(override_tier) = candidate
662            .manual_override
663            .as_ref()
664            .and_then(|override_spec| override_spec.tier.clone())
665        {
666            return override_tier;
667        }
668        return "tier-0".to_string();
669    }
670    if let Some(override_tier) = candidate
671        .manual_override
672        .as_ref()
673        .and_then(|override_spec| override_spec.tier.clone())
674    {
675        return override_tier;
676    }
677    if !gates.passes {
678        return "excluded".to_string();
679    }
680    if final_total >= 70 {
681        return "tier-1".to_string();
682    }
683    if final_total >= 50 {
684        return "tier-2".to_string();
685    }
686    "excluded".to_string()
687}
688
689fn build_summary(items: &[ScoredCandidate], top_n: usize) -> ScoringSummary {
690    let histogram = build_histogram(items);
691    let top_overall = items
692        .iter()
693        .take(top_n)
694        .map(|item| RankedEntry {
695            id: item.id.clone(),
696            score: item.score.final_total,
697            tier: item.tier.clone(),
698            rank: item.rank,
699        })
700        .collect::<Vec<_>>();
701
702    let mut by_tier = BTreeMap::<String, Vec<&ScoredCandidate>>::new();
703    for item in items {
704        let tier = item
705            .source_tier
706            .clone()
707            .unwrap_or_else(|| "unknown".to_string());
708        by_tier.entry(tier).or_default().push(item);
709    }
710
711    let mut top_by_source_tier = BTreeMap::new();
712    for (tier, entries) in by_tier {
713        let mut top_entries = entries
714            .into_iter()
715            .take(top_n)
716            .map(|item| RankedEntry {
717                id: item.id.clone(),
718                score: item.score.final_total,
719                tier: item.tier.clone(),
720                rank: item.rank,
721            })
722            .collect::<Vec<_>>();
723        top_entries.sort_by_key(|entry| entry.rank);
724        top_by_source_tier.insert(tier, top_entries);
725    }
726
727    let manual_overrides = items
728        .iter()
729        .filter_map(|item| {
730            item.manual_override
731                .as_ref()
732                .map(|override_spec| ManualOverrideEntry {
733                    id: item.id.clone(),
734                    reason: override_spec.reason.clone(),
735                    tier: override_spec.tier.clone(),
736                })
737        })
738        .collect::<Vec<_>>();
739
740    ScoringSummary {
741        histogram,
742        top_overall,
743        top_by_source_tier,
744        manual_overrides,
745    }
746}
747
748fn build_histogram(items: &[ScoredCandidate]) -> Vec<ScoreHistogramBucket> {
749    let mut buckets = BTreeMap::<u32, u32>::new();
750    for item in items {
751        let bucket = item.score.final_total / 10;
752        *buckets.entry(bucket).or_insert(0) += 1;
753    }
754    (0..=10)
755        .map(|bucket| {
756            let start = bucket * 10;
757            let end = if bucket == 10 { 100 } else { start + 9 };
758            ScoreHistogramBucket {
759                range: format!("{start}-{end}"),
760                count: buckets.get(&bucket).copied().unwrap_or(0),
761            }
762        })
763        .collect()
764}
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
767#[serde(rename_all = "snake_case")]
768pub enum VoiSkipReason {
769    Disabled,
770    MissingTelemetry,
771    StaleEvidence,
772    BudgetExceeded,
773    BelowUtilityFloor,
774}
775
776#[derive(Debug, Clone, Deserialize, Serialize)]
777#[serde(rename_all = "camelCase")]
778pub struct VoiCandidate {
779    pub id: String,
780    pub utility_score: f64,
781    pub estimated_overhead_ms: u32,
782    #[serde(default)]
783    pub last_seen_at: Option<String>,
784    #[serde(default = "default_voi_candidate_enabled")]
785    pub enabled: bool,
786}
787
788const fn default_voi_candidate_enabled() -> bool {
789    true
790}
791
792#[derive(Debug, Clone, Deserialize, Serialize)]
793#[serde(rename_all = "camelCase")]
794pub struct VoiPlannerConfig {
795    pub enabled: bool,
796    pub overhead_budget_ms: u32,
797    #[serde(default)]
798    pub max_candidates: Option<usize>,
799    #[serde(default)]
800    pub stale_after_minutes: Option<i64>,
801    #[serde(default)]
802    pub min_utility_score: Option<f64>,
803}
804
805impl Default for VoiPlannerConfig {
806    fn default() -> Self {
807        Self {
808            enabled: true,
809            overhead_budget_ms: 100,
810            max_candidates: None,
811            stale_after_minutes: Some(120),
812            min_utility_score: Some(0.0),
813        }
814    }
815}
816
817#[derive(Debug, Clone, Deserialize, Serialize)]
818#[serde(rename_all = "camelCase")]
819pub struct VoiPlannedCandidate {
820    pub id: String,
821    pub utility_score: f64,
822    pub estimated_overhead_ms: u32,
823    pub utility_per_ms: f64,
824    pub cumulative_overhead_ms: u32,
825}
826
827#[derive(Debug, Clone, Deserialize, Serialize)]
828#[serde(rename_all = "camelCase")]
829pub struct VoiSkippedCandidate {
830    pub id: String,
831    pub reason: VoiSkipReason,
832}
833
834#[derive(Debug, Clone, Deserialize, Serialize)]
835#[serde(rename_all = "camelCase")]
836pub struct VoiPlan {
837    pub selected: Vec<VoiPlannedCandidate>,
838    pub skipped: Vec<VoiSkippedCandidate>,
839    pub used_overhead_ms: u32,
840    pub remaining_overhead_ms: u32,
841}
842
843pub fn plan_voi_candidates(
844    candidates: &[VoiCandidate],
845    now: DateTime<Utc>,
846    config: &VoiPlannerConfig,
847) -> VoiPlan {
848    let mut selected = Vec::new();
849    let mut skipped = Vec::new();
850    if !config.enabled {
851        skipped.extend(candidates.iter().map(|candidate| VoiSkippedCandidate {
852            id: candidate.id.clone(),
853            reason: VoiSkipReason::Disabled,
854        }));
855        return VoiPlan {
856            selected,
857            skipped,
858            used_overhead_ms: 0,
859            remaining_overhead_ms: config.overhead_budget_ms,
860        };
861    }
862
863    let mut ranked = candidates.to_vec();
864    ranked.sort_by(compare_voi_candidates_desc);
865
866    let max_candidates = config.max_candidates.unwrap_or(usize::MAX);
867    let stale_after_minutes = config.stale_after_minutes.unwrap_or(120).max(0);
868    let min_utility_score = config.min_utility_score.unwrap_or(0.0).max(0.0);
869    let mut used_overhead_ms = 0_u32;
870
871    for candidate in ranked {
872        if !candidate.enabled {
873            skipped.push(VoiSkippedCandidate {
874                id: candidate.id,
875                reason: VoiSkipReason::Disabled,
876            });
877            continue;
878        }
879        if normalized_utility(candidate.utility_score) < min_utility_score {
880            skipped.push(VoiSkippedCandidate {
881                id: candidate.id,
882                reason: VoiSkipReason::BelowUtilityFloor,
883            });
884            continue;
885        }
886        if let Some(reason) = evaluate_candidate_freshness(
887            candidate.last_seen_at.as_deref(),
888            now,
889            stale_after_minutes,
890        ) {
891            skipped.push(VoiSkippedCandidate {
892                id: candidate.id,
893                reason,
894            });
895            continue;
896        }
897        if selected.len() >= max_candidates
898            || used_overhead_ms.saturating_add(candidate.estimated_overhead_ms)
899                > config.overhead_budget_ms
900        {
901            skipped.push(VoiSkippedCandidate {
902                id: candidate.id,
903                reason: VoiSkipReason::BudgetExceeded,
904            });
905            continue;
906        }
907        used_overhead_ms = used_overhead_ms.saturating_add(candidate.estimated_overhead_ms);
908        let upm = utility_per_ms(&candidate);
909        let score = normalized_utility(candidate.utility_score);
910        let id = candidate.id;
911        selected.push(VoiPlannedCandidate {
912            id,
913            utility_score: score,
914            estimated_overhead_ms: candidate.estimated_overhead_ms,
915            utility_per_ms: upm,
916            cumulative_overhead_ms: used_overhead_ms,
917        });
918    }
919
920    VoiPlan {
921        selected,
922        skipped,
923        used_overhead_ms,
924        remaining_overhead_ms: config.overhead_budget_ms.saturating_sub(used_overhead_ms),
925    }
926}
927
928fn compare_voi_candidates_desc(left: &VoiCandidate, right: &VoiCandidate) -> std::cmp::Ordering {
929    utility_per_ms(right)
930        .partial_cmp(&utility_per_ms(left))
931        .unwrap_or(std::cmp::Ordering::Equal)
932        .then_with(|| {
933            normalized_utility(right.utility_score)
934                .partial_cmp(&normalized_utility(left.utility_score))
935                .unwrap_or(std::cmp::Ordering::Equal)
936        })
937        .then_with(|| left.estimated_overhead_ms.cmp(&right.estimated_overhead_ms))
938        .then_with(|| left.id.cmp(&right.id))
939}
940
941fn evaluate_candidate_freshness(
942    last_seen_at: Option<&str>,
943    now: DateTime<Utc>,
944    stale_after_minutes: i64,
945) -> Option<VoiSkipReason> {
946    let Some(raw) = last_seen_at else {
947        return Some(VoiSkipReason::MissingTelemetry);
948    };
949    let Ok(parsed) = DateTime::parse_from_rfc3339(raw) else {
950        return Some(VoiSkipReason::MissingTelemetry);
951    };
952    let minutes = now
953        .signed_duration_since(parsed.with_timezone(&Utc))
954        .num_minutes();
955    if minutes > stale_after_minutes {
956        Some(VoiSkipReason::StaleEvidence)
957    } else {
958        None
959    }
960}
961
962const fn normalized_utility(value: f64) -> f64 {
963    if value.is_finite() {
964        value.max(0.0)
965    } else {
966        0.0
967    }
968}
969
970fn utility_per_ms(candidate: &VoiCandidate) -> f64 {
971    if candidate.estimated_overhead_ms == 0 {
972        0.0
973    } else {
974        normalized_utility(candidate.utility_score) / f64::from(candidate.estimated_overhead_ms)
975    }
976}
977
978#[derive(Debug, Clone, Deserialize, Serialize)]
979#[serde(rename_all = "camelCase")]
980pub struct MeanFieldShardObservation {
981    pub shard_id: String,
982    pub queue_pressure: f64,
983    pub tail_latency_ratio: f64,
984    pub starvation_risk: f64,
985}
986
987#[derive(Debug, Clone, Deserialize, Serialize)]
988#[serde(rename_all = "camelCase")]
989pub struct MeanFieldShardState {
990    pub shard_id: String,
991    pub routing_weight: f64,
992    pub batch_budget: u32,
993    pub help_factor: f64,
994    pub backoff_factor: f64,
995    #[serde(default)]
996    pub last_routing_delta: f64,
997}
998
999#[derive(Debug, Clone, Deserialize, Serialize)]
1000#[serde(rename_all = "camelCase")]
1001pub struct MeanFieldControllerConfig {
1002    pub queue_gain: f64,
1003    pub latency_gain: f64,
1004    pub starvation_gain: f64,
1005    pub damping: f64,
1006    pub max_step: f64,
1007    pub min_routing_weight: f64,
1008    pub max_routing_weight: f64,
1009    pub min_batch_budget: u32,
1010    pub max_batch_budget: u32,
1011    pub min_help_factor: f64,
1012    pub max_help_factor: f64,
1013    pub min_backoff_factor: f64,
1014    pub max_backoff_factor: f64,
1015    pub convergence_epsilon: f64,
1016}
1017
1018impl Default for MeanFieldControllerConfig {
1019    fn default() -> Self {
1020        Self {
1021            queue_gain: 0.55,
1022            latency_gain: 0.35,
1023            starvation_gain: 0.50,
1024            damping: 0.60,
1025            max_step: 0.20,
1026            min_routing_weight: 0.10,
1027            max_routing_weight: 3.00,
1028            min_batch_budget: 1,
1029            max_batch_budget: 64,
1030            min_help_factor: 0.50,
1031            max_help_factor: 2.50,
1032            min_backoff_factor: 1.00,
1033            max_backoff_factor: 3.50,
1034            convergence_epsilon: 0.02,
1035        }
1036    }
1037}
1038
1039#[derive(Debug, Clone, Deserialize, Serialize)]
1040#[serde(rename_all = "camelCase")]
1041pub struct MeanFieldShardControl {
1042    pub shard_id: String,
1043    pub routing_weight: f64,
1044    pub batch_budget: u32,
1045    pub help_factor: f64,
1046    pub backoff_factor: f64,
1047    pub routing_delta: f64,
1048    pub stability_margin: f64,
1049    pub clipped: bool,
1050    pub oscillation_guarded: bool,
1051}
1052
1053#[derive(Debug, Clone, Deserialize, Serialize)]
1054#[serde(rename_all = "camelCase")]
1055pub struct MeanFieldControllerReport {
1056    pub global_pressure: f64,
1057    pub converged: bool,
1058    pub controls: Vec<MeanFieldShardControl>,
1059    pub clipped_count: usize,
1060    pub oscillation_guard_count: usize,
1061}
1062
1063pub fn compute_mean_field_controls(
1064    observations: &[MeanFieldShardObservation],
1065    previous: &[MeanFieldShardState],
1066    config: &MeanFieldControllerConfig,
1067) -> MeanFieldControllerReport {
1068    let mut previous_by_shard = BTreeMap::new();
1069    for state in previous {
1070        previous_by_shard.insert(state.shard_id.clone(), state.clone());
1071    }
1072
1073    let sanitized = observations
1074        .iter()
1075        .map(|observation| {
1076            (
1077                observation.shard_id.clone(),
1078                sanitize_observation(observation),
1079            )
1080        })
1081        .collect::<Vec<_>>();
1082    let sanitized_config = sanitize_mean_field_config(config);
1083    let global_pressure = if sanitized.is_empty() {
1084        0.0
1085    } else {
1086        sanitized
1087            .iter()
1088            .map(|(_, obs)| obs.composite_pressure)
1089            .sum::<f64>()
1090            / usize_to_f64(sanitized.len())
1091    };
1092
1093    let mut controls = Vec::with_capacity(sanitized.len());
1094    let mut clipped_count = 0_usize;
1095    let mut oscillation_guard_count = 0_usize;
1096    let mut total_absolute_delta = 0.0;
1097
1098    for (shard_id, observation) in sanitized {
1099        let baseline = previous_by_shard
1100            .get(&shard_id)
1101            .cloned()
1102            .unwrap_or_else(|| default_mean_field_state(&shard_id, &sanitized_config));
1103
1104        let control = compute_control_for_shard(
1105            &shard_id,
1106            observation,
1107            &baseline,
1108            global_pressure,
1109            &sanitized_config,
1110        );
1111        if control.clipped {
1112            clipped_count = clipped_count.saturating_add(1);
1113        }
1114        if control.oscillation_guarded {
1115            oscillation_guard_count = oscillation_guard_count.saturating_add(1);
1116        }
1117        total_absolute_delta += control.routing_delta.abs();
1118        controls.push(control);
1119    }
1120
1121    controls.sort_by(|left, right| left.shard_id.cmp(&right.shard_id));
1122    let converged = if controls.is_empty() {
1123        true
1124    } else {
1125        (total_absolute_delta / usize_to_f64(controls.len()))
1126            <= sanitized_config.convergence_epsilon
1127    };
1128
1129    MeanFieldControllerReport {
1130        global_pressure,
1131        converged,
1132        controls,
1133        clipped_count,
1134        oscillation_guard_count,
1135    }
1136}
1137
1138#[derive(Debug, Clone, Copy)]
1139struct SanitizedMeanFieldObservation {
1140    queue_pressure: f64,
1141    latency_pressure: f64,
1142    starvation_risk: f64,
1143    composite_pressure: f64,
1144}
1145
1146#[derive(Debug, Clone, Copy)]
1147struct SanitizedMeanFieldConfig {
1148    queue_gain: f64,
1149    latency_gain: f64,
1150    starvation_gain: f64,
1151    damping: f64,
1152    max_step: f64,
1153    min_routing_weight: f64,
1154    max_routing_weight: f64,
1155    min_batch_budget: u32,
1156    max_batch_budget: u32,
1157    min_help_factor: f64,
1158    max_help_factor: f64,
1159    min_backoff_factor: f64,
1160    max_backoff_factor: f64,
1161    convergence_epsilon: f64,
1162}
1163
1164fn sanitize_observation(observation: &MeanFieldShardObservation) -> SanitizedMeanFieldObservation {
1165    let queue_pressure = non_negative_finite(observation.queue_pressure).clamp(0.0, 1.0);
1166    let latency_pressure =
1167        non_negative_finite(observation.tail_latency_ratio - 1.0).clamp(0.0, 1.0);
1168    let starvation_risk = non_negative_finite(observation.starvation_risk).clamp(0.0, 1.0);
1169    let composite_pressure = (queue_pressure + latency_pressure + starvation_risk) / 3.0;
1170    SanitizedMeanFieldObservation {
1171        queue_pressure,
1172        latency_pressure,
1173        starvation_risk,
1174        composite_pressure,
1175    }
1176}
1177
1178fn sanitize_mean_field_config(config: &MeanFieldControllerConfig) -> SanitizedMeanFieldConfig {
1179    let min_routing_weight = non_negative_finite(config.min_routing_weight);
1180    let max_routing_weight = config.max_routing_weight.max(min_routing_weight);
1181    let min_batch_budget = config.min_batch_budget.min(config.max_batch_budget);
1182    let max_batch_budget = config.max_batch_budget.max(min_batch_budget);
1183    let min_help_factor = non_negative_finite(config.min_help_factor);
1184    let max_help_factor = config.max_help_factor.max(min_help_factor);
1185    let min_backoff_factor = non_negative_finite(config.min_backoff_factor);
1186    let max_backoff_factor = config.max_backoff_factor.max(min_backoff_factor);
1187    SanitizedMeanFieldConfig {
1188        queue_gain: non_negative_finite(config.queue_gain),
1189        latency_gain: non_negative_finite(config.latency_gain),
1190        starvation_gain: non_negative_finite(config.starvation_gain),
1191        damping: non_negative_finite(config.damping).min(1.0),
1192        max_step: non_negative_finite(config.max_step),
1193        min_routing_weight,
1194        max_routing_weight,
1195        min_batch_budget,
1196        max_batch_budget,
1197        min_help_factor,
1198        max_help_factor,
1199        min_backoff_factor,
1200        max_backoff_factor,
1201        convergence_epsilon: non_negative_finite(config.convergence_epsilon),
1202    }
1203}
1204
1205fn compute_control_for_shard(
1206    shard_id: &str,
1207    observation: SanitizedMeanFieldObservation,
1208    baseline: &MeanFieldShardState,
1209    global_pressure: f64,
1210    config: &SanitizedMeanFieldConfig,
1211) -> MeanFieldShardControl {
1212    let pressure_offset = observation.composite_pressure - global_pressure;
1213    let instability = config.starvation_gain.mul_add(
1214        -observation.starvation_risk,
1215        config.latency_gain.mul_add(
1216            observation.latency_pressure,
1217            config
1218                .queue_gain
1219                .mul_add(observation.queue_pressure, pressure_offset),
1220        ),
1221    );
1222    let target_routing = baseline.routing_weight - instability;
1223    let mut routing_delta = target_routing - baseline.routing_weight;
1224    let oscillation_guarded = if baseline.last_routing_delta.signum() != 0.0
1225        && routing_delta.signum() != 0.0
1226        && baseline.last_routing_delta.signum() != routing_delta.signum()
1227    {
1228        routing_delta *= 0.5;
1229        true
1230    } else {
1231        false
1232    };
1233
1234    let clipped_delta = routing_delta.clamp(-config.max_step, config.max_step);
1235    let step_clipped = (clipped_delta - routing_delta).abs() > f64::EPSILON;
1236    let damped_routing = clipped_delta.mul_add(config.damping, baseline.routing_weight);
1237    let bounded_routing =
1238        damped_routing.clamp(config.min_routing_weight, config.max_routing_weight);
1239    let routing_boundary_clipped = (bounded_routing - damped_routing).abs() > f64::EPSILON;
1240    let routing_clipped = step_clipped || routing_boundary_clipped;
1241
1242    let normalized_batch = (-0.4_f64)
1243        .mul_add(
1244            observation.latency_pressure,
1245            (-0.6_f64).mul_add(observation.queue_pressure, 1.0),
1246        )
1247        .clamp(0.0, 1.0);
1248    let desired_batch = f64::from(config.max_batch_budget) * normalized_batch;
1249    let batch_budget = quantize_batch_budget(
1250        desired_batch,
1251        config.min_batch_budget,
1252        config.max_batch_budget,
1253    );
1254
1255    let help_factor = config
1256        .starvation_gain
1257        .mul_add(observation.starvation_risk, 1.0)
1258        .clamp(config.min_help_factor, config.max_help_factor);
1259    let backoff_factor = config
1260        .latency_gain
1261        .mul_add(
1262            observation.latency_pressure,
1263            config.queue_gain.mul_add(observation.queue_pressure, 1.0),
1264        )
1265        .clamp(config.min_backoff_factor, config.max_backoff_factor);
1266    let stability_margin = (config.max_step - routing_delta.abs()).max(0.0);
1267
1268    MeanFieldShardControl {
1269        shard_id: shard_id.to_string(),
1270        routing_weight: bounded_routing,
1271        batch_budget,
1272        help_factor,
1273        backoff_factor,
1274        routing_delta: bounded_routing - baseline.routing_weight,
1275        stability_margin,
1276        clipped: routing_clipped,
1277        oscillation_guarded,
1278    }
1279}
1280
1281fn quantize_batch_budget(desired_batch: f64, min_batch_budget: u32, max_batch_budget: u32) -> u32 {
1282    let mut selected = min_batch_budget;
1283    let mut smallest_distance = f64::INFINITY;
1284    for budget in min_batch_budget..=max_batch_budget {
1285        let distance = (desired_batch - f64::from(budget)).abs();
1286        if distance < smallest_distance {
1287            selected = budget;
1288            smallest_distance = distance;
1289        }
1290    }
1291    selected
1292}
1293
1294fn default_mean_field_state(
1295    shard_id: &str,
1296    config: &SanitizedMeanFieldConfig,
1297) -> MeanFieldShardState {
1298    MeanFieldShardState {
1299        shard_id: shard_id.to_string(),
1300        routing_weight: 1.0,
1301        batch_budget: u32::midpoint(config.min_batch_budget, config.max_batch_budget),
1302        help_factor: 1.0,
1303        backoff_factor: 1.0,
1304        last_routing_delta: 0.0,
1305    }
1306}
1307
1308fn usize_to_f64(value: usize) -> f64 {
1309    let bounded = u32::try_from(value).unwrap_or(u32::MAX);
1310    f64::from(bounded)
1311}
1312
1313#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1314#[serde(rename_all = "snake_case")]
1315pub enum OpeGateReason {
1316    Approved,
1317    NoValidSamples,
1318    InsufficientSupport,
1319    HighUncertainty,
1320    ExcessiveRegret,
1321}
1322
1323#[derive(Debug, Clone, Deserialize, Serialize)]
1324#[serde(rename_all = "camelCase")]
1325pub struct OpeTraceSample {
1326    pub action: String,
1327    pub behavior_propensity: f64,
1328    pub target_propensity: f64,
1329    pub outcome: f64,
1330    #[serde(default)]
1331    pub baseline_outcome: Option<f64>,
1332    #[serde(default)]
1333    pub direct_method_prediction: Option<f64>,
1334    #[serde(default)]
1335    pub context_lineage: Option<String>,
1336}
1337
1338#[derive(Debug, Clone, Deserialize, Serialize)]
1339#[serde(rename_all = "camelCase")]
1340pub struct OpeEvaluatorConfig {
1341    pub max_importance_weight: f64,
1342    pub min_effective_sample_size: f64,
1343    pub max_standard_error: f64,
1344    pub confidence_z: f64,
1345    pub max_regret_delta: f64,
1346}
1347
1348impl Default for OpeEvaluatorConfig {
1349    fn default() -> Self {
1350        Self {
1351            max_importance_weight: 25.0,
1352            min_effective_sample_size: 8.0,
1353            max_standard_error: 0.25,
1354            confidence_z: 1.96,
1355            max_regret_delta: 0.05,
1356        }
1357    }
1358}
1359
1360#[derive(Debug, Clone, Deserialize, Serialize)]
1361#[serde(rename_all = "camelCase")]
1362pub struct OpeEstimatorSummary {
1363    pub estimate: f64,
1364    pub variance: f64,
1365    pub standard_error: f64,
1366    pub ci_low: f64,
1367    pub ci_high: f64,
1368}
1369
1370#[derive(Debug, Clone, Deserialize, Serialize)]
1371#[serde(rename_all = "camelCase")]
1372pub struct OpeDiagnostics {
1373    pub total_samples: usize,
1374    pub valid_samples: usize,
1375    pub skipped_invalid_samples: usize,
1376    pub direct_method_fallback_samples: usize,
1377    pub clipped_weight_samples: usize,
1378    pub sum_importance_weight: f64,
1379    pub max_importance_weight: f64,
1380    pub effective_sample_size: f64,
1381}
1382
1383#[derive(Debug, Clone, Deserialize, Serialize)]
1384#[serde(rename_all = "camelCase")]
1385pub struct OpeGateDecision {
1386    pub passed: bool,
1387    pub reason: OpeGateReason,
1388}
1389
1390#[derive(Debug, Clone, Deserialize, Serialize)]
1391#[serde(rename_all = "camelCase")]
1392pub struct OpeEvaluationReport {
1393    pub ips: OpeEstimatorSummary,
1394    pub wis: OpeEstimatorSummary,
1395    pub doubly_robust: OpeEstimatorSummary,
1396    pub baseline_mean: f64,
1397    pub estimated_regret_delta: f64,
1398    pub diagnostics: OpeDiagnostics,
1399    pub gate: OpeGateDecision,
1400}
1401
1402#[derive(Debug, Clone, Copy)]
1403struct NormalizedOpeSample {
1404    importance_weight: f64,
1405    outcome: f64,
1406    baseline: f64,
1407    direct_method: f64,
1408}
1409
1410#[allow(clippy::too_many_lines, clippy::cast_precision_loss)]
1411pub fn evaluate_off_policy(
1412    samples: &[OpeTraceSample],
1413    config: &OpeEvaluatorConfig,
1414) -> OpeEvaluationReport {
1415    let max_importance_weight = non_negative_finite(config.max_importance_weight);
1416    let min_effective_sample_size = non_negative_finite(config.min_effective_sample_size);
1417    let max_standard_error = non_negative_finite(config.max_standard_error);
1418    let confidence_z = positive_finite_or(config.confidence_z, 1.96);
1419    let max_regret_delta = non_negative_finite(config.max_regret_delta);
1420
1421    let mut normalized_samples = Vec::with_capacity(samples.len());
1422    let mut skipped_invalid_samples = 0_usize;
1423    let mut direct_method_fallback_samples = 0_usize;
1424    let mut clipped_weight_samples = 0_usize;
1425
1426    for sample in samples {
1427        if let Some((normalized, clipped_weight, fallback_direct_method)) =
1428            normalize_ope_sample(sample, max_importance_weight)
1429        {
1430            if clipped_weight {
1431                clipped_weight_samples = clipped_weight_samples.saturating_add(1);
1432            }
1433            if fallback_direct_method {
1434                direct_method_fallback_samples = direct_method_fallback_samples.saturating_add(1);
1435            }
1436            normalized_samples.push(normalized);
1437        } else {
1438            skipped_invalid_samples = skipped_invalid_samples.saturating_add(1);
1439        }
1440    }
1441
1442    let valid_samples = normalized_samples.len();
1443    let mut sum_importance_weight: f64 = 0.0;
1444    let mut sum_importance_weight_sq: f64 = 0.0;
1445    let mut max_seen_importance_weight: f64 = 0.0;
1446    for sample in &normalized_samples {
1447        sum_importance_weight += sample.importance_weight;
1448        sum_importance_weight_sq += sample.importance_weight * sample.importance_weight;
1449        max_seen_importance_weight = max_seen_importance_weight.max(sample.importance_weight);
1450    }
1451    let effective_sample_size = if sum_importance_weight_sq > 0.0 {
1452        (sum_importance_weight * sum_importance_weight) / sum_importance_weight_sq
1453    } else {
1454        0.0
1455    };
1456
1457    let valid_samples_f64 = valid_samples as f64;
1458    let mut ips_effects = Vec::with_capacity(valid_samples);
1459    let mut wis_effects = Vec::with_capacity(valid_samples);
1460    let mut doubly_robust_effects = Vec::with_capacity(valid_samples);
1461    let mut baseline_values = Vec::with_capacity(valid_samples);
1462
1463    for sample in &normalized_samples {
1464        ips_effects.push(sample.importance_weight * sample.outcome);
1465        doubly_robust_effects.push(
1466            sample
1467                .importance_weight
1468                .mul_add(sample.outcome - sample.direct_method, sample.direct_method),
1469        );
1470        baseline_values.push(sample.baseline);
1471    }
1472    if sum_importance_weight > 0.0 {
1473        for sample in &normalized_samples {
1474            wis_effects.push(
1475                (sample.importance_weight * sample.outcome * valid_samples_f64)
1476                    / sum_importance_weight,
1477            );
1478        }
1479    } else {
1480        wis_effects.resize(valid_samples, 0.0);
1481    }
1482
1483    let ips = summarize_estimator(&ips_effects, confidence_z);
1484    let wis = summarize_estimator(&wis_effects, confidence_z);
1485    let doubly_robust = summarize_estimator(&doubly_robust_effects, confidence_z);
1486    let baseline_mean = arithmetic_mean(&baseline_values);
1487    let estimated_regret_delta = baseline_mean - doubly_robust.estimate;
1488
1489    let gate = if valid_samples == 0 {
1490        OpeGateDecision {
1491            passed: false,
1492            reason: OpeGateReason::NoValidSamples,
1493        }
1494    } else if effective_sample_size < min_effective_sample_size {
1495        OpeGateDecision {
1496            passed: false,
1497            reason: OpeGateReason::InsufficientSupport,
1498        }
1499    } else if doubly_robust.standard_error > max_standard_error {
1500        OpeGateDecision {
1501            passed: false,
1502            reason: OpeGateReason::HighUncertainty,
1503        }
1504    } else if estimated_regret_delta > max_regret_delta {
1505        OpeGateDecision {
1506            passed: false,
1507            reason: OpeGateReason::ExcessiveRegret,
1508        }
1509    } else {
1510        OpeGateDecision {
1511            passed: true,
1512            reason: OpeGateReason::Approved,
1513        }
1514    };
1515
1516    OpeEvaluationReport {
1517        ips,
1518        wis,
1519        doubly_robust,
1520        baseline_mean,
1521        estimated_regret_delta,
1522        diagnostics: OpeDiagnostics {
1523            total_samples: samples.len(),
1524            valid_samples,
1525            skipped_invalid_samples,
1526            direct_method_fallback_samples,
1527            clipped_weight_samples,
1528            sum_importance_weight,
1529            max_importance_weight: max_seen_importance_weight,
1530            effective_sample_size,
1531        },
1532        gate,
1533    }
1534}
1535
1536fn normalize_ope_sample(
1537    sample: &OpeTraceSample,
1538    max_importance_weight: f64,
1539) -> Option<(NormalizedOpeSample, bool, bool)> {
1540    if !sample.outcome.is_finite()
1541        || !sample.behavior_propensity.is_finite()
1542        || sample.behavior_propensity <= 0.0
1543        || !sample.target_propensity.is_finite()
1544        || sample.target_propensity < 0.0
1545    {
1546        return None;
1547    }
1548    let raw_weight = sample.target_propensity / sample.behavior_propensity;
1549    if !raw_weight.is_finite() || raw_weight < 0.0 {
1550        return None;
1551    }
1552    let clipped_weight = raw_weight.min(max_importance_weight);
1553    let clipped = clipped_weight < raw_weight;
1554    let baseline = sample
1555        .baseline_outcome
1556        .filter(|value| value.is_finite())
1557        .unwrap_or(sample.outcome);
1558    let (direct_method, fallback_direct_method) = match sample.direct_method_prediction {
1559        Some(value) if value.is_finite() => (value, false),
1560        _ => (sample.outcome, true),
1561    };
1562    Some((
1563        NormalizedOpeSample {
1564            importance_weight: clipped_weight,
1565            outcome: sample.outcome,
1566            baseline,
1567            direct_method,
1568        },
1569        clipped,
1570        fallback_direct_method,
1571    ))
1572}
1573
1574#[allow(clippy::cast_precision_loss)]
1575fn summarize_estimator(effects: &[f64], confidence_z: f64) -> OpeEstimatorSummary {
1576    if effects.is_empty() {
1577        return OpeEstimatorSummary {
1578            estimate: 0.0,
1579            variance: 0.0,
1580            standard_error: 0.0,
1581            ci_low: 0.0,
1582            ci_high: 0.0,
1583        };
1584    }
1585    let sample_count = effects.len() as f64;
1586    let estimate = arithmetic_mean(effects);
1587    let variance = if effects.len() > 1 {
1588        effects
1589            .iter()
1590            .map(|value| {
1591                let centered = *value - estimate;
1592                centered * centered
1593            })
1594            .sum::<f64>()
1595            / (sample_count - 1.0)
1596    } else {
1597        0.0
1598    };
1599    let standard_error = (variance / sample_count).sqrt();
1600    let margin = confidence_z * standard_error;
1601    OpeEstimatorSummary {
1602        estimate,
1603        variance,
1604        standard_error,
1605        ci_low: estimate - margin,
1606        ci_high: estimate + margin,
1607    }
1608}
1609
1610#[allow(clippy::cast_precision_loss)]
1611fn arithmetic_mean(values: &[f64]) -> f64 {
1612    if values.is_empty() {
1613        0.0
1614    } else {
1615        values.iter().sum::<f64>() / values.len() as f64
1616    }
1617}
1618
1619const fn non_negative_finite(value: f64) -> f64 {
1620    if value.is_finite() {
1621        if value > 0.0 { value } else { 0.0 }
1622    } else {
1623        0.0
1624    }
1625}
1626
1627fn positive_finite_or(value: f64, fallback: f64) -> f64 {
1628    if value.is_finite() && value > 0.0 {
1629        value
1630    } else {
1631        fallback
1632    }
1633}
1634
1635#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1636#[serde(rename_all = "camelCase")]
1637pub struct InterferenceMatrixCompletenessReport {
1638    pub expected_pairs: usize,
1639    pub observed_pairs: usize,
1640    pub missing_pairs: Vec<String>,
1641    pub duplicate_pairs: Vec<String>,
1642    pub unknown_pairs: Vec<String>,
1643    pub complete: bool,
1644}
1645
1646fn normalize_interference_lever(raw: &str) -> Option<String> {
1647    let normalized = raw.trim().to_ascii_lowercase();
1648    if normalized.is_empty() {
1649        None
1650    } else {
1651        Some(normalized)
1652    }
1653}
1654
1655fn canonicalize_interference_pair(first: &str, second: &str) -> Option<(String, String)> {
1656    let left = normalize_interference_lever(first)?;
1657    let right = normalize_interference_lever(second)?;
1658    if left <= right {
1659        Some((left, right))
1660    } else {
1661        Some((right, left))
1662    }
1663}
1664
1665pub fn parse_interference_pair_key(key: &str) -> Option<(String, String)> {
1666    let mut parts = key.split('+');
1667    let first = parts.next()?;
1668    let second = parts.next()?;
1669    if parts.next().is_some() {
1670        return None;
1671    }
1672    canonicalize_interference_pair(first, second)
1673}
1674
1675pub fn format_interference_pair_key(first: &str, second: &str) -> Option<String> {
1676    let (left, right) = canonicalize_interference_pair(first, second)?;
1677    Some(format!("{left}+{right}"))
1678}
1679
1680pub fn evaluate_interference_matrix_completeness(
1681    levers: &[String],
1682    observed_pair_keys: &[String],
1683) -> InterferenceMatrixCompletenessReport {
1684    let ordered_levers: Vec<String> = levers
1685        .iter()
1686        .filter_map(|lever| normalize_interference_lever(lever))
1687        .collect::<BTreeSet<_>>()
1688        .into_iter()
1689        .collect();
1690
1691    let mut expected_pairs = BTreeSet::new();
1692    for (idx, left) in ordered_levers.iter().enumerate() {
1693        for right in ordered_levers.iter().skip(idx + 1) {
1694            expected_pairs.insert(format!("{left}+{right}"));
1695        }
1696    }
1697
1698    let mut seen_pairs = BTreeSet::new();
1699    let mut duplicate_pairs = BTreeSet::new();
1700    let mut unknown_pairs = BTreeSet::new();
1701    let mut observed_pairs = BTreeSet::new();
1702
1703    for raw_key in observed_pair_keys {
1704        let Some((left, right)) = parse_interference_pair_key(raw_key) else {
1705            unknown_pairs.insert(raw_key.clone());
1706            continue;
1707        };
1708
1709        let key = format!("{left}+{right}");
1710        if !seen_pairs.insert(key.clone()) {
1711            duplicate_pairs.insert(key.clone());
1712            continue;
1713        }
1714
1715        if expected_pairs.contains(&key) {
1716            observed_pairs.insert(key);
1717        } else {
1718            unknown_pairs.insert(key);
1719        }
1720    }
1721
1722    let missing_pairs = expected_pairs
1723        .difference(&observed_pairs)
1724        .cloned()
1725        .collect::<Vec<_>>();
1726    let duplicate_pairs = duplicate_pairs.into_iter().collect::<Vec<_>>();
1727    let unknown_pairs = unknown_pairs.into_iter().collect::<Vec<_>>();
1728
1729    InterferenceMatrixCompletenessReport {
1730        expected_pairs: expected_pairs.len(),
1731        observed_pairs: observed_pairs.len(),
1732        missing_pairs: missing_pairs.clone(),
1733        duplicate_pairs: duplicate_pairs.clone(),
1734        unknown_pairs: unknown_pairs.clone(),
1735        complete: missing_pairs.is_empty()
1736            && duplicate_pairs.is_empty()
1737            && unknown_pairs.is_empty(),
1738    }
1739}
1740
1741#[cfg(test)]
1742mod tests {
1743    use super::*;
1744    use chrono::TimeZone;
1745
1746    fn empty_signals() -> Signals {
1747        Signals::default()
1748    }
1749
1750    fn empty_tags() -> Tags {
1751        Tags::default()
1752    }
1753
1754    fn minimal_candidate(id: &str) -> CandidateInput {
1755        CandidateInput {
1756            id: id.to_string(),
1757            name: None,
1758            source_tier: None,
1759            signals: Signals::default(),
1760            tags: Tags::default(),
1761            recency: Recency::default(),
1762            compat: Compatibility::default(),
1763            license: LicenseInfo::default(),
1764            gates: Gates::default(),
1765            risk: RiskInfo::default(),
1766            manual_override: None,
1767        }
1768    }
1769
1770    #[test]
1771    fn parse_interference_pair_key_normalizes_order_and_case() {
1772        let parsed = parse_interference_pair_key("Queue+marshal").expect("pair must parse");
1773        assert_eq!(parsed.0, "marshal");
1774        assert_eq!(parsed.1, "queue");
1775
1776        let formatted =
1777            format_interference_pair_key(" Queue ", "marshal ").expect("pair must format");
1778        assert_eq!(formatted, "marshal+queue");
1779    }
1780
1781    #[test]
1782    fn parse_interference_pair_key_rejects_invalid_shapes() {
1783        assert!(parse_interference_pair_key("").is_none());
1784        assert!(parse_interference_pair_key("queue").is_none());
1785        assert!(parse_interference_pair_key("a+b+c").is_none());
1786        assert!(parse_interference_pair_key(" + ").is_none());
1787    }
1788
1789    #[test]
1790    fn interference_matrix_completeness_detects_missing_duplicate_and_unknown_pairs() {
1791        let levers = vec![
1792            "queue".to_string(),
1793            "policy".to_string(),
1794            "execute".to_string(),
1795        ];
1796        let observed = vec![
1797            "queue+policy".to_string(),
1798            "policy+queue".to_string(),  // duplicate (canonicalized)
1799            "queue+marshal".to_string(), // unknown pair
1800            "broken".to_string(),        // malformed key
1801        ];
1802
1803        let report = evaluate_interference_matrix_completeness(&levers, &observed);
1804        assert_eq!(report.expected_pairs, 3);
1805        assert_eq!(report.observed_pairs, 1);
1806        assert_eq!(
1807            report.missing_pairs,
1808            vec!["execute+policy".to_string(), "execute+queue".to_string()]
1809        );
1810        assert_eq!(report.duplicate_pairs, vec!["policy+queue".to_string()]);
1811        assert_eq!(
1812            report.unknown_pairs,
1813            vec!["broken".to_string(), "marshal+queue".to_string()]
1814        );
1815        assert!(!report.complete);
1816    }
1817
1818    #[test]
1819    fn interference_matrix_completeness_passes_with_full_unique_matrix() {
1820        let levers = vec![
1821            "marshal".to_string(),
1822            "queue".to_string(),
1823            "schedule".to_string(),
1824            "policy".to_string(),
1825        ];
1826        let observed = vec![
1827            "marshal+queue".to_string(),
1828            "marshal+schedule".to_string(),
1829            "marshal+policy".to_string(),
1830            "queue+schedule".to_string(),
1831            "queue+policy".to_string(),
1832            "schedule+policy".to_string(),
1833        ];
1834
1835        let report = evaluate_interference_matrix_completeness(&levers, &observed);
1836        assert_eq!(report.expected_pairs, 6);
1837        assert_eq!(report.observed_pairs, 6);
1838        assert!(report.missing_pairs.is_empty());
1839        assert!(report.duplicate_pairs.is_empty());
1840        assert!(report.unknown_pairs.is_empty());
1841        assert!(report.complete);
1842    }
1843
1844    // =========================================================================
1845    // score_github_stars
1846    // =========================================================================
1847
1848    #[test]
1849    fn github_stars_none_returns_zero_and_records_missing() {
1850        let mut missing = BTreeSet::new();
1851        let signals = empty_signals();
1852        assert_eq!(score_github_stars(&signals, &mut missing), 0);
1853        assert!(missing.contains("signals.github_stars"));
1854    }
1855
1856    #[test]
1857    fn github_stars_tiers() {
1858        let cases = [
1859            (0, 0),
1860            (49, 5),
1861            (50, 5),
1862            (199, 6),
1863            (200, 6),
1864            (499, 7),
1865            (500, 7),
1866            (999, 8),
1867            (1_000, 8),
1868            (1_999, 9),
1869            (2_000, 9),
1870            (4_999, 10),
1871            (5_000, 10),
1872            (100_000, 10),
1873        ];
1874        for (stars, expected) in cases {
1875            let mut missing = BTreeSet::new();
1876            let signals = Signals {
1877                github_stars: Some(stars),
1878                ..Default::default()
1879            };
1880            assert_eq!(
1881                score_github_stars(&signals, &mut missing),
1882                expected,
1883                "stars={stars}"
1884            );
1885        }
1886    }
1887
1888    // =========================================================================
1889    // score_npm_downloads
1890    // =========================================================================
1891
1892    #[test]
1893    fn npm_downloads_none_returns_zero() {
1894        let mut missing = BTreeSet::new();
1895        assert_eq!(score_npm_downloads(&empty_signals(), &mut missing), 0);
1896        assert!(missing.contains("signals.npm_downloads_month"));
1897    }
1898
1899    #[test]
1900    fn npm_downloads_tiers() {
1901        let cases = [
1902            (0, 0),
1903            (499, 5),
1904            (500, 5),
1905            (2_000, 6),
1906            (10_000, 7),
1907            (50_000, 8),
1908        ];
1909        for (dl, expected) in cases {
1910            let mut missing = BTreeSet::new();
1911            let signals = Signals {
1912                npm_downloads_month: Some(dl),
1913                ..Default::default()
1914            };
1915            assert_eq!(
1916                score_npm_downloads(&signals, &mut missing),
1917                expected,
1918                "downloads={dl}"
1919            );
1920        }
1921    }
1922
1923    // =========================================================================
1924    // score_marketplace_installs
1925    // =========================================================================
1926
1927    #[test]
1928    fn marketplace_installs_no_marketplace_records_missing() {
1929        let mut missing = BTreeSet::new();
1930        assert_eq!(
1931            score_marketplace_installs(&empty_signals(), &mut missing),
1932            0
1933        );
1934        assert!(missing.contains("signals.marketplace.installs_month"));
1935    }
1936
1937    #[test]
1938    fn marketplace_installs_none_records_missing() {
1939        let mut missing = BTreeSet::new();
1940        let signals = Signals {
1941            marketplace: Some(MarketplaceSignals::default()),
1942            ..Default::default()
1943        };
1944        assert_eq!(score_marketplace_installs(&signals, &mut missing), 0);
1945        assert!(missing.contains("signals.marketplace.installs_month"));
1946    }
1947
1948    #[test]
1949    fn marketplace_installs_tiers() {
1950        let cases = [(0, 0), (99, 0), (100, 1), (500, 2), (2_000, 4), (10_000, 5)];
1951        for (installs, expected) in cases {
1952            let mut missing = BTreeSet::new();
1953            let signals = Signals {
1954                marketplace: Some(MarketplaceSignals {
1955                    installs_month: Some(installs),
1956                    ..Default::default()
1957                }),
1958                ..Default::default()
1959            };
1960            assert_eq!(
1961                score_marketplace_installs(&signals, &mut missing),
1962                expected,
1963                "installs={installs}"
1964            );
1965        }
1966    }
1967
1968    // =========================================================================
1969    // score_forks
1970    // =========================================================================
1971
1972    #[test]
1973    fn forks_none_returns_zero() {
1974        let mut missing = BTreeSet::new();
1975        assert_eq!(score_forks(&empty_signals(), &mut missing), 0);
1976        assert!(missing.contains("signals.github_forks"));
1977    }
1978
1979    #[test]
1980    fn forks_tiers() {
1981        let cases = [(0, 0), (49, 0), (50, 1), (200, 1), (500, 2), (10_000, 2)];
1982        for (f, expected) in cases {
1983            let mut missing = BTreeSet::new();
1984            let signals = Signals {
1985                github_forks: Some(f),
1986                ..Default::default()
1987            };
1988            assert_eq!(score_forks(&signals, &mut missing), expected, "forks={f}");
1989        }
1990    }
1991
1992    // =========================================================================
1993    // score_references
1994    // =========================================================================
1995
1996    #[test]
1997    fn references_empty() {
1998        let signals = empty_signals();
1999        assert_eq!(score_references(&signals), 0);
2000    }
2001
2002    #[test]
2003    fn references_deduplicates_trimmed() {
2004        let signals = Signals {
2005            references: vec![" ref1 ".to_string(), "ref1".to_string(), "ref2".to_string()],
2006            ..Default::default()
2007        };
2008        assert_eq!(score_references(&signals), 2); // 2 unique => score 2
2009    }
2010
2011    #[test]
2012    fn references_tiers() {
2013        let make = |n: usize| -> Signals {
2014            Signals {
2015                references: (0..n).map(|i| format!("ref-{i}")).collect(),
2016                ..Default::default()
2017            }
2018        };
2019        assert_eq!(score_references(&make(1)), 0);
2020        assert_eq!(score_references(&make(2)), 2);
2021        assert_eq!(score_references(&make(5)), 3);
2022        assert_eq!(score_references(&make(10)), 4);
2023        assert_eq!(score_references(&make(20)), 4);
2024    }
2025
2026    #[test]
2027    fn references_ignores_empty_and_whitespace() {
2028        let signals = Signals {
2029            references: vec![String::new(), "  ".to_string(), "real".to_string()],
2030            ..Default::default()
2031        };
2032        assert_eq!(score_references(&signals), 0); // 1 unique => 0
2033    }
2034
2035    // =========================================================================
2036    // score_official_visibility
2037    // =========================================================================
2038
2039    #[test]
2040    fn official_visibility_all_none_records_missing() {
2041        let mut missing = BTreeSet::new();
2042        assert_eq!(score_official_visibility(&empty_signals(), &mut missing), 0);
2043        assert!(missing.contains("signals.official_visibility"));
2044    }
2045
2046    #[test]
2047    fn official_visibility_listing_highest() {
2048        let mut missing = BTreeSet::new();
2049        let signals = Signals {
2050            official_listing: Some(true),
2051            pi_mono_example: Some(true),
2052            badlogic_gist: Some(true),
2053            ..Default::default()
2054        };
2055        assert_eq!(score_official_visibility(&signals, &mut missing), 10);
2056    }
2057
2058    #[test]
2059    fn official_visibility_example_mid() {
2060        let mut missing = BTreeSet::new();
2061        let signals = Signals {
2062            pi_mono_example: Some(true),
2063            ..Default::default()
2064        };
2065        assert_eq!(score_official_visibility(&signals, &mut missing), 8);
2066    }
2067
2068    #[test]
2069    fn official_visibility_gist_lowest() {
2070        let mut missing = BTreeSet::new();
2071        let signals = Signals {
2072            badlogic_gist: Some(true),
2073            ..Default::default()
2074        };
2075        assert_eq!(score_official_visibility(&signals, &mut missing), 6);
2076    }
2077
2078    // =========================================================================
2079    // score_marketplace_visibility
2080    // =========================================================================
2081
2082    #[test]
2083    fn marketplace_visibility_no_marketplace() {
2084        let mut missing = BTreeSet::new();
2085        assert_eq!(
2086            score_marketplace_visibility(&empty_signals(), &mut missing),
2087            0
2088        );
2089        assert!(missing.contains("signals.marketplace.rank"));
2090        assert!(missing.contains("signals.marketplace.featured"));
2091    }
2092
2093    #[test]
2094    fn marketplace_visibility_rank_tiers() {
2095        let cases = [
2096            (5, 6),
2097            (10, 6),
2098            (30, 4),
2099            (50, 4),
2100            (80, 2),
2101            (100, 2),
2102            (200, 0),
2103        ];
2104        for (rank, expected) in cases {
2105            let mut missing = BTreeSet::new();
2106            let signals = Signals {
2107                marketplace: Some(MarketplaceSignals {
2108                    rank: Some(rank),
2109                    ..Default::default()
2110                }),
2111                ..Default::default()
2112            };
2113            assert_eq!(
2114                score_marketplace_visibility(&signals, &mut missing),
2115                expected,
2116                "rank={rank}"
2117            );
2118        }
2119    }
2120
2121    #[test]
2122    fn marketplace_visibility_featured_adds_2_capped_at_6() {
2123        let mut missing = BTreeSet::new();
2124        let signals = Signals {
2125            marketplace: Some(MarketplaceSignals {
2126                rank: Some(5),
2127                featured: Some(true),
2128                ..Default::default()
2129            }),
2130            ..Default::default()
2131        };
2132        // rank=5 -> 6, featured -> +2, capped at 6
2133        assert_eq!(score_marketplace_visibility(&signals, &mut missing), 6);
2134    }
2135
2136    // =========================================================================
2137    // score_runtime_tier
2138    // =========================================================================
2139
2140    #[test]
2141    fn runtime_tier_values() {
2142        let cases = [
2143            (None, 0),
2144            (Some("pkg-with-deps"), 6),
2145            (Some("provider-ext"), 6),
2146            (Some("multi-file"), 4),
2147            (Some("legacy-js"), 2),
2148            (Some("unknown-tier"), 0),
2149        ];
2150        for (runtime, expected) in cases {
2151            let tags = Tags {
2152                runtime: runtime.map(String::from),
2153                ..Default::default()
2154            };
2155            assert_eq!(score_runtime_tier(&tags), expected, "runtime={runtime:?}");
2156        }
2157    }
2158
2159    // =========================================================================
2160    // score_interaction
2161    // =========================================================================
2162
2163    #[test]
2164    fn interaction_empty() {
2165        assert_eq!(score_interaction(&empty_tags()), 0);
2166    }
2167
2168    #[test]
2169    fn interaction_all_tags() {
2170        let tags = Tags {
2171            interaction: vec![
2172                "provider".to_string(),
2173                "ui_integration".to_string(),
2174                "event_hook".to_string(),
2175                "slash_command".to_string(),
2176                "tool_only".to_string(),
2177            ],
2178            ..Default::default()
2179        };
2180        // 3+2+2+1+1 = 9, capped at 8
2181        assert_eq!(score_interaction(&tags), 8);
2182    }
2183
2184    #[test]
2185    fn interaction_single_provider() {
2186        let tags = Tags {
2187            interaction: vec!["provider".to_string()],
2188            ..Default::default()
2189        };
2190        assert_eq!(score_interaction(&tags), 3);
2191    }
2192
2193    // =========================================================================
2194    // score_hostcalls
2195    // =========================================================================
2196
2197    #[test]
2198    fn hostcalls_empty() {
2199        assert_eq!(score_hostcalls(&empty_tags()), 0);
2200    }
2201
2202    #[test]
2203    fn hostcalls_all_capabilities() {
2204        let tags = Tags {
2205            capabilities: vec![
2206                "exec".to_string(),
2207                "http".to_string(),
2208                "read".to_string(),
2209                "ui".to_string(),
2210                "session".to_string(),
2211            ],
2212            ..Default::default()
2213        };
2214        // 2+2+1+1+1 = 7, capped at 6
2215        assert_eq!(score_hostcalls(&tags), 6);
2216    }
2217
2218    #[test]
2219    fn hostcalls_write_and_edit_count_as_one() {
2220        let tags = Tags {
2221            capabilities: vec!["write".to_string(), "edit".to_string()],
2222            ..Default::default()
2223        };
2224        // write matches the read|write|edit arm => 1, edit also matches => still 1
2225        assert_eq!(score_hostcalls(&tags), 1);
2226    }
2227
2228    // =========================================================================
2229    // score_activity
2230    // =========================================================================
2231
2232    #[test]
2233    fn activity_none_returns_zero() {
2234        let mut missing = BTreeSet::new();
2235        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2236        assert_eq!(score_activity(&Recency::default(), as_of, &mut missing), 0);
2237        assert!(missing.contains("recency.updated_at"));
2238    }
2239
2240    #[test]
2241    fn activity_recent_gets_max() {
2242        let mut missing = BTreeSet::new();
2243        let as_of = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
2244        let recency = Recency {
2245            updated_at: Some("2026-01-15T00:00:00Z".to_string()),
2246        };
2247        assert_eq!(score_activity(&recency, as_of, &mut missing), 14);
2248    }
2249
2250    #[test]
2251    fn activity_tiers() {
2252        let as_of = Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap();
2253        let cases = [
2254            ("2026-06-15T00:00:00Z", 14), // 16 days
2255            ("2026-04-15T00:00:00Z", 11), // ~77 days
2256            ("2026-02-01T00:00:00Z", 8),  // ~150 days
2257            ("2025-10-01T00:00:00Z", 5),  // ~273 days
2258            ("2025-01-01T00:00:00Z", 2),  // ~547 days
2259            ("2023-01-01T00:00:00Z", 0),  // >730 days
2260        ];
2261        for (date, expected) in cases {
2262            let mut missing = BTreeSet::new();
2263            let recency = Recency {
2264                updated_at: Some(date.to_string()),
2265            };
2266            assert_eq!(
2267                score_activity(&recency, as_of, &mut missing),
2268                expected,
2269                "date={date}"
2270            );
2271        }
2272    }
2273
2274    #[test]
2275    fn activity_invalid_date_returns_zero() {
2276        let mut missing = BTreeSet::new();
2277        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2278        let recency = Recency {
2279            updated_at: Some("not-a-date".to_string()),
2280        };
2281        assert_eq!(score_activity(&recency, as_of, &mut missing), 0);
2282        assert!(missing.contains("recency.updated_at"));
2283    }
2284
2285    // =========================================================================
2286    // score_compatibility
2287    // =========================================================================
2288
2289    #[test]
2290    fn compatibility_unmodified_is_max() {
2291        let compat = Compatibility {
2292            status: Some(CompatStatus::Unmodified),
2293            ..Default::default()
2294        };
2295        assert_eq!(score_compatibility(&compat), 20);
2296    }
2297
2298    #[test]
2299    fn compatibility_requires_shims() {
2300        let compat = Compatibility {
2301            status: Some(CompatStatus::RequiresShims),
2302            ..Default::default()
2303        };
2304        assert_eq!(score_compatibility(&compat), 15);
2305    }
2306
2307    #[test]
2308    fn compatibility_runtime_gap() {
2309        let compat = Compatibility {
2310            status: Some(CompatStatus::RuntimeGap),
2311            ..Default::default()
2312        };
2313        assert_eq!(score_compatibility(&compat), 10);
2314    }
2315
2316    #[test]
2317    fn compatibility_blocked_is_zero() {
2318        let compat = Compatibility {
2319            status: Some(CompatStatus::Blocked),
2320            ..Default::default()
2321        };
2322        assert_eq!(score_compatibility(&compat), 0);
2323    }
2324
2325    #[test]
2326    fn compatibility_adjustment_positive() {
2327        let compat = Compatibility {
2328            status: Some(CompatStatus::RequiresShims),
2329            adjustment: Some(3),
2330            ..Default::default()
2331        };
2332        assert_eq!(score_compatibility(&compat), 18);
2333    }
2334
2335    #[test]
2336    fn compatibility_adjustment_negative_clamped() {
2337        let compat = Compatibility {
2338            status: Some(CompatStatus::RuntimeGap),
2339            adjustment: Some(-15),
2340            ..Default::default()
2341        };
2342        // 10 + (-15) = -5, clamped to 0
2343        assert_eq!(score_compatibility(&compat), 0);
2344    }
2345
2346    #[test]
2347    fn compatibility_adjustment_capped_at_20() {
2348        let compat = Compatibility {
2349            status: Some(CompatStatus::Unmodified),
2350            adjustment: Some(10),
2351            ..Default::default()
2352        };
2353        // 20 + 10 = 30, capped at 20
2354        assert_eq!(score_compatibility(&compat), 20);
2355    }
2356
2357    // =========================================================================
2358    // score_risk
2359    // =========================================================================
2360
2361    #[test]
2362    fn risk_none_is_zero() {
2363        assert_eq!(score_risk(&RiskInfo::default()), 0);
2364    }
2365
2366    #[test]
2367    fn risk_penalty_override() {
2368        let risk = RiskInfo {
2369            penalty: Some(7),
2370            level: Some(RiskLevel::Critical),
2371            ..Default::default()
2372        };
2373        // Explicit penalty overrides level
2374        assert_eq!(score_risk(&risk), 7);
2375    }
2376
2377    #[test]
2378    fn risk_penalty_capped_at_15() {
2379        let risk = RiskInfo {
2380            penalty: Some(50),
2381            ..Default::default()
2382        };
2383        assert_eq!(score_risk(&risk), 15);
2384    }
2385
2386    #[test]
2387    fn risk_level_tiers() {
2388        let cases = [
2389            (RiskLevel::Low, 0),
2390            (RiskLevel::Moderate, 5),
2391            (RiskLevel::High, 10),
2392            (RiskLevel::Critical, 15),
2393        ];
2394        for (level, expected) in cases {
2395            let risk = RiskInfo {
2396                level: Some(level),
2397                ..Default::default()
2398            };
2399            assert_eq!(score_risk(&risk), expected, "level={level:?}");
2400        }
2401    }
2402
2403    // =========================================================================
2404    // compute_gates
2405    // =========================================================================
2406
2407    #[test]
2408    fn gates_all_false_by_default() {
2409        let candidate = minimal_candidate("test");
2410        let gates = compute_gates(&candidate);
2411        assert!(!gates.provenance_pinned);
2412        assert!(!gates.license_ok);
2413        assert!(!gates.deterministic);
2414        assert!(!gates.unmodified); // Unknown status -> not unmodified
2415        assert!(!gates.passes);
2416    }
2417
2418    #[test]
2419    fn gates_all_pass() {
2420        let mut candidate = minimal_candidate("test");
2421        candidate.gates.provenance_pinned = Some(true);
2422        candidate.gates.deterministic = Some(true);
2423        candidate.license.redistribution = Some(Redistribution::Ok);
2424        candidate.compat.status = Some(CompatStatus::Unmodified);
2425        let gates = compute_gates(&candidate);
2426        assert!(gates.passes);
2427    }
2428
2429    #[test]
2430    fn gates_license_restricted_counts_as_ok() {
2431        let mut candidate = minimal_candidate("test");
2432        candidate.gates.provenance_pinned = Some(true);
2433        candidate.gates.deterministic = Some(true);
2434        candidate.license.redistribution = Some(Redistribution::Restricted);
2435        candidate.compat.status = Some(CompatStatus::RequiresShims);
2436        let gates = compute_gates(&candidate);
2437        assert!(gates.license_ok);
2438        assert!(gates.passes);
2439    }
2440
2441    #[test]
2442    fn gates_license_exclude_fails() {
2443        let mut candidate = minimal_candidate("test");
2444        candidate.license.redistribution = Some(Redistribution::Exclude);
2445        let gates = compute_gates(&candidate);
2446        assert!(!gates.license_ok);
2447    }
2448
2449    #[test]
2450    fn gates_unmodified_includes_requires_shims_and_runtime_gap() {
2451        for status in [
2452            CompatStatus::Unmodified,
2453            CompatStatus::RequiresShims,
2454            CompatStatus::RuntimeGap,
2455        ] {
2456            let mut candidate = minimal_candidate("test");
2457            candidate.compat.status = Some(status);
2458            let gates = compute_gates(&candidate);
2459            assert!(gates.unmodified, "status={status:?} should be unmodified");
2460        }
2461    }
2462
2463    // =========================================================================
2464    // compute_tier
2465    // =========================================================================
2466
2467    #[test]
2468    fn tier_official_is_tier_0() {
2469        let mut candidate = minimal_candidate("test");
2470        candidate.signals.pi_mono_example = Some(true);
2471        let gates = compute_gates(&candidate);
2472        assert_eq!(compute_tier(&candidate, &gates, 0), "tier-0");
2473    }
2474
2475    #[test]
2476    fn tier_official_source_tier_is_tier_0() {
2477        let mut candidate = minimal_candidate("test");
2478        candidate.source_tier = Some("official-pi-mono".to_string());
2479        let gates = compute_gates(&candidate);
2480        assert_eq!(compute_tier(&candidate, &gates, 0), "tier-0");
2481    }
2482
2483    #[test]
2484    fn tier_manual_override_on_official() {
2485        let mut candidate = minimal_candidate("test");
2486        candidate.signals.pi_mono_example = Some(true);
2487        candidate.manual_override = Some(ManualOverride {
2488            reason: "special".to_string(),
2489            tier: Some("tier-1".to_string()),
2490        });
2491        let gates = compute_gates(&candidate);
2492        assert_eq!(compute_tier(&candidate, &gates, 0), "tier-1");
2493    }
2494
2495    #[test]
2496    fn tier_excluded_when_gates_fail() {
2497        let candidate = minimal_candidate("test");
2498        let gates = compute_gates(&candidate); // gates fail
2499        assert_eq!(compute_tier(&candidate, &gates, 100), "excluded");
2500    }
2501
2502    #[test]
2503    fn tier_1_at_70_plus() {
2504        let mut candidate = minimal_candidate("test");
2505        candidate.gates.provenance_pinned = Some(true);
2506        candidate.gates.deterministic = Some(true);
2507        candidate.license.redistribution = Some(Redistribution::Ok);
2508        candidate.compat.status = Some(CompatStatus::Unmodified);
2509        let gates = compute_gates(&candidate);
2510        assert_eq!(compute_tier(&candidate, &gates, 70), "tier-1");
2511        assert_eq!(compute_tier(&candidate, &gates, 100), "tier-1");
2512    }
2513
2514    #[test]
2515    fn tier_2_at_50_to_69() {
2516        let mut candidate = minimal_candidate("test");
2517        candidate.gates.provenance_pinned = Some(true);
2518        candidate.gates.deterministic = Some(true);
2519        candidate.license.redistribution = Some(Redistribution::Ok);
2520        candidate.compat.status = Some(CompatStatus::Unmodified);
2521        let gates = compute_gates(&candidate);
2522        assert_eq!(compute_tier(&candidate, &gates, 50), "tier-2");
2523        assert_eq!(compute_tier(&candidate, &gates, 69), "tier-2");
2524    }
2525
2526    #[test]
2527    fn tier_excluded_below_50() {
2528        let mut candidate = minimal_candidate("test");
2529        candidate.gates.provenance_pinned = Some(true);
2530        candidate.gates.deterministic = Some(true);
2531        candidate.license.redistribution = Some(Redistribution::Ok);
2532        candidate.compat.status = Some(CompatStatus::Unmodified);
2533        let gates = compute_gates(&candidate);
2534        assert_eq!(compute_tier(&candidate, &gates, 49), "excluded");
2535    }
2536
2537    // =========================================================================
2538    // compare_scored
2539    // =========================================================================
2540
2541    #[test]
2542    fn compare_scored_by_final_total() {
2543        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2544        let mut a = score_candidate(&minimal_candidate("a"), as_of);
2545        let mut b = score_candidate(&minimal_candidate("b"), as_of);
2546        a.score.final_total = 80;
2547        b.score.final_total = 60;
2548        assert_eq!(compare_scored(&a, &b), std::cmp::Ordering::Greater);
2549    }
2550
2551    #[test]
2552    fn compare_scored_tiebreak_by_id() {
2553        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2554        let a = score_candidate(&minimal_candidate("alpha"), as_of);
2555        let b = score_candidate(&minimal_candidate("beta"), as_of);
2556        // Same scores, tiebreak by id
2557        assert_eq!(
2558            compare_scored(&a, &b),
2559            std::cmp::Ordering::Less // "alpha" < "beta"
2560        );
2561    }
2562
2563    // =========================================================================
2564    // build_histogram
2565    // =========================================================================
2566
2567    #[test]
2568    fn histogram_empty() {
2569        let histogram = build_histogram(&[]);
2570        assert_eq!(histogram.len(), 11);
2571        for bucket in &histogram {
2572            assert_eq!(bucket.count, 0);
2573        }
2574    }
2575
2576    #[test]
2577    fn histogram_ranges_correct() {
2578        let histogram = build_histogram(&[]);
2579        assert_eq!(histogram[0].range, "0-9");
2580        assert_eq!(histogram[5].range, "50-59");
2581        assert_eq!(histogram[10].range, "100-100");
2582    }
2583
2584    #[test]
2585    fn histogram_counts_correctly() {
2586        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2587        let mut items: Vec<ScoredCandidate> = (0..3)
2588            .map(|i| score_candidate(&minimal_candidate(&format!("c{i}")), as_of))
2589            .collect();
2590        items[0].score.final_total = 15; // bucket 1 (10-19)
2591        items[1].score.final_total = 15; // bucket 1 (10-19)
2592        items[2].score.final_total = 75; // bucket 7 (70-79)
2593        let histogram = build_histogram(&items);
2594        assert_eq!(histogram[1].count, 2);
2595        assert_eq!(histogram[7].count, 1);
2596    }
2597
2598    // =========================================================================
2599    // score_popularity (composite)
2600    // =========================================================================
2601
2602    #[test]
2603    fn popularity_capped_at_30() {
2604        let mut missing = BTreeSet::new();
2605        let signals = Signals {
2606            official_listing: Some(true), // 10
2607            github_stars: Some(10_000),   // 10
2608            marketplace: Some(MarketplaceSignals {
2609                rank: Some(1),        // 6
2610                featured: Some(true), // +2 (capped at 6)
2611                ..Default::default()
2612            }),
2613            references: (0..20).map(|i| format!("ref-{i}")).collect(), // 4
2614            ..Default::default()
2615        };
2616        let (total, _) = score_popularity(&signals, &mut missing);
2617        assert_eq!(total, 30); // capped
2618    }
2619
2620    // =========================================================================
2621    // score_adoption (composite)
2622    // =========================================================================
2623
2624    #[test]
2625    fn adoption_capped_at_15() {
2626        let mut missing = BTreeSet::new();
2627        let signals = Signals {
2628            npm_downloads_month: Some(100_000), // 8
2629            github_forks: Some(1_000),          // 2
2630            marketplace: Some(MarketplaceSignals {
2631                installs_month: Some(50_000), // 5
2632                ..Default::default()
2633            }),
2634            ..Default::default()
2635        };
2636        let (total, _) = score_adoption(&signals, &mut missing);
2637        assert_eq!(total, 15); // capped
2638    }
2639
2640    // =========================================================================
2641    // score_coverage (composite)
2642    // =========================================================================
2643
2644    #[test]
2645    fn coverage_capped_at_20() {
2646        let tags = Tags {
2647            runtime: Some("pkg-with-deps".to_string()), // 6
2648            interaction: vec![
2649                "provider".to_string(),
2650                "ui_integration".to_string(),
2651                "event_hook".to_string(),
2652                "slash_command".to_string(),
2653                "tool_only".to_string(),
2654            ], // 8
2655            capabilities: vec![
2656                "exec".to_string(),
2657                "http".to_string(),
2658                "read".to_string(),
2659                "ui".to_string(),
2660                "session".to_string(),
2661            ], // 6
2662        };
2663        let (total, _) = score_coverage(&tags);
2664        assert_eq!(total, 20); // capped
2665    }
2666
2667    // =========================================================================
2668    // score_candidates (integration)
2669    // =========================================================================
2670
2671    #[test]
2672    fn score_candidates_ranks_correctly() {
2673        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2674        let generated_at = as_of;
2675        let mut high = minimal_candidate("high");
2676        high.signals.github_stars = Some(10_000);
2677        high.signals.pi_mono_example = Some(true);
2678        high.compat.status = Some(CompatStatus::Unmodified);
2679        high.recency.updated_at = Some("2025-12-15T00:00:00Z".to_string());
2680
2681        let low = minimal_candidate("low");
2682
2683        let report = score_candidates(&[high, low], as_of, generated_at, 5);
2684        assert_eq!(report.schema, "pi.ext.scoring.v1");
2685        assert_eq!(report.items.len(), 2);
2686        assert_eq!(report.items[0].rank, 1);
2687        assert_eq!(report.items[1].rank, 2);
2688        assert!(report.items[0].score.final_total >= report.items[1].score.final_total);
2689    }
2690
2691    #[test]
2692    fn score_candidates_empty_input() {
2693        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2694        let report = score_candidates(&[], as_of, as_of, 5);
2695        assert!(report.items.is_empty());
2696        assert!(report.summary.top_overall.is_empty());
2697    }
2698
2699    // =========================================================================
2700    // Serde round-trips
2701    // =========================================================================
2702
2703    #[test]
2704    fn compat_status_serde_roundtrip() {
2705        for status in [
2706            CompatStatus::Unmodified,
2707            CompatStatus::RequiresShims,
2708            CompatStatus::RuntimeGap,
2709            CompatStatus::Blocked,
2710            CompatStatus::Unknown,
2711        ] {
2712            let json = serde_json::to_string(&status).unwrap();
2713            let back: CompatStatus = serde_json::from_str(&json).unwrap();
2714            assert_eq!(back, status);
2715        }
2716    }
2717
2718    #[test]
2719    fn redistribution_serde_roundtrip() {
2720        for red in [
2721            Redistribution::Ok,
2722            Redistribution::Restricted,
2723            Redistribution::Exclude,
2724            Redistribution::Unknown,
2725        ] {
2726            let json = serde_json::to_string(&red).unwrap();
2727            let back: Redistribution = serde_json::from_str(&json).unwrap();
2728            assert_eq!(back, red);
2729        }
2730    }
2731
2732    #[test]
2733    fn risk_level_serde_roundtrip() {
2734        for level in [
2735            RiskLevel::Low,
2736            RiskLevel::Moderate,
2737            RiskLevel::High,
2738            RiskLevel::Critical,
2739        ] {
2740            let json = serde_json::to_string(&level).unwrap();
2741            let back: RiskLevel = serde_json::from_str(&json).unwrap();
2742            assert_eq!(back, level);
2743        }
2744    }
2745
2746    #[test]
2747    fn scoring_report_serde_roundtrip() {
2748        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2749        let mut candidate = minimal_candidate("test-ext");
2750        candidate.signals.github_stars = Some(500);
2751        candidate.compat.status = Some(CompatStatus::Unmodified);
2752        let report = score_candidates(&[candidate], as_of, as_of, 5);
2753        let json = serde_json::to_string(&report).unwrap();
2754        let back: ScoringReport = serde_json::from_str(&json).unwrap();
2755        assert_eq!(back.items.len(), 1);
2756        assert_eq!(back.items[0].id, "test-ext");
2757    }
2758
2759    // =========================================================================
2760    // Missing signals tracking
2761    // =========================================================================
2762
2763    #[test]
2764    fn missing_signals_collected_for_empty_candidate() {
2765        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2766        let candidate = minimal_candidate("bare");
2767        let scored = score_candidate(&candidate, as_of);
2768        assert!(!scored.missing_signals.is_empty());
2769        assert!(
2770            scored
2771                .missing_signals
2772                .contains(&"signals.github_stars".to_string())
2773        );
2774        assert!(
2775            scored
2776                .missing_signals
2777                .contains(&"signals.github_forks".to_string())
2778        );
2779        assert!(
2780            scored
2781                .missing_signals
2782                .contains(&"recency.updated_at".to_string())
2783        );
2784    }
2785
2786    // =========================================================================
2787    // VOI planner
2788    // =========================================================================
2789
2790    fn voi_candidate(
2791        id: &str,
2792        utility_score: f64,
2793        estimated_overhead_ms: u32,
2794        last_seen_at: Option<&str>,
2795    ) -> VoiCandidate {
2796        VoiCandidate {
2797            id: id.to_string(),
2798            utility_score,
2799            estimated_overhead_ms,
2800            last_seen_at: last_seen_at.map(std::string::ToString::to_string),
2801            enabled: true,
2802        }
2803    }
2804
2805    #[test]
2806    fn voi_planner_budget_feasible_selection_and_skip_reason() {
2807        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2808        let config = VoiPlannerConfig {
2809            enabled: true,
2810            overhead_budget_ms: 9,
2811            max_candidates: None,
2812            stale_after_minutes: Some(180),
2813            min_utility_score: Some(0.0),
2814        };
2815        let candidates = vec![
2816            voi_candidate("fast-high", 9.0, 3, Some("2026-01-09T23:00:00Z")),
2817            voi_candidate("expensive", 12.0, 7, Some("2026-01-09T23:00:00Z")),
2818            voi_candidate("small", 4.0, 2, Some("2026-01-09T23:00:00Z")),
2819        ];
2820
2821        let plan = plan_voi_candidates(&candidates, now, &config);
2822        assert_eq!(
2823            plan.selected
2824                .iter()
2825                .map(|entry| entry.id.as_str())
2826                .collect::<Vec<_>>(),
2827            vec!["fast-high", "small"]
2828        );
2829        assert_eq!(plan.used_overhead_ms, 5);
2830        assert_eq!(plan.remaining_overhead_ms, 4);
2831        assert_eq!(plan.skipped.len(), 1);
2832        assert_eq!(plan.skipped[0].id, "expensive");
2833        assert_eq!(plan.skipped[0].reason, VoiSkipReason::BudgetExceeded);
2834    }
2835
2836    #[test]
2837    fn voi_planner_is_deterministic_across_input_order() {
2838        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2839        let config = VoiPlannerConfig {
2840            enabled: true,
2841            overhead_budget_ms: 20,
2842            max_candidates: Some(3),
2843            stale_after_minutes: Some(180),
2844            min_utility_score: Some(0.0),
2845        };
2846        let a = voi_candidate("a", 8.0, 2, Some("2026-01-09T23:00:00Z"));
2847        let b = voi_candidate("b", 8.0, 2, Some("2026-01-09T23:00:00Z"));
2848        let c = voi_candidate("c", 8.0, 2, Some("2026-01-09T23:00:00Z"));
2849
2850        let plan_1 = plan_voi_candidates(&[a.clone(), b.clone(), c.clone()], now, &config);
2851        let plan_2 = plan_voi_candidates(&[c, a, b], now, &config);
2852
2853        let ids_1 = plan_1
2854            .selected
2855            .iter()
2856            .map(|entry| entry.id.clone())
2857            .collect::<Vec<_>>();
2858        let ids_2 = plan_2
2859            .selected
2860            .iter()
2861            .map(|entry| entry.id.clone())
2862            .collect::<Vec<_>>();
2863        assert_eq!(ids_1, ids_2);
2864    }
2865
2866    #[test]
2867    fn voi_planner_rejects_stale_and_below_floor_candidates() {
2868        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2869        let config = VoiPlannerConfig {
2870            enabled: true,
2871            overhead_budget_ms: 20,
2872            max_candidates: None,
2873            stale_after_minutes: Some(60),
2874            min_utility_score: Some(5.0),
2875        };
2876        let candidates = vec![
2877            voi_candidate("fresh-good", 6.0, 2, Some("2026-01-09T23:30:00Z")),
2878            voi_candidate("stale", 7.0, 2, Some("2026-01-08T00:00:00Z")),
2879            voi_candidate("low-utility", 2.0, 1, Some("2026-01-09T23:30:00Z")),
2880            voi_candidate("missing-telemetry", 7.0, 1, None),
2881        ];
2882
2883        let plan = plan_voi_candidates(&candidates, now, &config);
2884        assert_eq!(plan.selected.len(), 1);
2885        assert_eq!(plan.selected[0].id, "fresh-good");
2886        assert_eq!(plan.skipped.len(), 3);
2887        assert!(
2888            plan.skipped
2889                .iter()
2890                .any(|entry| entry.id == "stale" && entry.reason == VoiSkipReason::StaleEvidence)
2891        );
2892        assert!(plan.skipped.iter().any(|entry| {
2893            entry.id == "low-utility" && entry.reason == VoiSkipReason::BelowUtilityFloor
2894        }));
2895        assert!(plan.skipped.iter().any(|entry| {
2896            entry.id == "missing-telemetry" && entry.reason == VoiSkipReason::MissingTelemetry
2897        }));
2898    }
2899
2900    #[test]
2901    fn voi_planner_disabled_returns_no_selection() {
2902        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2903        let config = VoiPlannerConfig {
2904            enabled: false,
2905            overhead_budget_ms: 20,
2906            max_candidates: None,
2907            stale_after_minutes: Some(120),
2908            min_utility_score: Some(0.0),
2909        };
2910        let candidates = vec![voi_candidate("a", 8.0, 2, Some("2026-01-09T23:00:00Z"))];
2911        let plan = plan_voi_candidates(&candidates, now, &config);
2912        assert!(plan.selected.is_empty());
2913        assert_eq!(plan.used_overhead_ms, 0);
2914        assert_eq!(plan.remaining_overhead_ms, 20);
2915        assert_eq!(plan.skipped.len(), 1);
2916        assert_eq!(plan.skipped[0].reason, VoiSkipReason::Disabled);
2917    }
2918
2919    fn ope_sample(
2920        action: &str,
2921        behavior_propensity: f64,
2922        target_propensity: f64,
2923        outcome: f64,
2924    ) -> OpeTraceSample {
2925        OpeTraceSample {
2926            action: action.to_string(),
2927            behavior_propensity,
2928            target_propensity,
2929            outcome,
2930            baseline_outcome: Some(outcome),
2931            direct_method_prediction: Some(outcome),
2932            context_lineage: Some(format!("ctx:{action}")),
2933        }
2934    }
2935
2936    fn assert_close(left: f64, right: f64, epsilon: f64) {
2937        assert!(
2938            (left - right).abs() <= epsilon,
2939            "values differ: left={left}, right={right}, epsilon={epsilon}"
2940        );
2941    }
2942
2943    #[test]
2944    fn ope_matches_ground_truth_when_behavior_equals_target() {
2945        let config = OpeEvaluatorConfig {
2946            max_importance_weight: 50.0,
2947            min_effective_sample_size: 1.0,
2948            max_standard_error: 10.0,
2949            confidence_z: 1.96,
2950            max_regret_delta: 1.0,
2951        };
2952        let samples = vec![
2953            ope_sample("a", 0.5, 0.5, 1.0),
2954            ope_sample("a", 0.5, 0.5, 0.0),
2955            ope_sample("a", 0.5, 0.5, 1.0),
2956            ope_sample("a", 0.5, 0.5, 1.0),
2957            ope_sample("a", 0.5, 0.5, 0.0),
2958            ope_sample("a", 0.5, 0.5, 1.0),
2959        ];
2960
2961        let report = evaluate_off_policy(&samples, &config);
2962        let expected_mean = 4.0 / 6.0;
2963        assert_close(report.ips.estimate, expected_mean, 1e-9);
2964        assert_close(report.wis.estimate, expected_mean, 1e-9);
2965        assert_close(report.doubly_robust.estimate, expected_mean, 1e-9);
2966        assert_eq!(report.gate.reason, OpeGateReason::Approved);
2967        assert!(report.gate.passed);
2968    }
2969
2970    #[test]
2971    fn ope_fails_closed_under_extreme_propensity_skew() {
2972        let config = OpeEvaluatorConfig {
2973            max_importance_weight: 100.0,
2974            min_effective_sample_size: 4.0,
2975            max_standard_error: 10.0,
2976            confidence_z: 1.96,
2977            max_regret_delta: 10.0,
2978        };
2979        let mut samples = vec![ope_sample("candidate", 0.02, 1.0, 0.0)];
2980        for _ in 0..9 {
2981            samples.push(ope_sample("candidate", 1.0, 0.02, 1.0));
2982        }
2983
2984        let report = evaluate_off_policy(&samples, &config);
2985        assert!(report.diagnostics.effective_sample_size < 2.0);
2986        assert_eq!(report.gate.reason, OpeGateReason::InsufficientSupport);
2987        assert!(!report.gate.passed);
2988    }
2989
2990    #[test]
2991    fn ope_fails_closed_when_no_valid_samples_exist() {
2992        let config = OpeEvaluatorConfig::default();
2993        let samples = vec![
2994            ope_sample("invalid", 0.0, 0.5, 1.0),
2995            ope_sample("invalid", -1.0, 0.5, 1.0),
2996        ];
2997
2998        let report = evaluate_off_policy(&samples, &config);
2999        assert_eq!(report.diagnostics.valid_samples, 0);
3000        assert_eq!(report.gate.reason, OpeGateReason::NoValidSamples);
3001        assert!(!report.gate.passed);
3002    }
3003
3004    #[test]
3005    fn ope_is_stable_across_input_order() {
3006        let config = OpeEvaluatorConfig {
3007            max_importance_weight: 50.0,
3008            min_effective_sample_size: 1.0,
3009            max_standard_error: 10.0,
3010            confidence_z: 1.96,
3011            max_regret_delta: 10.0,
3012        };
3013        let samples = vec![
3014            ope_sample("a", 0.40, 0.30, 0.2),
3015            ope_sample("a", 0.50, 0.60, 0.8),
3016            ope_sample("a", 0.70, 0.20, 0.1),
3017            ope_sample("a", 0.30, 0.50, 0.7),
3018        ];
3019        let mut reversed = samples.clone();
3020        reversed.reverse();
3021
3022        let original = evaluate_off_policy(&samples, &config);
3023        let swapped = evaluate_off_policy(&reversed, &config);
3024        assert_close(original.ips.estimate, swapped.ips.estimate, 1e-12);
3025        assert_close(original.wis.estimate, swapped.wis.estimate, 1e-12);
3026        assert_close(
3027            original.doubly_robust.estimate,
3028            swapped.doubly_robust.estimate,
3029            1e-12,
3030        );
3031        assert_eq!(original.gate.reason, swapped.gate.reason);
3032        assert_close(
3033            original.diagnostics.effective_sample_size,
3034            swapped.diagnostics.effective_sample_size,
3035            1e-12,
3036        );
3037    }
3038
3039    fn mean_field_observation(
3040        shard_id: &str,
3041        queue_pressure: f64,
3042        tail_latency_ratio: f64,
3043        starvation_risk: f64,
3044    ) -> MeanFieldShardObservation {
3045        MeanFieldShardObservation {
3046            shard_id: shard_id.to_string(),
3047            queue_pressure,
3048            tail_latency_ratio,
3049            starvation_risk,
3050        }
3051    }
3052
3053    fn mean_field_state(
3054        shard_id: &str,
3055        routing_weight: f64,
3056        batch_budget: u32,
3057        last_routing_delta: f64,
3058    ) -> MeanFieldShardState {
3059        MeanFieldShardState {
3060            shard_id: shard_id.to_string(),
3061            routing_weight,
3062            batch_budget,
3063            help_factor: 1.0,
3064            backoff_factor: 1.0,
3065            last_routing_delta,
3066        }
3067    }
3068
3069    #[test]
3070    fn mean_field_controls_are_deterministic_across_input_order() {
3071        let config = MeanFieldControllerConfig::default();
3072        let observations = vec![
3073            mean_field_observation("shard-b", 0.7, 1.3, 0.1),
3074            mean_field_observation("shard-a", 0.3, 1.1, 0.2),
3075            mean_field_observation("shard-c", 0.5, 1.0, 0.4),
3076        ];
3077        let previous = vec![
3078            mean_field_state("shard-c", 1.2, 24, 0.0),
3079            mean_field_state("shard-a", 0.9, 18, 0.0),
3080            mean_field_state("shard-b", 1.1, 20, 0.0),
3081        ];
3082
3083        let baseline = compute_mean_field_controls(&observations, &previous, &config);
3084        let reversed_observations = observations.iter().rev().cloned().collect::<Vec<_>>();
3085        let reversed_previous = previous.iter().rev().cloned().collect::<Vec<_>>();
3086        let reversed =
3087            compute_mean_field_controls(&reversed_observations, &reversed_previous, &config);
3088
3089        assert_eq!(
3090            baseline
3091                .controls
3092                .iter()
3093                .map(|control| control.shard_id.as_str())
3094                .collect::<Vec<_>>(),
3095            vec!["shard-a", "shard-b", "shard-c"]
3096        );
3097        assert_eq!(
3098            baseline
3099                .controls
3100                .iter()
3101                .map(|control| control.shard_id.clone())
3102                .collect::<Vec<_>>(),
3103            reversed
3104                .controls
3105                .iter()
3106                .map(|control| control.shard_id.clone())
3107                .collect::<Vec<_>>()
3108        );
3109        assert_close(baseline.global_pressure, reversed.global_pressure, 1e-12);
3110    }
3111
3112    #[test]
3113    fn mean_field_clips_unstable_steps_to_max_step() {
3114        let config = MeanFieldControllerConfig {
3115            max_step: 0.05,
3116            ..MeanFieldControllerConfig::default()
3117        };
3118        let observations = vec![mean_field_observation("shard-a", 1.0, 2.0, 0.0)];
3119        let previous = vec![mean_field_state("shard-a", 1.0, 32, 0.0)];
3120
3121        let report = compute_mean_field_controls(&observations, &previous, &config);
3122        assert_eq!(report.controls.len(), 1);
3123        let control = &report.controls[0];
3124        assert!(control.clipped);
3125        assert!(control.routing_delta.abs() <= config.max_step + 1e-12);
3126        assert!(control.stability_margin <= config.max_step + 1e-12);
3127    }
3128
3129    #[test]
3130    fn mean_field_oscillation_guard_reduces_sign_flip_delta() {
3131        let config = MeanFieldControllerConfig {
3132            max_step: 0.30,
3133            ..MeanFieldControllerConfig::default()
3134        };
3135        let observations = vec![mean_field_observation("shard-a", 1.0, 1.7, 0.0)];
3136        let previous = vec![mean_field_state("shard-a", 1.2, 20, 0.12)];
3137
3138        let report = compute_mean_field_controls(&observations, &previous, &config);
3139        let control = &report.controls[0];
3140        assert!(control.oscillation_guarded);
3141        assert!(report.oscillation_guard_count >= 1);
3142    }
3143
3144    #[test]
3145    fn mean_field_marks_converged_for_small_average_delta() {
3146        let config = MeanFieldControllerConfig {
3147            queue_gain: 0.0,
3148            latency_gain: 0.0,
3149            starvation_gain: 0.0,
3150            damping: 1.0,
3151            convergence_epsilon: 0.05,
3152            ..MeanFieldControllerConfig::default()
3153        };
3154        let observations = vec![
3155            mean_field_observation("shard-a", 0.40, 1.0, 0.1),
3156            mean_field_observation("shard-b", 0.42, 1.0, 0.1),
3157        ];
3158        let previous = vec![
3159            mean_field_state("shard-a", 1.0, 24, 0.0),
3160            mean_field_state("shard-b", 1.0, 24, 0.0),
3161        ];
3162
3163        let report = compute_mean_field_controls(&observations, &previous, &config);
3164        assert!(report.converged);
3165    }
3166
3167    // ── Property tests ──
3168
3169    mod proptest_scoring {
3170        use super::*;
3171        use proptest::prelude::*;
3172
3173        fn arb_signals() -> impl Strategy<Value = Signals> {
3174            (
3175                any::<Option<bool>>(),
3176                any::<Option<bool>>(),
3177                any::<Option<bool>>(),
3178                prop::option::of(0..100_000u64),
3179                prop::option::of(0..10_000u64),
3180                prop::option::of(0..1_000_000u64),
3181            )
3182                .prop_map(|(listing, example, gist, stars, forks, npm)| Signals {
3183                    official_listing: listing,
3184                    pi_mono_example: example,
3185                    badlogic_gist: gist,
3186                    github_stars: stars,
3187                    github_forks: forks,
3188                    npm_downloads_month: npm,
3189                    references: Vec::new(),
3190                    marketplace: None,
3191                })
3192        }
3193
3194        fn arb_tags() -> impl Strategy<Value = Tags> {
3195            let runtime = prop::option::of(prop::sample::select(vec![
3196                "pkg-with-deps".to_string(),
3197                "provider-ext".to_string(),
3198                "multi-file".to_string(),
3199                "legacy-js".to_string(),
3200            ]));
3201            runtime.prop_map(|rt| Tags {
3202                runtime: rt,
3203                interaction: Vec::new(),
3204                capabilities: Vec::new(),
3205            })
3206        }
3207
3208        fn arb_compat_status() -> impl Strategy<Value = CompatStatus> {
3209            prop::sample::select(vec![
3210                CompatStatus::Unmodified,
3211                CompatStatus::RequiresShims,
3212                CompatStatus::RuntimeGap,
3213                CompatStatus::Blocked,
3214                CompatStatus::Unknown,
3215            ])
3216        }
3217
3218        fn arb_risk_level() -> impl Strategy<Value = RiskLevel> {
3219            prop::sample::select(vec![
3220                RiskLevel::Low,
3221                RiskLevel::Moderate,
3222                RiskLevel::High,
3223                RiskLevel::Critical,
3224            ])
3225        }
3226
3227        proptest! {
3228            #[test]
3229            fn score_github_stars_bounded(stars in 0..10_000_000u64) {
3230                let signals = Signals {
3231                    github_stars: Some(stars),
3232                    ..Signals::default()
3233                };
3234                let mut missing = BTreeSet::new();
3235                let score = score_github_stars(&signals, &mut missing);
3236                assert!(score <= 10, "github_stars score {score} exceeds max 10");
3237            }
3238
3239            #[test]
3240            fn score_github_stars_monotonic(a in 0..100_000u64, b in 0..100_000u64) {
3241                let mut missing = BTreeSet::new();
3242                let sig_a = Signals { github_stars: Some(a), ..Signals::default() };
3243                let sig_b = Signals { github_stars: Some(b), ..Signals::default() };
3244                let score_a = score_github_stars(&sig_a, &mut missing);
3245                let score_b = score_github_stars(&sig_b, &mut missing);
3246                if a <= b {
3247                    assert!(
3248                        score_a <= score_b,
3249                        "monotonicity: stars {a} → {score_a}, {b} → {score_b}"
3250                    );
3251                }
3252            }
3253
3254            #[test]
3255            fn score_npm_downloads_bounded(downloads in 0..10_000_000u64) {
3256                let signals = Signals {
3257                    npm_downloads_month: Some(downloads),
3258                    ..Signals::default()
3259                };
3260                let mut missing = BTreeSet::new();
3261                let score = score_npm_downloads(&signals, &mut missing);
3262                assert!(score <= 8, "npm_downloads score {score} exceeds max 8");
3263            }
3264
3265            #[test]
3266            fn score_compatibility_bounded(
3267                status in arb_compat_status(),
3268                adjustment in -20..20i8,
3269            ) {
3270                let compat = Compatibility {
3271                    status: Some(status),
3272                    blocked_reasons: Vec::new(),
3273                    required_shims: Vec::new(),
3274                    adjustment: Some(adjustment),
3275                };
3276                let score = score_compatibility(&compat);
3277                assert!(score <= 20, "compatibility score {score} exceeds max 20");
3278            }
3279
3280            #[test]
3281            fn score_risk_bounded(
3282                level in prop::option::of(arb_risk_level()),
3283                penalty in prop::option::of(0..100u8),
3284            ) {
3285                let risk = RiskInfo { level, penalty, flags: Vec::new() };
3286                let score = score_risk(&risk);
3287                assert!(score <= 15, "risk penalty {score} exceeds max 15");
3288            }
3289
3290            #[test]
3291            fn normalized_utility_nonnegative(value in prop::num::f64::ANY) {
3292                let result = normalized_utility(value);
3293                assert!(
3294                    result >= 0.0,
3295                    "normalized_utility({value}) = {result} must be >= 0.0"
3296                );
3297            }
3298
3299            #[test]
3300            fn normalized_utility_handles_special_floats(
3301                value in prop::sample::select(vec![
3302                    f64::NAN, f64::INFINITY, f64::NEG_INFINITY,
3303                    0.0, -0.0, f64::MIN, f64::MAX, f64::MIN_POSITIVE,
3304                ]),
3305            ) {
3306                let result = normalized_utility(value);
3307                assert!(
3308                    result.is_finite(),
3309                    "normalized_utility({value}) = {result} must be finite"
3310                );
3311                assert!(result >= 0.0, "must be non-negative");
3312            }
3313
3314            #[test]
3315            fn normalized_utility_idempotent(value in prop::num::f64::ANY) {
3316                let once = normalized_utility(value);
3317                let twice = normalized_utility(once);
3318                assert!(
3319                    (once - twice).abs() < f64::EPSILON || (once.is_nan() && twice.is_nan()),
3320                    "normalized_utility must be idempotent: {once} vs {twice}"
3321                );
3322            }
3323
3324            #[test]
3325            fn base_total_is_sum_of_components(
3326                signals in arb_signals(),
3327                tags in arb_tags(),
3328            ) {
3329                let mut missing = BTreeSet::new();
3330                let (popularity, _) = score_popularity(&signals, &mut missing);
3331                let (adoption, _) = score_adoption(&signals, &mut missing);
3332                let (coverage, _) = score_coverage(&tags);
3333                // Each clamped component is bounded
3334                assert!(popularity <= 30, "popularity {popularity} > 30");
3335                assert!(adoption <= 15, "adoption {adoption} > 15");
3336                assert!(coverage <= 20, "coverage {coverage} > 20");
3337            }
3338
3339            #[test]
3340            fn gates_passes_iff_all_true(
3341                prov in any::<bool>(),
3342                det in any::<bool>(),
3343                license_ok in any::<bool>(),
3344                unmod in any::<bool>(),
3345            ) {
3346                let expected = prov && det && license_ok && unmod;
3347                let gates = GateStatus {
3348                    provenance_pinned: prov,
3349                    license_ok,
3350                    deterministic: det,
3351                    unmodified: unmod,
3352                    passes: expected,
3353                };
3354                assert!(
3355                    gates.passes == expected,
3356                    "gates.passes should be AND of all flags"
3357                );
3358            }
3359        }
3360    }
3361}