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