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