1use std::collections::{BTreeMap, BTreeSet};
4
5use kmp_domain::{KmpBundle, bare_label_value};
6use serde::Serialize;
7
8#[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 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 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}