kmp_application/memory/
write_neighborhood.rs1use 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#[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 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}