Skip to main content

kmp_application/memory/
visual_label.rs

1//! The labels an about holds, counted for a renderer.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use kmp_domain::{KmpBundle, bare_label_value};
6use serde::Serialize;
7
8/// One label the about holds, as a renderer draws it: the key (`dimension`)
9/// and the value, both as the coordinates spell them and as the catalogue
10/// speaks, with how many entries stand in it across all time (`entries`) and
11/// how many inside the projected range on the selected clock (`in_range`).
12///
13/// A label with nothing in range is still here. A lane that exists only
14/// while the window holds one of its entries cannot show a reader that a
15/// label is empty *here*; it can only fail to show the label at all. The
16/// catalogue is read off the same `contains_entry` edges `kmp_wake` reads,
17/// before the read's own dimension filter narrows them, so the rows a
18/// renderer draws are the about's labels, not the window's.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct VisualLabel {
21    pub dimension: String,
22    pub scope_id: String,
23    pub value: String,
24    pub in_range: usize,
25    pub entries: usize,
26    pub last_observed_at: Option<String>,
27}
28
29impl VisualLabel {
30    /// Every label the bundle's `contains_entry` edges name, most used
31    /// first, then by key and value, so the order is the same on every run.
32    /// `in_range` is zero until the projection counts its own entries.
33    pub fn catalogue(bundle: &KmpBundle) -> Vec<Self> {
34        let mut uses = BTreeMap::<(String, String), LabelUse>::new();
35        for relationship in bundle
36            .relationships()
37            .iter()
38            .filter(|relationship| relationship.relationship_type() == "contains_entry")
39        {
40            let explanation = relationship.explanation();
41            let (Some(dimension), Some(scope_id)) = (
42                explanation
43                    .dimension()
44                    .map(str::trim)
45                    .filter(|key| !key.is_empty()),
46                explanation
47                    .scope_id()
48                    .map(str::trim)
49                    .filter(|scope| !scope.is_empty()),
50            ) else {
51                continue;
52            };
53            let observed = explanation
54                .observed_at()
55                .or(explanation.occurred_at())
56                .map(str::trim)
57                .filter(|value| !value.is_empty());
58            let label = uses
59                .entry((dimension.to_string(), scope_id.to_string()))
60                .or_default();
61            label
62                .entries
63                .insert(relationship.target_node_id().to_string());
64            if let Some(observed) = observed
65                && label
66                    .last_observed_at
67                    .as_deref()
68                    .is_none_or(|current| observed > current)
69            {
70                label.last_observed_at = Some(observed.to_string());
71            }
72        }
73        let mut labels = uses
74            .into_iter()
75            .map(|((dimension, scope_id), label)| Self {
76                value: bare_label_value(&scope_id),
77                dimension,
78                scope_id,
79                in_range: 0,
80                entries: label.entries.len(),
81                last_observed_at: label.last_observed_at,
82            })
83            .collect::<Vec<_>>();
84        Self::sort(&mut labels);
85        labels
86    }
87
88    /// The same catalogue with the projected range counted in: every label a
89    /// positioned entry stands in gains its `in_range`, and a label the
90    /// entries name that the catalogue somehow lacks is added rather than
91    /// dropped, so a row is never drawn for a label the list does not hold.
92    pub fn counted<'a>(
93        mut labels: Vec<Self>,
94        in_range: impl IntoIterator<Item = &'a (String, String)>,
95    ) -> Vec<Self> {
96        let mut counts = BTreeMap::<(String, String), usize>::new();
97        for (dimension, scope_id) in in_range {
98            *counts
99                .entry((dimension.clone(), scope_id.clone()))
100                .or_default() += 1;
101        }
102        for label in &mut labels {
103            label.in_range = counts
104                .remove(&(label.dimension.clone(), label.scope_id.clone()))
105                .unwrap_or_default();
106        }
107        for ((dimension, scope_id), count) in counts {
108            labels.push(Self {
109                value: bare_label_value(&scope_id),
110                dimension,
111                scope_id,
112                in_range: count,
113                entries: count,
114                last_observed_at: None,
115            });
116        }
117        Self::sort(&mut labels);
118        labels
119    }
120
121    fn sort(labels: &mut [Self]) {
122        labels.sort_by(|left, right| {
123            right
124                .entries
125                .cmp(&left.entries)
126                .then_with(|| left.dimension.cmp(&right.dimension))
127                .then_with(|| left.value.cmp(&right.value))
128        });
129    }
130}
131
132#[derive(Default)]
133struct LabelUse {
134    entries: BTreeSet<String>,
135    last_observed_at: Option<String>,
136}
137
138#[cfg(test)]
139mod tests {
140    use std::collections::BTreeMap;
141
142    use kmp_domain::{
143        BundleMetadata, BundleNode, BundleRelationship, CaseId, RelationExplanation,
144        RelationSemanticClass, Role,
145    };
146
147    use super::*;
148
149    fn node(id: &str, kind: &str) -> BundleNode {
150        BundleNode::new(
151            id,
152            kind,
153            id,
154            "fixture",
155            "ACTIVE",
156            Vec::new(),
157            BTreeMap::new(),
158        )
159    }
160
161    fn contains(
162        dimension: &str,
163        scope_id: &str,
164        entry: &str,
165        observed_at: &str,
166    ) -> BundleRelationship {
167        BundleRelationship::new(
168            scope_id,
169            entry,
170            "contains_entry",
171            RelationExplanation::new(RelationSemanticClass::Structural)
172                .with_dimension(dimension)
173                .with_scope_id(scope_id)
174                .with_observed_at(observed_at),
175        )
176    }
177
178    fn bundle(relationships: Vec<BundleRelationship>) -> KmpBundle {
179        let mut ids = BTreeSet::new();
180        for relationship in &relationships {
181            ids.insert(relationship.source_node_id().to_string());
182            ids.insert(relationship.target_node_id().to_string());
183        }
184        KmpBundle::new(
185            CaseId::new("about:a").expect("about"),
186            Role::new("memory").expect("role"),
187            node("about:a", "memory_anchor"),
188            ids.into_iter()
189                .map(|id| {
190                    node(
191                        &id,
192                        if id.contains(":dimension:") {
193                            "memory_dimension"
194                        } else {
195                            "decision"
196                        },
197                    )
198                })
199                .collect(),
200            relationships,
201            Vec::new(),
202            BundleMetadata::initial("test"),
203        )
204        .expect("bundle")
205    }
206
207    const TASK_A: &str = "about:a:dimension:task-a";
208    const TASK_B: &str = "about:a:dimension:task-b";
209    const PROCESS: &str = "about:a:dimension:p-1";
210
211    #[test]
212    fn the_catalogue_names_every_pair_most_used_first_with_bare_values() {
213        let labels = VisualLabel::catalogue(&bundle(vec![
214            contains("task", TASK_A, "e-1", "2026-09-01T00:00:00Z"),
215            contains("task", TASK_A, "e-2", "2026-09-03T00:00:00Z"),
216            contains("task", TASK_B, "e-3", "2026-09-02T00:00:00Z"),
217            contains("agentic_process", PROCESS, "e-1", "2026-09-01T00:00:00Z"),
218            contains("agentic_process", PROCESS, "e-2", "2026-09-03T00:00:00Z"),
219            contains("agentic_process", PROCESS, "e-3", "2026-09-02T00:00:00Z"),
220        ]));
221        let summary = labels
222            .iter()
223            .map(|label| format!("{}={} {}", label.dimension, label.value, label.entries))
224            .collect::<Vec<_>>();
225        assert_eq!(
226            summary,
227            vec!["agentic_process=p-1 3", "task=task-a 2", "task=task-b 1"]
228        );
229        assert_eq!(
230            labels[1].scope_id, TASK_A,
231            "the namespaced id stays beside the bare value"
232        );
233        assert_eq!(
234            labels[1].last_observed_at.as_deref(),
235            Some("2026-09-03T00:00:00Z")
236        );
237        assert!(labels.iter().all(|label| label.in_range == 0));
238    }
239
240    #[test]
241    fn counting_the_range_keeps_empty_labels_and_adds_unlisted_ones() {
242        let catalogue = VisualLabel::catalogue(&bundle(vec![
243            contains("task", TASK_A, "e-1", "2026-09-01T00:00:00Z"),
244            contains("task", TASK_B, "e-3", "2026-09-02T00:00:00Z"),
245        ]));
246        let in_range = [
247            ("task".to_string(), TASK_A.to_string()),
248            ("task".to_string(), TASK_A.to_string()),
249            (
250                "incident".to_string(),
251                "about:a:dimension:inc-9".to_string(),
252            ),
253        ];
254        let counted = VisualLabel::counted(catalogue, in_range.iter());
255        let by_value = counted
256            .iter()
257            .map(|label| (label.value.as_str(), (label.in_range, label.entries)))
258            .collect::<BTreeMap<_, _>>();
259        assert_eq!(by_value["task-a"], (2, 1));
260        assert_eq!(by_value["task-b"], (0, 1), "empty here, still a row");
261        assert_eq!(by_value["inc-9"], (1, 1), "never a row without a label");
262    }
263}