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