1use std::collections::{HashMap, HashSet};
23use std::path::PathBuf;
24use std::sync::Arc;
25use std::time::Duration;
26
27use crate::audit::{AuditEvent, AuditSink, NullAuditSink};
28use crate::plan::{
29 CallerId, Cohort, CohortRow, Deliverable, DeliverableStatus, LockInfo, PlanGraph, PlanId,
30 PlanStatus, PlannerError,
31};
32use crate::ports::Planner;
33use async_trait::async_trait;
34use chrono::{DateTime, Utc};
35use serde_json::json;
36use sha2::{Digest, Sha256};
37use tokio::sync::Mutex;
38
39use crate::algorithm::CpmAlgorithm;
40use crate::estimator::EffortEstimator;
41use crate::locks::PlanState;
42use crate::task::{Task, TaskKind};
43
44pub const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
47
48pub const DEFAULT_EFFORT_HOURS: f32 = 1.0;
56
57pub type ClockFn = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
60
61pub struct BasicCpmPlanner {
63 plans: Arc<Mutex<HashMap<PlanId, PlanState>>>,
64 dedup: Arc<Mutex<HashMap<String, PlanId>>>,
67 audit: Arc<dyn AuditSink>,
68 ttl: Duration,
69 clock: ClockFn,
70}
71
72impl BasicCpmPlanner {
73 pub fn new() -> Self {
77 Self::with_audit(Arc::new(NullAuditSink))
78 }
79
80 pub fn with_audit(audit: Arc<dyn AuditSink>) -> Self {
83 Self::with_parts(audit, DEFAULT_TTL, Arc::new(Utc::now))
84 }
85
86 pub fn with_ttl(mut self, ttl: Duration) -> Self {
88 self.ttl = ttl;
89 self
90 }
91
92 pub fn with_clock(mut self, clock: ClockFn) -> Self {
95 self.clock = clock;
96 self
97 }
98
99 pub fn with_parts(audit: Arc<dyn AuditSink>, ttl: Duration, clock: ClockFn) -> Self {
102 Self {
103 plans: Arc::new(Mutex::new(HashMap::new())),
104 dedup: Arc::new(Mutex::new(HashMap::new())),
105 audit,
106 ttl,
107 clock,
108 }
109 }
110
111 fn now(&self) -> DateTime<Utc> {
112 (self.clock)()
113 }
114
115 async fn flush_audit(&self, events: Vec<AuditEvent>) {
118 for ev in events {
119 if let Err(err) = self.audit.record(ev).await {
124 tracing::warn!(error = %err, "audit sink failed to record planner event");
125 }
126 }
127 }
128}
129
130impl Default for BasicCpmPlanner {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136fn hash_graph(graph: &PlanGraph) -> String {
144 let mut deliverables: Vec<_> = graph
148 .deliverables
149 .iter()
150 .map(|d| {
151 let mut prereqs = d.prerequisites.clone();
152 prereqs.sort();
153 let mut files: Vec<String> = d
154 .owned_files
155 .iter()
156 .map(|p| p.to_string_lossy().into_owned())
157 .collect();
158 files.sort();
159 json!({
160 "id": d.id,
161 "owned_files": files,
162 "prerequisites": prereqs,
163 "estimated_effort_hours": d.estimated_effort_hours,
164 "metadata": d.metadata,
165 })
166 })
167 .collect();
168 deliverables.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
169
170 let payload = json!({
171 "deliverables": deliverables,
172 "max_chained_dispatch": graph.max_chained_dispatch,
173 });
174
175 let serialised = serde_json::to_vec(&payload)
181 .expect("INVARIANT: plan-graph hash payload is JSON-serialisable");
182 let mut hasher = Sha256::new();
183 hasher.update(&serialised);
184 format!("{:x}", hasher.finalize())
185}
186
187fn validate_graph(graph: &PlanGraph) -> Result<(), PlannerError> {
190 let mut seen_ids: HashSet<&str> = HashSet::new();
192 for d in &graph.deliverables {
193 if !seen_ids.insert(d.id.as_str()) {
194 return Err(PlannerError::InvalidGraph {
195 reason: format!("duplicate deliverable id '{}'", d.id),
196 });
197 }
198 }
199
200 let id_set: HashSet<&str> = graph.deliverables.iter().map(|d| d.id.as_str()).collect();
202 for d in &graph.deliverables {
203 for p in &d.prerequisites {
204 if !id_set.contains(p.as_str()) {
205 return Err(PlannerError::InvalidGraph {
206 reason: format!(
207 "prerequisite '{p}' for deliverable '{}' does not exist",
208 d.id
209 ),
210 });
211 }
212 }
213 }
214
215 let mut file_owner: HashMap<&PathBuf, &str> = HashMap::new();
217 for d in &graph.deliverables {
218 for f in &d.owned_files {
219 if let Some(other) = file_owner.insert(f, d.id.as_str()) {
220 return Err(PlannerError::InvalidGraph {
221 reason: format!(
222 "file '{}' is owned by both '{}' and '{}'",
223 f.display(),
224 other,
225 d.id
226 ),
227 });
228 }
229 }
230 }
231
232 let mut indeg: HashMap<&str, usize> = HashMap::new();
234 let mut succs: HashMap<&str, Vec<&str>> = HashMap::new();
235 for d in &graph.deliverables {
236 indeg.entry(d.id.as_str()).or_insert(0);
237 succs.entry(d.id.as_str()).or_default();
238 }
239 for d in &graph.deliverables {
240 for p in &d.prerequisites {
241 *indeg.entry(d.id.as_str()).or_insert(0) += 1;
242 succs.entry(p.as_str()).or_default().push(d.id.as_str());
243 }
244 }
245 let mut queue: Vec<&str> = indeg
246 .iter()
247 .filter_map(|(k, v)| if *v == 0 { Some(*k) } else { None })
248 .collect();
249 let mut popped = 0_usize;
250 while let Some(node) = queue.pop() {
251 popped += 1;
252 if let Some(s) = succs.get(node).cloned() {
253 for next in s {
254 if let Some(deg) = indeg.get_mut(next) {
255 *deg -= 1;
256 if *deg == 0 {
257 queue.push(next);
258 }
259 }
260 }
261 }
262 }
263 if popped < graph.deliverables.len() {
264 let mut cycle_members: Vec<&str> = indeg
271 .iter()
272 .filter_map(|(k, v)| if *v > 0 { Some(*k) } else { None })
273 .collect();
274 cycle_members.sort_unstable();
275 return Err(PlannerError::InvalidGraph {
276 reason: format!(
277 "cycle detected in prerequisite graph involving deliverables: [{}]",
278 cycle_members.join(", ")
279 ),
280 });
281 }
282
283 Ok(())
284}
285
286fn deliverable_to_task(d: &Deliverable, estimator: &EffortEstimator) -> Task {
295 let description = d
296 .metadata
297 .get("description")
298 .and_then(|v| v.as_str())
299 .unwrap_or_default()
300 .to_string();
301 let kind = TaskKind::Custom { description };
302
303 let effort_hours = match d.estimated_effort_hours {
304 Some(explicit) => explicit,
305 None => {
306 let is_complex = d
308 .metadata
309 .get("complexity")
310 .or_else(|| d.metadata.get("is_complex"))
311 .and_then(serde_json::Value::as_bool)
312 .unwrap_or(false);
313 estimator.estimate(&kind, is_complex)
314 }
315 };
316
317 Task {
318 id: d.id.clone(),
319 name: d.id.clone(),
320 kind,
321 effort_hours,
322 dependencies: d.prerequisites.clone(),
323 ..Task::default()
324 }
325}
326
327fn make_acquired_event(lock: &LockInfo, owned_files: &[PathBuf]) -> AuditEvent {
332 AuditEvent::new("plan.lock.acquired")
333 .with_actor(lock.caller_id.as_str())
334 .with_payload(json!({
335 "plan_id": lock.plan_id.as_str(),
336 "deliverable_id": lock.deliverable_id,
337 "caller_id": lock.caller_id.as_str(),
338 "acquired_at": lock.acquired_at,
339 "expires_at": lock.expires_at,
340 "owned_files": owned_files,
341 }))
342}
343
344fn make_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
345 AuditEvent::new("plan.lock.released")
346 .with_actor(lock.caller_id.as_str())
347 .with_payload(json!({
348 "plan_id": lock.plan_id.as_str(),
349 "deliverable_id": lock.deliverable_id,
350 "caller_id": lock.caller_id.as_str(),
351 "reason": reason,
352 }))
353}
354
355fn make_expired_event(lock: &LockInfo, expired_at: DateTime<Utc>) -> AuditEvent {
356 AuditEvent::new("plan.lock.expired")
357 .with_actor(lock.caller_id.as_str())
358 .with_payload(json!({
359 "plan_id": lock.plan_id.as_str(),
360 "deliverable_id": lock.deliverable_id,
361 "last_caller_id": lock.caller_id.as_str(),
362 "expired_at": expired_at,
363 }))
364}
365
366fn make_force_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
367 AuditEvent::new("plan.lock.force_released")
368 .with_actor(lock.caller_id.as_str())
369 .with_payload(json!({
370 "plan_id": lock.plan_id.as_str(),
371 "deliverable_id": lock.deliverable_id,
372 "last_caller_id": lock.caller_id.as_str(),
373 "reason": reason,
374 }))
375}
376
377fn priority_key(
385 deliverable_id: &str,
386 cp_positions: &HashMap<&str, usize>,
387 es_by_id: &HashMap<&str, f32>,
388) -> (u8, i64, String) {
389 if let Some(pos) = cp_positions.get(deliverable_id) {
390 (0, *pos as i64, deliverable_id.to_string())
392 } else {
393 let es = match es_by_id.get(deliverable_id) {
400 Some(&es) => es,
401 None => unreachable!(
402 "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
403 CPM earliest-start table — ready set and CPM result are out of sync"
404 ),
405 };
406 let es_scaled = (es * 1000.0).round() as i64;
407 (1, es_scaled, deliverable_id.to_string())
408 }
409}
410
411#[async_trait]
416impl Planner for BasicCpmPlanner {
417 async fn submit_plan(&self, graph: PlanGraph) -> Result<PlanId, PlannerError> {
418 validate_graph(&graph)?;
419 let graph_hash = hash_graph(&graph);
420
421 {
423 let dedup = self.dedup.lock().await;
424 if let Some(existing) = dedup.get(&graph_hash) {
425 return Ok(existing.clone());
426 }
427 }
428
429 let estimator = EffortEstimator::new();
433 let mut tasks: Vec<Task> = graph
434 .deliverables
435 .iter()
436 .map(|d| deliverable_to_task(d, &estimator))
437 .collect();
438 let cached_result = CpmAlgorithm::calculate(&mut tasks);
439
440 if !cached_result.unscheduled.is_empty() {
446 return Err(PlannerError::InvalidGraph {
447 reason: format!(
448 "internal CPM inconsistency: deliverables passed cycle validation but could \
449 not be scheduled: [{}]",
450 cached_result.unscheduled.join(", ")
451 ),
452 });
453 }
454
455 let mut statuses: HashMap<String, DeliverableStatus> =
457 HashMap::with_capacity(graph.deliverables.len());
458 for d in &graph.deliverables {
459 let status = if d.prerequisites.is_empty() {
460 DeliverableStatus::Ready
461 } else {
462 DeliverableStatus::Pending
463 };
464 statuses.insert(d.id.clone(), status);
465 }
466
467 let plan_id = PlanId(format!("plan_{}", uuid::Uuid::new_v4().simple()));
471 let state = PlanState::new(graph, statuses, cached_result);
472
473 let mut dedup = self.dedup.lock().await;
474 if let Some(existing) = dedup.get(&graph_hash) {
475 return Ok(existing.clone());
476 }
477 let mut plans = self.plans.lock().await;
478 dedup.insert(graph_hash, plan_id.clone());
479 plans.insert(plan_id.clone(), state);
480 Ok(plan_id)
481 }
482
483 async fn acquire_cohort(
484 &self,
485 plan_id: &PlanId,
486 caller_id: &CallerId,
487 max_count: usize,
488 ) -> Result<Cohort, PlannerError> {
489 let now = self.now();
490 let expires_at = now
491 + chrono::Duration::from_std(self.ttl)
492 .expect("INVARIANT: planner TTL fits in chrono::Duration");
493
494 let (cohort, audit_buf) = {
497 let mut plans = self.plans.lock().await;
498 let state = plans
499 .get_mut(plan_id)
500 .ok_or_else(|| PlannerError::PlanNotFound {
501 plan_id: plan_id.0.clone(),
502 })?;
503
504 let mut audit_buf: Vec<AuditEvent> = Vec::new();
505
506 let reaped = state.reap_expired(now);
508 for lock in &reaped {
509 audit_buf.push(make_expired_event(lock, now));
510 }
511
512 let cp_positions: HashMap<&str, usize> = state
514 .cached_result
515 .critical_path
516 .iter()
517 .enumerate()
518 .map(|(i, id)| (id.as_str(), i))
519 .collect();
520 let es_by_id: HashMap<&str, f32> = state
521 .cached_result
522 .tasks
523 .iter()
524 .map(|t| (t.id.as_str(), t.earliest_start))
525 .collect();
526
527 let mut ready: Vec<&Deliverable> = state
529 .graph
530 .deliverables
531 .iter()
532 .filter(|d| {
533 matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
534 && !state.locks.contains_key(&d.id)
535 })
536 .collect();
537 ready.sort_by_key(|d| priority_key(&d.id, &cp_positions, &es_by_id));
538
539 let mut selected: Vec<Deliverable> = Vec::new();
541 let mut selected_files: HashSet<PathBuf> = HashSet::new();
542 for candidate in ready {
543 if selected.len() == max_count {
544 break;
545 }
546 let conflict = candidate.owned_files.iter().any(|f| {
547 selected_files.contains(f) || state.file_to_deliverable.contains_key(f)
548 });
549 if conflict {
550 continue;
551 }
552 for f in &candidate.owned_files {
553 selected_files.insert(f.clone());
554 }
555 selected.push(candidate.clone());
556 }
557
558 let mut rows: Vec<CohortRow> = Vec::with_capacity(selected.len());
567 for d in selected {
568 let lock = LockInfo {
569 plan_id: plan_id.clone(),
570 deliverable_id: d.id.clone(),
571 caller_id: caller_id.clone(),
572 acquired_at: now,
573 expires_at,
574 };
575 state
576 .statuses
577 .insert(d.id.clone(), DeliverableStatus::InProgress);
578 for f in &d.owned_files {
579 state.file_to_deliverable.insert(f.clone(), d.id.clone());
580 }
581 state.locks.insert(d.id.clone(), lock.clone());
582 audit_buf.push(make_acquired_event(&lock, &d.owned_files));
583 rows.push(CohortRow {
584 deliverable: d,
585 lock,
586 });
587 }
588
589 let cohort = Cohort {
590 plan_id: plan_id.clone(),
591 rows,
592 };
593
594 (cohort, audit_buf)
595 };
596
597 self.flush_audit(audit_buf).await;
598 Ok(cohort)
599 }
600
601 async fn mark_status(
602 &self,
603 plan_id: &PlanId,
604 deliverable_id: &str,
605 caller_id: &CallerId,
606 status: DeliverableStatus,
607 ) -> Result<(), PlannerError> {
608 let audit_buf = {
609 let mut plans = self.plans.lock().await;
610 let state = plans
611 .get_mut(plan_id)
612 .ok_or_else(|| PlannerError::PlanNotFound {
613 plan_id: plan_id.0.clone(),
614 })?;
615
616 if !state
618 .graph
619 .deliverables
620 .iter()
621 .any(|d| d.id == deliverable_id)
622 {
623 return Err(PlannerError::DeliverableNotFound {
624 plan_id: plan_id.0.clone(),
625 deliverable_id: deliverable_id.to_string(),
626 });
627 }
628
629 if let Some(lock) = state.locks.get(deliverable_id) {
631 if lock.caller_id != *caller_id {
632 return Err(PlannerError::LockNotHeld {
633 caller_id: caller_id.0.clone(),
634 deliverable_id: deliverable_id.to_string(),
635 });
636 }
637 }
638
639 let mut audit_buf: Vec<AuditEvent> = Vec::new();
640
641 let release_reason: Option<&'static str> = match &status {
643 DeliverableStatus::Complete => Some("completed"),
644 DeliverableStatus::Failed { .. } => Some("failed"),
645 _ => None,
646 };
647
648 if let Some(reason) = release_reason {
649 if let Some(lock) = state.locks.remove(deliverable_id) {
650 let owned_files: Vec<PathBuf> = match state
653 .graph
654 .deliverables
655 .iter()
656 .find(|d| d.id == deliverable_id)
657 {
658 Some(d) => d.owned_files.clone(),
659 None => unreachable!(
660 "deliverable {deliverable_id} present in locks but missing from \
661 graph — invariant broken"
662 ),
663 };
664 for f in &owned_files {
665 state.file_to_deliverable.remove(f);
666 }
667 audit_buf.push(make_released_event(&lock, reason));
668 }
669 }
670
671 state
673 .statuses
674 .insert(deliverable_id.to_string(), status.clone());
675
676 if matches!(status, DeliverableStatus::Complete) {
678 let dependents: Vec<String> = state
679 .graph
680 .deliverables
681 .iter()
682 .filter(|d| d.prerequisites.iter().any(|p| p == deliverable_id))
683 .map(|d| d.id.clone())
684 .collect();
685 for dep_id in dependents {
686 let dep = match state.graph.deliverables.iter().find(|d| d.id == dep_id) {
687 Some(d) => d,
688 None => {
689 unreachable!("dependent id {dep_id} present in graph but not findable")
690 }
691 };
692 let all_done = dep.prerequisites.iter().all(|p| {
693 matches!(state.statuses.get(p), Some(DeliverableStatus::Complete))
694 });
695 let currently_pending = matches!(
696 state.statuses.get(&dep_id),
697 Some(DeliverableStatus::Pending)
698 );
699 if all_done && currently_pending {
700 state.statuses.insert(dep_id, DeliverableStatus::Ready);
701 }
702 }
703 }
704
705 audit_buf
706 };
707
708 self.flush_audit(audit_buf).await;
716 Ok(())
717 }
718
719 async fn heartbeat(
720 &self,
721 plan_id: &PlanId,
722 deliverable_id: &str,
723 caller_id: &CallerId,
724 ) -> Result<(), PlannerError> {
725 let now = self.now();
726 let expires_at = now
727 + chrono::Duration::from_std(self.ttl)
728 .expect("INVARIANT: planner TTL fits in chrono::Duration");
729
730 let mut plans = self.plans.lock().await;
731 let state = plans
732 .get_mut(plan_id)
733 .ok_or_else(|| PlannerError::PlanNotFound {
734 plan_id: plan_id.0.clone(),
735 })?;
736
737 let lock =
738 state
739 .locks
740 .get_mut(deliverable_id)
741 .ok_or_else(|| PlannerError::LockNotHeld {
742 caller_id: caller_id.0.clone(),
743 deliverable_id: deliverable_id.to_string(),
744 })?;
745
746 if lock.caller_id != *caller_id {
747 return Err(PlannerError::LockNotHeld {
748 caller_id: caller_id.0.clone(),
749 deliverable_id: deliverable_id.to_string(),
750 });
751 }
752
753 if lock.expires_at < now {
756 return Err(PlannerError::LockExpired {
757 deliverable_id: deliverable_id.to_string(),
758 expired_at: lock.expires_at,
759 });
760 }
761
762 lock.expires_at = expires_at;
763 Ok(())
764 }
765
766 async fn status(&self, plan_id: &PlanId) -> Result<PlanStatus, PlannerError> {
767 let plans = self.plans.lock().await;
768 let state = plans
769 .get(plan_id)
770 .ok_or_else(|| PlannerError::PlanNotFound {
771 plan_id: plan_id.0.clone(),
772 })?;
773
774 let deliverables: Vec<(String, DeliverableStatus)> = state
776 .graph
777 .deliverables
778 .iter()
779 .map(|d| {
780 let status = state
781 .statuses
782 .get(&d.id)
783 .cloned()
784 .unwrap_or(DeliverableStatus::Pending);
785 (d.id.clone(), status)
786 })
787 .collect();
788
789 Ok(PlanStatus {
790 plan_id: plan_id.clone(),
791 deliverables,
792 critical_path: state.cached_result.critical_path.clone(),
793 critical_path_hours: state.cached_result.critical_path_duration,
794 locks_held: state.locks.values().cloned().collect(),
795 })
796 }
797
798 async fn force_release(
799 &self,
800 plan_id: &PlanId,
801 deliverable_id: &str,
802 reason: &str,
803 ) -> Result<(), PlannerError> {
804 let audit_buf = {
805 let mut plans = self.plans.lock().await;
806 let state = plans
807 .get_mut(plan_id)
808 .ok_or_else(|| PlannerError::PlanNotFound {
809 plan_id: plan_id.0.clone(),
810 })?;
811
812 if !state
813 .graph
814 .deliverables
815 .iter()
816 .any(|d| d.id == deliverable_id)
817 {
818 return Err(PlannerError::DeliverableNotFound {
819 plan_id: plan_id.0.clone(),
820 deliverable_id: deliverable_id.to_string(),
821 });
822 }
823
824 let mut audit_buf: Vec<AuditEvent> = Vec::new();
825 if let Some(lock) = state.locks.remove(deliverable_id) {
826 let owned_files: Vec<PathBuf> = match state
829 .graph
830 .deliverables
831 .iter()
832 .find(|d| d.id == deliverable_id)
833 {
834 Some(d) => d.owned_files.clone(),
835 None => unreachable!(
836 "deliverable {deliverable_id} present in locks but missing from graph"
837 ),
838 };
839 for f in &owned_files {
840 state.file_to_deliverable.remove(f);
841 }
842 state
843 .statuses
844 .insert(deliverable_id.to_string(), DeliverableStatus::Ready);
845 audit_buf.push(make_force_released_event(&lock, reason));
846 }
847
848 audit_buf
849 };
850
851 self.flush_audit(audit_buf).await;
852 Ok(())
853 }
854}
855
856#[cfg(test)]
857#[allow(clippy::float_cmp)]
858mod tests {
859 use super::*;
860
861 fn deliverable(id: &str, effort: Option<f32>, metadata: serde_json::Value) -> Deliverable {
862 Deliverable {
863 id: id.to_string(),
864 owned_files: Vec::new(),
865 prerequisites: Vec::new(),
866 estimated_effort_hours: effort,
867 metadata,
868 }
869 }
870
871 #[test]
872 fn explicit_effort_wins_over_estimator() {
873 let estimator = EffortEstimator::new();
874 let d = deliverable("D1", Some(2.5), json!({}));
875 let task = deliverable_to_task(&d, &estimator);
876 assert_eq!(task.effort_hours, 2.5);
877 }
878
879 #[test]
880 fn missing_effort_uses_estimator_not_flat_default() {
881 let estimator = EffortEstimator::new();
884 let d = deliverable("D1", None, json!({}));
885 let task = deliverable_to_task(&d, &estimator);
886 let expected = estimator.estimate(
887 &TaskKind::Custom {
888 description: String::new(),
889 },
890 false,
891 );
892 assert_eq!(task.effort_hours, expected);
893 assert_ne!(task.effort_hours, DEFAULT_EFFORT_HOURS);
894 }
895
896 #[test]
897 fn complexity_metadata_hint_raises_estimate() {
898 let estimator = EffortEstimator::new();
899 let simple = deliverable_to_task(&deliverable("S", None, json!({})), &estimator);
900 let complex = deliverable_to_task(
901 &deliverable("C", None, json!({ "complexity": true })),
902 &estimator,
903 );
904 assert!(complex.effort_hours > simple.effort_hours);
905 }
906
907 #[test]
908 fn priority_key_critical_tier_orders_by_position() {
909 let mut cp = HashMap::new();
910 cp.insert("A", 0usize);
911 cp.insert("B", 1usize);
912 let es: HashMap<&str, f32> = HashMap::new();
913 let ka = priority_key("A", &cp, &es);
914 let kb = priority_key("B", &cp, &es);
915 assert!(ka < kb);
916 }
917
918 #[test]
919 fn priority_key_noncritical_uses_es() {
920 let cp: HashMap<&str, usize> = HashMap::new();
921 let mut es = HashMap::new();
922 es.insert("X", 1.0_f32);
923 es.insert("Y", 3.0_f32);
924 let kx = priority_key("X", &cp, &es);
925 let ky = priority_key("Y", &cp, &es);
926 assert_eq!(kx.0, 1);
928 assert!(kx < ky);
929 }
930
931 #[test]
932 #[should_panic(expected = "absent from the cached CPM earliest-start table")]
933 fn priority_key_missing_es_is_invariant_breach() {
934 let cp: HashMap<&str, usize> = HashMap::new();
935 let es: HashMap<&str, f32> = HashMap::new();
936 let _ = priority_key("ghost", &cp, &es);
938 }
939}