1use crate::config::CuConfig;
5use crate::config::Flavor;
6use crate::config::MAX_RT_PRIORITY;
7use crate::config::SchedulingPolicy;
8use alloc::collections::BTreeMap;
9use alloc::collections::BTreeSet;
10use alloc::collections::VecDeque;
11use alloc::format;
12use alloc::string::String;
13use alloc::string::ToString;
14use alloc::vec::Vec;
15use cu29_traits::CuError;
16use cu29_traits::CuResult;
17use serde::Deserialize;
18use serde::Serialize;
19
20const PROFILE_VERSION: u32 = 1;
21
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct CuContract {
27 pub chains: Vec<CuChain>,
29 #[serde(default)]
31 pub sources: Vec<CuSourceRate>,
32 pub cpus: Vec<usize>,
34 pub max_in_flight: u32,
36 #[serde(default = "default_headroom")]
39 pub headroom: f64,
40 #[serde(default)]
42 pub worker_policy: SchedulingPolicy,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct CuChain {
51 pub id: String,
52 pub source: String,
53 pub sink: String,
54 pub deadline_ms: u32,
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct CuSourceRate {
61 pub task: String,
62 pub period_ms: u32,
63}
64
65fn default_headroom() -> f64 {
66 0.2
67}
68
69impl CuContract {
70 pub fn validate(&self, config: &CuConfig, mission: Option<&str>) -> CuResult<()> {
74 let graph = config.get_graph(mission)?;
75 let tasks: BTreeSet<String> = graph
76 .get_all_nodes()
77 .into_iter()
78 .filter(|(_, node)| node.get_flavor() == Flavor::Task)
79 .map(|(_, node)| node.get_id())
80 .collect();
81 let mut ids = BTreeSet::new();
82 for chain in &self.chains {
83 if chain.id.is_empty() || !ids.insert(&chain.id) {
84 return Err(CuError::from(format!(
85 "Chain id '{}' is empty or used twice",
86 chain.id
87 )));
88 }
89 for task in [&chain.source, &chain.sink] {
90 if !tasks.contains(task) {
91 return Err(CuError::from(format!(
92 "Chain '{}' names unknown task '{task}'",
93 chain.id
94 )));
95 }
96 }
97 if chain.deadline_ms == 0 {
98 return Err(CuError::from(format!(
99 "Chain '{}' needs a positive deadline",
100 chain.id
101 )));
102 }
103 let mut seen = BTreeSet::new();
104 let mut queue = VecDeque::from([chain.source.as_str()]);
105 while let Some(node) = queue.pop_front() {
106 if !seen.insert(node) {
107 continue;
108 }
109 for edge in graph.edges().filter(|edge| edge.src == node) {
110 queue.push_back(edge.dst.as_str());
111 }
112 }
113 if !seen.contains(chain.sink.as_str()) {
114 return Err(CuError::from(format!(
115 "Chain '{}': task '{}' is not reachable from '{}'",
116 chain.id, chain.sink, chain.source
117 )));
118 }
119 }
120 let mut source_tasks = BTreeSet::new();
121 for source in &self.sources {
122 if !source_tasks.insert(source.task.as_str()) {
123 return Err(CuError::from(format!(
124 "Source rate for task '{}' is listed twice",
125 source.task
126 )));
127 }
128 if !tasks.contains(&source.task) {
129 return Err(CuError::from(format!(
130 "Source rate names unknown task '{}'",
131 source.task
132 )));
133 }
134 if source.period_ms == 0 {
135 return Err(CuError::from(format!(
136 "Source '{}' needs a positive period",
137 source.task
138 )));
139 }
140 }
141 if self.cpus.is_empty() {
142 return Err(CuError::from("The contract needs at least one CPU"));
143 }
144 if self.cpus.iter().collect::<BTreeSet<_>>().len() != self.cpus.len() {
145 return Err(CuError::from("The contract lists a CPU twice"));
146 }
147 if self.max_in_flight == 0 {
148 return Err(CuError::from("max_in_flight must be positive"));
149 }
150 if !(0.0..1.0).contains(&self.headroom) {
151 return Err(CuError::from(
152 "headroom must be a fraction of the deadline in [0, 1)",
153 ));
154 }
155 match self.worker_policy {
156 SchedulingPolicy::Fifo { priority } | SchedulingPolicy::RoundRobin { priority }
157 if priority > MAX_RT_PRIORITY.saturating_sub(2) =>
158 {
159 return Err(CuError::from(format!(
160 "worker_policy priority {priority} leaves no room for the high-priority worker and dispatcher"
161 )));
162 }
163 _ => {}
164 }
165 Ok(())
166 }
167
168 pub fn deserialize_ron(text: &str) -> CuResult<Self> {
169 ron::from_str(text).map_err(|e| CuError::new_with_cause("Could not parse contract", e))
170 }
171
172 pub fn serialize_ron(&self) -> CuResult<String> {
173 ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
174 .map_err(|e| CuError::new_with_cause("Could not serialize contract", e))
175 }
176
177 #[cfg(feature = "std")]
178 pub fn read(path: &std::path::Path) -> CuResult<Self> {
179 let text = std::fs::read_to_string(path)
180 .map_err(|e| CuError::new_with_cause("Could not read contract", e))?;
181 Self::deserialize_ron(&text)
182 }
183
184 #[cfg(feature = "std")]
185 pub fn write(&self, path: &std::path::Path) -> CuResult<()> {
186 std::fs::write(path, self.serialize_ron()?)
187 .map_err(|e| CuError::new_with_cause("Could not write contract", e))
188 }
189}
190
191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct CuProfile {
196 pub version: u32,
197 pub config_signature: String,
200 pub mission: String,
201 pub copperlists: u64,
203 pub window_ns: u64,
205 pub copperlist_span: CuCostStats,
207 pub operations: BTreeMap<String, CuOperationProfile>,
208 pub chains: BTreeMap<String, CuChainProfile>,
209 pub sources: BTreeMap<String, CuSourceProfile>,
210}
211
212#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct CuOperationProfile {
216 pub fired: CuCostStats,
217 pub skipped: CuCostStats,
218 pub firing_rate_hz: f64,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub firing: Option<CuFiringPattern>,
223}
224
225#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct CuFiringPattern {
230 pub period: u32,
231 pub phases: Vec<u32>,
232}
233
234impl CuFiringPattern {
235 pub fn detect(first_id: u64, fired: &[bool], max_period: u32) -> Option<Self> {
239 (1..=max_period as usize)
240 .filter(|&period| fired.len() >= 2 * period)
241 .find(|&period| (period..fired.len()).all(|i| fired[i] == fired[i - period]))
242 .map(|period| Self {
243 period: period as u32,
244 phases: (0..period)
245 .filter(|&i| fired[i])
246 .map(|i| ((first_id + i as u64) % period as u64) as u32)
247 .collect(),
248 })
249 }
250
251 pub fn probability(&self, modulus: u32, offset: u32) -> f64 {
254 let g = gcd(self.period, modulus);
255 let hits = self
256 .phases
257 .iter()
258 .filter(|&&phase| phase % g == offset % g)
259 .count() as f64;
260 hits / f64::from(self.period / g)
261 }
262}
263
264fn gcd(a: u32, b: u32) -> u32 {
265 if b == 0 { a } else { gcd(b, a % b) }
266}
267
268#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct CuCostStats {
272 pub samples: u64,
273 pub min_ns: u64,
274 pub p50_ns: u64,
275 pub mean_ns: f64,
276 pub p95_ns: u64,
277 pub p99_ns: u64,
278 pub max_ns: u64,
279}
280
281impl CuCostStats {
282 pub fn from_samples(samples: &mut [u64]) -> Self {
284 if samples.is_empty() {
285 return Self::default();
286 }
287 samples.sort_unstable();
288 let percentile = |q: f64| {
289 let index = ((samples.len() - 1) as f64 * q + 0.5) as usize;
291 samples[index]
292 };
293 Self {
294 samples: samples.len() as u64,
295 min_ns: samples[0],
296 p50_ns: percentile(0.50),
297 mean_ns: samples.iter().map(|&v| v as f64).sum::<f64>() / samples.len() as f64,
298 p95_ns: percentile(0.95),
299 p99_ns: percentile(0.99),
300 max_ns: samples[samples.len() - 1],
301 }
302 }
303}
304
305#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct CuChainProfile {
309 pub deadline_ms: u32,
310 pub latency: CuCostStats,
311 pub misses: u64,
313}
314
315#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
317#[serde(deny_unknown_fields)]
318pub struct CuSourceProfile {
319 pub period_ms: u32,
320 pub fired: u64,
321 pub expected: f64,
324 pub delivered_rate: f64,
327}
328
329pub fn graph_signature(graph: &crate::config::CuGraph, mission: Option<&str>) -> String {
332 let mut parts = Vec::new();
333 parts.push(format!("mission={}", mission.unwrap_or("default")));
334 let mut nodes: Vec<_> = graph.get_all_nodes();
335 nodes.sort_by_key(|a| a.1.get_id());
336 for (_, node) in nodes {
337 let flavor = match node.get_flavor() {
338 Flavor::Task => "task",
339 Flavor::Bridge => "bridge",
340 };
341 parts.push(format!(
342 "node|{}|{}|{flavor}|kind={:?}|background={}|pool={}|anytime={:?}",
343 node.get_id(),
344 node.get_type(),
345 node.get_declared_task_kind(),
346 node.is_background(),
347 node.background_pool(),
348 node.anytime(),
349 ));
350 if let Some(resources) = node.get_resources() {
351 let mut resources: Vec<_> = resources.iter().collect();
352 resources.sort_by_key(|(binding, _)| *binding);
353 for (binding, resource) in resources {
354 parts.push(format!("resource|{}|{binding}|{resource}", node.get_id()));
355 }
356 }
357 if let Some(config) = node.get_instance_config() {
358 let mut values: Vec<_> = config.0.iter().collect();
359 values.sort_by_key(|(key, _)| *key);
360 for (key, value) in values {
361 parts.push(format!("config|{}|{key}|{value}", node.get_id()));
362 }
363 }
364 }
365 let endpoint = |node: &str, channel: Option<&str>| match channel {
366 Some(channel) => format!("{node}/{channel}"),
367 None => node.to_string(),
368 };
369 let mut edges: Vec<String> = graph
370 .edges()
371 .map(|cnx| {
372 format!(
373 "edge|{}|{}|{}",
374 endpoint(cnx.src.as_str(), cnx.src_channel.as_deref()),
375 endpoint(cnx.dst.as_str(), cnx.dst_channel.as_deref()),
376 cnx.msg
377 )
378 })
379 .collect();
380 edges.sort();
381 parts.extend(edges);
382 let joined = parts.join("\n");
383 let mut hash: u64 = 0xcbf29ce484222325;
384 for byte in joined.as_bytes() {
385 hash ^= u64::from(*byte);
386 hash = hash.wrapping_mul(0x100000001b3);
387 }
388 format!("fnv1a64:{hash:016x}")
389}
390
391impl CuProfile {
392 pub fn new(config_signature: String, mission: String) -> Self {
393 Self {
394 version: PROFILE_VERSION,
395 config_signature,
396 mission,
397 copperlists: 0,
398 window_ns: 0,
399 copperlist_span: CuCostStats::default(),
400 operations: BTreeMap::new(),
401 chains: BTreeMap::new(),
402 sources: BTreeMap::new(),
403 }
404 }
405
406 pub fn serialize_ron(&self) -> CuResult<String> {
407 ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
408 .map_err(|e| CuError::new_with_cause("Could not serialize profile", e))
409 }
410
411 pub fn deserialize_ron(text: &str) -> CuResult<Self> {
412 let profile: Self = ron::from_str(text)
413 .map_err(|e| CuError::new_with_cause("Could not parse profile", e))?;
414 if profile.version != PROFILE_VERSION {
415 return Err(CuError::from(format!(
416 "Unsupported profile version {}; expected {PROFILE_VERSION}",
417 profile.version
418 )));
419 }
420 Ok(profile)
421 }
422
423 #[cfg(feature = "std")]
424 pub fn read(path: &std::path::Path) -> CuResult<Self> {
425 let text = std::fs::read_to_string(path)
426 .map_err(|e| CuError::new_with_cause("Could not read profile", e))?;
427 Self::deserialize_ron(&text)
428 }
429
430 #[cfg(feature = "std")]
431 pub fn write(&self, path: &std::path::Path) -> CuResult<()> {
432 std::fs::write(path, self.serialize_ron()?)
433 .map_err(|e| CuError::new_with_cause("Could not write profile", e))
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 fn config() -> CuConfig {
442 CuConfig::deserialize_ron(
443 r#"(
444 tasks: [(id: "src", type: "Source"), (id: "work", type: "Work"), (id: "sink", type: "Sink"), (id: "other", type: "Source")],
445 cnx: [(src: "src", dst: "work", msg: "u32"), (src: "work", dst: "sink", msg: "u32"), (src: "other", dst: "__nc__", msg: "u32")],
446 )"#,
447 )
448 .unwrap()
449 }
450
451 fn contract() -> CuContract {
452 CuContract::deserialize_ron(
453 r#"(
454 chains: [(id: "hot", source: "src", sink: "sink", deadline_ms: 10)],
455 sources: [(task: "src", period_ms: 20)],
456 cpus: [0, 1],
457 max_in_flight: 2,
458 )"#,
459 )
460 .unwrap()
461 }
462
463 #[test]
464 fn contract_parses_with_defaults_and_validates() {
465 let contract = contract();
466 assert_eq!(contract.headroom, 0.2);
467 contract.validate(&config(), None).unwrap();
468 assert_eq!(
469 CuContract::deserialize_ron(&contract.serialize_ron().unwrap()).unwrap(),
470 contract
471 );
472 }
473
474 #[test]
475 fn contract_rejects_unknown_tasks_unreachable_sinks_and_bad_values() {
476 let config = config();
477 let mutations: [fn(&mut CuContract); 9] = [
478 |c| c.chains[0].sink = "missing".into(),
479 |c| c.chains[0].sink = "other".into(),
480 |c| c.chains[0].deadline_ms = 0,
481 |c| c.chains.push(c.chains[0].clone()),
482 |c| c.sources[0].task = "missing".into(),
483 |c| c.sources.push(c.sources[0].clone()),
484 |c| c.cpus = vec![1, 1],
485 |c| c.max_in_flight = 0,
486 |c| c.headroom = 1.0,
487 ];
488 for (index, mutate) in mutations.iter().enumerate() {
489 let mut invalid = contract();
490 mutate(&mut invalid);
491 assert!(invalid.validate(&config, None).is_err(), "mutation {index}");
492 }
493 }
494
495 #[test]
496 fn cost_stats_summarize_and_profile_round_trips() {
497 let mut samples = [5u64, 1, 4, 2, 3];
498 let stats = CuCostStats::from_samples(&mut samples);
499 assert_eq!(
500 (stats.samples, stats.min_ns, stats.p50_ns, stats.max_ns),
501 (5, 1, 3, 5)
502 );
503 assert_eq!(stats.mean_ns, 3.0);
504 assert_eq!(CuCostStats::from_samples(&mut []), CuCostStats::default());
505 let mut profile = CuProfile::new("sig".into(), "default".into());
506 profile.operations.insert(
507 "mission:default|task:src|phase:whole".into(),
508 CuOperationProfile {
509 fired: stats,
510 skipped: CuCostStats::default(),
511 firing_rate_hz: 50.0,
512 firing: None,
513 },
514 );
515 let text = profile.serialize_ron().unwrap();
516 assert_eq!(CuProfile::deserialize_ron(&text).unwrap(), profile);
517 assert!(CuProfile::deserialize_ron(&text.replace("version: 1", "version: 9")).is_err());
518 }
519
520 #[test]
521 fn a_firing_pattern_is_detected_and_projected_on_another_modulus() {
522 let fired: Vec<bool> = (2u64..40).map(|id| id.is_multiple_of(3)).collect();
524 let pattern = CuFiringPattern::detect(2, &fired, 64).unwrap();
525 assert_eq!((pattern.period, pattern.phases.clone()), (3, vec![0]));
526 assert_eq!(pattern.probability(3, 0), 1.0);
527 assert_eq!(pattern.probability(3, 1), 0.0);
528 assert!((pattern.probability(4, 1) - 1.0 / 3.0).abs() < 1e-9);
530 assert!(CuFiringPattern::detect(0, &[true, false, false, true, false], 64).is_none());
532 let every = CuFiringPattern::detect(0, &[true; 8], 64).unwrap();
533 assert_eq!((every.period, every.phases), (1, vec![0]));
534 }
535}