Skip to main content

perl_workspace/semantic/
invalidation.rs

1//! Per-category semantic fact invalidation planning.
2//!
3//! This module keeps the pure decision logic for incremental semantic shard
4//! replacement separate from `WorkspaceIndex`, which remains responsible for
5//! applying the plan to its stores and cross-file indexes.
6
7/// Per-category fact hashes for one semantic file shard.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct ShardCategoryHashes {
10    /// Whole-file content hash.
11    pub content_hash: u64,
12    /// Anchor fact category hash.
13    pub anchors_hash: Option<u64>,
14    /// Entity fact category hash.
15    pub entities_hash: Option<u64>,
16    /// Occurrence fact category hash.
17    pub occurrences_hash: Option<u64>,
18    /// Edge fact category hash.
19    pub edges_hash: Option<u64>,
20}
21
22/// Per-category incremental invalidation result.
23///
24/// Tracks which fact categories should be updated during a shard replacement so
25/// callers can observe skip behavior in tests and apply only the required
26/// cross-file index updates.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ShardReplaceResult {
29    /// `true` when the whole-file `content_hash` matched and the replacement
30    /// should be skipped entirely.
31    pub content_unchanged: bool,
32    /// `true` when the anchors category should be re-indexed.
33    pub anchors_updated: bool,
34    /// `true` when the entities category should be re-indexed.
35    pub entities_updated: bool,
36    /// `true` when the occurrences category should be re-indexed.
37    pub occurrences_updated: bool,
38    /// `true` when the edges category should be re-indexed.
39    pub edges_updated: bool,
40}
41
42/// Build the incremental replacement plan for old and new semantic shard
43/// hashes.
44pub fn plan_shard_replacement(
45    old: Option<ShardCategoryHashes>,
46    new: ShardCategoryHashes,
47) -> ShardReplaceResult {
48    if let Some(old_hashes) = old {
49        if old_hashes.content_hash == new.content_hash {
50            return ShardReplaceResult {
51                content_unchanged: true,
52                anchors_updated: false,
53                entities_updated: false,
54                occurrences_updated: false,
55                edges_updated: false,
56            };
57        }
58    }
59
60    ShardReplaceResult {
61        content_unchanged: false,
62        anchors_updated: category_hash_changed(
63            old.and_then(|hashes| hashes.anchors_hash),
64            new.anchors_hash,
65        ),
66        entities_updated: category_hash_changed(
67            old.and_then(|hashes| hashes.entities_hash),
68            new.entities_hash,
69        ),
70        occurrences_updated: category_hash_changed(
71            old.and_then(|hashes| hashes.occurrences_hash),
72            new.occurrences_hash,
73        ),
74        edges_updated: category_hash_changed(
75            old.and_then(|hashes| hashes.edges_hash),
76            new.edges_hash,
77        ),
78    }
79}
80
81/// Compare old and new per-category hashes.
82///
83/// Returns `true` when either hash is absent or when both hashes are present
84/// but differ. Missing hashes represent legacy shards, so callers must
85/// conservatively refresh that category.
86pub fn category_hash_changed(old: Option<u64>, new: Option<u64>) -> bool {
87    match (old, new) {
88        (Some(o), Some(n)) => o != n,
89        _ => true,
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    fn hashes(
98        content_hash: u64,
99        anchors_hash: Option<u64>,
100        entities_hash: Option<u64>,
101        occurrences_hash: Option<u64>,
102        edges_hash: Option<u64>,
103    ) -> ShardCategoryHashes {
104        ShardCategoryHashes {
105            content_hash,
106            anchors_hash,
107            entities_hash,
108            occurrences_hash,
109            edges_hash,
110        }
111    }
112
113    #[test]
114    fn unchanged_content_skips_all_categories() {
115        let old = hashes(1, Some(10), Some(20), Some(30), Some(40));
116        let new = hashes(1, Some(11), Some(21), Some(31), Some(41));
117
118        let plan = plan_shard_replacement(Some(old), new);
119
120        assert!(plan.content_unchanged);
121        assert!(!plan.anchors_updated);
122        assert!(!plan.entities_updated);
123        assert!(!plan.occurrences_updated);
124        assert!(!plan.edges_updated);
125    }
126
127    #[test]
128    fn changed_content_compares_each_category() {
129        let old = hashes(1, Some(10), Some(20), Some(30), Some(40));
130        let new = hashes(2, Some(10), Some(21), None, Some(40));
131
132        let plan = plan_shard_replacement(Some(old), new);
133
134        assert!(!plan.content_unchanged);
135        assert!(!plan.anchors_updated);
136        assert!(plan.entities_updated);
137        assert!(plan.occurrences_updated);
138        assert!(!plan.edges_updated);
139    }
140}