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 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            // The projection's summary is the complete canonical entry text.
166            // Long text is omitted whole, never clipped before a qualification.
167            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    // After the ordering above, which compares the stored keys themselves.
235    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}