Skip to main content

kmp_application/memory/
write_neighborhood.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use kmp_domain::{EntryLabels, KmpBundle, MemoryRelationType, labels_by_entry};
4use serde_json::json;
5use sha2::{Digest, Sha256};
6
7use super::{MemoryIngestCommand, NeighborhoodItem, NeighborhoodLink};
8
9const MAX_ITEMS: usize = 5;
10const TEXT_BYTES: usize = 320;
11const TARGET_BYTES: usize = 2048;
12
13/// A bounded review of explicit scope. Its fingerprint covers complete selected
14/// source material, including material omitted from the compact representation.
15#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
16pub struct WriteNeighborhood {
17    pub token: String,
18    pub items: Vec<NeighborhoodItem>,
19    pub links: Vec<NeighborhoodLink>,
20    pub eligible: usize,
21    pub omitted: usize,
22    pub omitted_conflicts: usize,
23    pub abouts: Vec<String>,
24    pub partial: bool,
25}
26
27pub(super) fn requires_review(command: &MemoryIngestCommand) -> bool {
28    command.neighborhood_review.is_some()
29        && command.memory.relations.iter().any(|relation| {
30            MemoryRelationType::new(&relation.rel)
31                .ok()
32                .is_none_or(|kind| kind.requires_writer_review())
33        })
34}
35
36pub(super) fn build_neighborhood(
37    command: &MemoryIngestCommand,
38    bundles: &[KmpBundle],
39) -> WriteNeighborhood {
40    let endpoints = command
41        .memory
42        .relations
43        .iter()
44        .flat_map(|link| [&link.source_ref, &link.target_ref])
45        .cloned()
46        .collect::<BTreeSet<_>>();
47    let labels = EntryLabels::from_coordinates(
48        command
49            .memory
50            .entries
51            .iter()
52            .flat_map(|entry| entry.coordinates.iter())
53            .map(|c| (c.dimension.as_str(), c.scope_id.as_str())),
54    );
55    let mut candidates = Vec::new();
56    let mut sources = BTreeMap::new();
57    for bundle in bundles {
58        let owner = bundle.root_node_id().as_str();
59        let catalogue = labels_by_entry(bundle);
60        let related = bundle
61            .relationships()
62            .iter()
63            .filter(|link| {
64                endpoints.contains(link.source_node_id())
65                    || endpoints.contains(link.target_node_id())
66            })
67            .filter(|link| {
68                *link.explanation().semantic_class()
69                    != kmp_domain::RelationSemanticClass::Structural
70            })
71            .flat_map(|link| [link.source_node_id(), link.target_node_id()])
72            .collect::<BTreeSet<_>>();
73        let scoped = |reference: &str| {
74            owner == command.about
75                && catalogue.get(reference).is_some_and(|other| {
76                    labels.keys().any(|key| {
77                        labels.values(key).is_some_and(|values| {
78                            other
79                                .values(key)
80                                .is_some_and(|other_values| !values.is_disjoint(other_values))
81                        })
82                    })
83                })
84        };
85        let conflicts = bundle
86            .relationships()
87            .iter()
88            .filter(|link| {
89                link.relationship_type() == "contradicts"
90                    && [link.source_node_id(), link.target_node_id()]
91                        .iter()
92                        .any(|reference| endpoints.contains(*reference) || scoped(reference))
93            })
94            .flat_map(|link| [link.source_node_id(), link.target_node_id()])
95            .collect::<BTreeSet<_>>();
96        for node in bundle.neighbor_nodes() {
97            let reference = node.node_id();
98            let reason = if conflicts.contains(reference) {
99                "explicit_conflict"
100            } else if endpoints.contains(reference) {
101                "proposed_endpoint"
102            } else if scoped(reference) && node.node_kind() == "constraint" {
103                "scoped_constraint"
104            } else if related.contains(reference) {
105                "direct_relation"
106            } else if scoped(reference) {
107                "shared_label_recent"
108            } else {
109                continue;
110            };
111            let payload = node
112                .properties()
113                .get("memory_payload_json")
114                .and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok());
115            let mut clocks = BTreeMap::<String, Vec<String>>::new();
116            if let Some(payload) = &payload {
117                for coordinate in payload["coordinates"].as_array().into_iter().flatten() {
118                    for name in [
119                        "occurred_at",
120                        "observed_at",
121                        "ingested_at",
122                        "valid_from",
123                        "valid_until",
124                    ] {
125                        if let Some(value) = coordinate[name].as_str() {
126                            let values = clocks.entry(name.into()).or_default();
127                            if !values.iter().any(|v| v == value) {
128                                values.push(value.into());
129                            }
130                        }
131                    }
132                }
133            }
134            // The projection's summary is the complete canonical entry text.
135            // Long text is omitted whole, never clipped before a qualification.
136            candidates.push(NeighborhoodItem {
137                about: owner.into(),
138                reference: reference.into(),
139                state: "stored".into(),
140                kind: node.node_kind().into(),
141                reason: reason.into(),
142                text: (node.summary().len() <= TEXT_BYTES).then(|| node.summary().to_owned()),
143                text_omitted: node.summary().len() > TEXT_BYTES,
144                clocks,
145            });
146            sources.insert(
147                reference.to_string(),
148                json!({"text":node.summary(), "properties":node.properties()}),
149            );
150        }
151    }
152    for entry in &command.memory.entries {
153        if !endpoints.contains(&entry.id) {
154            continue;
155        }
156        candidates.push(NeighborhoodItem {
157            about: command.about.clone(),
158            reference: entry.id.clone(),
159            state: "proposed".into(),
160            kind: entry.kind.clone(),
161            reason: "proposed_endpoint".into(),
162            text: (entry.text.len() <= TEXT_BYTES).then(|| entry.text.clone()),
163            text_omitted: entry.text.len() > TEXT_BYTES,
164            clocks: {
165                let mut clocks = BTreeMap::<String, Vec<String>>::new();
166                for coordinate in &entry.coordinates {
167                    for (axis, value) in [
168                        ("occurred_at", &coordinate.occurred_at),
169                        ("observed_at", &coordinate.observed_at),
170                        ("valid_from", &coordinate.valid_from),
171                        ("valid_until", &coordinate.valid_until),
172                    ] {
173                        if let Some(value) = value {
174                            let values = clocks.entry(axis.into()).or_default();
175                            if !values.contains(value) {
176                                values.push(value.clone());
177                            }
178                        }
179                    }
180                }
181                clocks
182            },
183        });
184    }
185    let priority = |item: &NeighborhoodItem| match item.reason.as_str() {
186        "explicit_conflict" => 0,
187        "scoped_constraint" => 1,
188        "proposed_endpoint" => 2,
189        "direct_relation" => 3,
190        _ => 4,
191    };
192    candidates.sort_by(|a, b| {
193        priority(a)
194            .cmp(&priority(b))
195            .then_with(|| {
196                b.clocks
197                    .get("ingested_at")
198                    .cmp(&a.clocks.get("ingested_at"))
199            })
200            .then_with(|| a.reference.cmp(&b.reference))
201            .then_with(|| a.state.cmp(&b.state))
202    });
203    let references = sources.keys().collect::<BTreeSet<_>>();
204    let links = bundles
205        .iter()
206        .flat_map(|bundle| bundle.relationships())
207        .filter(|link| {
208            references.contains(&link.source_node_id().to_string())
209                || references.contains(&link.target_node_id().to_string())
210        })
211        .map(|link| {
212            (
213                link.source_node_id(),
214                link.relationship_type(),
215                link.target_node_id(),
216                link.explanation().to_properties(),
217            )
218        })
219        .collect::<BTreeSet<_>>();
220    let mut hash = Sha256::new();
221    hash.update(b"kmp.write.neighborhood.v1\0");
222    hash.update(super::ingest::logical_digest(command));
223    hash.update(serde_json::to_vec(&(sources, links)).expect("literal neighborhood serializes"));
224    let conflicts = candidates
225        .iter()
226        .filter(|item| item.reason == "explicit_conflict")
227        .count();
228    let mut packet = WriteNeighborhood {
229        token: format!("{:x}", hash.finalize()),
230        eligible: candidates.len(),
231        items: candidates.into_iter().take(MAX_ITEMS).collect(),
232        omitted: 0,
233        omitted_conflicts: 0,
234        partial: false,
235        links: Vec::new(),
236        abouts: bundles
237            .iter()
238            .map(|bundle| bundle.root_node_id().as_str().to_owned())
239            .chain(std::iter::once(command.about.clone()))
240            .collect::<BTreeSet<_>>()
241            .into_iter()
242            .collect(),
243    };
244    loop {
245        packet.links = bundles
246            .iter()
247            .flat_map(|bundle| bundle.relationships())
248            .filter(|link| {
249                *link.explanation().semantic_class()
250                    != kmp_domain::RelationSemanticClass::Structural
251            })
252            .filter_map(|link| {
253                Some(NeighborhoodLink {
254                    from: packet.items.iter().position(|item| {
255                        item.state == "stored" && item.reference == link.source_node_id()
256                    })?,
257                    rel: link.relationship_type().to_string(),
258                    to: packet.items.iter().position(|item| {
259                        item.state == "stored" && item.reference == link.target_node_id()
260                    })?,
261                })
262            })
263            .collect();
264        packet.omitted = packet.eligible - packet.items.len();
265        packet.omitted_conflicts = conflicts
266            - packet
267                .items
268                .iter()
269                .filter(|item| item.reason == "explicit_conflict")
270                .count();
271        packet.partial = packet.omitted > 0 || packet.items.iter().any(|item| item.text_omitted);
272        if serde_json::to_vec(&packet)
273            .expect("packet serializes")
274            .len()
275            <= TARGET_BYTES
276            || packet.items.len() <= 1
277        {
278            break;
279        }
280        packet.items.pop();
281    }
282    packet
283}