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