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 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 related = bundle
92 .relationships()
93 .iter()
94 .filter(|link| {
95 endpoints.contains(link.source_node_id())
96 || endpoints.contains(link.target_node_id())
97 })
98 .filter(|link| {
99 *link.explanation().semantic_class()
100 != kmp_domain::RelationSemanticClass::Structural
101 })
102 .flat_map(|link| [link.source_node_id(), link.target_node_id()])
103 .collect::<BTreeSet<_>>();
104 let scoped = |reference: &str| {
105 owner == command.about
106 && catalogue.get(reference).is_some_and(|other| {
107 labels.keys().any(|key| {
108 labels.values(key).is_some_and(|values| {
109 other
110 .values(key)
111 .is_some_and(|other_values| !values.is_disjoint(other_values))
112 })
113 })
114 })
115 };
116 let conflicts = bundle
117 .relationships()
118 .iter()
119 .filter(|link| {
120 link.relationship_type() == "contradicts"
121 && [link.source_node_id(), link.target_node_id()]
122 .iter()
123 .any(|reference| endpoints.contains(*reference) || scoped(reference))
124 })
125 .flat_map(|link| [link.source_node_id(), link.target_node_id()])
126 .collect::<BTreeSet<_>>();
127 for node in bundle.neighbor_nodes() {
128 let reference = node.node_id();
129 let reason = if conflicts.contains(reference) {
130 "explicit_conflict"
131 } else if endpoints.contains(reference) {
132 "proposed_endpoint"
133 } else if scoped(reference) && node.node_kind() == "constraint" {
134 "scoped_constraint"
135 } else if related.contains(reference) {
136 "direct_relation"
137 } else if scoped(reference) {
138 "shared_label_recent"
139 } else {
140 continue;
141 };
142 let payload = node
143 .properties()
144 .get("memory_payload_json")
145 .and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok());
146 let mut clocks = BTreeMap::<String, Vec<String>>::new();
147 if let Some(payload) = &payload {
148 for coordinate in payload["coordinates"].as_array().into_iter().flatten() {
149 for name in [
150 "occurred_at",
151 "observed_at",
152 "ingested_at",
153 "valid_from",
154 "valid_until",
155 ] {
156 if let Some(value) = coordinate[name].as_str() {
157 let values = clocks.entry(name.into()).or_default();
158 if !values.iter().any(|v| v == value) {
159 values.push(value.into());
160 }
161 }
162 }
163 }
164 }
165 candidates.push(NeighborhoodItem {
168 about: owner.into(),
169 reference: reference.into(),
170 state: "stored".into(),
171 kind: node.node_kind().into(),
172 reason: reason.into(),
173 text: (node.summary().len() <= TEXT_BYTES).then(|| node.summary().to_owned()),
174 text_omitted: node.summary().len() > TEXT_BYTES,
175 clocks,
176 });
177 sources.insert(
178 reference.to_string(),
179 json!({"text":node.summary(), "properties":node.properties()}),
180 );
181 }
182 }
183 for entry in &command.memory.entries {
184 if !endpoints.contains(&entry.id) {
185 continue;
186 }
187 candidates.push(NeighborhoodItem {
188 about: command.about.clone(),
189 reference: entry.id.clone(),
190 state: "proposed".into(),
191 kind: entry.kind.clone(),
192 reason: "proposed_endpoint".into(),
193 text: (entry.text.len() <= TEXT_BYTES).then(|| entry.text.clone()),
194 text_omitted: entry.text.len() > TEXT_BYTES,
195 clocks: {
196 let mut clocks = BTreeMap::<String, Vec<String>>::new();
197 for coordinate in &entry.coordinates {
198 for (axis, value) in [
199 ("occurred_at", &coordinate.occurred_at),
200 ("observed_at", &coordinate.observed_at),
201 ("valid_from", &coordinate.valid_from),
202 ("valid_until", &coordinate.valid_until),
203 ] {
204 if let Some(value) = value {
205 let values = clocks.entry(axis.into()).or_default();
206 if !values.contains(value) {
207 values.push(value.clone());
208 }
209 }
210 }
211 }
212 clocks
213 },
214 });
215 }
216 let priority = |item: &NeighborhoodItem| match item.reason.as_str() {
217 "explicit_conflict" => 0,
218 "scoped_constraint" => 1,
219 "proposed_endpoint" => 2,
220 "direct_relation" => 3,
221 _ => 4,
222 };
223 candidates.sort_by(|a, b| {
224 priority(a)
225 .cmp(&priority(b))
226 .then_with(|| {
227 b.clocks
228 .get("ingested_at")
229 .cmp(&a.clocks.get("ingested_at"))
230 })
231 .then_with(|| a.reference.cmp(&b.reference))
232 .then_with(|| a.state.cmp(&b.state))
233 });
234 for candidate in &mut candidates {
236 candidate.clocks = readable_clocks(std::mem::take(&mut candidate.clocks));
237 }
238 let references = sources.keys().collect::<BTreeSet<_>>();
239 let links = bundles
240 .iter()
241 .flat_map(|bundle| bundle.relationships())
242 .filter(|link| {
243 references.contains(&link.source_node_id().to_string())
244 || references.contains(&link.target_node_id().to_string())
245 })
246 .map(|link| {
247 (
248 link.source_node_id(),
249 link.relationship_type(),
250 link.target_node_id(),
251 link.explanation().to_properties(),
252 )
253 })
254 .collect::<BTreeSet<_>>();
255 let mut hash = Sha256::new();
256 hash.update(b"kmp.write.neighborhood.v1\0");
257 hash.update(super::ingest::logical_digest(command));
258 hash.update(serde_json::to_vec(&(sources, links)).expect("literal neighborhood serializes"));
259 let conflicts = candidates
260 .iter()
261 .filter(|item| item.reason == "explicit_conflict")
262 .count();
263 let mut packet = WriteNeighborhood {
264 token: format!("{:x}", hash.finalize()),
265 eligible: candidates.len(),
266 items: candidates.into_iter().take(MAX_ITEMS).collect(),
267 omitted: 0,
268 omitted_conflicts: 0,
269 partial: false,
270 links: Vec::new(),
271 abouts: bundles
272 .iter()
273 .map(|bundle| bundle.root_node_id().as_str().to_owned())
274 .chain(std::iter::once(command.about.clone()))
275 .collect::<BTreeSet<_>>()
276 .into_iter()
277 .collect(),
278 };
279 loop {
280 packet.links = bundles
281 .iter()
282 .flat_map(|bundle| bundle.relationships())
283 .filter(|link| {
284 *link.explanation().semantic_class()
285 != kmp_domain::RelationSemanticClass::Structural
286 })
287 .filter_map(|link| {
288 Some(NeighborhoodLink {
289 from: packet.items.iter().position(|item| {
290 item.state == "stored" && item.reference == link.source_node_id()
291 })?,
292 rel: link.relationship_type().to_string(),
293 to: packet.items.iter().position(|item| {
294 item.state == "stored" && item.reference == link.target_node_id()
295 })?,
296 })
297 })
298 .collect();
299 packet.omitted = packet.eligible - packet.items.len();
300 packet.omitted_conflicts = conflicts
301 - packet
302 .items
303 .iter()
304 .filter(|item| item.reason == "explicit_conflict")
305 .count();
306 packet.partial = packet.omitted > 0 || packet.items.iter().any(|item| item.text_omitted);
307 if serde_json::to_vec(&packet)
308 .expect("packet serializes")
309 .len()
310 <= TARGET_BYTES
311 || packet.items.len() <= 1
312 {
313 break;
314 }
315 packet.items.pop();
316 }
317 packet
318}