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::predicates::InvariantResult;
15use super::{Atom, AtomId, IntentId};
16
17const SLICE_ID_BYTES: usize = 32;
18
19/// 32-byte SHA-256 content address of a [`Slice`] body.
20#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub struct SliceId(pub [u8; SLICE_ID_BYTES]);
22
23impl SliceId {
24    /// Produce a hex-encoded representation suitable for logs and JSON.
25    pub fn to_hex(&self) -> String {
26        hex::encode(self.0)
27    }
28
29    /// Parse a 64-character hex string into a `SliceId`.
30    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/// Opaque language-agnostic test identifier.
71#[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/// Opaque invariant predicate hash.
98#[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/// Human or system approval included in the slice audit trail.
125#[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/// Collector-facing coverage contract.
136///
137/// `tests_by_atom` answers "which tests exercise this atom?". `atoms_by_test`
138/// answers "which atoms are exercised by this test?". Derivation uses both
139/// directions to compute a fixed point: touched atoms select required tests,
140/// and required tests pull in any additional atoms those tests exercise.
141#[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    /// Add one bidirectional coverage edge.
155    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/// Missing parent edge that kept an atom out of a derived slice.
181#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
182pub struct UnresolvedParent {
183    pub atom: AtomId,
184    pub missing_parent: AtomId,
185}
186
187/// Lifecycle state of a derived slice.
188#[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/// Atomic unit shipped by Harn Flow.
199#[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
211/// Input contract for [`derive_slice`].
212pub 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
255/// Derive a deterministic, dependency-closed slice for a candidate intent set.
256pub 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}