Skip to main content

khive_changeset/
invert.rs

1//! Pure, stage-time inversion against caller-supplied current records.
2
3use std::collections::BTreeMap;
4
5use khive_types::{Entity, Id128, Link, Note, NoteStatus};
6
7use crate::{
8    ChangeSet, CreateOp, CreateTarget, DeleteOp, DeletePreimage, EdgePatch, EdgePreimage,
9    EntityPatch, EntityPreimage, Envelope, LinkOp, NotePatch, NotePreimage, Op, UpdateOp,
10    UpdatePatch, UpdatePreimage,
11};
12
13/// Live records read by the caller when staging a revert.
14///
15/// Each key must equal its record's identifier. The caller supplies records in
16/// the same field representation as the staged operations; inversion does not
17/// normalize properties, tags, namespaces, or default values. Ordered maps keep
18/// this input deterministic on native and wasm32 targets.
19///
20/// See `crates/khive-changeset/docs/api/invert.md` for caller responsibilities.
21#[derive(Clone, Debug, Default)]
22pub struct CurrentState {
23    /// Current entity records, keyed by their header identifiers.
24    pub entities: BTreeMap<Id128, Entity>,
25    /// Current note records, keyed by their header identifiers.
26    pub notes: BTreeMap<Id128, Note>,
27    /// Current edge records, keyed by their edge identifiers.
28    pub edges: BTreeMap<Id128, Link>,
29}
30
31/// A refusal to stage an inverse; no partially inverted change-set is returned.
32///
33/// Operation indexes are zero-based indexes in the original forward op-list.
34#[derive(Debug, thiserror::Error)]
35pub enum InvertError {
36    /// A revert must have its own provenance rather than reuse the forward envelope.
37    #[error("the inverse envelope must differ from the forward envelope")]
38    ReusedEnvelope,
39    /// This format cannot yet represent the exact inverse of this operation.
40    #[error("operation {index} ({op}) is not invertible")]
41    NotInvertible {
42        /// Original forward operation index.
43        index: usize,
44        /// Forward operation name.
45        op: &'static str,
46    },
47    /// The caller did not supply the target record in the required substrate map.
48    #[error("operation {index} is missing current record {id}")]
49    MissingCurrent {
50        /// Original forward operation index.
51        index: usize,
52        /// Identifier of the required current record.
53        id: Id128,
54    },
55    /// The working record no longer matches a field required by the forward operation.
56    #[error("operation {index} record {id} differs in field {field}")]
57    Invalidated {
58        /// Original forward operation index.
59        index: usize,
60        /// Identifier of the affected record.
61        id: Id128,
62        /// Mismatched field name; `id` also names map-key/record-ID mismatches.
63        field: &'static str,
64    },
65    /// The existing checked update constructor refused the swapped patch/preimage.
66    #[error("operation {index} inverse update is invalid: {reason}")]
67    InverseUpdate {
68        /// Original forward operation index.
69        index: usize,
70        /// The checked constructor's diagnostic.
71        reason: String,
72    },
73}
74
75impl ChangeSet {
76    /// Produces a reverse-ordered inverse with the caller's fresh envelope.
77    ///
78    /// Current records are checked and rewound in a private working copy, so
79    /// repeated updates compare against the correct intermediate values. An
80    /// update checks only fields it touches. A create or link must still match
81    /// its staged fields and be live; its inverse is a soft delete carrying the
82    /// supplied full record, including its actual timestamps.
83    ///
84    /// This is a pure stage-time check, not an atomic comparison at landing.
85    /// The caller reads current records and supplies the revert's provenance.
86    /// Neither this change-set nor `current` is modified, even on refusal.
87    ///
88    /// # Errors
89    ///
90    /// Refuses a reused envelope, delete/merge operations, missing or invalidated
91    /// current records, or a swapped update rejected by [`UpdateOp::new`].
92    pub fn invert(
93        &self,
94        envelope: Envelope,
95        current: &CurrentState,
96    ) -> Result<ChangeSet, InvertError> {
97        if envelope == self.envelope {
98            return Err(InvertError::ReusedEnvelope);
99        }
100        let mut working = current.clone();
101        let mut ops = Vec::with_capacity(self.ops.len());
102        for (index, op) in self.ops.iter().enumerate().rev() {
103            let inverse = match op {
104                Op::Create(create) => invert_create(index, create, &mut working)?,
105                Op::Link(link) => invert_link(index, link, &mut working)?,
106                Op::Update(update) => invert_update(index, update, &mut working)?,
107                Op::Delete(_) => {
108                    return Err(InvertError::NotInvertible {
109                        index,
110                        op: "delete",
111                    });
112                }
113                Op::Merge(_) => {
114                    return Err(InvertError::NotInvertible { index, op: "merge" });
115                }
116            };
117            ops.push(inverse);
118        }
119        Ok(ChangeSet::new(envelope, ops))
120    }
121}
122
123fn check(index: usize, id: Id128, field: &'static str, matches: bool) -> Result<(), InvertError> {
124    if matches {
125        Ok(())
126    } else {
127        Err(InvertError::Invalidated { index, id, field })
128    }
129}
130
131fn invert_create(
132    index: usize,
133    create: &CreateOp,
134    working: &mut CurrentState,
135) -> Result<Op, InvertError> {
136    let id = create.id;
137    let preimage = match &create.target {
138        CreateTarget::Entity(fields) => {
139            let row = working
140                .entities
141                .get(&id)
142                .ok_or(InvertError::MissingCurrent { index, id })?;
143            check(index, id, "id", row.header.id == id)?;
144            check(
145                index,
146                id,
147                "namespace",
148                row.header.namespace == create.namespace,
149            )?;
150            check(index, id, "deleted_at", row.deleted_at.is_none())?;
151            check(index, id, "kind", row.kind == fields.entity_kind)?;
152            check(
153                index,
154                id,
155                "entity_type",
156                row.entity_type == fields.entity_type,
157            )?;
158            check(index, id, "name", row.name == fields.name)?;
159            check(
160                index,
161                id,
162                "description",
163                row.description == fields.description,
164            )?;
165            check(index, id, "properties", row.properties == fields.properties)?;
166            check(index, id, "tags", row.tags == fields.tags)?;
167            let captured = row.clone();
168            working.entities.remove(&id);
169            DeletePreimage::Entity(Box::new(captured))
170        }
171        CreateTarget::Note(fields) => {
172            let row = working
173                .notes
174                .get(&id)
175                .ok_or(InvertError::MissingCurrent { index, id })?;
176            check(index, id, "id", row.header.id == id)?;
177            check(
178                index,
179                id,
180                "namespace",
181                row.header.namespace == create.namespace,
182            )?;
183            check(index, id, "deleted_at", row.deleted_at.is_none())?;
184            check(index, id, "kind", row.kind == fields.note_kind)?;
185            check(index, id, "status", row.status == NoteStatus::default())?;
186            check(index, id, "expires_at", row.expires_at.is_none())?;
187            check(index, id, "content", row.content == fields.content)?;
188            check(index, id, "properties", row.properties == fields.properties)?;
189            check(index, id, "tags", row.tags == fields.tags)?;
190            check(index, id, "salience", row.salience == fields.salience)?;
191            check(
192                index,
193                id,
194                "decay_factor",
195                row.decay_factor == fields.decay_factor,
196            )?;
197            let captured = row.clone();
198            working.notes.remove(&id);
199            DeletePreimage::Note(Box::new(captured))
200        }
201    };
202    Ok(Op::Delete(DeleteOp {
203        target_id: id,
204        hard: false,
205        preimage,
206    }))
207}
208
209fn invert_link(index: usize, link: &LinkOp, working: &mut CurrentState) -> Result<Op, InvertError> {
210    let id = link.id;
211    let row = working
212        .edges
213        .get(&id)
214        .ok_or(InvertError::MissingCurrent { index, id })?;
215    check(index, id, "id", row.id == id)?;
216    check(
217        index,
218        id,
219        "namespace",
220        row.namespace == link.namespace.as_str(),
221    )?;
222    check(index, id, "deleted_at", row.deleted_at.is_none())?;
223    check(index, id, "source", row.source == link.source)?;
224    check(index, id, "target", row.target == link.target)?;
225    check(index, id, "relation", row.relation == link.relation)?;
226    check(index, id, "weight", row.weight == link.weight)?;
227    check(index, id, "properties", row.properties == link.properties)?;
228    let captured = row.clone();
229    working.edges.remove(&id);
230    Ok(Op::Delete(DeleteOp {
231        target_id: id,
232        hard: false,
233        preimage: DeletePreimage::Edge(Box::new(captured)),
234    }))
235}
236
237fn invert_update(
238    index: usize,
239    update: &UpdateOp,
240    working: &mut CurrentState,
241) -> Result<Op, InvertError> {
242    let id = update.target_id();
243    match (update.patch(), update.preimage()) {
244        (UpdatePatch::Entity(patch), UpdatePreimage::Entity(prior)) => {
245            let row = working
246                .entities
247                .get_mut(&id)
248                .ok_or(InvertError::MissingCurrent { index, id })?;
249            check(index, id, "id", row.header.id == id)?;
250            if let Some(value) = &patch.name {
251                check(index, id, "name", &row.name == value)?;
252            }
253            if let Some(value) = &patch.description {
254                check(
255                    index,
256                    id,
257                    "description",
258                    row.description.as_ref() == value.as_ref(),
259                )?;
260            }
261            if let Some(value) = &patch.entity_type {
262                check(
263                    index,
264                    id,
265                    "entity_type",
266                    row.entity_type.as_ref() == value.as_ref(),
267                )?;
268            }
269            if let Some(value) = &patch.properties {
270                check(index, id, "properties", &row.properties == value)?;
271            }
272            if let Some(value) = &patch.tags {
273                check(index, id, "tags", &row.tags == value)?;
274            }
275            let inverse = swapped_update(index, update)?;
276            if let Some(value) = &prior.name {
277                row.name = value.clone();
278            }
279            if let Some(value) = &prior.description {
280                row.description = value.clone();
281            }
282            if let Some(value) = &prior.entity_type {
283                row.entity_type = value.clone();
284            }
285            if let Some(value) = &prior.properties {
286                row.properties = value.clone();
287            }
288            if let Some(value) = &prior.tags {
289                row.tags = value.clone();
290            }
291            Ok(Op::Update(inverse))
292        }
293        (UpdatePatch::Note(patch), UpdatePreimage::Note(prior)) => {
294            let row = working
295                .notes
296                .get_mut(&id)
297                .ok_or(InvertError::MissingCurrent { index, id })?;
298            check(index, id, "id", row.header.id == id)?;
299            if let Some(value) = &patch.content {
300                check(index, id, "content", &row.content == value)?;
301            }
302            if let Some(value) = patch.salience {
303                check(index, id, "salience", row.salience == value)?;
304            }
305            if let Some(value) = patch.decay_factor {
306                check(index, id, "decay_factor", row.decay_factor == value)?;
307            }
308            if let Some(value) = &patch.properties {
309                check(index, id, "properties", &row.properties == value)?;
310            }
311            if let Some(value) = &patch.tags {
312                check(index, id, "tags", &row.tags == value)?;
313            }
314            let inverse = swapped_update(index, update)?;
315            if let Some(value) = &prior.content {
316                row.content = value.clone();
317            }
318            if let Some(value) = prior.salience {
319                row.salience = value;
320            }
321            if let Some(value) = prior.decay_factor {
322                row.decay_factor = value;
323            }
324            if let Some(value) = &prior.properties {
325                row.properties = value.clone();
326            }
327            if let Some(value) = &prior.tags {
328                row.tags = value.clone();
329            }
330            Ok(Op::Update(inverse))
331        }
332        (UpdatePatch::Edge(patch), UpdatePreimage::Edge(prior)) => {
333            let row = working
334                .edges
335                .get_mut(&id)
336                .ok_or(InvertError::MissingCurrent { index, id })?;
337            check(index, id, "id", row.id == id)?;
338            if let Some(value) = patch.relation {
339                check(index, id, "relation", row.relation == value)?;
340            }
341            if let Some(value) = patch.weight {
342                check(index, id, "weight", row.weight == value)?;
343            }
344            let inverse = swapped_update(index, update)?;
345            if let Some(value) = prior.relation {
346                row.relation = value;
347            }
348            if let Some(value) = prior.weight {
349                row.weight = value;
350            }
351            Ok(Op::Update(inverse))
352        }
353        // The checked constructor and decoder prevent this pairing. Keep the
354        // refusal typed rather than panic if that invariant ever changes.
355        _ => swapped_update(index, update).map(Op::Update),
356    }
357}
358
359fn swapped_update(index: usize, update: &UpdateOp) -> Result<UpdateOp, InvertError> {
360    let patch = match update.preimage() {
361        UpdatePreimage::Entity(prior) => UpdatePatch::Entity(EntityPatch {
362            name: prior.name.clone(),
363            description: prior.description.clone(),
364            properties: prior.properties.clone(),
365            tags: prior.tags.clone(),
366            entity_type: prior.entity_type.clone(),
367        }),
368        UpdatePreimage::Note(prior) => UpdatePatch::Note(NotePatch {
369            content: prior.content.clone(),
370            salience: prior.salience,
371            decay_factor: prior.decay_factor,
372            properties: prior.properties.clone(),
373            tags: prior.tags.clone(),
374        }),
375        UpdatePreimage::Edge(prior) => UpdatePatch::Edge(EdgePatch {
376            relation: prior.relation,
377            weight: prior.weight,
378        }),
379    };
380    let preimage = match update.patch() {
381        UpdatePatch::Entity(patch) => UpdatePreimage::Entity(EntityPreimage {
382            name: patch.name.clone(),
383            description: patch.description.clone(),
384            properties: patch.properties.clone(),
385            tags: patch.tags.clone(),
386            entity_type: patch.entity_type.clone(),
387        }),
388        UpdatePatch::Note(patch) => UpdatePreimage::Note(NotePreimage {
389            content: patch.content.clone(),
390            salience: patch.salience,
391            decay_factor: patch.decay_factor,
392            properties: patch.properties.clone(),
393            tags: patch.tags.clone(),
394        }),
395        UpdatePatch::Edge(patch) => UpdatePreimage::Edge(EdgePreimage {
396            relation: patch.relation,
397            weight: patch.weight,
398        }),
399    };
400    UpdateOp::new(update.target_id(), patch, preimage)
401        .map_err(|reason| InvertError::InverseUpdate { index, reason })
402}