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.abs() > f64::EPSILON
1225        && routing_delta.abs() > f64::EPSILON
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
1449            .importance_weight
1450            .mul_add(sample.importance_weight, sum_importance_weight_sq);
1451        max_seen_importance_weight = max_seen_importance_weight.max(sample.importance_weight);
1452    }
1453    let effective_sample_size = if sum_importance_weight_sq > 0.0 {
1454        (sum_importance_weight * sum_importance_weight) / sum_importance_weight_sq
1455    } else {
1456        0.0
1457    };
1458
1459    let valid_samples_f64 = valid_samples as f64;
1460    let mut ips_effects = Vec::with_capacity(valid_samples);
1461    let mut wis_effects = Vec::with_capacity(valid_samples);
1462    let mut doubly_robust_effects = Vec::with_capacity(valid_samples);
1463    let mut baseline_values = Vec::with_capacity(valid_samples);
1464
1465    for sample in &normalized_samples {
1466        ips_effects.push(sample.importance_weight * sample.outcome);
1467        doubly_robust_effects.push(
1468            sample
1469                .importance_weight
1470                .mul_add(sample.outcome - sample.direct_method, sample.direct_method),
1471        );
1472        baseline_values.push(sample.baseline);
1473    }
1474    if sum_importance_weight > 0.0 {
1475        for sample in &normalized_samples {
1476            wis_effects.push(
1477                (sample.importance_weight * sample.outcome * valid_samples_f64)
1478                    / sum_importance_weight,
1479            );
1480        }
1481    } else {
1482        wis_effects.resize(valid_samples, 0.0);
1483    }
1484
1485    let ips = summarize_estimator(&ips_effects, confidence_z);
1486    let wis = summarize_estimator(&wis_effects, confidence_z);
1487    let doubly_robust = summarize_estimator(&doubly_robust_effects, confidence_z);
1488    let baseline_mean = arithmetic_mean(&baseline_values);
1489    let estimated_regret_delta = baseline_mean - doubly_robust.estimate;
1490
1491    let gate = if valid_samples == 0 {
1492        OpeGateDecision {
1493            passed: false,
1494            reason: OpeGateReason::NoValidSamples,
1495        }
1496    } else if effective_sample_size < min_effective_sample_size {
1497        OpeGateDecision {
1498            passed: false,
1499            reason: OpeGateReason::InsufficientSupport,
1500        }
1501    } else if doubly_robust.standard_error > max_standard_error {
1502        OpeGateDecision {
1503            passed: false,
1504            reason: OpeGateReason::HighUncertainty,
1505        }
1506    } else if estimated_regret_delta > max_regret_delta {
1507        OpeGateDecision {
1508            passed: false,
1509            reason: OpeGateReason::ExcessiveRegret,
1510        }
1511    } else {
1512        OpeGateDecision {
1513            passed: true,
1514            reason: OpeGateReason::Approved,
1515        }
1516    };
1517
1518    OpeEvaluationReport {
1519        ips,
1520        wis,
1521        doubly_robust,
1522        baseline_mean,
1523        estimated_regret_delta,
1524        diagnostics: OpeDiagnostics {
1525            total_samples: samples.len(),
1526            valid_samples,
1527            skipped_invalid_samples,
1528            direct_method_fallback_samples,
1529            clipped_weight_samples,
1530            sum_importance_weight,
1531            max_importance_weight: max_seen_importance_weight,
1532            effective_sample_size,
1533        },
1534        gate,
1535    }
1536}
1537
1538fn normalize_ope_sample(
1539    sample: &OpeTraceSample,
1540    max_importance_weight: f64,
1541) -> Option<(NormalizedOpeSample, bool, bool)> {
1542    if !sample.outcome.is_finite()
1543        || !sample.behavior_propensity.is_finite()
1544        || sample.behavior_propensity <= 0.0
1545        || !sample.target_propensity.is_finite()
1546        || sample.target_propensity < 0.0
1547    {
1548        return None;
1549    }
1550    let raw_weight = sample.target_propensity / sample.behavior_propensity;
1551    if !raw_weight.is_finite() || raw_weight < 0.0 {
1552        return None;
1553    }
1554    let clipped_weight = raw_weight.min(max_importance_weight);
1555    let clipped = clipped_weight < raw_weight;
1556    let baseline = sample
1557        .baseline_outcome
1558        .filter(|value| value.is_finite())
1559        .unwrap_or(sample.outcome);
1560    let (direct_method, fallback_direct_method) = match sample.direct_method_prediction {
1561        Some(value) if value.is_finite() => (value, false),
1562        _ => (sample.outcome, true),
1563    };
1564    Some((
1565        NormalizedOpeSample {
1566            importance_weight: clipped_weight,
1567            outcome: sample.outcome,
1568            baseline,
1569            direct_method,
1570        },
1571        clipped,
1572        fallback_direct_method,
1573    ))
1574}
1575
1576#[allow(clippy::cast_precision_loss)]
1577fn summarize_estimator(effects: &[f64], confidence_z: f64) -> OpeEstimatorSummary {
1578    if effects.is_empty() {
1579        return OpeEstimatorSummary {
1580            estimate: 0.0,
1581            variance: 0.0,
1582            standard_error: 0.0,
1583            ci_low: 0.0,
1584            ci_high: 0.0,
1585        };
1586    }
1587    let sample_count = effects.len() as f64;
1588    let estimate = arithmetic_mean(effects);
1589    let variance = if effects.len() > 1 {
1590        effects
1591            .iter()
1592            .map(|value| {
1593                let centered = *value - estimate;
1594                centered * centered
1595            })
1596            .sum::<f64>()
1597            / (sample_count - 1.0)
1598    } else {
1599        0.0
1600    };
1601    let standard_error = (variance / sample_count).sqrt();
1602    let margin = confidence_z * standard_error;
1603    OpeEstimatorSummary {
1604        estimate,
1605        variance,
1606        standard_error,
1607        ci_low: estimate - margin,
1608        ci_high: estimate + margin,
1609    }
1610}
1611
1612#[allow(clippy::cast_precision_loss)]
1613fn arithmetic_mean(values: &[f64]) -> f64 {
1614    if values.is_empty() {
1615        0.0
1616    } else {
1617        values.iter().sum::<f64>() / values.len() as f64
1618    }
1619}
1620
1621const fn non_negative_finite(value: f64) -> f64 {
1622    if value.is_finite() {
1623        if value > 0.0 { value } else { 0.0 }
1624    } else {
1625        0.0
1626    }
1627}
1628
1629fn positive_finite_or(value: f64, fallback: f64) -> f64 {
1630    if value.is_finite() && value > 0.0 {
1631        value
1632    } else {
1633        fallback
1634    }
1635}
1636
1637#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1638#[serde(rename_all = "camelCase")]
1639pub struct InterferenceMatrixCompletenessReport {
1640    pub expected_pairs: usize,
1641    pub observed_pairs: usize,
1642    pub missing_pairs: Vec<String>,
1643    pub duplicate_pairs: Vec<String>,
1644    pub unknown_pairs: Vec<String>,
1645    pub complete: bool,
1646}
1647
1648fn normalize_interference_lever(raw: &str) -> Option<String> {
1649    let normalized = raw.trim().to_ascii_lowercase();
1650    if normalized.is_empty() {
1651        None
1652    } else {
1653        Some(normalized)
1654    }
1655}
1656
1657fn canonicalize_interference_pair(first: &str, second: &str) -> Option<(String, String)> {
1658    let left = normalize_interference_lever(first)?;
1659    let right = normalize_interference_lever(second)?;
1660    if left <= right {
1661        Some((left, right))
1662    } else {
1663        Some((right, left))
1664    }
1665}
1666
1667pub fn parse_interference_pair_key(key: &str) -> Option<(String, String)> {
1668    let mut parts = key.split('+');
1669    let first = parts.next()?;
1670    let second = parts.next()?;
1671    if parts.next().is_some() {
1672        return None;
1673    }
1674    canonicalize_interference_pair(first, second)
1675}
1676
1677pub fn format_interference_pair_key(first: &str, second: &str) -> Option<String> {
1678    let (left, right) = canonicalize_interference_pair(first, second)?;
1679    Some(format!("{left}+{right}"))
1680}
1681
1682pub fn evaluate_interference_matrix_completeness(
1683    levers: &[String],
1684    observed_pair_keys: &[String],
1685) -> InterferenceMatrixCompletenessReport {
1686    let ordered_levers: Vec<String> = levers
1687        .iter()
1688        .filter_map(|lever| normalize_interference_lever(lever))
1689        .collect::<BTreeSet<_>>()
1690        .into_iter()
1691        .collect();
1692
1693    let mut expected_pairs = BTreeSet::new();
1694    for (idx, left) in ordered_levers.iter().enumerate() {
1695        for right in ordered_levers.iter().skip(idx + 1) {
1696            expected_pairs.insert(format!("{left}+{right}"));
1697        }
1698    }
1699
1700    let mut seen_pairs = BTreeSet::new();
1701    let mut duplicate_pairs = BTreeSet::new();
1702    let mut unknown_pairs = BTreeSet::new();
1703    let mut observed_pairs = BTreeSet::new();
1704
1705    for raw_key in observed_pair_keys {
1706        let Some((left, right)) = parse_interference_pair_key(raw_key) else {
1707            unknown_pairs.insert(raw_key.clone());
1708            continue;
1709        };
1710
1711        let key = format!("{left}+{right}");
1712        if !seen_pairs.insert(key.clone()) {
1713            duplicate_pairs.insert(key.clone());
1714            continue;
1715        }
1716
1717        if expected_pairs.contains(&key) {
1718            observed_pairs.insert(key);
1719        } else {
1720            unknown_pairs.insert(key);
1721        }
1722    }
1723
1724    let missing_pairs = expected_pairs
1725        .difference(&observed_pairs)
1726        .cloned()
1727        .collect::<Vec<_>>();
1728    let duplicate_pairs = duplicate_pairs.into_iter().collect::<Vec<_>>();
1729    let unknown_pairs = unknown_pairs.into_iter().collect::<Vec<_>>();
1730
1731    InterferenceMatrixCompletenessReport {
1732        expected_pairs: expected_pairs.len(),
1733        observed_pairs: observed_pairs.len(),
1734        missing_pairs: missing_pairs.clone(),
1735        duplicate_pairs: duplicate_pairs.clone(),
1736        unknown_pairs: unknown_pairs.clone(),
1737        complete: missing_pairs.is_empty()
1738            && duplicate_pairs.is_empty()
1739            && unknown_pairs.is_empty(),
1740    }
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    use super::*;
1746    use chrono::TimeZone;
1747
1748    fn empty_signals() -> Signals {
1749        Signals::default()
1750    }
1751
1752    fn empty_tags() -> Tags {
1753        Tags::default()
1754    }
1755
1756    fn minimal_candidate(id: &str) -> CandidateInput {
1757        CandidateInput {
1758            id: id.to_string(),
1759            name: None,
1760            source_tier: None,
1761            signals: Signals::default(),
1762            tags: Tags::default(),
1763            recency: Recency::default(),
1764            compat: Compatibility::default(),
1765            license: LicenseInfo::default(),
1766            gates: Gates::default(),
1767            risk: RiskInfo::default(),
1768            manual_override: None,
1769        }
1770    }
1771
1772    #[test]
1773    fn parse_interference_pair_key_normalizes_order_and_case() {
1774        let parsed = parse_interference_pair_key("Queue+marshal").expect("pair must parse");
1775        assert_eq!(parsed.0, "marshal");
1776        assert_eq!(parsed.1, "queue");
1777
1778        let formatted =
1779            format_interference_pair_key(" Queue ", "marshal ").expect("pair must format");
1780        assert_eq!(formatted, "marshal+queue");
1781    }
1782
1783    #[test]
1784    fn parse_interference_pair_key_rejects_invalid_shapes() {
1785        assert!(parse_interference_pair_key("").is_none());
1786        assert!(parse_interference_pair_key("queue").is_none());
1787        assert!(parse_interference_pair_key("a+b+c").is_none());
1788        assert!(parse_interference_pair_key(" + ").is_none());
1789    }
1790
1791    #[test]
1792    fn interference_matrix_completeness_detects_missing_duplicate_and_unknown_pairs() {
1793        let levers = vec![
1794            "queue".to_string(),
1795            "policy".to_string(),
1796            "execute".to_string(),
1797        ];
1798        let observed = vec![
1799            "queue+policy".to_string(),
1800            "policy+queue".to_string(),  // duplicate (canonicalized)
1801            "queue+marshal".to_string(), // unknown pair
1802            "broken".to_string(),        // malformed key
1803        ];
1804
1805        let report = evaluate_interference_matrix_completeness(&levers, &observed);
1806        assert_eq!(report.expected_pairs, 3);
1807        assert_eq!(report.observed_pairs, 1);
1808        assert_eq!(
1809            report.missing_pairs,
1810            vec!["execute+policy".to_string(), "execute+queue".to_string()]
1811        );
1812        assert_eq!(report.duplicate_pairs, vec!["policy+queue".to_string()]);
1813        assert_eq!(
1814            report.unknown_pairs,
1815            vec!["broken".to_string(), "marshal+queue".to_string()]
1816        );
1817        assert!(!report.complete);
1818    }
1819
1820    #[test]
1821    fn interference_matrix_completeness_passes_with_full_unique_matrix() {
1822        let levers = vec![
1823            "marshal".to_string(),
1824            "queue".to_string(),
1825            "schedule".to_string(),
1826            "policy".to_string(),
1827        ];
1828        let observed = vec![
1829            "marshal+queue".to_string(),
1830            "marshal+schedule".to_string(),
1831            "marshal+policy".to_string(),
1832            "queue+schedule".to_string(),
1833            "queue+policy".to_string(),
1834            "schedule+policy".to_string(),
1835        ];
1836
1837        let report = evaluate_interference_matrix_completeness(&levers, &observed);
1838        assert_eq!(report.expected_pairs, 6);
1839        assert_eq!(report.observed_pairs, 6);
1840        assert!(report.missing_pairs.is_empty());
1841        assert!(report.duplicate_pairs.is_empty());
1842        assert!(report.unknown_pairs.is_empty());
1843        assert!(report.complete);
1844    }
1845
1846    // =========================================================================
1847    // score_github_stars
1848    // =========================================================================
1849
1850    #[test]
1851    fn github_stars_none_returns_zero_and_records_missing() {
1852        let mut missing = BTreeSet::new();
1853        let signals = empty_signals();
1854        assert_eq!(score_github_stars(&signals, &mut missing), 0);
1855        assert!(missing.contains("signals.github_stars"));
1856    }
1857
1858    #[test]
1859    fn github_stars_tiers() {
1860        let cases = [
1861            (0, 0),
1862            (49, 5),
1863            (50, 5),
1864            (199, 6),
1865            (200, 6),
1866            (499, 7),
1867            (500, 7),
1868            (999, 8),
1869            (1_000, 8),
1870            (1_999, 9),
1871            (2_000, 9),
1872            (4_999, 10),
1873            (5_000, 10),
1874            (100_000, 10),
1875        ];
1876        for (stars, expected) in cases {
1877            let mut missing = BTreeSet::new();
1878            let signals = Signals {
1879                github_stars: Some(stars),
1880                ..Default::default()
1881            };
1882            assert_eq!(
1883                score_github_stars(&signals, &mut missing),
1884                expected,
1885                "stars={stars}"
1886            );
1887        }
1888    }
1889
1890    // =========================================================================
1891    // score_npm_downloads
1892    // =========================================================================
1893
1894    #[test]
1895    fn npm_downloads_none_returns_zero() {
1896        let mut missing = BTreeSet::new();
1897        assert_eq!(score_npm_downloads(&empty_signals(), &mut missing), 0);
1898        assert!(missing.contains("signals.npm_downloads_month"));
1899    }
1900
1901    #[test]
1902    fn npm_downloads_tiers() {
1903        let cases = [
1904            (0, 0),
1905            (499, 5),
1906            (500, 5),
1907            (2_000, 6),
1908            (10_000, 7),
1909            (50_000, 8),
1910        ];
1911        for (dl, expected) in cases {
1912            let mut missing = BTreeSet::new();
1913            let signals = Signals {
1914                npm_downloads_month: Some(dl),
1915                ..Default::default()
1916            };
1917            assert_eq!(
1918                score_npm_downloads(&signals, &mut missing),
1919                expected,
1920                "downloads={dl}"
1921            );
1922        }
1923    }
1924
1925    // =========================================================================
1926    // score_marketplace_installs
1927    // =========================================================================
1928
1929    #[test]
1930    fn marketplace_installs_no_marketplace_records_missing() {
1931        let mut missing = BTreeSet::new();
1932        assert_eq!(
1933            score_marketplace_installs(&empty_signals(), &mut missing),
1934            0
1935        );
1936        assert!(missing.contains("signals.marketplace.installs_month"));
1937    }
1938
1939    #[test]
1940    fn marketplace_installs_none_records_missing() {
1941        let mut missing = BTreeSet::new();
1942        let signals = Signals {
1943            marketplace: Some(MarketplaceSignals::default()),
1944            ..Default::default()
1945        };
1946        assert_eq!(score_marketplace_installs(&signals, &mut missing), 0);
1947        assert!(missing.contains("signals.marketplace.installs_month"));
1948    }
1949
1950    #[test]
1951    fn marketplace_installs_tiers() {
1952        let cases = [(0, 0), (99, 0), (100, 1), (500, 2), (2_000, 4), (10_000, 5)];
1953        for (installs, expected) in cases {
1954            let mut missing = BTreeSet::new();
1955            let signals = Signals {
1956                marketplace: Some(MarketplaceSignals {
1957                    installs_month: Some(installs),
1958                    ..Default::default()
1959                }),
1960                ..Default::default()
1961            };
1962            assert_eq!(
1963                score_marketplace_installs(&signals, &mut missing),
1964                expected,
1965                "installs={installs}"
1966            );
1967        }
1968    }
1969
1970    // =========================================================================
1971    // score_forks
1972    // =========================================================================
1973
1974    #[test]
1975    fn forks_none_returns_zero() {
1976        let mut missing = BTreeSet::new();
1977        assert_eq!(score_forks(&empty_signals(), &mut missing), 0);
1978        assert!(missing.contains("signals.github_forks"));
1979    }
1980
1981    #[test]
1982    fn forks_tiers() {
1983        let cases = [(0, 0), (49, 0), (50, 1), (200, 1), (500, 2), (10_000, 2)];
1984        for (f, expected) in cases {
1985            let mut missing = BTreeSet::new();
1986            let signals = Signals {
1987                github_forks: Some(f),
1988                ..Default::default()
1989            };
1990            assert_eq!(score_forks(&signals, &mut missing), expected, "forks={f}");
1991        }
1992    }
1993
1994    // =========================================================================
1995    // score_references
1996    // =========================================================================
1997
1998    #[test]
1999    fn references_empty() {
2000        let signals = empty_signals();
2001        assert_eq!(score_references(&signals), 0);
2002    }
2003
2004    #[test]
2005    fn references_deduplicates_trimmed() {
2006        let signals = Signals {
2007            references: vec![" ref1 ".to_string(), "ref1".to_string(), "ref2".to_string()],
2008            ..Default::default()
2009        };
2010        assert_eq!(score_references(&signals), 2); // 2 unique => score 2
2011    }
2012
2013    #[test]
2014    fn references_tiers() {
2015        let make = |n: usize| -> Signals {
2016            Signals {
2017                references: (0..n).map(|i| format!("ref-{i}")).collect(),
2018                ..Default::default()
2019            }
2020        };
2021        assert_eq!(score_references(&make(1)), 0);
2022        assert_eq!(score_references(&make(2)), 2);
2023        assert_eq!(score_references(&make(5)), 3);
2024        assert_eq!(score_references(&make(10)), 4);
2025        assert_eq!(score_references(&make(20)), 4);
2026    }
2027
2028    #[test]
2029    fn references_ignores_empty_and_whitespace() {
2030        let signals = Signals {
2031            references: vec![String::new(), "  ".to_string(), "real".to_string()],
2032            ..Default::default()
2033        };
2034        assert_eq!(score_references(&signals), 0); // 1 unique => 0
2035    }
2036
2037    // =========================================================================
2038    // score_official_visibility
2039    // =========================================================================
2040
2041    #[test]
2042    fn official_visibility_all_none_records_missing() {
2043        let mut missing = BTreeSet::new();
2044        assert_eq!(score_official_visibility(&empty_signals(), &mut missing), 0);
2045        assert!(missing.contains("signals.official_visibility"));
2046    }
2047
2048    #[test]
2049    fn official_visibility_listing_highest() {
2050        let mut missing = BTreeSet::new();
2051        let signals = Signals {
2052            official_listing: Some(true),
2053            pi_mono_example: Some(true),
2054            badlogic_gist: Some(true),
2055            ..Default::default()
2056        };
2057        assert_eq!(score_official_visibility(&signals, &mut missing), 10);
2058    }
2059
2060    #[test]
2061    fn official_visibility_example_mid() {
2062        let mut missing = BTreeSet::new();
2063        let signals = Signals {
2064            pi_mono_example: Some(true),
2065            ..Default::default()
2066        };
2067        assert_eq!(score_official_visibility(&signals, &mut missing), 8);
2068    }
2069
2070    #[test]
2071    fn official_visibility_gist_lowest() {
2072        let mut missing = BTreeSet::new();
2073        let signals = Signals {
2074            badlogic_gist: Some(true),
2075            ..Default::default()
2076        };
2077        assert_eq!(score_official_visibility(&signals, &mut missing), 6);
2078    }
2079
2080    // =========================================================================
2081    // score_marketplace_visibility
2082    // =========================================================================
2083
2084    #[test]
2085    fn marketplace_visibility_no_marketplace() {
2086        let mut missing = BTreeSet::new();
2087        assert_eq!(
2088            score_marketplace_visibility(&empty_signals(), &mut missing),
2089            0
2090        );
2091        assert!(missing.contains("signals.marketplace.rank"));
2092        assert!(missing.contains("signals.marketplace.featured"));
2093    }
2094
2095    #[test]
2096    fn marketplace_visibility_rank_tiers() {
2097        let cases = [
2098            (5, 6),
2099            (10, 6),
2100            (30, 4),
2101            (50, 4),
2102            (80, 2),
2103            (100, 2),
2104            (200, 0),
2105        ];
2106        for (rank, expected) in cases {
2107            let mut missing = BTreeSet::new();
2108            let signals = Signals {
2109                marketplace: Some(MarketplaceSignals {
2110                    rank: Some(rank),
2111                    ..Default::default()
2112                }),
2113                ..Default::default()
2114            };
2115            assert_eq!(
2116                score_marketplace_visibility(&signals, &mut missing),
2117                expected,
2118                "rank={rank}"
2119            );
2120        }
2121    }
2122
2123    #[test]
2124    fn marketplace_visibility_featured_adds_2_capped_at_6() {
2125        let mut missing = BTreeSet::new();
2126        let signals = Signals {
2127            marketplace: Some(MarketplaceSignals {
2128                rank: Some(5),
2129                featured: Some(true),
2130                ..Default::default()
2131            }),
2132            ..Default::default()
2133        };
2134        // rank=5 -> 6, featured -> +2, capped at 6
2135        assert_eq!(score_marketplace_visibility(&signals, &mut missing), 6);
2136    }
2137
2138    // =========================================================================
2139    // score_runtime_tier
2140    // =========================================================================
2141
2142    #[test]
2143    fn runtime_tier_values() {
2144        let cases = [
2145            (None, 0),
2146            (Some("pkg-with-deps"), 6),
2147            (Some("provider-ext"), 6),
2148            (Some("multi-file"), 4),
2149            (Some("legacy-js"), 2),
2150            (Some("unknown-tier"), 0),
2151        ];
2152        for (runtime, expected) in cases {
2153            let tags = Tags {
2154                runtime: runtime.map(String::from),
2155                ..Default::default()
2156            };
2157            assert_eq!(score_runtime_tier(&tags), expected, "runtime={runtime:?}");
2158        }
2159    }
2160
2161    // =========================================================================
2162    // score_interaction
2163    // =========================================================================
2164
2165    #[test]
2166    fn interaction_empty() {
2167        assert_eq!(score_interaction(&empty_tags()), 0);
2168    }
2169
2170    #[test]
2171    fn interaction_all_tags() {
2172        let tags = Tags {
2173            interaction: vec![
2174                "provider".to_string(),
2175                "ui_integration".to_string(),
2176                "event_hook".to_string(),
2177                "slash_command".to_string(),
2178                "tool_only".to_string(),
2179            ],
2180            ..Default::default()
2181        };
2182        // 3+2+2+1+1 = 9, capped at 8
2183        assert_eq!(score_interaction(&tags), 8);
2184    }
2185
2186    #[test]
2187    fn interaction_single_provider() {
2188        let tags = Tags {
2189            interaction: vec!["provider".to_string()],
2190            ..Default::default()
2191        };
2192        assert_eq!(score_interaction(&tags), 3);
2193    }
2194
2195    // =========================================================================
2196    // score_hostcalls
2197    // =========================================================================
2198
2199    #[test]
2200    fn hostcalls_empty() {
2201        assert_eq!(score_hostcalls(&empty_tags()), 0);
2202    }
2203
2204    #[test]
2205    fn hostcalls_all_capabilities() {
2206        let tags = Tags {
2207            capabilities: vec![
2208                "exec".to_string(),
2209                "http".to_string(),
2210                "read".to_string(),
2211                "ui".to_string(),
2212                "session".to_string(),
2213            ],
2214            ..Default::default()
2215        };
2216        // 2+2+1+1+1 = 7, capped at 6
2217        assert_eq!(score_hostcalls(&tags), 6);
2218    }
2219
2220    #[test]
2221    fn hostcalls_write_and_edit_count_as_one() {
2222        let tags = Tags {
2223            capabilities: vec!["write".to_string(), "edit".to_string()],
2224            ..Default::default()
2225        };
2226        // write matches the read|write|edit arm => 1, edit also matches => still 1
2227        assert_eq!(score_hostcalls(&tags), 1);
2228    }
2229
2230    // =========================================================================
2231    // score_activity
2232    // =========================================================================
2233
2234    #[test]
2235    fn activity_none_returns_zero() {
2236        let mut missing = BTreeSet::new();
2237        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2238        assert_eq!(score_activity(&Recency::default(), as_of, &mut missing), 0);
2239        assert!(missing.contains("recency.updated_at"));
2240    }
2241
2242    #[test]
2243    fn activity_recent_gets_max() {
2244        let mut missing = BTreeSet::new();
2245        let as_of = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
2246        let recency = Recency {
2247            updated_at: Some("2026-01-15T00:00:00Z".to_string()),
2248        };
2249        assert_eq!(score_activity(&recency, as_of, &mut missing), 14);
2250    }
2251
2252    #[test]
2253    fn activity_tiers() {
2254        let as_of = Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap();
2255        let cases = [
2256            ("2026-06-15T00:00:00Z", 14), // 16 days
2257            ("2026-04-15T00:00:00Z", 11), // ~77 days
2258            ("2026-02-01T00:00:00Z", 8),  // ~150 days
2259            ("2025-10-01T00:00:00Z", 5),  // ~273 days
2260            ("2025-01-01T00:00:00Z", 2),  // ~547 days
2261            ("2023-01-01T00:00:00Z", 0),  // >730 days
2262        ];
2263        for (date, expected) in cases {
2264            let mut missing = BTreeSet::new();
2265            let recency = Recency {
2266                updated_at: Some(date.to_string()),
2267            };
2268            assert_eq!(
2269                score_activity(&recency, as_of, &mut missing),
2270                expected,
2271                "date={date}"
2272            );
2273        }
2274    }
2275
2276    #[test]
2277    fn activity_invalid_date_returns_zero() {
2278        let mut missing = BTreeSet::new();
2279        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2280        let recency = Recency {
2281            updated_at: Some("not-a-date".to_string()),
2282        };
2283        assert_eq!(score_activity(&recency, as_of, &mut missing), 0);
2284        assert!(missing.contains("recency.updated_at"));
2285    }
2286
2287    // =========================================================================
2288    // score_compatibility
2289    // =========================================================================
2290
2291    #[test]
2292    fn compatibility_unmodified_is_max() {
2293        let compat = Compatibility {
2294            status: Some(CompatStatus::Unmodified),
2295            ..Default::default()
2296        };
2297        assert_eq!(score_compatibility(&compat), 20);
2298    }
2299
2300    #[test]
2301    fn compatibility_requires_shims() {
2302        let compat = Compatibility {
2303            status: Some(CompatStatus::RequiresShims),
2304            ..Default::default()
2305        };
2306        assert_eq!(score_compatibility(&compat), 15);
2307    }
2308
2309    #[test]
2310    fn compatibility_runtime_gap() {
2311        let compat = Compatibility {
2312            status: Some(CompatStatus::RuntimeGap),
2313            ..Default::default()
2314        };
2315        assert_eq!(score_compatibility(&compat), 10);
2316    }
2317
2318    #[test]
2319    fn compatibility_blocked_is_zero() {
2320        let compat = Compatibility {
2321            status: Some(CompatStatus::Blocked),
2322            ..Default::default()
2323        };
2324        assert_eq!(score_compatibility(&compat), 0);
2325    }
2326
2327    #[test]
2328    fn compatibility_adjustment_positive() {
2329        let compat = Compatibility {
2330            status: Some(CompatStatus::RequiresShims),
2331            adjustment: Some(3),
2332            ..Default::default()
2333        };
2334        assert_eq!(score_compatibility(&compat), 18);
2335    }
2336
2337    #[test]
2338    fn compatibility_adjustment_negative_clamped() {
2339        let compat = Compatibility {
2340            status: Some(CompatStatus::RuntimeGap),
2341            adjustment: Some(-15),
2342            ..Default::default()
2343        };
2344        // 10 + (-15) = -5, clamped to 0
2345        assert_eq!(score_compatibility(&compat), 0);
2346    }
2347
2348    #[test]
2349    fn compatibility_adjustment_capped_at_20() {
2350        let compat = Compatibility {
2351            status: Some(CompatStatus::Unmodified),
2352            adjustment: Some(10),
2353            ..Default::default()
2354        };
2355        // 20 + 10 = 30, capped at 20
2356        assert_eq!(score_compatibility(&compat), 20);
2357    }
2358
2359    // =========================================================================
2360    // score_risk
2361    // =========================================================================
2362
2363    #[test]
2364    fn risk_none_is_zero() {
2365        assert_eq!(score_risk(&RiskInfo::default()), 0);
2366    }
2367
2368    #[test]
2369    fn risk_penalty_override() {
2370        let risk = RiskInfo {
2371            penalty: Some(7),
2372            level: Some(RiskLevel::Critical),
2373            ..Default::default()
2374        };
2375        // Explicit penalty overrides level
2376        assert_eq!(score_risk(&risk), 7);
2377    }
2378
2379    #[test]
2380    fn risk_penalty_capped_at_15() {
2381        let risk = RiskInfo {
2382            penalty: Some(50),
2383            ..Default::default()
2384        };
2385        assert_eq!(score_risk(&risk), 15);
2386    }
2387
2388    #[test]
2389    fn risk_level_tiers() {
2390        let cases = [
2391            (RiskLevel::Low, 0),
2392            (RiskLevel::Moderate, 5),
2393            (RiskLevel::High, 10),
2394            (RiskLevel::Critical, 15),
2395        ];
2396        for (level, expected) in cases {
2397            let risk = RiskInfo {
2398                level: Some(level),
2399                ..Default::default()
2400            };
2401            assert_eq!(score_risk(&risk), expected, "level={level:?}");
2402        }
2403    }
2404
2405    // =========================================================================
2406    // compute_gates
2407    // =========================================================================
2408
2409    #[test]
2410    fn gates_all_false_by_default() {
2411        let candidate = minimal_candidate("test");
2412        let gates = compute_gates(&candidate);
2413        assert!(!gates.provenance_pinned);
2414        assert!(!gates.license_ok);
2415        assert!(!gates.deterministic);
2416        assert!(!gates.unmodified); // Unknown status -> not unmodified
2417        assert!(!gates.passes);
2418    }
2419
2420    #[test]
2421    fn gates_all_pass() {
2422        let mut candidate = minimal_candidate("test");
2423        candidate.gates.provenance_pinned = Some(true);
2424        candidate.gates.deterministic = Some(true);
2425        candidate.license.redistribution = Some(Redistribution::Ok);
2426        candidate.compat.status = Some(CompatStatus::Unmodified);
2427        let gates = compute_gates(&candidate);
2428        assert!(gates.passes);
2429    }
2430
2431    #[test]
2432    fn gates_license_restricted_counts_as_ok() {
2433        let mut candidate = minimal_candidate("test");
2434        candidate.gates.provenance_pinned = Some(true);
2435        candidate.gates.deterministic = Some(true);
2436        candidate.license.redistribution = Some(Redistribution::Restricted);
2437        candidate.compat.status = Some(CompatStatus::RequiresShims);
2438        let gates = compute_gates(&candidate);
2439        assert!(gates.license_ok);
2440        assert!(gates.passes);
2441    }
2442
2443    #[test]
2444    fn gates_license_exclude_fails() {
2445        let mut candidate = minimal_candidate("test");
2446        candidate.license.redistribution = Some(Redistribution::Exclude);
2447        let gates = compute_gates(&candidate);
2448        assert!(!gates.license_ok);
2449    }
2450
2451    #[test]
2452    fn gates_unmodified_includes_requires_shims_and_runtime_gap() {
2453        for status in [
2454            CompatStatus::Unmodified,
2455            CompatStatus::RequiresShims,
2456            CompatStatus::RuntimeGap,
2457        ] {
2458            let mut candidate = minimal_candidate("test");
2459            candidate.compat.status = Some(status);
2460            let gates = compute_gates(&candidate);
2461            assert!(gates.unmodified, "status={status:?} should be unmodified");
2462        }
2463    }
2464
2465    // =========================================================================
2466    // compute_tier
2467    // =========================================================================
2468
2469    #[test]
2470    fn tier_official_is_tier_0() {
2471        let mut candidate = minimal_candidate("test");
2472        candidate.signals.pi_mono_example = Some(true);
2473        let gates = compute_gates(&candidate);
2474        assert_eq!(compute_tier(&candidate, &gates, 0), "tier-0");
2475    }
2476
2477    #[test]
2478    fn tier_official_source_tier_is_tier_0() {
2479        let mut candidate = minimal_candidate("test");
2480        candidate.source_tier = Some("official-pi-mono".to_string());
2481        let gates = compute_gates(&candidate);
2482        assert_eq!(compute_tier(&candidate, &gates, 0), "tier-0");
2483    }
2484
2485    #[test]
2486    fn tier_manual_override_on_official() {
2487        let mut candidate = minimal_candidate("test");
2488        candidate.signals.pi_mono_example = Some(true);
2489        candidate.manual_override = Some(ManualOverride {
2490            reason: "special".to_string(),
2491            tier: Some("tier-1".to_string()),
2492        });
2493        let gates = compute_gates(&candidate);
2494        assert_eq!(compute_tier(&candidate, &gates, 0), "tier-1");
2495    }
2496
2497    #[test]
2498    fn tier_excluded_when_gates_fail() {
2499        let candidate = minimal_candidate("test");
2500        let gates = compute_gates(&candidate); // gates fail
2501        assert_eq!(compute_tier(&candidate, &gates, 100), "excluded");
2502    }
2503
2504    #[test]
2505    fn tier_1_at_70_plus() {
2506        let mut candidate = minimal_candidate("test");
2507        candidate.gates.provenance_pinned = Some(true);
2508        candidate.gates.deterministic = Some(true);
2509        candidate.license.redistribution = Some(Redistribution::Ok);
2510        candidate.compat.status = Some(CompatStatus::Unmodified);
2511        let gates = compute_gates(&candidate);
2512        assert_eq!(compute_tier(&candidate, &gates, 70), "tier-1");
2513        assert_eq!(compute_tier(&candidate, &gates, 100), "tier-1");
2514    }
2515
2516    #[test]
2517    fn tier_2_at_50_to_69() {
2518        let mut candidate = minimal_candidate("test");
2519        candidate.gates.provenance_pinned = Some(true);
2520        candidate.gates.deterministic = Some(true);
2521        candidate.license.redistribution = Some(Redistribution::Ok);
2522        candidate.compat.status = Some(CompatStatus::Unmodified);
2523        let gates = compute_gates(&candidate);
2524        assert_eq!(compute_tier(&candidate, &gates, 50), "tier-2");
2525        assert_eq!(compute_tier(&candidate, &gates, 69), "tier-2");
2526    }
2527
2528    #[test]
2529    fn tier_excluded_below_50() {
2530        let mut candidate = minimal_candidate("test");
2531        candidate.gates.provenance_pinned = Some(true);
2532        candidate.gates.deterministic = Some(true);
2533        candidate.license.redistribution = Some(Redistribution::Ok);
2534        candidate.compat.status = Some(CompatStatus::Unmodified);
2535        let gates = compute_gates(&candidate);
2536        assert_eq!(compute_tier(&candidate, &gates, 49), "excluded");
2537    }
2538
2539    // =========================================================================
2540    // compare_scored
2541    // =========================================================================
2542
2543    #[test]
2544    fn compare_scored_by_final_total() {
2545        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2546        let mut a = score_candidate(&minimal_candidate("a"), as_of);
2547        let mut b = score_candidate(&minimal_candidate("b"), as_of);
2548        a.score.final_total = 80;
2549        b.score.final_total = 60;
2550        assert_eq!(compare_scored(&a, &b), std::cmp::Ordering::Greater);
2551    }
2552
2553    #[test]
2554    fn compare_scored_tiebreak_by_id() {
2555        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2556        let a = score_candidate(&minimal_candidate("alpha"), as_of);
2557        let b = score_candidate(&minimal_candidate("beta"), as_of);
2558        // Same scores, tiebreak by id
2559        assert_eq!(
2560            compare_scored(&a, &b),
2561            std::cmp::Ordering::Less // "alpha" < "beta"
2562        );
2563    }
2564
2565    // =========================================================================
2566    // build_histogram
2567    // =========================================================================
2568
2569    #[test]
2570    fn histogram_empty() {
2571        let histogram = build_histogram(&[]);
2572        assert_eq!(histogram.len(), 11);
2573        for bucket in &histogram {
2574            assert_eq!(bucket.count, 0);
2575        }
2576    }
2577
2578    #[test]
2579    fn histogram_ranges_correct() {
2580        let histogram = build_histogram(&[]);
2581        assert_eq!(histogram[0].range, "0-9");
2582        assert_eq!(histogram[5].range, "50-59");
2583        assert_eq!(histogram[10].range, "100-100");
2584    }
2585
2586    #[test]
2587    fn histogram_counts_correctly() {
2588        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2589        let mut items: Vec<ScoredCandidate> = (0..3)
2590            .map(|i| score_candidate(&minimal_candidate(&format!("c{i}")), as_of))
2591            .collect();
2592        items[0].score.final_total = 15; // bucket 1 (10-19)
2593        items[1].score.final_total = 15; // bucket 1 (10-19)
2594        items[2].score.final_total = 75; // bucket 7 (70-79)
2595        let histogram = build_histogram(&items);
2596        assert_eq!(histogram[1].count, 2);
2597        assert_eq!(histogram[7].count, 1);
2598    }
2599
2600    // =========================================================================
2601    // score_popularity (composite)
2602    // =========================================================================
2603
2604    #[test]
2605    fn popularity_capped_at_30() {
2606        let mut missing = BTreeSet::new();
2607        let signals = Signals {
2608            official_listing: Some(true), // 10
2609            github_stars: Some(10_000),   // 10
2610            marketplace: Some(MarketplaceSignals {
2611                rank: Some(1),        // 6
2612                featured: Some(true), // +2 (capped at 6)
2613                ..Default::default()
2614            }),
2615            references: (0..20).map(|i| format!("ref-{i}")).collect(), // 4
2616            ..Default::default()
2617        };
2618        let (total, _) = score_popularity(&signals, &mut missing);
2619        assert_eq!(total, 30); // capped
2620    }
2621
2622    // =========================================================================
2623    // score_adoption (composite)
2624    // =========================================================================
2625
2626    #[test]
2627    fn adoption_capped_at_15() {
2628        let mut missing = BTreeSet::new();
2629        let signals = Signals {
2630            npm_downloads_month: Some(100_000), // 8
2631            github_forks: Some(1_000),          // 2
2632            marketplace: Some(MarketplaceSignals {
2633                installs_month: Some(50_000), // 5
2634                ..Default::default()
2635            }),
2636            ..Default::default()
2637        };
2638        let (total, _) = score_adoption(&signals, &mut missing);
2639        assert_eq!(total, 15); // capped
2640    }
2641
2642    // =========================================================================
2643    // score_coverage (composite)
2644    // =========================================================================
2645
2646    #[test]
2647    fn coverage_capped_at_20() {
2648        let tags = Tags {
2649            runtime: Some("pkg-with-deps".to_string()), // 6
2650            interaction: vec![
2651                "provider".to_string(),
2652                "ui_integration".to_string(),
2653                "event_hook".to_string(),
2654                "slash_command".to_string(),
2655                "tool_only".to_string(),
2656            ], // 8
2657            capabilities: vec![
2658                "exec".to_string(),
2659                "http".to_string(),
2660                "read".to_string(),
2661                "ui".to_string(),
2662                "session".to_string(),
2663            ], // 6
2664        };
2665        let (total, _) = score_coverage(&tags);
2666        assert_eq!(total, 20); // capped
2667    }
2668
2669    // =========================================================================
2670    // score_candidates (integration)
2671    // =========================================================================
2672
2673    #[test]
2674    fn score_candidates_ranks_correctly() {
2675        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2676        let generated_at = as_of;
2677        let mut high = minimal_candidate("high");
2678        high.signals.github_stars = Some(10_000);
2679        high.signals.pi_mono_example = Some(true);
2680        high.compat.status = Some(CompatStatus::Unmodified);
2681        high.recency.updated_at = Some("2025-12-15T00:00:00Z".to_string());
2682
2683        let low = minimal_candidate("low");
2684
2685        let report = score_candidates(&[high, low], as_of, generated_at, 5);
2686        assert_eq!(report.schema, "pi.ext.scoring.v1");
2687        assert_eq!(report.items.len(), 2);
2688        assert_eq!(report.items[0].rank, 1);
2689        assert_eq!(report.items[1].rank, 2);
2690        assert!(report.items[0].score.final_total >= report.items[1].score.final_total);
2691    }
2692
2693    #[test]
2694    fn score_candidates_empty_input() {
2695        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2696        let report = score_candidates(&[], as_of, as_of, 5);
2697        assert!(report.items.is_empty());
2698        assert!(report.summary.top_overall.is_empty());
2699    }
2700
2701    // =========================================================================
2702    // Serde round-trips
2703    // =========================================================================
2704
2705    #[test]
2706    fn compat_status_serde_roundtrip() {
2707        for status in [
2708            CompatStatus::Unmodified,
2709            CompatStatus::RequiresShims,
2710            CompatStatus::RuntimeGap,
2711            CompatStatus::Blocked,
2712            CompatStatus::Unknown,
2713        ] {
2714            let json = serde_json::to_string(&status).unwrap();
2715            let back: CompatStatus = serde_json::from_str(&json).unwrap();
2716            assert_eq!(back, status);
2717        }
2718    }
2719
2720    #[test]
2721    fn redistribution_serde_roundtrip() {
2722        for red in [
2723            Redistribution::Ok,
2724            Redistribution::Restricted,
2725            Redistribution::Exclude,
2726            Redistribution::Unknown,
2727        ] {
2728            let json = serde_json::to_string(&red).unwrap();
2729            let back: Redistribution = serde_json::from_str(&json).unwrap();
2730            assert_eq!(back, red);
2731        }
2732    }
2733
2734    #[test]
2735    fn risk_level_serde_roundtrip() {
2736        for level in [
2737            RiskLevel::Low,
2738            RiskLevel::Moderate,
2739            RiskLevel::High,
2740            RiskLevel::Critical,
2741        ] {
2742            let json = serde_json::to_string(&level).unwrap();
2743            let back: RiskLevel = serde_json::from_str(&json).unwrap();
2744            assert_eq!(back, level);
2745        }
2746    }
2747
2748    #[test]
2749    fn scoring_report_serde_roundtrip() {
2750        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2751        let mut candidate = minimal_candidate("test-ext");
2752        candidate.signals.github_stars = Some(500);
2753        candidate.compat.status = Some(CompatStatus::Unmodified);
2754        let report = score_candidates(&[candidate], as_of, as_of, 5);
2755        let json = serde_json::to_string(&report).unwrap();
2756        let back: ScoringReport = serde_json::from_str(&json).unwrap();
2757        assert_eq!(back.items.len(), 1);
2758        assert_eq!(back.items[0].id, "test-ext");
2759    }
2760
2761    // =========================================================================
2762    // Missing signals tracking
2763    // =========================================================================
2764
2765    #[test]
2766    fn missing_signals_collected_for_empty_candidate() {
2767        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
2768        let candidate = minimal_candidate("bare");
2769        let scored = score_candidate(&candidate, as_of);
2770        assert!(!scored.missing_signals.is_empty());
2771        assert!(
2772            scored
2773                .missing_signals
2774                .contains(&"signals.github_stars".to_string())
2775        );
2776        assert!(
2777            scored
2778                .missing_signals
2779                .contains(&"signals.github_forks".to_string())
2780        );
2781        assert!(
2782            scored
2783                .missing_signals
2784                .contains(&"recency.updated_at".to_string())
2785        );
2786    }
2787
2788    // =========================================================================
2789    // VOI planner
2790    // =========================================================================
2791
2792    fn voi_candidate(
2793        id: &str,
2794        utility_score: f64,
2795        estimated_overhead_ms: u32,
2796        last_seen_at: Option<&str>,
2797    ) -> VoiCandidate {
2798        VoiCandidate {
2799            id: id.to_string(),
2800            utility_score,
2801            estimated_overhead_ms,
2802            last_seen_at: last_seen_at.map(std::string::ToString::to_string),
2803            enabled: true,
2804        }
2805    }
2806
2807    #[test]
2808    fn voi_planner_budget_feasible_selection_and_skip_reason() {
2809        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2810        let config = VoiPlannerConfig {
2811            enabled: true,
2812            overhead_budget_ms: 9,
2813            max_candidates: None,
2814            stale_after_minutes: Some(180),
2815            min_utility_score: Some(0.0),
2816        };
2817        let candidates = vec![
2818            voi_candidate("fast-high", 9.0, 3, Some("2026-01-09T23:00:00Z")),
2819            voi_candidate("expensive", 12.0, 7, Some("2026-01-09T23:00:00Z")),
2820            voi_candidate("small", 4.0, 2, Some("2026-01-09T23:00:00Z")),
2821        ];
2822
2823        let plan = plan_voi_candidates(&candidates, now, &config);
2824        assert_eq!(
2825            plan.selected
2826                .iter()
2827                .map(|entry| entry.id.as_str())
2828                .collect::<Vec<_>>(),
2829            vec!["fast-high", "small"]
2830        );
2831        assert_eq!(plan.used_overhead_ms, 5);
2832        assert_eq!(plan.remaining_overhead_ms, 4);
2833        assert_eq!(plan.skipped.len(), 1);
2834        assert_eq!(plan.skipped[0].id, "expensive");
2835        assert_eq!(plan.skipped[0].reason, VoiSkipReason::BudgetExceeded);
2836    }
2837
2838    #[test]
2839    fn voi_planner_is_deterministic_across_input_order() {
2840        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2841        let config = VoiPlannerConfig {
2842            enabled: true,
2843            overhead_budget_ms: 20,
2844            max_candidates: Some(3),
2845            stale_after_minutes: Some(180),
2846            min_utility_score: Some(0.0),
2847        };
2848        let a = voi_candidate("a", 8.0, 2, Some("2026-01-09T23:00:00Z"));
2849        let b = voi_candidate("b", 8.0, 2, Some("2026-01-09T23:00:00Z"));
2850        let c = voi_candidate("c", 8.0, 2, Some("2026-01-09T23:00:00Z"));
2851
2852        let plan_1 = plan_voi_candidates(&[a.clone(), b.clone(), c.clone()], now, &config);
2853        let plan_2 = plan_voi_candidates(&[c, a, b], now, &config);
2854
2855        let ids_1 = plan_1
2856            .selected
2857            .iter()
2858            .map(|entry| entry.id.clone())
2859            .collect::<Vec<_>>();
2860        let ids_2 = plan_2
2861            .selected
2862            .iter()
2863            .map(|entry| entry.id.clone())
2864            .collect::<Vec<_>>();
2865        assert_eq!(ids_1, ids_2);
2866    }
2867
2868    #[test]
2869    fn voi_planner_rejects_stale_and_below_floor_candidates() {
2870        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2871        let config = VoiPlannerConfig {
2872            enabled: true,
2873            overhead_budget_ms: 20,
2874            max_candidates: None,
2875            stale_after_minutes: Some(60),
2876            min_utility_score: Some(5.0),
2877        };
2878        let candidates = vec![
2879            voi_candidate("fresh-good", 6.0, 2, Some("2026-01-09T23:30:00Z")),
2880            voi_candidate("stale", 7.0, 2, Some("2026-01-08T00:00:00Z")),
2881            voi_candidate("low-utility", 2.0, 1, Some("2026-01-09T23:30:00Z")),
2882            voi_candidate("missing-telemetry", 7.0, 1, None),
2883        ];
2884
2885        let plan = plan_voi_candidates(&candidates, now, &config);
2886        assert_eq!(plan.selected.len(), 1);
2887        assert_eq!(plan.selected[0].id, "fresh-good");
2888        assert_eq!(plan.skipped.len(), 3);
2889        assert!(
2890            plan.skipped
2891                .iter()
2892                .any(|entry| entry.id == "stale" && entry.reason == VoiSkipReason::StaleEvidence)
2893        );
2894        assert!(plan.skipped.iter().any(|entry| {
2895            entry.id == "low-utility" && entry.reason == VoiSkipReason::BelowUtilityFloor
2896        }));
2897        assert!(plan.skipped.iter().any(|entry| {
2898            entry.id == "missing-telemetry" && entry.reason == VoiSkipReason::MissingTelemetry
2899        }));
2900    }
2901
2902    #[test]
2903    fn voi_planner_disabled_returns_no_selection() {
2904        let now = Utc.with_ymd_and_hms(2026, 1, 10, 0, 0, 0).unwrap();
2905        let config = VoiPlannerConfig {
2906            enabled: false,
2907            overhead_budget_ms: 20,
2908            max_candidates: None,
2909            stale_after_minutes: Some(120),
2910            min_utility_score: Some(0.0),
2911        };
2912        let candidates = vec![voi_candidate("a", 8.0, 2, Some("2026-01-09T23:00:00Z"))];
2913        let plan = plan_voi_candidates(&candidates, now, &config);
2914        assert!(plan.selected.is_empty());
2915        assert_eq!(plan.used_overhead_ms, 0);
2916        assert_eq!(plan.remaining_overhead_ms, 20);
2917        assert_eq!(plan.skipped.len(), 1);
2918        assert_eq!(plan.skipped[0].reason, VoiSkipReason::Disabled);
2919    }
2920
2921    fn ope_sample(
2922        action: &str,
2923        behavior_propensity: f64,
2924        target_propensity: f64,
2925        outcome: f64,
2926    ) -> OpeTraceSample {
2927        OpeTraceSample {
2928            action: action.to_string(),
2929            behavior_propensity,
2930            target_propensity,
2931            outcome,
2932            baseline_outcome: Some(outcome),
2933            direct_method_prediction: Some(outcome),
2934            context_lineage: Some(format!("ctx:{action}")),
2935        }
2936    }
2937
2938    fn assert_close(left: f64, right: f64, epsilon: f64) {
2939        assert!(
2940            (left - right).abs() <= epsilon,
2941            "values differ: left={left}, right={right}, epsilon={epsilon}"
2942        );
2943    }
2944
2945    #[test]
2946    fn ope_matches_ground_truth_when_behavior_equals_target() {
2947        let config = OpeEvaluatorConfig {
2948            max_importance_weight: 50.0,
2949            min_effective_sample_size: 1.0,
2950            max_standard_error: 10.0,
2951            confidence_z: 1.96,
2952            max_regret_delta: 1.0,
2953        };
2954        let samples = vec![
2955            ope_sample("a", 0.5, 0.5, 1.0),
2956            ope_sample("a", 0.5, 0.5, 0.0),
2957            ope_sample("a", 0.5, 0.5, 1.0),
2958            ope_sample("a", 0.5, 0.5, 1.0),
2959            ope_sample("a", 0.5, 0.5, 0.0),
2960            ope_sample("a", 0.5, 0.5, 1.0),
2961        ];
2962
2963        let report = evaluate_off_policy(&samples, &config);
2964        let expected_mean = 4.0 / 6.0;
2965        assert_close(report.ips.estimate, expected_mean, 1e-9);
2966        assert_close(report.wis.estimate, expected_mean, 1e-9);
2967        assert_close(report.doubly_robust.estimate, expected_mean, 1e-9);
2968        assert_eq!(report.gate.reason, OpeGateReason::Approved);
2969        assert!(report.gate.passed);
2970    }
2971
2972    #[test]
2973    fn ope_fails_closed_under_extreme_propensity_skew() {
2974        let config = OpeEvaluatorConfig {
2975            max_importance_weight: 100.0,
2976            min_effective_sample_size: 4.0,
2977            max_standard_error: 10.0,
2978            confidence_z: 1.96,
2979            max_regret_delta: 10.0,
2980        };
2981        let mut samples = vec![ope_sample("candidate", 0.02, 1.0, 0.0)];
2982        for _ in 0..9 {
2983            samples.push(ope_sample("candidate", 1.0, 0.02, 1.0));
2984        }
2985
2986        let report = evaluate_off_policy(&samples, &config);
2987        assert!(report.diagnostics.effective_sample_size < 2.0);
2988        assert_eq!(report.gate.reason, OpeGateReason::InsufficientSupport);
2989        assert!(!report.gate.passed);
2990    }
2991
2992    #[test]
2993    fn ope_fails_closed_when_no_valid_samples_exist() {
2994        let config = OpeEvaluatorConfig::default();
2995        let samples = vec![
2996            ope_sample("invalid", 0.0, 0.5, 1.0),
2997            ope_sample("invalid", -1.0, 0.5, 1.0),
2998        ];
2999
3000        let report = evaluate_off_policy(&samples, &config);
3001        assert_eq!(report.diagnostics.valid_samples, 0);
3002        assert_eq!(report.gate.reason, OpeGateReason::NoValidSamples);
3003        assert!(!report.gate.passed);
3004    }
3005
3006    #[test]
3007    fn ope_is_stable_across_input_order() {
3008        let config = OpeEvaluatorConfig {
3009            max_importance_weight: 50.0,
3010            min_effective_sample_size: 1.0,
3011            max_standard_error: 10.0,
3012            confidence_z: 1.96,
3013            max_regret_delta: 10.0,
3014        };
3015        let samples = vec![
3016            ope_sample("a", 0.40, 0.30, 0.2),
3017            ope_sample("a", 0.50, 0.60, 0.8),
3018            ope_sample("a", 0.70, 0.20, 0.1),
3019            ope_sample("a", 0.30, 0.50, 0.7),
3020        ];
3021        let mut reversed = samples.clone();
3022        reversed.reverse();
3023
3024        let original = evaluate_off_policy(&samples, &config);
3025        let swapped = evaluate_off_policy(&reversed, &config);
3026        assert_close(original.ips.estimate, swapped.ips.estimate, 1e-12);
3027        assert_close(original.wis.estimate, swapped.wis.estimate, 1e-12);
3028        assert_close(
3029            original.doubly_robust.estimate,
3030            swapped.doubly_robust.estimate,
3031            1e-12,
3032        );
3033        assert_eq!(original.gate.reason, swapped.gate.reason);
3034        assert_close(
3035            original.diagnostics.effective_sample_size,
3036            swapped.diagnostics.effective_sample_size,
3037            1e-12,
3038        );
3039    }
3040
3041    fn mean_field_observation(
3042        shard_id: &str,
3043        queue_pressure: f64,
3044        tail_latency_ratio: f64,
3045        starvation_risk: f64,
3046    ) -> MeanFieldShardObservation {
3047        MeanFieldShardObservation {
3048            shard_id: shard_id.to_string(),
3049            queue_pressure,
3050            tail_latency_ratio,
3051            starvation_risk,
3052        }
3053    }
3054
3055    fn mean_field_state(
3056        shard_id: &str,
3057        routing_weight: f64,
3058        batch_budget: u32,
3059        last_routing_delta: f64,
3060    ) -> MeanFieldShardState {
3061        MeanFieldShardState {
3062            shard_id: shard_id.to_string(),
3063            routing_weight,
3064            batch_budget,
3065            help_factor: 1.0,
3066            backoff_factor: 1.0,
3067            last_routing_delta,
3068        }
3069    }
3070
3071    #[test]
3072    fn mean_field_controls_are_deterministic_across_input_order() {
3073        let config = MeanFieldControllerConfig::default();
3074        let observations = vec![
3075            mean_field_observation("shard-b", 0.7, 1.3, 0.1),
3076            mean_field_observation("shard-a", 0.3, 1.1, 0.2),
3077            mean_field_observation("shard-c", 0.5, 1.0, 0.4),
3078        ];
3079        let previous = vec![
3080            mean_field_state("shard-c", 1.2, 24, 0.0),
3081            mean_field_state("shard-a", 0.9, 18, 0.0),
3082            mean_field_state("shard-b", 1.1, 20, 0.0),
3083        ];
3084
3085        let baseline = compute_mean_field_controls(&observations, &previous, &config);
3086        let reversed_observations = observations.iter().rev().cloned().collect::<Vec<_>>();
3087        let reversed_previous = previous.iter().rev().cloned().collect::<Vec<_>>();
3088        let reversed =
3089            compute_mean_field_controls(&reversed_observations, &reversed_previous, &config);
3090
3091        assert_eq!(
3092            baseline
3093                .controls
3094                .iter()
3095                .map(|control| control.shard_id.as_str())
3096                .collect::<Vec<_>>(),
3097            vec!["shard-a", "shard-b", "shard-c"]
3098        );
3099        assert_eq!(
3100            baseline
3101                .controls
3102                .iter()
3103                .map(|control| control.shard_id.clone())
3104                .collect::<Vec<_>>(),
3105            reversed
3106                .controls
3107                .iter()
3108                .map(|control| control.shard_id.clone())
3109                .collect::<Vec<_>>()
3110        );
3111        assert_close(baseline.global_pressure, reversed.global_pressure, 1e-12);
3112    }
3113
3114    #[test]
3115    fn mean_field_clips_unstable_steps_to_max_step() {
3116        let config = MeanFieldControllerConfig {
3117            max_step: 0.05,
3118            ..MeanFieldControllerConfig::default()
3119        };
3120        let observations = vec![mean_field_observation("shard-a", 1.0, 2.0, 0.0)];
3121        let previous = vec![mean_field_state("shard-a", 1.0, 32, 0.0)];
3122
3123        let report = compute_mean_field_controls(&observations, &previous, &config);
3124        assert_eq!(report.controls.len(), 1);
3125        let control = &report.controls[0];
3126        assert!(control.clipped);
3127        assert!(control.routing_delta.abs() <= config.max_step + 1e-12);
3128        assert!(control.stability_margin <= config.max_step + 1e-12);
3129    }
3130
3131    #[test]
3132    fn mean_field_oscillation_guard_reduces_sign_flip_delta() {
3133        let config = MeanFieldControllerConfig {
3134            max_step: 0.30,
3135            ..MeanFieldControllerConfig::default()
3136        };
3137        let observations = vec![mean_field_observation("shard-a", 1.0, 1.7, 0.0)];
3138        let previous = vec![mean_field_state("shard-a", 1.2, 20, 0.12)];
3139
3140        let report = compute_mean_field_controls(&observations, &previous, &config);
3141        let control = &report.controls[0];
3142        assert!(control.oscillation_guarded);
3143        assert!(report.oscillation_guard_count >= 1);
3144    }
3145
3146    #[test]
3147    fn mean_field_marks_converged_for_small_average_delta() {
3148        let config = MeanFieldControllerConfig {
3149            queue_gain: 0.0,
3150            latency_gain: 0.0,
3151            starvation_gain: 0.0,
3152            damping: 1.0,
3153            convergence_epsilon: 0.05,
3154            ..MeanFieldControllerConfig::default()
3155        };
3156        let observations = vec![
3157            mean_field_observation("shard-a", 0.40, 1.0, 0.1),
3158            mean_field_observation("shard-b", 0.42, 1.0, 0.1),
3159        ];
3160        let previous = vec![
3161            mean_field_state("shard-a", 1.0, 24, 0.0),
3162            mean_field_state("shard-b", 1.0, 24, 0.0),
3163        ];
3164
3165        let report = compute_mean_field_controls(&observations, &previous, &config);
3166        assert!(report.converged);
3167    }
3168
3169    // ── Property tests ──
3170
3171    mod proptest_scoring {
3172        use super::*;
3173        use proptest::prelude::*;
3174
3175        fn arb_signals() -> impl Strategy<Value = Signals> {
3176            (
3177                any::<Option<bool>>(),
3178                any::<Option<bool>>(),
3179                any::<Option<bool>>(),
3180                prop::option::of(0..100_000u64),
3181                prop::option::of(0..10_000u64),
3182                prop::option::of(0..1_000_000u64),
3183            )
3184                .prop_map(|(listing, example, gist, stars, forks, npm)| Signals {
3185                    official_listing: listing,
3186                    pi_mono_example: example,
3187                    badlogic_gist: gist,
3188                    github_stars: stars,
3189                    github_forks: forks,
3190                    npm_downloads_month: npm,
3191                    references: Vec::new(),
3192                    marketplace: None,
3193                })
3194        }
3195
3196        fn arb_tags() -> impl Strategy<Value = Tags> {
3197            let runtime = prop::option::of(prop::sample::select(vec![
3198                "pkg-with-deps".to_string(),
3199                "provider-ext".to_string(),
3200                "multi-file".to_string(),
3201                "legacy-js".to_string(),
3202            ]));
3203            runtime.prop_map(|rt| Tags {
3204                runtime: rt,
3205                interaction: Vec::new(),
3206                capabilities: Vec::new(),
3207            })
3208        }
3209
3210        fn arb_compat_status() -> impl Strategy<Value = CompatStatus> {
3211            prop::sample::select(vec![
3212                CompatStatus::Unmodified,
3213                CompatStatus::RequiresShims,
3214                CompatStatus::RuntimeGap,
3215                CompatStatus::Blocked,
3216                CompatStatus::Unknown,
3217            ])
3218        }
3219
3220        fn arb_risk_level() -> impl Strategy<Value = RiskLevel> {
3221            prop::sample::select(vec![
3222                RiskLevel::Low,
3223                RiskLevel::Moderate,
3224                RiskLevel::High,
3225                RiskLevel::Critical,
3226            ])
3227        }
3228
3229        proptest! {
3230            #[test]
3231            fn score_github_stars_bounded(stars in 0..10_000_000u64) {
3232                let signals = Signals {
3233                    github_stars: Some(stars),
3234                    ..Signals::default()
3235                };
3236                let mut missing = BTreeSet::new();
3237                let score = score_github_stars(&signals, &mut missing);
3238                assert!(score <= 10, "github_stars score {score} exceeds max 10");
3239            }
3240
3241            #[test]
3242            fn score_github_stars_monotonic(a in 0..100_000u64, b in 0..100_000u64) {
3243                let mut missing = BTreeSet::new();
3244                let sig_a = Signals { github_stars: Some(a), ..Signals::default() };
3245                let sig_b = Signals { github_stars: Some(b), ..Signals::default() };
3246                let score_a = score_github_stars(&sig_a, &mut missing);
3247                let score_b = score_github_stars(&sig_b, &mut missing);
3248                if a <= b {
3249                    assert!(
3250                        score_a <= score_b,
3251                        "monotonicity: stars {a} → {score_a}, {b} → {score_b}"
3252                    );
3253                }
3254            }
3255
3256            #[test]
3257            fn score_npm_downloads_bounded(downloads in 0..10_000_000u64) {
3258                let signals = Signals {
3259                    npm_downloads_month: Some(downloads),
3260                    ..Signals::default()
3261                };
3262                let mut missing = BTreeSet::new();
3263                let score = score_npm_downloads(&signals, &mut missing);
3264                assert!(score <= 8, "npm_downloads score {score} exceeds max 8");
3265            }
3266
3267            #[test]
3268            fn score_compatibility_bounded(
3269                status in arb_compat_status(),
3270                adjustment in -20..20i8,
3271            ) {
3272                let compat = Compatibility {
3273                    status: Some(status),
3274                    blocked_reasons: Vec::new(),
3275                    required_shims: Vec::new(),
3276                    adjustment: Some(adjustment),
3277                };
3278                let score = score_compatibility(&compat);
3279                assert!(score <= 20, "compatibility score {score} exceeds max 20");
3280            }
3281
3282            #[test]
3283            fn score_risk_bounded(
3284                level in prop::option::of(arb_risk_level()),
3285                penalty in prop::option::of(0..100u8),
3286            ) {
3287                let risk = RiskInfo { level, penalty, flags: Vec::new() };
3288                let score = score_risk(&risk);
3289                assert!(score <= 15, "risk penalty {score} exceeds max 15");
3290            }
3291
3292            #[test]
3293            fn normalized_utility_nonnegative(value in prop::num::f64::ANY) {
3294                let result = normalized_utility(value);
3295                assert!(
3296                    result >= 0.0,
3297                    "normalized_utility({value}) = {result} must be >= 0.0"
3298                );
3299            }
3300
3301            #[test]
3302            fn normalized_utility_handles_special_floats(
3303                value in prop::sample::select(vec![
3304                    f64::NAN, f64::INFINITY, f64::NEG_INFINITY,
3305                    0.0, -0.0, f64::MIN, f64::MAX, f64::MIN_POSITIVE,
3306                ]),
3307            ) {
3308                let result = normalized_utility(value);
3309                assert!(
3310                    result.is_finite(),
3311                    "normalized_utility({value}) = {result} must be finite"
3312                );
3313                assert!(result >= 0.0, "must be non-negative");
3314            }
3315
3316            #[test]
3317            fn normalized_utility_idempotent(value in prop::num::f64::ANY) {
3318                let once = normalized_utility(value);
3319                let twice = normalized_utility(once);
3320                assert!(
3321                    (once - twice).abs() < f64::EPSILON || (once.is_nan() && twice.is_nan()),
3322                    "normalized_utility must be idempotent: {once} vs {twice}"
3323                );
3324            }
3325
3326            #[test]
3327            fn base_total_is_sum_of_components(
3328                signals in arb_signals(),
3329                tags in arb_tags(),
3330            ) {
3331                let mut missing = BTreeSet::new();
3332                let (popularity, _) = score_popularity(&signals, &mut missing);
3333                let (adoption, _) = score_adoption(&signals, &mut missing);
3334                let (coverage, _) = score_coverage(&tags);
3335                // Each clamped component is bounded
3336                assert!(popularity <= 30, "popularity {popularity} > 30");
3337                assert!(adoption <= 15, "adoption {adoption} > 15");
3338                assert!(coverage <= 20, "coverage {coverage} > 20");
3339            }
3340
3341            #[test]
3342            fn gates_passes_iff_all_true(
3343                prov in any::<bool>(),
3344                det in any::<bool>(),
3345                license_ok in any::<bool>(),
3346                unmod in any::<bool>(),
3347            ) {
3348                let expected = prov && det && license_ok && unmod;
3349                let gates = GateStatus {
3350                    provenance_pinned: prov,
3351                    license_ok,
3352                    deterministic: det,
3353                    unmodified: unmod,
3354                    passes: expected,
3355                };
3356                assert!(
3357                    gates.passes == expected,
3358                    "gates.passes should be AND of all flags"
3359                );
3360            }
3361        }
3362    }
3363}