Skip to main content

harn_vm/flow/
slice.rs

1//! Harn Flow `Slice` primitive.
2//!
3//! A slice is a deterministic DAG-closed bundle of atoms plus the smallest test
4//! gate implied by collector-provided atom coverage. The derivation code stays
5//! storage-agnostic: callers provide an atom index, intent-to-atom references,
6//! and a coverage map populated by language-specific collectors.
7
8use 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/// 32-byte SHA-256 content address of a [`Slice`] body.
19#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct SliceId(pub [u8; SLICE_ID_BYTES]);
21
22impl SliceId {
23    /// Produce a hex-encoded representation suitable for logs and JSON.
24    pub fn to_hex(&self) -> String {
25        hex::encode(self.0)
26    }
27
28    /// Parse a 64-character hex string into a `SliceId`.
29    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/// Opaque language-agnostic test identifier.
70#[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/// Opaque invariant predicate hash.
97#[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/// Result of applying an invariant predicate while deriving a slice.
124#[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/// Human or system approval included in the slice audit trail.
133#[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/// Collector-facing coverage contract.
144///
145/// `tests_by_atom` answers "which tests exercise this atom?". `atoms_by_test`
146/// answers "which atoms are exercised by this test?". Derivation uses both
147/// directions to compute a fixed point: touched atoms select required tests,
148/// and required tests pull in any additional atoms those tests exercise.
149#[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    /// Add one bidirectional coverage edge.
163    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/// Missing parent edge that kept an atom out of a derived slice.
189#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
190pub struct UnresolvedParent {
191    pub atom: AtomId,
192    pub missing_parent: AtomId,
193}
194
195/// Lifecycle state of a derived slice.
196#[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/// Atomic unit shipped by Harn Flow.
207#[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
219/// Input contract for [`derive_slice`].
220pub 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
263/// Derive a deterministic, dependency-closed slice for a candidate intent set.
264pub 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}