1#[cfg(feature = "planning")]
2use std::collections::{HashMap, HashSet};
3
4#[cfg(feature = "planning")]
5use anyhow::Result;
6#[cfg(feature = "planning")]
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
11pub struct BuildIntent {
12 #[serde(default)]
13 pub crate_names: Vec<String>,
14 #[serde(default)]
15 pub namespace: Option<String>,
16 #[serde(default)]
17 pub cargo_lock_deps: Vec<(String, String)>,
18}
19
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
27#[cfg_attr(kani, derive(kani::Arbitrary))]
28#[serde(rename_all = "snake_case")]
29pub enum CandidateSource {
30 Shard,
32 History,
34 KeyCache,
37 #[default]
38 #[serde(other)]
39 Unknown,
40}
41
42impl CandidateSource {
43 pub fn confidence_rank(self) -> u8 {
46 match self {
47 CandidateSource::Shard => 0,
48 CandidateSource::History => 1,
49 CandidateSource::KeyCache => 2,
50 CandidateSource::Unknown => 3,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct PrefetchCandidate {
57 pub cache_key: String,
58 pub crate_name: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub compile_time_ms: Option<u64>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub size_bytes: Option<u64>,
69 #[serde(default)]
70 pub source: CandidateSource,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub demand_index: Option<u32>,
75}
76
77impl PrefetchCandidate {
78 pub fn new(cache_key: String, crate_name: String) -> Self {
80 Self {
81 cache_key,
82 crate_name,
83 compile_time_ms: None,
84 size_bytes: None,
85 source: CandidateSource::Unknown,
86 demand_index: None,
87 }
88 }
89
90 pub fn with_source(mut self, source: CandidateSource) -> Self {
91 self.source = source;
92 self
93 }
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum PrefetchDisposition {
99 Execute,
100 UseFallback,
101 DoNothing,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct PrefetchPlan {
106 #[serde(default)]
107 pub plan_id: Option<String>,
108 #[serde(default)]
109 pub planner: Option<String>,
110 pub disposition: PrefetchDisposition,
111 #[serde(default)]
112 pub candidates: Vec<PrefetchCandidate>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct PlanLimits {
124 pub key_cache_per_crate: usize,
128 pub key_cache_total: usize,
131 pub history_per_crate: usize,
135}
136
137impl Default for PlanLimits {
138 fn default() -> Self {
139 Self {
140 key_cache_per_crate: 2,
141 key_cache_total: 64,
142 history_per_crate: 2,
143 }
144 }
145}
146
147#[cfg(feature = "planning")]
156const URGENCY_BUCKET: u32 = 16;
157
158#[cfg(feature = "planning")]
176pub fn dispatch_sort_key(candidate: &PrefetchCandidate) -> (u32, u8, std::cmp::Reverse<u64>) {
177 let bucket = candidate
178 .demand_index
179 .map(|index| index / URGENCY_BUCKET)
180 .unwrap_or(u32::MAX);
181 (
182 bucket,
183 candidate.source.confidence_rank(),
184 std::cmp::Reverse(candidate.compile_time_ms.unwrap_or(0)),
187 )
188}
189
190#[cfg(feature = "planning")]
191fn sort_candidates_for_dispatch(candidates: &mut [PrefetchCandidate]) {
192 candidates.sort_by_key(dispatch_sort_key);
193}
194
195#[cfg(feature = "planning")]
200fn cap_to(items: &mut Vec<PrefetchCandidate>, limit: usize) -> usize {
201 if limit == 0 || items.len() <= limit {
202 return 0;
203 }
204 let dropped = items.len() - limit;
205 items.truncate(limit);
206 dropped
207}
208
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
212pub struct PlanComposition {
213 pub from_shards: usize,
214 pub from_history: usize,
215 pub from_key_cache: usize,
216 pub dropped_history_per_crate: usize,
218 pub dropped_key_cache_per_crate: usize,
219 pub dropped_key_cache_total: usize,
220}
221
222impl PlanComposition {
223 pub fn dropped_total(&self) -> usize {
224 self.dropped_history_per_crate
225 + self.dropped_key_cache_per_crate
226 + self.dropped_key_cache_total
227 }
228}
229
230#[cfg(feature = "planning")]
231#[async_trait]
232pub trait PlannerDataSource {
233 async fn shard_candidates(
234 &self,
235 namespace: &str,
236 deps: &[(String, String)],
237 ) -> Result<Vec<PrefetchCandidate>>;
238
239 async fn history_candidates(&self, crate_names: &[String]) -> Result<Vec<PrefetchCandidate>>;
240
241 async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>>;
242}
243
244#[cfg(feature = "planning")]
245pub async fn build_prefetch_plan<T>(
246 source: &T,
247 intent: &BuildIntent,
248 planner_name: &str,
249) -> Result<PrefetchPlan>
250where
251 T: PlannerDataSource + Sync + ?Sized,
252{
253 build_prefetch_plan_with_limits(source, intent, planner_name, PlanLimits::default())
254 .await
255 .map(|(plan, _composition)| plan)
256}
257
258#[cfg(feature = "planning")]
261pub async fn build_prefetch_plan_with_limits<T>(
262 source: &T,
263 intent: &BuildIntent,
264 planner_name: &str,
265 limits: PlanLimits,
266) -> Result<(PrefetchPlan, PlanComposition)>
267where
268 T: PlannerDataSource + Sync + ?Sized,
269{
270 let crate_order = crate_query_order(intent);
271 let demand_index = demand_index_map(&crate_order);
272 let mut seen = HashSet::new();
273 let mut resolved_crates = HashSet::new();
274 let mut candidates = Vec::new();
275 let mut composition = PlanComposition::default();
276
277 if let Some(namespace) = intent.namespace.as_deref()
285 && !intent.cargo_lock_deps.is_empty()
286 {
287 if let Ok(shard_candidates) = source
289 .shard_candidates(namespace, &intent.cargo_lock_deps)
290 .await
291 {
292 for candidate in order_candidates_by_crate_order(shard_candidates, intent) {
293 resolved_crates.insert(candidate.crate_name.clone());
294 if seen.insert(candidate.cache_key.clone()) {
295 candidates.push(candidate.with_source(CandidateSource::Shard));
296 }
297 }
298 }
299 }
300
301 let unresolved = |resolved: &HashSet<String>| -> Vec<String> {
302 crate_order
303 .iter()
304 .filter(|name| !resolved.contains(*name))
305 .cloned()
306 .collect()
307 };
308
309 composition.from_shards = candidates.len();
310
311 let history_query = unresolved(&resolved_crates);
312 if !history_query.is_empty() {
313 let mut by_crate: HashMap<String, Vec<PrefetchCandidate>> = HashMap::new();
316 for candidate in order_candidates_by_crate_order(
317 source.history_candidates(&history_query).await?,
318 intent,
319 ) {
320 by_crate
321 .entry(candidate.crate_name.clone())
322 .or_default()
323 .push(candidate.with_source(CandidateSource::History));
324 }
325
326 for crate_name in &history_query {
327 let Some(mut for_crate) = by_crate.remove(crate_name) else {
328 continue;
329 };
330 composition.dropped_history_per_crate +=
331 cap_to(&mut for_crate, limits.history_per_crate);
332 for candidate in for_crate {
333 resolved_crates.insert(candidate.crate_name.clone());
334 if seen.insert(candidate.cache_key.clone()) {
335 composition.from_history += 1;
336 candidates.push(candidate);
337 }
338 }
339 }
340 }
341
342 for crate_name in unresolved(&resolved_crates) {
348 if limits.key_cache_total > 0 && composition.from_key_cache >= limits.key_cache_total {
349 composition.dropped_key_cache_total += source
352 .key_cache_keys_for_crate(&crate_name)
353 .await?
354 .into_iter()
355 .filter(|key| !seen.contains(key))
356 .count();
357 continue;
358 }
359
360 let mut for_crate: Vec<PrefetchCandidate> = source
361 .key_cache_keys_for_crate(&crate_name)
362 .await?
363 .into_iter()
364 .map(|cache_key| {
365 PrefetchCandidate::new(cache_key, crate_name.clone())
366 .with_source(CandidateSource::KeyCache)
367 })
368 .collect();
369
370 composition.dropped_key_cache_per_crate +=
371 cap_to(&mut for_crate, limits.key_cache_per_crate);
372
373 for candidate in for_crate {
374 if limits.key_cache_total > 0 && composition.from_key_cache >= limits.key_cache_total {
375 composition.dropped_key_cache_total += 1;
376 continue;
377 }
378 if seen.insert(candidate.cache_key.clone()) {
379 composition.from_key_cache += 1;
380 candidates.push(candidate);
381 }
382 }
383 }
384
385 for candidate in &mut candidates {
388 candidate.demand_index = demand_index.get(&candidate.crate_name).copied();
389 }
390
391 sort_candidates_for_dispatch(&mut candidates);
394
395 Ok((execute_plan(planner_name, candidates), composition))
396}
397
398#[cfg(feature = "planning")]
399fn execute_plan(planner_name: &str, candidates: Vec<PrefetchCandidate>) -> PrefetchPlan {
400 let planner = planner_name.trim();
401 PrefetchPlan {
402 plan_id: None,
403 planner: Some(if planner.is_empty() {
404 "planner".to_string()
405 } else {
406 planner.to_string()
407 }),
408 disposition: PrefetchDisposition::Execute,
409 candidates,
410 }
411}
412
413#[cfg(feature = "planning")]
415fn demand_index_map(crate_order: &[String]) -> HashMap<String, u32> {
416 crate_order
417 .iter()
418 .enumerate()
419 .map(|(index, name)| (name.clone(), index as u32))
420 .collect()
421}
422
423#[cfg(feature = "planning")]
424fn crate_query_order(intent: &BuildIntent) -> Vec<String> {
425 let mut seen = HashSet::new();
426 intent
427 .crate_names
428 .iter()
429 .filter(|crate_name| seen.insert((*crate_name).clone()))
430 .cloned()
431 .collect()
432}
433
434#[cfg(feature = "planning")]
435fn order_candidates_by_crate_order(
436 mut candidates: Vec<PrefetchCandidate>,
437 intent: &BuildIntent,
438) -> Vec<PrefetchCandidate> {
439 if intent.crate_names.is_empty() {
440 return candidates;
441 }
442
443 let mut priority = HashMap::new();
444 for (index, crate_name) in crate_query_order(intent).iter().enumerate() {
445 priority.entry(crate_name.clone()).or_insert(index);
446 }
447
448 let mut indexed_candidates = candidates.drain(..).enumerate().collect::<Vec<_>>();
449 indexed_candidates.sort_by_key(|(index, candidate)| {
450 (
451 priority
452 .get(&candidate.crate_name)
453 .copied()
454 .unwrap_or(usize::MAX),
455 *index,
456 )
457 });
458 indexed_candidates
459 .into_iter()
460 .map(|(_, candidate)| candidate)
461 .collect()
462}
463
464#[cfg(all(kani, feature = "planning"))]
471mod kani_proofs {
472 use super::*;
473
474 fn candidate(
475 source: CandidateSource,
476 compile_time_ms: Option<u64>,
477 demand_index: Option<u32>,
478 ) -> PrefetchCandidate {
479 PrefetchCandidate {
480 cache_key: String::new(),
481 crate_name: String::new(),
482 compile_time_ms,
483 size_bytes: None,
484 source,
485 demand_index,
486 }
487 }
488
489 #[kani::proof]
490 fn known_demand_always_precedes_unknown_demand() {
491 let demand_index: u32 = kani::any();
492 let known = candidate(kani::any(), kani::any(), Some(demand_index));
493 let unknown = candidate(kani::any(), kani::any(), None);
494 kani::cover!();
495
496 assert!(dispatch_sort_key(&known) < dispatch_sort_key(&unknown));
497 }
498
499 #[kani::proof]
500 fn earlier_urgency_bucket_always_wins() {
501 let earlier: u32 = kani::any();
502 let later: u32 = kani::any();
503 kani::assume(earlier / URGENCY_BUCKET < later / URGENCY_BUCKET);
504 let earlier_candidate = candidate(kani::any(), kani::any(), Some(earlier));
505 let later_candidate = candidate(kani::any(), kani::any(), Some(later));
506 kani::cover!();
507
508 assert!(dispatch_sort_key(&earlier_candidate) < dispatch_sort_key(&later_candidate));
509 }
510
511 #[kani::proof]
512 fn confidence_always_wins_within_a_bucket() {
513 let better_source: CandidateSource = kani::any();
514 let worse_source: CandidateSource = kani::any();
515 kani::assume(better_source.confidence_rank() < worse_source.confidence_rank());
516 let better_demand: u32 = kani::any();
517 let worse_demand: u32 = kani::any();
518 kani::assume(better_demand / URGENCY_BUCKET == worse_demand / URGENCY_BUCKET);
519 let better = candidate(better_source, kani::any(), Some(better_demand));
520 let worse = candidate(worse_source, kani::any(), Some(worse_demand));
521 kani::cover!();
522
523 assert!(dispatch_sort_key(&better) < dispatch_sort_key(&worse));
524 }
525
526 #[kani::proof]
527 fn higher_compile_cost_wins_after_urgency_and_confidence() {
528 let cheaper: u64 = kani::any();
529 let costlier: u64 = kani::any();
530 kani::assume(cheaper < costlier);
531 let cheaper_demand: u32 = kani::any();
532 let costlier_demand: u32 = kani::any();
533 kani::assume(cheaper_demand / URGENCY_BUCKET == costlier_demand / URGENCY_BUCKET);
534 let source: CandidateSource = kani::any();
535 let cheaper_candidate = candidate(source, Some(cheaper), Some(cheaper_demand));
536 let costlier_candidate = candidate(source, Some(costlier), Some(costlier_demand));
537 kani::cover!();
538
539 assert!(dispatch_sort_key(&costlier_candidate) < dispatch_sort_key(&cheaper_candidate));
540 }
541
542 #[kani::proof]
543 fn known_positive_compile_cost_precedes_unknown_cost() {
544 let known_cost: u64 = kani::any();
545 kani::assume(known_cost > 0);
546 let demand_index: u32 = kani::any();
547 let source: CandidateSource = kani::any();
548 let known = candidate(source, Some(known_cost), Some(demand_index));
549 let unknown = candidate(source, None, Some(demand_index));
550 kani::cover!();
551
552 assert!(dispatch_sort_key(&known) < dispatch_sort_key(&unknown));
553 }
554
555 #[kani::proof]
556 fn dispatch_sort_preserves_equal_key_order() {
557 let source: CandidateSource = kani::any();
558 let compile_time_ms: Option<u64> = kani::any();
559 let demand_index: Option<u32> = kani::any();
560 let mut first = candidate(source, compile_time_ms, demand_index);
561 first.size_bytes = Some(1);
562 let mut second = candidate(source, compile_time_ms, demand_index);
563 second.size_bytes = Some(2);
564 let mut candidates = [first, second];
565
566 sort_candidates_for_dispatch(&mut candidates);
567 kani::cover!();
568
569 assert_eq!(candidates[0].size_bytes, Some(1));
570 assert_eq!(candidates[1].size_bytes, Some(2));
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[cfg(feature = "planning")]
579 use std::collections::HashMap;
580
581 #[cfg(feature = "planning")]
582 use anyhow::anyhow;
583
584 #[cfg(feature = "planning")]
585 use proptest::prelude::*;
586
587 #[cfg(feature = "planning")]
588 #[derive(Default)]
589 struct FakePlannerDataSource {
590 shard_candidates: Vec<PrefetchCandidate>,
591 shard_error: bool,
592 history_candidates: Vec<PrefetchCandidate>,
593 history_by_crate: HashMap<String, String>,
594 key_cache: HashMap<String, Vec<String>>,
595 }
596
597 #[cfg(feature = "planning")]
598 #[async_trait]
599 impl PlannerDataSource for FakePlannerDataSource {
600 async fn shard_candidates(
601 &self,
602 _namespace: &str,
603 _deps: &[(String, String)],
604 ) -> Result<Vec<PrefetchCandidate>> {
605 if self.shard_error {
606 Err(anyhow!("shard lookup failed"))
607 } else {
608 Ok(self.shard_candidates.clone())
609 }
610 }
611
612 async fn history_candidates(
613 &self,
614 crate_names: &[String],
615 ) -> Result<Vec<PrefetchCandidate>> {
616 if !self.history_candidates.is_empty() {
617 return Ok(self.history_candidates.clone());
618 }
619
620 Ok(crate_names
621 .iter()
622 .filter_map(|crate_name| {
623 self.history_by_crate.get(crate_name).map(|cache_key| {
624 PrefetchCandidate::new(cache_key.clone(), crate_name.clone())
625 })
626 })
627 .collect())
628 }
629
630 async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>> {
631 Ok(self.key_cache.get(crate_name).cloned().unwrap_or_default())
632 }
633 }
634
635 #[test]
636 fn test_build_intent_serde_roundtrip() {
637 let intent = BuildIntent {
638 crate_names: vec!["serde".into(), "tokio".into()],
639 namespace: Some("x86_64/hash/release".into()),
640 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
641 };
642
643 let json = serde_json::to_string(&intent).unwrap();
644 let parsed: BuildIntent = serde_json::from_str(&json).unwrap();
645 assert_eq!(parsed, intent);
646 }
647
648 #[test]
649 fn test_build_intent_defaults_missing_fields() {
650 let parsed: BuildIntent = serde_json::from_str(r#"{"crate_names":["serde"]}"#).unwrap();
651 assert_eq!(parsed.crate_names, vec!["serde"]);
652 assert!(parsed.namespace.is_none());
653 assert!(parsed.cargo_lock_deps.is_empty());
654 }
655
656 #[test]
657 fn test_prefetch_plan_serde_roundtrip() {
658 let plan = PrefetchPlan {
659 plan_id: Some("plan-1".into()),
660 planner: Some("local".into()),
661 disposition: PrefetchDisposition::Execute,
662 candidates: vec![PrefetchCandidate::new("abc".into(), "serde".into())],
663 };
664
665 let json = serde_json::to_string(&plan).unwrap();
666 let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
667 assert_eq!(parsed, plan);
668 }
669
670 #[test]
671 fn test_prefetch_plan_missing_disposition_is_rejected() {
672 let err = serde_json::from_str::<PrefetchPlan>(
673 r#"{"planner":"legacy","candidates":[{"cache_key":"abc","crate_name":"serde"}]}"#,
674 )
675 .unwrap_err();
676 assert!(err.to_string().contains("missing field"));
677 }
678
679 #[test]
680 fn test_prefetch_plan_do_nothing_roundtrip() {
681 let plan = PrefetchPlan {
682 plan_id: Some("plan-2".into()),
683 planner: Some("remote".into()),
684 disposition: PrefetchDisposition::DoNothing,
685 candidates: vec![],
686 };
687
688 let json = serde_json::to_string(&plan).unwrap();
689 let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
690 assert_eq!(parsed, plan);
691 }
692
693 #[cfg(feature = "planning")]
694 #[tokio::test]
695 async fn test_build_prefetch_plan_prefers_shard_candidates() {
696 let source = FakePlannerDataSource {
697 shard_candidates: vec![PrefetchCandidate::new("from-shard".into(), "serde".into())],
698 ..Default::default()
699 };
700 let intent = BuildIntent {
701 crate_names: vec!["serde".into()],
702 namespace: Some("linux/hash/release".into()),
703 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
704 };
705
706 let plan = build_prefetch_plan(&source, &intent, "fallback")
707 .await
708 .unwrap();
709
710 assert_eq!(plan.disposition, PrefetchDisposition::Execute);
711 assert_eq!(plan.planner.as_deref(), Some("fallback"));
712 assert_eq!(plan.candidates.len(), 1);
713 assert_eq!(plan.candidates[0].cache_key, "from-shard");
714 }
715
716 #[cfg(feature = "planning")]
717 #[tokio::test]
718 async fn test_build_prefetch_plan_falls_back_to_history_and_key_cache() {
719 let mut source = FakePlannerDataSource {
720 shard_error: true,
721 history_candidates: vec![PrefetchCandidate::new("history-key".into(), "serde".into())],
722 ..Default::default()
723 };
724 source.key_cache.insert(
725 "tokio".into(),
726 vec!["tokio-key".into(), "history-key".into()],
727 );
728
729 let intent = BuildIntent {
730 crate_names: vec!["serde".into(), "tokio".into()],
731 namespace: Some("linux/hash/debug".into()),
732 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
733 };
734
735 let plan = build_prefetch_plan(&source, &intent, "fallback")
736 .await
737 .unwrap();
738
739 assert_eq!(plan.disposition, PrefetchDisposition::Execute);
740 assert_eq!(plan.candidates.len(), 2);
741 assert_eq!(plan.candidates[0].cache_key, "history-key");
742 assert_eq!(plan.candidates[1].cache_key, "tokio-key");
743 }
744
745 #[cfg(feature = "planning")]
746 #[tokio::test]
747 async fn test_build_prefetch_plan_orders_shard_candidates_by_crate_order() {
748 let source = FakePlannerDataSource {
749 shard_candidates: vec![
750 PrefetchCandidate::new("app-key".into(), "app".into()),
751 PrefetchCandidate::new("dep-key".into(), "dep".into()),
752 PrefetchCandidate::new("middle-key".into(), "middle".into()),
753 ],
754 ..Default::default()
755 };
756 let intent = BuildIntent {
757 crate_names: vec!["dep".into(), "middle".into(), "app".into()],
758 namespace: Some("linux/hash/debug".into()),
759 cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
760 };
761
762 let plan = build_prefetch_plan(&source, &intent, "fallback")
763 .await
764 .unwrap();
765
766 let keys = plan
767 .candidates
768 .iter()
769 .map(|candidate| candidate.cache_key.as_str())
770 .collect::<Vec<_>>();
771 assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
772 }
773
774 #[cfg(feature = "planning")]
783 #[tokio::test]
784 async fn test_build_prefetch_plan_fills_crates_a_partial_shard_hit_missed() {
785 let mut source = FakePlannerDataSource {
786 shard_candidates: vec![PrefetchCandidate::new("dep-shard-key".into(), "dep".into())],
788 history_by_crate: HashMap::from([("middle".into(), "middle-history-key".into())]),
789 ..Default::default()
790 };
791 source
792 .key_cache
793 .insert("app".into(), vec!["app-key-cache-key".into()]);
794
795 let intent = BuildIntent {
796 crate_names: vec!["dep".into(), "middle".into(), "app".into()],
797 namespace: Some("linux/hash/debug".into()),
798 cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
799 };
800
801 let plan = build_prefetch_plan(&source, &intent, "fallback")
802 .await
803 .unwrap();
804
805 let keys = plan
806 .candidates
807 .iter()
808 .map(|candidate| candidate.cache_key.as_str())
809 .collect::<Vec<_>>();
810 assert_eq!(
811 keys,
812 vec!["dep-shard-key", "middle-history-key", "app-key-cache-key"],
813 "each source should fill the crates the higher-confidence ones left unresolved"
814 );
815 }
816
817 #[cfg(feature = "planning")]
821 #[tokio::test]
822 async fn test_build_prefetch_plan_does_not_requery_shard_resolved_crates() {
823 let mut source = FakePlannerDataSource {
824 shard_candidates: vec![PrefetchCandidate::new(
825 "serde-shard-key".into(),
826 "serde".into(),
827 )],
828 history_by_crate: HashMap::from([("serde".into(), "serde-stale-history-key".into())]),
829 ..Default::default()
830 };
831 source
832 .key_cache
833 .insert("serde".into(), vec!["serde-stale-key-cache-key".into()]);
834
835 let intent = BuildIntent {
836 crate_names: vec!["serde".into()],
837 namespace: Some("linux/hash/debug".into()),
838 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
839 };
840
841 let plan = build_prefetch_plan(&source, &intent, "fallback")
842 .await
843 .unwrap();
844
845 let keys = plan
846 .candidates
847 .iter()
848 .map(|candidate| candidate.cache_key.as_str())
849 .collect::<Vec<_>>();
850 assert_eq!(keys, vec!["serde-shard-key"]);
851 }
852
853 #[cfg(feature = "planning")]
854 #[tokio::test]
855 async fn test_build_prefetch_plan_queries_history_by_crate_order() {
856 let source = FakePlannerDataSource {
857 history_by_crate: HashMap::from([
858 ("app".into(), "app-key".into()),
859 ("dep".into(), "dep-key".into()),
860 ("middle".into(), "middle-key".into()),
861 ]),
862 ..Default::default()
863 };
864 let intent = BuildIntent {
865 crate_names: vec!["dep".into(), "middle".into(), "app".into()],
866 namespace: None,
867 cargo_lock_deps: vec![],
868 };
869
870 let plan = build_prefetch_plan(&source, &intent, "fallback")
871 .await
872 .unwrap();
873
874 let keys = plan
875 .candidates
876 .iter()
877 .map(|candidate| candidate.cache_key.as_str())
878 .collect::<Vec<_>>();
879 assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
880 }
881
882 #[cfg(feature = "planning")]
883 proptest! {
884 #[test]
885 fn property_crate_query_order_preserves_first_occurrence(
886 crate_ids in proptest::collection::vec(any::<u8>(), 1..64),
887 ) {
888 let mut crate_names = crate_ids
889 .into_iter()
890 .map(|id| format!("crate-{id}"))
891 .collect::<Vec<_>>();
892 crate_names.push(crate_names[0].clone());
893 let intent = BuildIntent {
894 crate_names: crate_names.clone(),
895 ..Default::default()
896 };
897
898 let mut expected = Vec::new();
899 for crate_name in crate_names {
900 if !expected.contains(&crate_name) {
901 expected.push(crate_name);
902 }
903 }
904
905 prop_assert_eq!(crate_query_order(&intent), expected);
906 }
907
908 #[test]
909 fn property_candidate_ordering_is_a_stable_priority_permutation(
910 requested_ids in proptest::collection::vec(0u8..8, 1..24),
911 candidate_ids in proptest::collection::vec(0u8..16, 0..48),
912 ) {
913 let requested_names = requested_ids
914 .into_iter()
915 .map(|id| format!("crate-{id}"))
916 .collect::<Vec<_>>();
917 let intent = BuildIntent {
918 crate_names: requested_names.clone(),
919 ..Default::default()
920 };
921
922 let mut candidate_names = Vec::with_capacity(candidate_ids.len() + 4);
925 candidate_names.push("not-requested".to_string());
926 candidate_names.push(requested_names[0].clone());
927 candidate_names.extend(
928 candidate_ids
929 .into_iter()
930 .map(|id| format!("crate-{id}")),
931 );
932 candidate_names.push("not-requested".to_string());
933 candidate_names.push(requested_names[0].clone());
934
935 let candidates = candidate_names
936 .into_iter()
937 .enumerate()
938 .map(|(index, crate_name)| {
939 PrefetchCandidate::new(format!("key-{index}"), crate_name)
940 })
941 .collect::<Vec<_>>();
942
943 let mut expected = candidates.clone();
944 expected.sort_by_key(|candidate| {
945 requested_names
946 .iter()
947 .position(|name| name == &candidate.crate_name)
948 .unwrap_or(usize::MAX)
949 });
950
951 prop_assert_eq!(
952 order_candidates_by_crate_order(candidates, &intent),
953 expected
954 );
955 }
956 }
957 #[cfg(feature = "planning")]
960 fn candidate(
961 key: &str,
962 crate_name: &str,
963 source: CandidateSource,
964 compile_time_ms: Option<u64>,
965 demand_index: Option<u32>,
966 ) -> PrefetchCandidate {
967 PrefetchCandidate {
968 cache_key: key.into(),
969 crate_name: crate_name.into(),
970 compile_time_ms,
971 size_bytes: None,
972 source,
973 demand_index,
974 }
975 }
976
977 #[test]
980 fn test_confidence_rank_orders_sources() {
981 assert!(
982 CandidateSource::Shard.confidence_rank() < CandidateSource::History.confidence_rank()
983 );
984 assert!(
985 CandidateSource::History.confidence_rank()
986 < CandidateSource::KeyCache.confidence_rank()
987 );
988 assert!(
989 CandidateSource::KeyCache.confidence_rank()
990 < CandidateSource::Unknown.confidence_rank()
991 );
992 }
993
994 #[test]
997 fn test_unknown_candidate_source_deserializes() {
998 let candidate: PrefetchCandidate =
999 serde_json::from_str(r#"{"cache_key":"k","crate_name":"c","source":"telepathy"}"#)
1000 .unwrap();
1001 assert_eq!(candidate.source, CandidateSource::Unknown);
1002 }
1003
1004 #[test]
1007 fn test_legacy_candidate_wire_still_parses() {
1008 let candidate: PrefetchCandidate =
1009 serde_json::from_str(r#"{"cache_key":"k","crate_name":"c"}"#).unwrap();
1010 assert_eq!(candidate.compile_time_ms, None);
1011 assert_eq!(candidate.size_bytes, None);
1012 assert_eq!(candidate.demand_index, None);
1013 assert_eq!(candidate.source, CandidateSource::Unknown);
1014 }
1015
1016 #[cfg(feature = "planning")]
1019 #[test]
1020 fn test_dispatch_order_prefers_urgency_over_value() {
1021 let urgent_cheap = candidate("a", "a", CandidateSource::Shard, Some(10), Some(0));
1022 let late_expensive = candidate("b", "b", CandidateSource::Shard, Some(100_000), Some(500));
1023 assert!(dispatch_sort_key(&urgent_cheap) < dispatch_sort_key(&late_expensive));
1024 }
1025
1026 #[cfg(feature = "planning")]
1028 #[test]
1029 fn test_dispatch_order_prefers_value_within_a_bucket() {
1030 let cheap = candidate("a", "a", CandidateSource::Shard, Some(10), Some(0));
1031 let costly = candidate("b", "b", CandidateSource::Shard, Some(5_000), Some(1));
1032 assert!(
1033 dispatch_sort_key(&costly) < dispatch_sort_key(&cheap),
1034 "same bucket, same source: the expensive rebuild goes first"
1035 );
1036 }
1037
1038 #[cfg(feature = "planning")]
1041 #[test]
1042 fn test_dispatch_order_buckets_nearby_positions_together() {
1043 let first = candidate("a", "a", CandidateSource::Shard, Some(10), Some(0));
1044 let nearby = candidate("b", "b", CandidateSource::Shard, Some(10), Some(15));
1045 let next_bucket = candidate("c", "c", CandidateSource::Shard, Some(10), Some(16));
1046 assert_eq!(
1047 dispatch_sort_key(&first),
1048 dispatch_sort_key(&nearby),
1049 "positions 0 and 15 share a bucket"
1050 );
1051 assert!(
1052 dispatch_sort_key(&nearby) < dispatch_sort_key(&next_bucket),
1053 "position 16 starts the next bucket"
1054 );
1055 }
1056
1057 #[cfg(feature = "planning")]
1060 #[test]
1061 fn test_dispatch_order_prefers_confidence_within_a_bucket() {
1062 let shard_cheap = candidate("a", "a", CandidateSource::Shard, Some(1), Some(0));
1063 let guess_costly = candidate("b", "b", CandidateSource::KeyCache, Some(99_999), Some(0));
1064 assert!(dispatch_sort_key(&shard_cheap) < dispatch_sort_key(&guess_costly));
1065 }
1066
1067 #[cfg(feature = "planning")]
1070 #[test]
1071 fn test_dispatch_order_places_unknown_cost_last_within_its_bucket() {
1072 let known = candidate("a", "a", CandidateSource::Shard, Some(1), Some(0));
1073 let unknown = candidate("b", "b", CandidateSource::Shard, None, Some(0));
1074 assert!(dispatch_sort_key(&known) < dispatch_sort_key(&unknown));
1075
1076 let late_known = candidate("c", "c", CandidateSource::Shard, Some(50_000), Some(999));
1079 assert!(dispatch_sort_key(&unknown) < dispatch_sort_key(&late_known));
1080 }
1081
1082 #[cfg(feature = "planning")]
1084 #[test]
1085 fn test_dispatch_order_places_unknown_demand_last() {
1086 let known = candidate("a", "a", CandidateSource::Shard, Some(1), Some(100_000));
1087 let no_demand = candidate("b", "b", CandidateSource::Shard, Some(50_000), None);
1088 assert!(dispatch_sort_key(&known) < dispatch_sort_key(&no_demand));
1089 }
1090
1091 #[cfg(feature = "planning")]
1093 #[test]
1094 fn test_dispatch_sort_orders_candidates_and_preserves_ties() {
1095 let late = candidate("late", "late", CandidateSource::Shard, Some(1), Some(32));
1096 let first_tie = candidate("first", "first", CandidateSource::Shard, Some(10), Some(0));
1097 let second_tie = candidate(
1098 "second",
1099 "second",
1100 CandidateSource::Shard,
1101 Some(10),
1102 Some(0),
1103 );
1104 let mut candidates = vec![late, first_tie, second_tie];
1105
1106 sort_candidates_for_dispatch(&mut candidates);
1107
1108 let keys = candidates
1109 .iter()
1110 .map(|candidate| candidate.cache_key.as_str())
1111 .collect::<Vec<_>>();
1112 assert_eq!(keys, vec!["first", "second", "late"]);
1113 }
1114
1115 #[cfg(feature = "planning")]
1117 #[tokio::test]
1118 async fn test_key_cache_per_crate_cap() {
1119 let mut source = FakePlannerDataSource::default();
1120 source.key_cache.insert(
1121 "serde".into(),
1122 vec!["k1".into(), "k2".into(), "k3".into(), "k4".into()],
1123 );
1124 let intent = BuildIntent {
1125 crate_names: vec!["serde".into()],
1126 ..Default::default()
1127 };
1128
1129 let (plan, composition) = build_prefetch_plan_with_limits(
1130 &source,
1131 &intent,
1132 "fallback",
1133 PlanLimits {
1134 key_cache_per_crate: 2,
1135 ..PlanLimits::default()
1136 },
1137 )
1138 .await
1139 .unwrap();
1140
1141 assert_eq!(plan.candidates.len(), 2, "capped to two variants");
1142 assert_eq!(composition.from_key_cache, 2);
1143 assert_eq!(composition.dropped_key_cache_per_crate, 2);
1144 assert!(composition.dropped_total() > 0, "truncation is visible");
1145 }
1146
1147 #[cfg(feature = "planning")]
1150 #[tokio::test]
1151 async fn test_key_cache_total_cap_counts_skipped_crates() {
1152 let mut source = FakePlannerDataSource::default();
1153 for name in ["a", "b", "c"] {
1154 source
1155 .key_cache
1156 .insert(name.into(), vec![format!("{name}-k1")]);
1157 }
1158 let intent = BuildIntent {
1159 crate_names: vec!["a".into(), "b".into(), "c".into()],
1160 ..Default::default()
1161 };
1162
1163 let (plan, composition) = build_prefetch_plan_with_limits(
1164 &source,
1165 &intent,
1166 "fallback",
1167 PlanLimits {
1168 key_cache_total: 1,
1169 ..PlanLimits::default()
1170 },
1171 )
1172 .await
1173 .unwrap();
1174
1175 assert_eq!(plan.candidates.len(), 1);
1176 assert_eq!(composition.from_key_cache, 1);
1177 assert_eq!(
1178 composition.dropped_key_cache_total, 2,
1179 "the two skipped crates are counted, not silently missing"
1180 );
1181 }
1182
1183 #[cfg(feature = "planning")]
1185 #[tokio::test]
1186 async fn test_zero_limit_disables_the_cap() {
1187 let mut source = FakePlannerDataSource::default();
1188 source
1189 .key_cache
1190 .insert("serde".into(), vec!["k1".into(), "k2".into(), "k3".into()]);
1191 let intent = BuildIntent {
1192 crate_names: vec!["serde".into()],
1193 ..Default::default()
1194 };
1195
1196 let (plan, composition) = build_prefetch_plan_with_limits(
1197 &source,
1198 &intent,
1199 "fallback",
1200 PlanLimits {
1201 key_cache_per_crate: 0,
1202 key_cache_total: 0,
1203 history_per_crate: 0,
1204 },
1205 )
1206 .await
1207 .unwrap();
1208
1209 assert_eq!(plan.candidates.len(), 3);
1210 assert_eq!(composition.dropped_total(), 0);
1211 }
1212
1213 #[cfg(feature = "planning")]
1216 #[tokio::test]
1217 async fn test_plan_stamps_source_and_demand_index() {
1218 let source = FakePlannerDataSource {
1219 history_by_crate: HashMap::from([
1220 ("dep".into(), "dep-key".into()),
1221 ("app".into(), "app-key".into()),
1222 ]),
1223 ..Default::default()
1224 };
1225 let intent = BuildIntent {
1226 crate_names: vec!["dep".into(), "app".into()],
1227 ..Default::default()
1228 };
1229
1230 let (plan, _) =
1231 build_prefetch_plan_with_limits(&source, &intent, "fallback", PlanLimits::default())
1232 .await
1233 .unwrap();
1234
1235 let dep = plan
1236 .candidates
1237 .iter()
1238 .find(|c| c.crate_name == "dep")
1239 .expect("dep planned");
1240 let app = plan
1241 .candidates
1242 .iter()
1243 .find(|c| c.crate_name == "app")
1244 .expect("app planned");
1245 assert_eq!(dep.source, CandidateSource::History);
1246 assert_eq!(dep.demand_index, Some(0));
1247 assert_eq!(app.demand_index, Some(1), "position follows crate order");
1248 }
1249
1250 #[cfg(feature = "planning")]
1254 #[test]
1255 fn test_cap_to_reports_the_dropped_count() {
1256 let mut items: Vec<PrefetchCandidate> = (0..5)
1257 .map(|i| PrefetchCandidate::new(format!("k{i}"), "c".into()))
1258 .collect();
1259 assert_eq!(cap_to(&mut items, 2), 3);
1260 assert_eq!(items.len(), 2);
1261 assert_eq!(items[0].cache_key, "k0", "keeps the FIRST N");
1262
1263 let mut exact: Vec<PrefetchCandidate> =
1264 vec![PrefetchCandidate::new("a".into(), "c".into())];
1265 assert_eq!(cap_to(&mut exact, 1), 0, "exactly at the limit drops none");
1266 assert_eq!(cap_to(&mut exact, 0), 0, "0 disables the cap");
1267 assert_eq!(exact.len(), 1);
1268 }
1269
1270 #[test]
1273 fn test_dropped_total_sums_every_class() {
1274 let composition = PlanComposition {
1275 dropped_history_per_crate: 1,
1276 dropped_key_cache_per_crate: 2,
1277 dropped_key_cache_total: 4,
1278 ..PlanComposition::default()
1279 };
1280 assert_eq!(composition.dropped_total(), 7);
1281 assert_eq!(PlanComposition::default().dropped_total(), 0);
1282 }
1283
1284 #[cfg(feature = "planning")]
1288 #[tokio::test]
1289 async fn test_history_per_crate_cap() {
1290 let source = FakePlannerDataSource {
1291 history_candidates: vec![
1292 PrefetchCandidate::new("serde-newest".into(), "serde".into()),
1293 PrefetchCandidate::new("serde-older".into(), "serde".into()),
1294 PrefetchCandidate::new("serde-oldest".into(), "serde".into()),
1295 ],
1296 ..Default::default()
1297 };
1298 let intent = BuildIntent {
1299 crate_names: vec!["serde".into()],
1300 ..Default::default()
1301 };
1302
1303 let (plan, composition) = build_prefetch_plan_with_limits(
1304 &source,
1305 &intent,
1306 "fallback",
1307 PlanLimits {
1308 history_per_crate: 1,
1309 ..PlanLimits::default()
1310 },
1311 )
1312 .await
1313 .unwrap();
1314
1315 assert_eq!(composition.from_history, 1);
1316 assert_eq!(composition.dropped_history_per_crate, 2);
1317 assert_eq!(
1318 plan.candidates
1319 .iter()
1320 .map(|c| c.cache_key.as_str())
1321 .collect::<Vec<_>>(),
1322 vec!["serde-newest"]
1323 );
1324 }
1325
1326 #[cfg(feature = "planning")]
1330 #[tokio::test]
1331 async fn test_key_cache_total_skips_a_whole_crate_and_counts_all_of_it() {
1332 let mut source = FakePlannerDataSource::default();
1333 source.key_cache.insert("a".into(), vec!["a1".into()]);
1334 source.key_cache.insert(
1335 "b".into(),
1336 vec![
1337 "b1".into(),
1338 "b2".into(),
1339 "b3".into(),
1340 "b4".into(),
1341 "b5".into(),
1342 ],
1343 );
1344 let intent = BuildIntent {
1345 crate_names: vec!["a".into(), "b".into()],
1346 ..Default::default()
1347 };
1348
1349 let (_plan, composition) = build_prefetch_plan_with_limits(
1350 &source,
1351 &intent,
1352 "fallback",
1353 PlanLimits {
1354 key_cache_per_crate: 2,
1355 key_cache_total: 1,
1356 history_per_crate: 2,
1357 },
1358 )
1359 .await
1360 .unwrap();
1361
1362 assert_eq!(composition.from_key_cache, 1, "only crate `a` fits");
1363 assert_eq!(
1364 composition.dropped_key_cache_total, 5,
1365 "all five of `b`'s variants are counted, not the two a per-crate \
1366 cap would have admitted"
1367 );
1368 assert_eq!(
1369 composition.dropped_key_cache_per_crate, 0,
1370 "`b` was skipped before the per-crate cap applied"
1371 );
1372 }
1373
1374 #[cfg(feature = "planning")]
1377 #[tokio::test]
1378 async fn test_key_cache_total_can_run_out_mid_crate() {
1379 let mut source = FakePlannerDataSource::default();
1380 source
1381 .key_cache
1382 .insert("serde".into(), vec!["k1".into(), "k2".into(), "k3".into()]);
1383 let intent = BuildIntent {
1384 crate_names: vec!["serde".into()],
1385 ..Default::default()
1386 };
1387
1388 let (_plan, composition) = build_prefetch_plan_with_limits(
1389 &source,
1390 &intent,
1391 "fallback",
1392 PlanLimits {
1393 key_cache_per_crate: 2,
1394 key_cache_total: 1,
1395 history_per_crate: 2,
1396 },
1397 )
1398 .await
1399 .unwrap();
1400
1401 assert_eq!(composition.from_key_cache, 1);
1402 assert_eq!(
1403 composition.dropped_key_cache_per_crate, 1,
1404 "k3 dropped by the per-crate cap"
1405 );
1406 assert_eq!(
1407 composition.dropped_key_cache_total, 1,
1408 "k2 dropped by the plan-wide cap, mid-crate"
1409 );
1410 }
1411}