Skip to main content

holos_tda/
index_stream.rs

1//! Stateful index streams with proof output.
2
3use crate::{
4    CorrespondenceMode, Error, IndexDeltaProof, IndexProofError, IndexSnapshotProof,
5    IndexTransition, PersistenceIndex, Result, SparseDistanceMatrix, TopologyPatch,
6};
7
8/// Proof record emitted by one stream step.
9#[derive(Debug, Clone)]
10pub enum IndexStreamProof {
11    /// Warm proof against the preceding root.
12    Delta(IndexDeltaProof),
13    /// Cold proof after the listed-edge envelope changed.
14    Snapshot(IndexSnapshotProof),
15}
16
17impl IndexStreamProof {
18    /// Encode this record in its canonical format.
19    pub fn encode(&self) -> std::result::Result<Vec<u8>, IndexProofError> {
20        match self {
21            Self::Delta(proof) => proof.encode(),
22            Self::Snapshot(proof) => proof.encode(),
23        }
24    }
25
26    /// Whether this record starts a new proof state.
27    pub fn is_snapshot(&self) -> bool {
28        matches!(self, Self::Snapshot(_))
29    }
30}
31
32/// One committed step in an [`IndexStream`].
33#[derive(Debug, Clone)]
34pub struct IndexStreamStep {
35    /// One-based sequence number in this stream.
36    pub sequence: u64,
37    /// Exact index transition.
38    pub transition: IndexTransition,
39    /// Proof of the new root.
40    pub proof: IndexStreamProof,
41}
42
43/// Stateful transaction stream over persistence-index versions.
44#[derive(Debug, Clone)]
45pub struct IndexStream {
46    index: PersistenceIndex,
47    sequence: u64,
48}
49
50impl IndexStream {
51    /// Start a stream at a compiled index version.
52    pub fn new(index: PersistenceIndex) -> Self {
53        Self { index, sequence: 0 }
54    }
55
56    /// Current index version.
57    pub fn current(&self) -> &PersistenceIndex {
58        &self.index
59    }
60
61    /// Number of committed steps.
62    pub fn sequence(&self) -> u64 {
63        self.sequence
64    }
65
66    /// Produce a cold proof for the current version.
67    pub fn checkpoint(&self) -> std::result::Result<IndexSnapshotProof, IndexProofError> {
68        IndexSnapshotProof::from_index(&self.index)
69    }
70
71    /// Apply one active-topology patch atomically.
72    pub fn apply_patch(
73        &mut self,
74        patch: &TopologyPatch,
75        correspondence_mode: CorrespondenceMode,
76    ) -> Result<IndexStreamStep> {
77        let old = self.index.clone();
78        let transition = old.transition_patch_with(patch, correspondence_mode)?;
79        self.commit(old, transition)
80    }
81
82    /// Apply one graph state atomically.
83    ///
84    /// An envelope change emits a cold checkpoint. A fixed-envelope change
85    /// emits a warm delta against the preceding root.
86    pub fn apply_graph(
87        &mut self,
88        graph: &SparseDistanceMatrix,
89        correspondence_mode: CorrespondenceMode,
90    ) -> Result<IndexStreamStep> {
91        let old = self.index.clone();
92        let transition = old.transition_with(graph, correspondence_mode)?;
93        self.commit(old, transition)
94    }
95
96    /// Apply an ordered patch batch atomically.
97    ///
98    /// If one patch fails, neither the index nor the sequence changes.
99    pub fn apply_patches(
100        &mut self,
101        patches: &[TopologyPatch],
102        correspondence_mode: CorrespondenceMode,
103    ) -> Result<Vec<IndexStreamStep>> {
104        let mut candidate = self.clone();
105        let mut steps = Vec::with_capacity(patches.len());
106        for patch in patches {
107            steps.push(candidate.apply_patch(patch, correspondence_mode)?);
108        }
109        *self = candidate;
110        Ok(steps)
111    }
112
113    fn commit(
114        &mut self,
115        old: PersistenceIndex,
116        transition: IndexTransition,
117    ) -> Result<IndexStreamStep> {
118        let proof = if old.topology() == transition.index.topology()
119            && old.graph().len() == transition.index.graph().len()
120        {
121            IndexStreamProof::Delta(
122                IndexDeltaProof::between(&old, &transition.index).map_err(proof_error)?,
123            )
124        } else {
125            IndexStreamProof::Snapshot(
126                IndexSnapshotProof::from_index(&transition.index).map_err(proof_error)?,
127            )
128        };
129        let sequence = self
130            .sequence
131            .checked_add(1)
132            .ok_or_else(|| Error::InvalidInput("index stream sequence overflow".into()))?;
133        self.index = transition.index.clone();
134        self.sequence = sequence;
135        Ok(IndexStreamStep {
136            sequence,
137            transition,
138            proof,
139        })
140    }
141}
142
143fn proof_error(error: IndexProofError) -> Error {
144    Error::InvalidInput(error.to_string())
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::{CertificateLimits, IndexEdit, IndexParams, RipsParams};
151
152    fn graph(weight: f64) -> SparseDistanceMatrix {
153        SparseDistanceMatrix::from_triplets(
154            5,
155            &[
156                (0, 1, weight),
157                (0, 2, 1.0),
158                (1, 2, 1.5),
159                (0, 3, 1.1),
160                (1, 3, 1.6),
161                (0, 4, 1.2),
162                (1, 4, 1.7),
163            ],
164        )
165        .unwrap()
166    }
167
168    #[test]
169    fn stream_emits_warm_and_cold_records() {
170        let mut params = RipsParams::new(1).with_modulus(3);
171        params.threshold = Some(2.0);
172        let index = PersistenceIndex::compile(
173            &graph(0.0),
174            &params,
175            IndexParams::default(),
176            CertificateLimits::default(),
177        )
178        .unwrap();
179        let mut stream = IndexStream::new(index);
180        let patch = TopologyPatch::new(vec![IndexEdit::deactivate(0, 1)]);
181        let warm = stream
182            .apply_patch(&patch, CorrespondenceMode::Omit)
183            .unwrap();
184        assert_eq!(warm.sequence, 1);
185        assert!(!warm.proof.is_snapshot());
186
187        let changed_envelope = SparseDistanceMatrix::from_triplets(5, &[(0, 1, 0.5)]).unwrap();
188        let cold = stream
189            .apply_graph(&changed_envelope, CorrespondenceMode::Omit)
190            .unwrap();
191        assert_eq!(cold.sequence, 2);
192        assert!(cold.proof.is_snapshot());
193    }
194
195    #[test]
196    fn patch_batch_is_atomic() {
197        let mut params = RipsParams::new(1);
198        params.threshold = Some(2.0);
199        let index = PersistenceIndex::compile(
200            &graph(0.0),
201            &params,
202            IndexParams::default(),
203            CertificateLimits::default(),
204        )
205        .unwrap();
206        let mut stream = IndexStream::new(index);
207        let old_root = stream.current().version();
208        let patches = [
209            TopologyPatch::new(vec![IndexEdit::set_weight(0, 2, 1.1)]),
210            TopologyPatch::new(vec![IndexEdit::activate(0, 1, 3.0)]),
211        ];
212        assert!(
213            stream
214                .apply_patches(&patches, CorrespondenceMode::Omit)
215                .is_err()
216        );
217        assert_eq!(stream.sequence(), 0);
218        assert_eq!(stream.current().version(), old_root);
219    }
220}