Skip to main content

kmp_application/memory/
write_neighborhood.rs

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/// A bounded review of explicit scope. Its fingerprint covers complete selected
16/// source material, including material omitted from the compact representation.
17#[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    /// Abouts backed by a memory bundle before this command. This remains
26    /// complete even when compact rendering omits every stored item.
27    pub stored_abouts: Vec<String>,
28    pub abouts: Vec<String>,
29    pub partial: bool,
30}
31
32/// Clocks leave for the writer in the spelling every other surface answers
33/// with.
34///
35/// The kernel persists instants as `unix:<offset seconds>:<nanos>` so its keys
36/// sort chronologically, but that key is not a Unix timestamp: read literally,
37/// `unix:101789041600:000000000` lands over three thousand years from the
38/// 2026-09-10T12:00:00Z it denotes. An agent reading its own write's
39/// neighborhood cannot compare what it cannot interpret (#683).
40///
41/// A value that is not an instant stays exactly as it is; it is better to show
42/// an unparsed clock than to drop one.
43fn 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                // Two spellings of one instant are one instant: a proposed
51                // `+02:00` and a stored `Z` must not read as two clocks.
52                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        // A relation-only command carries no source coordinates. Its review
95        // still includes constraints in the endpoints' stored memberships;
96        // read them here without turning them into entry writes or identity.
97        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            // The projection's summary is the complete canonical entry text.
190            // Long text is omitted whole, never clipped before a qualification.
191            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    // After the ordering above, which compares the stored keys themselves.
259    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}