1use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use sha2::{Digest, Sha256};
13
14use super::{Atom, AtomId, IntentId};
15
16const SLICE_ID_BYTES: usize = 32;
17
18#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct SliceId(pub [u8; SLICE_ID_BYTES]);
21
22impl SliceId {
23 pub fn to_hex(&self) -> String {
25 hex::encode(self.0)
26 }
27
28 pub fn from_hex(raw: &str) -> Result<Self, SliceDerivationError> {
30 let bytes = hex::decode(raw)
31 .map_err(|error| SliceDerivationError::InvalidSliceId(error.to_string()))?;
32 if bytes.len() != SLICE_ID_BYTES {
33 return Err(SliceDerivationError::InvalidSliceId(format!(
34 "SliceId must be {SLICE_ID_BYTES} bytes, got {}",
35 bytes.len()
36 )));
37 }
38 let mut out = [0u8; SLICE_ID_BYTES];
39 out.copy_from_slice(&bytes);
40 Ok(Self(out))
41 }
42}
43
44impl fmt::Debug for SliceId {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(f, "SliceId({})", self.to_hex())
47 }
48}
49
50impl fmt::Display for SliceId {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 write!(f, "{}", self.to_hex())
53 }
54}
55
56impl Serialize for SliceId {
57 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
58 serializer.serialize_str(&self.to_hex())
59 }
60}
61
62impl<'de> Deserialize<'de> for SliceId {
63 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
64 let raw = String::deserialize(deserializer)?;
65 SliceId::from_hex(&raw).map_err(serde::de::Error::custom)
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71#[serde(transparent)]
72pub struct TestId(String);
73
74impl TestId {
75 pub fn new(value: impl Into<String>) -> Self {
76 Self(value.into())
77 }
78
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl From<&str> for TestId {
85 fn from(value: &str) -> Self {
86 Self::new(value)
87 }
88}
89
90impl From<String> for TestId {
91 fn from(value: String) -> Self {
92 Self::new(value)
93 }
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
98#[serde(transparent)]
99pub struct PredicateHash(String);
100
101impl PredicateHash {
102 pub fn new(value: impl Into<String>) -> Self {
103 Self(value.into())
104 }
105
106 pub fn as_str(&self) -> &str {
107 &self.0
108 }
109}
110
111impl From<&str> for PredicateHash {
112 fn from(value: &str) -> Self {
113 Self::new(value)
114 }
115}
116
117impl From<String> for PredicateHash {
118 fn from(value: String) -> Self {
119 Self::new(value)
120 }
121}
122
123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(tag = "status", rename_all = "snake_case")]
126pub enum InvariantResult {
127 Passed,
128 Failed { reason: String },
129 Waived { reason: String },
130}
131
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134pub struct Approval {
135 pub reviewer: String,
136 pub approved_at: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub reason: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub signature: Option<String>,
141}
142
143#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
150pub struct CoverageMap {
151 #[serde(default)]
152 pub tests_by_atom: BTreeMap<AtomId, BTreeSet<TestId>>,
153 #[serde(default)]
154 pub atoms_by_test: BTreeMap<TestId, BTreeSet<AtomId>>,
155}
156
157impl CoverageMap {
158 pub fn new() -> Self {
159 Self::default()
160 }
161
162 pub fn insert(&mut self, atom: AtomId, test: TestId) {
164 self.tests_by_atom
165 .entry(atom)
166 .or_default()
167 .insert(test.clone());
168 self.atoms_by_test.entry(test).or_default().insert(atom);
169 }
170
171 fn tests_for_atoms(&self, atoms: &BTreeSet<AtomId>) -> BTreeSet<TestId> {
172 atoms
173 .iter()
174 .filter_map(|atom| self.tests_by_atom.get(atom))
175 .flat_map(|tests| tests.iter().cloned())
176 .collect()
177 }
178
179 fn atoms_for_tests(&self, tests: &BTreeSet<TestId>) -> BTreeSet<AtomId> {
180 tests
181 .iter()
182 .filter_map(|test| self.atoms_by_test.get(test))
183 .flat_map(|atoms| atoms.iter().copied())
184 .collect()
185 }
186}
187
188#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
190pub struct UnresolvedParent {
191 pub atom: AtomId,
192 pub missing_parent: AtomId,
193}
194
195#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(tag = "status", rename_all = "snake_case")]
198pub enum SliceStatus {
199 Ready,
200 Empty,
201 Blocked {
202 unresolved_parents: Vec<UnresolvedParent>,
203 },
204}
205
206#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
208pub struct Slice {
209 pub id: SliceId,
210 pub atoms: Vec<AtomId>,
211 pub intents: Vec<IntentId>,
212 pub invariants_applied: Vec<(PredicateHash, InvariantResult)>,
213 pub required_tests: Vec<TestId>,
214 pub approval_chain: Vec<Approval>,
215 pub base_ref: AtomId,
216 pub status: SliceStatus,
217}
218
219pub struct SliceDerivationInput<'a> {
221 pub atoms: &'a BTreeMap<AtomId, Atom>,
222 pub intents: &'a BTreeMap<IntentId, Vec<AtomId>>,
223 pub candidate_intents: Vec<IntentId>,
224 pub coverage: &'a CoverageMap,
225 pub invariants_applied: Vec<(PredicateHash, InvariantResult)>,
226 pub approval_chain: Vec<Approval>,
227 pub base_ref: AtomId,
228}
229
230#[derive(Debug, PartialEq, Eq)]
231pub enum SliceDerivationError {
232 MissingIntent(IntentId),
233 MissingAtom(AtomId),
234 CyclicAtomParents,
235 InvalidSliceId(String),
236 Serialize(String),
237}
238
239impl fmt::Display for SliceDerivationError {
240 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241 match self {
242 SliceDerivationError::MissingIntent(id) => {
243 write!(f, "slice derivation missing intent {id}")
244 }
245 SliceDerivationError::MissingAtom(id) => {
246 write!(f, "slice derivation missing atom {id}")
247 }
248 SliceDerivationError::CyclicAtomParents => {
249 write!(f, "slice derivation found cyclic atom parents")
250 }
251 SliceDerivationError::InvalidSliceId(message) => {
252 write!(f, "slice id invalid: {message}")
253 }
254 SliceDerivationError::Serialize(message) => {
255 write!(f, "slice derivation serialization failed: {message}")
256 }
257 }
258 }
259}
260
261impl std::error::Error for SliceDerivationError {}
262
263pub fn derive_slice(input: SliceDerivationInput<'_>) -> Result<Slice, SliceDerivationError> {
265 let intents = sorted_unique(input.candidate_intents);
266 let mut seeds = BTreeSet::new();
267 for intent in &intents {
268 let atom_ids = input
269 .intents
270 .get(intent)
271 .ok_or(SliceDerivationError::MissingIntent(*intent))?;
272 for atom_id in atom_ids {
273 if !input.atoms.contains_key(atom_id) {
274 return Err(SliceDerivationError::MissingAtom(*atom_id));
275 }
276 seeds.insert(*atom_id);
277 }
278 }
279
280 let mut included = BTreeSet::new();
281 let mut required_tests = BTreeSet::new();
282 let mut unresolved = BTreeSet::new();
283
284 loop {
285 let closure = resolve_coherent_closure(&seeds, input.atoms, &mut unresolved)?;
286 let tests = input.coverage.tests_for_atoms(&closure);
287 let coverage_atoms = input.coverage.atoms_for_tests(&tests);
288
289 let mut next_seeds = seeds.clone();
290 for atom_id in coverage_atoms {
291 if !input.atoms.contains_key(&atom_id) {
292 return Err(SliceDerivationError::MissingAtom(atom_id));
293 }
294 next_seeds.insert(atom_id);
295 }
296
297 if next_seeds == seeds && tests == required_tests && closure == included {
298 included = closure;
299 required_tests = tests;
300 break;
301 }
302
303 seeds = next_seeds;
304 included = closure;
305 required_tests = tests;
306 }
307
308 let atom_order = stable_topological_order(&included, input.atoms)?;
309 let required_tests = required_tests.into_iter().collect::<Vec<_>>();
310 let unresolved_parents = unresolved.into_iter().collect::<Vec<_>>();
311 let status = if !unresolved_parents.is_empty() {
312 SliceStatus::Blocked { unresolved_parents }
313 } else if atom_order.is_empty() {
314 SliceStatus::Empty
315 } else {
316 SliceStatus::Ready
317 };
318
319 let mut slice = Slice {
320 id: SliceId([0; SLICE_ID_BYTES]),
321 atoms: atom_order,
322 intents,
323 invariants_applied: input.invariants_applied,
324 required_tests,
325 approval_chain: input.approval_chain,
326 base_ref: input.base_ref,
327 status,
328 };
329 slice.id = compute_slice_id(&slice)?;
330 Ok(slice)
331}
332
333fn sorted_unique(mut intents: Vec<IntentId>) -> Vec<IntentId> {
334 intents.sort();
335 intents.dedup();
336 intents
337}
338
339fn resolve_coherent_closure(
340 seeds: &BTreeSet<AtomId>,
341 atoms: &BTreeMap<AtomId, Atom>,
342 unresolved: &mut BTreeSet<UnresolvedParent>,
343) -> Result<BTreeSet<AtomId>, SliceDerivationError> {
344 let mut included = BTreeSet::new();
345 let mut visiting = BTreeSet::new();
346 let mut rejected = BTreeSet::new();
347 let mut cycle_detected = false;
348 for seed in seeds {
349 resolve_atom(
350 *seed,
351 atoms,
352 &mut included,
353 &mut visiting,
354 &mut rejected,
355 unresolved,
356 &mut cycle_detected,
357 );
358 }
359 if cycle_detected {
360 Err(SliceDerivationError::CyclicAtomParents)
361 } else {
362 Ok(included)
363 }
364}
365
366fn resolve_atom(
367 atom_id: AtomId,
368 atoms: &BTreeMap<AtomId, Atom>,
369 included: &mut BTreeSet<AtomId>,
370 visiting: &mut BTreeSet<AtomId>,
371 rejected: &mut BTreeSet<AtomId>,
372 unresolved: &mut BTreeSet<UnresolvedParent>,
373 cycle_detected: &mut bool,
374) -> bool {
375 if included.contains(&atom_id) {
376 return true;
377 }
378 if rejected.contains(&atom_id) {
379 return false;
380 }
381 if !visiting.insert(atom_id) {
382 *cycle_detected = true;
383 return false;
384 }
385
386 let Some(atom) = atoms.get(&atom_id) else {
387 rejected.insert(atom_id);
388 visiting.remove(&atom_id);
389 return false;
390 };
391
392 let mut parents_resolved = true;
393 for parent in &atom.parents {
394 if !atoms.contains_key(parent) {
395 unresolved.insert(UnresolvedParent {
396 atom: atom_id,
397 missing_parent: *parent,
398 });
399 parents_resolved = false;
400 continue;
401 }
402 if !resolve_atom(
403 *parent,
404 atoms,
405 included,
406 visiting,
407 rejected,
408 unresolved,
409 cycle_detected,
410 ) {
411 parents_resolved = false;
412 }
413 }
414
415 visiting.remove(&atom_id);
416 if parents_resolved {
417 included.insert(atom_id);
418 true
419 } else {
420 rejected.insert(atom_id);
421 false
422 }
423}
424
425fn stable_topological_order(
426 included: &BTreeSet<AtomId>,
427 atoms: &BTreeMap<AtomId, Atom>,
428) -> Result<Vec<AtomId>, SliceDerivationError> {
429 let mut indegree = BTreeMap::<AtomId, usize>::new();
430 let mut children = BTreeMap::<AtomId, BTreeSet<AtomId>>::new();
431 for atom_id in included {
432 indegree.insert(*atom_id, 0);
433 }
434
435 for atom_id in included {
436 let atom = atoms
437 .get(atom_id)
438 .ok_or(SliceDerivationError::MissingAtom(*atom_id))?;
439 for parent in atom
440 .parents
441 .iter()
442 .filter(|parent| included.contains(parent))
443 {
444 *indegree.entry(*atom_id).or_default() += 1;
445 children.entry(*parent).or_default().insert(*atom_id);
446 }
447 }
448
449 let mut ready = indegree
450 .iter()
451 .filter_map(|(atom_id, degree)| (*degree == 0).then_some(*atom_id))
452 .collect::<BTreeSet<_>>();
453 let mut ordered = Vec::with_capacity(included.len());
454
455 while let Some(atom_id) = ready.pop_first() {
456 ordered.push(atom_id);
457 if let Some(children) = children.get(&atom_id) {
458 for child in children {
459 let degree = indegree.get_mut(child).expect("child has indegree");
460 *degree -= 1;
461 if *degree == 0 {
462 ready.insert(*child);
463 }
464 }
465 }
466 }
467
468 if ordered.len() != included.len() {
469 return Err(SliceDerivationError::CyclicAtomParents);
470 }
471 Ok(ordered)
472}
473
474fn compute_slice_id(slice: &Slice) -> Result<SliceId, SliceDerivationError> {
475 #[derive(Serialize)]
476 struct SliceBody<'a> {
477 atoms: &'a [AtomId],
478 intents: &'a [IntentId],
479 invariants_applied: &'a [(PredicateHash, InvariantResult)],
480 required_tests: &'a [TestId],
481 approval_chain: &'a [Approval],
482 base_ref: AtomId,
483 status: &'a SliceStatus,
484 }
485
486 let body = SliceBody {
487 atoms: &slice.atoms,
488 intents: &slice.intents,
489 invariants_applied: &slice.invariants_applied,
490 required_tests: &slice.required_tests,
491 approval_chain: &slice.approval_chain,
492 base_ref: slice.base_ref,
493 status: &slice.status,
494 };
495 let bytes = serde_json::to_vec(&body)
496 .map_err(|error| SliceDerivationError::Serialize(error.to_string()))?;
497 Ok(SliceId(Sha256::digest(bytes).into()))
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::flow::{AtomSignature, Provenance, TextOp};
504 use proptest::prelude::*;
505 use time::OffsetDateTime;
506
507 fn id(byte: u8) -> AtomId {
508 AtomId([byte; 32])
509 }
510
511 fn intent(name: impl Into<String>) -> IntentId {
512 IntentId(Sha256::digest(name.into().as_bytes()).into())
513 }
514
515 fn test(name: impl Into<String>) -> TestId {
516 TestId::new(name)
517 }
518
519 fn atom(atom_id: AtomId, parents: Vec<AtomId>) -> Atom {
520 Atom {
521 id: atom_id,
522 ops: Vec::<TextOp>::new(),
523 parents,
524 provenance: Provenance {
525 principal: "user:alice".to_string(),
526 persona: "ship-captain".to_string(),
527 agent_run_id: "run-0001".to_string(),
528 tool_call_id: None,
529 trace_id: "trace-0001".to_string(),
530 transcript_ref: "transcript:0001".to_string(),
531 timestamp: OffsetDateTime::from_unix_timestamp(0).unwrap(),
532 },
533 signature: AtomSignature {
534 principal_key: [0; 32],
535 principal_sig: [0; 64],
536 persona_key: [0; 32],
537 persona_sig: [0; 64],
538 },
539 inverse_of: None,
540 }
541 }
542
543 fn derive(
544 atoms: &BTreeMap<AtomId, Atom>,
545 intents: &BTreeMap<IntentId, Vec<AtomId>>,
546 candidate_intents: Vec<IntentId>,
547 coverage: &CoverageMap,
548 ) -> Slice {
549 derive_slice(SliceDerivationInput {
550 atoms,
551 intents,
552 candidate_intents,
553 coverage,
554 invariants_applied: Vec::new(),
555 approval_chain: Vec::new(),
556 base_ref: id(0),
557 })
558 .unwrap()
559 }
560
561 proptest! {
562 #[test]
563 fn closure_contains_candidate_ancestors_and_parents_precede_children(
564 parent_bits in proptest::collection::vec(any::<u16>(), 1..12),
565 selected_bits in any::<u16>(),
566 ) {
567 let mut atoms = BTreeMap::new();
568 let mut expected_closure = BTreeSet::new();
569 let mut parents_by_atom = Vec::new();
570 for (index, bits) in parent_bits.iter().copied().enumerate() {
571 let atom_id = id((index + 1) as u8);
572 let parents = (0..index)
573 .filter(|parent| bits & (1 << (parent % 16)) != 0)
574 .map(|parent| id((parent + 1) as u8))
575 .collect::<Vec<_>>();
576 atoms.insert(atom_id, atom(atom_id, parents.clone()));
577 parents_by_atom.push((atom_id, parents));
578 }
579
580 let selected = parents_by_atom
581 .iter()
582 .enumerate()
583 .filter_map(|(index, (atom_id, _))| {
584 (selected_bits & (1 << (index % 16)) != 0).then_some(*atom_id)
585 })
586 .collect::<Vec<_>>();
587 let selected = if selected.is_empty() {
588 vec![parents_by_atom[0].0]
589 } else {
590 selected
591 };
592
593 fn add_ancestors(
594 atom_id: AtomId,
595 atoms: &BTreeMap<AtomId, Atom>,
596 expected: &mut BTreeSet<AtomId>,
597 ) {
598 if !expected.insert(atom_id) {
599 return;
600 }
601 for parent in &atoms.get(&atom_id).unwrap().parents {
602 add_ancestors(*parent, atoms, expected);
603 }
604 }
605
606 for atom_id in &selected {
607 add_ancestors(*atom_id, &atoms, &mut expected_closure);
608 }
609
610 let intents = BTreeMap::from([(intent("intent:selected"), selected)]);
611 let slice = derive(
612 &atoms,
613 &intents,
614 vec![intent("intent:selected")],
615 &CoverageMap::new(),
616 );
617
618 prop_assert_eq!(
619 slice.atoms.iter().copied().collect::<BTreeSet<_>>(),
620 expected_closure
621 );
622 prop_assert_eq!(slice.status, SliceStatus::Ready);
623
624 let positions = slice
625 .atoms
626 .iter()
627 .enumerate()
628 .map(|(index, atom_id)| (*atom_id, index))
629 .collect::<BTreeMap<_, _>>();
630 for atom_id in &slice.atoms {
631 for parent in &atoms.get(atom_id).unwrap().parents {
632 prop_assert!(positions[parent] < positions[atom_id]);
633 }
634 }
635 }
636
637 #[test]
638 fn stability_across_rederivations(candidate_flip in any::<bool>()) {
639 let atom_a = id(1);
640 let atom_b = id(2);
641 let atom_c = id(3);
642 let atoms = BTreeMap::from([
643 (atom_c, atom(atom_c, vec![atom_a, atom_b])),
644 (atom_a, atom(atom_a, Vec::new())),
645 (atom_b, atom(atom_b, vec![atom_a])),
646 ]);
647 let intents = BTreeMap::from([
648 (intent("intent:beta"), vec![atom_c]),
649 (intent("intent:alpha"), vec![atom_b]),
650 ]);
651 let candidates = if candidate_flip {
652 vec![intent("intent:beta"), intent("intent:alpha")]
653 } else {
654 vec![intent("intent:alpha"), intent("intent:beta")]
655 };
656 let mut coverage = CoverageMap::new();
657 coverage.insert(atom_b, test("test:flow"));
658 coverage.insert(atom_c, test("test:flow"));
659
660 let first = derive(&atoms, &intents, candidates.clone(), &coverage);
661 let second = derive(&atoms, &intents, candidates.into_iter().rev().collect(), &coverage);
662
663 prop_assert_eq!(&first, &second);
664 prop_assert_eq!(first.atoms, vec![atom_a, atom_b, atom_c]);
665 prop_assert_eq!(first.required_tests, vec![test("test:flow")]);
666 }
667 }
668
669 #[test]
670 fn coverage_map_selects_tests_and_pulls_test_covered_atoms() {
671 let touched = id(1);
672 let helper_parent = id(2);
673 let helper = id(3);
674 let atoms = BTreeMap::from([
675 (touched, atom(touched, Vec::new())),
676 (helper_parent, atom(helper_parent, Vec::new())),
677 (helper, atom(helper, vec![helper_parent])),
678 ]);
679 let intents = BTreeMap::from([(intent("intent:change"), vec![touched])]);
680 let mut coverage = CoverageMap::new();
681 coverage.insert(touched, test("test:targeted"));
682 coverage.insert(helper, test("test:targeted"));
683
684 let slice = derive(&atoms, &intents, vec![intent("intent:change")], &coverage);
685
686 assert_eq!(slice.atoms, vec![touched, helper_parent, helper]);
687 assert_eq!(slice.required_tests, vec![test("test:targeted")]);
688 assert_eq!(slice.status, SliceStatus::Ready);
689 }
690
691 #[test]
692 fn atoms_with_unresolved_parents_are_excluded_and_mark_slice_blocked() {
693 let parent = id(1);
694 let child = id(2);
695 let atoms = BTreeMap::from([(child, atom(child, vec![parent]))]);
696 let intents = BTreeMap::from([(intent("intent:change"), vec![child])]);
697
698 let slice = derive(
699 &atoms,
700 &intents,
701 vec![intent("intent:change")],
702 &CoverageMap::new(),
703 );
704
705 assert!(slice.atoms.is_empty());
706 assert_eq!(slice.required_tests, Vec::<TestId>::new());
707 assert_eq!(
708 slice.status,
709 SliceStatus::Blocked {
710 unresolved_parents: vec![UnresolvedParent {
711 atom: child,
712 missing_parent: parent,
713 }],
714 }
715 );
716 }
717
718 #[test]
719 fn cyclic_parent_graph_is_rejected() {
720 let atom_a = id(1);
721 let atom_b = id(2);
722 let atoms = BTreeMap::from([
723 (atom_a, atom(atom_a, vec![atom_b])),
724 (atom_b, atom(atom_b, vec![atom_a])),
725 ]);
726 let intents = BTreeMap::from([(intent("intent:cycle"), vec![atom_a])]);
727
728 let error = derive_slice(SliceDerivationInput {
729 atoms: &atoms,
730 intents: &intents,
731 candidate_intents: vec![intent("intent:cycle")],
732 coverage: &CoverageMap::new(),
733 invariants_applied: Vec::new(),
734 approval_chain: Vec::new(),
735 base_ref: id(0),
736 })
737 .unwrap_err();
738
739 assert_eq!(error, SliceDerivationError::CyclicAtomParents);
740 }
741
742 #[test]
743 fn slice_id_round_trips_through_json() {
744 let atom_id = id(1);
745 let atoms = BTreeMap::from([(atom_id, atom(atom_id, Vec::new()))]);
746 let intents = BTreeMap::from([(intent("intent:change"), vec![atom_id])]);
747 let slice = derive(
748 &atoms,
749 &intents,
750 vec![intent("intent:change")],
751 &CoverageMap::new(),
752 );
753
754 let json = serde_json::to_vec(&slice).unwrap();
755 let decoded: Slice = serde_json::from_slice(&json).unwrap();
756 assert_eq!(decoded, slice);
757 }
758}