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