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    pub abouts: Vec<String>,
26    pub partial: bool,
27}
28
29/// Clocks leave for the writer in the spelling every other surface answers
30/// with.
31///
32/// The kernel persists instants as `unix:<offset seconds>:<nanos>` so its keys
33/// sort chronologically, but that key is not a Unix timestamp: read literally,
34/// `unix:101789041600:000000000` lands over three thousand years from the
35/// 2026-09-10T12:00:00Z it denotes. An agent reading its own write's
36/// neighborhood cannot compare what it cannot interpret (#683).
37///
38/// A value that is not an instant stays exactly as it is; it is better to show
39/// an unparsed clock than to drop one.
40fn 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                // Two spellings of one instant are one instant: a proposed
48                // `+02:00` and a stored `Z` must not read as two clocks.
49                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        // A relation-only command carries no source coordinates. Its review
92        // still includes constraints in the endpoints' stored memberships;
93        // read them here without turning them into entry writes or identity.
94        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            // The projection's summary is the complete canonical entry text.
187            // Long text is omitted whole, never clipped before a qualification.
188            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    // After the ordering above, which compares the stored keys themselves.
256    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}