1use std::collections::{BTreeMap, BTreeSet};
2
3use kmp_domain::{
4 DimensionSelection, TemporalAxis, TemporalCoordinate, TemporalCursor, TemporalDirection,
5 TemporalWindow,
6};
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9
10use crate::ApplicationError;
11
12use super::{TemporalIncludeOptions, TemporalMemoryQuery, TemporalMemoryResult, VisualLabel};
13
14pub const MAX_VISUAL_SOURCE_ENTRIES: usize = 65_536;
15pub const MAX_VISUAL_PAGE_ENTRIES: usize = 2_048;
16pub const MAX_VISUAL_BINS: usize = 512;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum VisualLevelOfDetail {
21 #[default]
22 Atlas,
23 Episode,
24 Moment,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct VisualProjectionQuery {
29 pub about: String,
30 pub from: String,
31 pub to: String,
32 pub axis: TemporalAxis,
33 pub dimensions: DimensionSelection,
34 pub level_of_detail: VisualLevelOfDetail,
35 pub bin_count: usize,
36 pub page_entries: usize,
37 pub cursor: Option<String>,
38 pub depth: u32,
39}
40
41impl VisualProjectionQuery {
42 pub fn temporal_query(&self) -> Result<TemporalMemoryQuery, ApplicationError> {
43 Ok(TemporalMemoryQuery {
44 about: self.about.clone(),
45 direction: TemporalDirection::Goto,
46 axis: self.axis,
47 cursor: TemporalCursor::time(self.to.clone())?,
48 dimensions: self.dimensions.clone(),
49 window: TemporalWindow::new(0, 0),
50 limit_entries: Some(MAX_VISUAL_SOURCE_ENTRIES),
51 include: TemporalIncludeOptions {
52 evidence: false,
53 relations: true,
54 raw_refs: false,
55 },
56 token_budget: 262_144,
57 depth: self.depth.max(1),
58 max_tier: None,
59 })
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct VisualRange {
65 pub from: String,
66 pub to: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct VisualBin {
74 pub dimension: String,
75 pub scope_id: String,
76 pub from: String,
77 pub to: String,
78 pub total: usize,
79 pub by_kind: BTreeMap<String, usize>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct VisualCluster {
84 pub dimension: String,
85 pub scope_id: String,
86 pub from: String,
87 pub to: String,
88 pub total: usize,
89 pub refs: Vec<String>,
90 pub by_kind: BTreeMap<String, usize>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct VisualEntry {
95 pub ref_id: String,
96 pub kind: String,
97 pub text: String,
98 pub coordinates: Vec<TemporalCoordinateView>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102pub struct TemporalCoordinateView {
103 pub dimension: String,
104 pub scope_id: String,
105 pub occurred_at: Option<String>,
106 pub observed_at: Option<String>,
107 pub ingested_at: Option<String>,
108 pub valid_from: Option<String>,
109 pub valid_until: Option<String>,
110 pub sequence: Option<u32>,
111 pub rank: Option<u32>,
112 #[serde(skip_serializing_if = "Option::is_none")]
115 pub method: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub why: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub motivation: Option<String>,
120}
121
122impl From<&TemporalCoordinate> for TemporalCoordinateView {
123 fn from(value: &TemporalCoordinate) -> Self {
124 Self {
125 dimension: value.dimension().to_string(),
126 scope_id: value.scope_id().to_string(),
127 occurred_at: value.occurred_at().map(ToString::to_string),
128 observed_at: value.observed_at().map(ToString::to_string),
129 ingested_at: value.ingested_at().map(ToString::to_string),
130 valid_from: value.valid_from().map(ToString::to_string),
131 valid_until: value.valid_until().map(ToString::to_string),
132 sequence: value.sequence(),
133 rank: value.rank(),
134 method: value.origin().method().map(ToString::to_string),
135 why: value.origin().rationale().map(ToString::to_string),
136 motivation: value.origin().motivation().map(ToString::to_string),
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142pub struct VisualRelation {
143 pub from: String,
144 pub to: String,
145 pub rel: String,
146 pub class: String,
147 pub why: Option<String>,
148 pub evidence: Option<String>,
149 pub confidence: Option<String>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize)]
153pub struct VisualMetric {
154 pub name: String,
155 pub value: f64,
156 pub unit: String,
157 pub scope: String,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161pub struct VisualProjectionPage {
162 pub returned: usize,
163 pub total: usize,
164 pub has_more: bool,
165 pub next_cursor: Option<String>,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize)]
169pub struct VisualProjectionResult {
170 pub contract: String,
171 pub about: String,
172 pub axis: TemporalAxisView,
173 pub level_of_detail: VisualLevelOfDetail,
174 pub range: VisualRange,
175 pub bins: Vec<VisualBin>,
176 pub clusters: Vec<VisualCluster>,
177 pub entries: Vec<VisualEntry>,
178 pub by_kind: BTreeMap<String, usize>,
181 pub relations: Vec<VisualRelation>,
182 pub metrics: Vec<VisualMetric>,
183 pub labels: Vec<VisualLabel>,
188 pub included_dimensions: Vec<String>,
189 pub missing_dimensions: Vec<String>,
190 pub revision: u64,
191 pub content_hash: String,
192 pub page: VisualProjectionPage,
193 pub truncated: bool,
194 pub missing: Vec<String>,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
198#[serde(rename_all = "snake_case")]
199pub enum TemporalAxisView {
200 Default,
201 Occurred,
202 Observed,
203 Ingested,
204 Validity,
205}
206
207impl From<TemporalAxis> for TemporalAxisView {
208 fn from(value: TemporalAxis) -> Self {
209 match value {
210 TemporalAxis::Default => Self::Default,
211 TemporalAxis::Occurred => Self::Occurred,
212 TemporalAxis::Observed => Self::Observed,
213 TemporalAxis::Ingested => Self::Ingested,
214 TemporalAxis::Validity => Self::Validity,
215 }
216 }
217}
218
219#[derive(Debug, Clone)]
220struct PositionedEntry {
221 entry: VisualEntry,
222 position: i128,
223 position_text: String,
224 labels: BTreeSet<(String, String)>,
226}
227
228pub fn build_visual_projection(
232 query: &VisualProjectionQuery,
233 temporal: TemporalMemoryResult,
234 catalogue: Vec<VisualLabel>,
235) -> Result<VisualProjectionResult, ApplicationError> {
236 let from = timestamp_nanos(&query.from).ok_or_else(|| {
237 ApplicationError::Validation("visual projection `from` is not a timestamp".to_string())
238 })?;
239 let to = timestamp_nanos(&query.to).ok_or_else(|| {
240 ApplicationError::Validation("visual projection `to` is not a timestamp".to_string())
241 })?;
242 if to <= from {
243 return Err(ApplicationError::Validation(
244 "visual projection range requires `to` after `from`".to_string(),
245 ));
246 }
247 let bin_count = query.bin_count.clamp(1, MAX_VISUAL_BINS);
248 let page_entries = query.page_entries.clamp(1, MAX_VISUAL_PAGE_ENTRIES);
249 let revision = temporal.source_bundle.metadata().revision;
250 let content_hash = temporal.source_bundle.metadata().content_hash.clone();
251 let selection_hash = selection_hash(query, revision, &content_hash);
252 let offset = cursor_offset(query.cursor.as_deref(), &selection_hash)?;
253
254 let mut positioned = temporal
255 .traversal
256 .entries()
257 .iter()
258 .filter_map(|entry| {
259 let (position, position_text) = entry
260 .coordinates()
261 .iter()
262 .filter_map(|coordinate| {
263 let value = axis_time(coordinate, query.axis)?;
264 Some((timestamp_nanos(value)?, value.to_string()))
265 })
266 .min_by_key(|(position, _)| *position)?;
267 if position < from || position >= to {
268 return None;
269 }
270 let labels = entry
271 .coordinates()
272 .iter()
273 .map(|coordinate| {
274 (
275 coordinate.dimension().to_string(),
276 coordinate.scope_id().to_string(),
277 )
278 })
279 .collect();
280 Some(PositionedEntry {
281 entry: VisualEntry {
282 ref_id: entry.ref_id().to_string(),
283 kind: entry.kind().to_string(),
284 text: entry.text().to_string(),
285 coordinates: entry
286 .coordinates()
287 .iter()
288 .map(TemporalCoordinateView::from)
289 .collect(),
290 },
291 position,
292 position_text,
293 labels,
294 })
295 })
296 .collect::<Vec<_>>();
297 positioned.sort_by(|left, right| {
298 left.position
299 .cmp(&right.position)
300 .then_with(|| left.entry.ref_id.cmp(&right.entry.ref_id))
301 });
302
303 let by_kind = positioned
304 .iter()
305 .fold(BTreeMap::new(), |mut counts, entry| {
306 *counts.entry(entry.entry.kind.clone()).or_default() += 1;
307 counts
308 });
309 let bins = visual_bins(&positioned, from, to, bin_count);
310 let clusters = if query.level_of_detail == VisualLevelOfDetail::Episode {
311 visual_clusters(&positioned, from, to, bin_count)
312 } else {
313 Vec::new()
314 };
315 let total = positioned.len();
316 let end = offset.saturating_add(page_entries).min(total);
317 let entries = if query.level_of_detail == VisualLevelOfDetail::Moment {
318 positioned[offset.min(total)..end]
319 .iter()
320 .map(|entry| entry.entry.clone())
321 .collect()
322 } else {
323 Vec::new()
324 };
325 let page_returned = if query.level_of_detail == VisualLevelOfDetail::Moment {
326 entries.len()
327 } else {
328 total
329 };
330 let has_more = query.level_of_detail == VisualLevelOfDetail::Moment && end < total;
331 let next_cursor = has_more.then(|| format!("kmp-visual-v1:{selection_hash}:{end}"));
332 let page_refs = entries
333 .iter()
334 .map(|entry| entry.ref_id.clone())
335 .collect::<BTreeSet<_>>();
336 let relations = if query.level_of_detail == VisualLevelOfDetail::Moment {
337 visual_relations(&temporal, &page_refs)
338 } else {
339 Vec::new()
340 };
341 let included_dimensions = positioned
342 .iter()
343 .flat_map(|entry| entry.labels.iter().map(|(dimension, _)| dimension.clone()))
344 .collect::<BTreeSet<_>>()
345 .into_iter()
346 .collect::<Vec<_>>();
347 let labels = VisualLabel::counted(
348 catalogue,
349 positioned.iter().flat_map(|entry| entry.labels.iter()),
350 );
351 let causal = causal_relation_count(&relations);
352 let relation_count = relations.len();
353 let source_truncated = temporal.traversal.page().has_more();
354 let missing = if source_truncated {
355 vec!["visual_source_entries".to_string()]
356 } else {
357 Vec::new()
358 };
359
360 Ok(VisualProjectionResult {
361 contract: "kmp.visual.projection.v1".to_string(),
362 about: query.about.clone(),
363 axis: query.axis.into(),
364 level_of_detail: query.level_of_detail,
365 range: VisualRange {
366 from: query.from.clone(),
367 to: query.to.clone(),
368 },
369 bins,
370 clusters,
371 entries,
372 by_kind,
373 relations,
374 labels,
375 metrics: vec![
376 VisualMetric {
377 name: "entry_count".to_string(),
378 value: total as f64,
379 unit: "entries".to_string(),
380 scope: "selected_range".to_string(),
381 },
382 VisualMetric {
383 name: "relation_count".to_string(),
384 value: relation_count as f64,
385 unit: "relations".to_string(),
386 scope: "selected_subgraph".to_string(),
387 },
388 VisualMetric {
389 name: "causal_density".to_string(),
390 value: ratio(causal, relation_count),
391 unit: "ratio".to_string(),
392 scope: "selected_subgraph".to_string(),
393 },
394 ],
395 included_dimensions,
396 missing_dimensions: temporal.traversal.missing_dimensions().to_vec(),
397 revision,
398 content_hash,
399 page: VisualProjectionPage {
400 returned: page_returned,
401 total,
402 has_more,
403 next_cursor,
404 },
405 truncated: source_truncated || has_more,
406 missing,
407 })
408}
409
410fn visual_bins(entries: &[PositionedEntry], from: i128, to: i128, count: usize) -> Vec<VisualBin> {
411 let span = (to - from).max(1);
412 let mut bins = BTreeMap::<(String, String, usize), (usize, BTreeMap<String, usize>)>::new();
413 for entry in entries {
414 let index = (((entry.position - from) * count as i128) / span)
415 .clamp(0, count.saturating_sub(1) as i128) as usize;
416 for (dimension, scope_id) in &entry.labels {
417 let (total, by_kind) = bins
418 .entry((dimension.clone(), scope_id.clone(), index))
419 .or_default();
420 *total += 1;
421 *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
422 }
423 }
424 bins.into_iter()
425 .map(
426 |((dimension, scope_id, index), (total, by_kind))| VisualBin {
427 dimension,
428 scope_id,
429 from: nanos_timestamp(from + span * index as i128 / count as i128),
430 to: nanos_timestamp(from + span * (index + 1) as i128 / count as i128),
431 total,
432 by_kind,
433 },
434 )
435 .collect()
436}
437
438fn visual_clusters(
439 entries: &[PositionedEntry],
440 from: i128,
441 to: i128,
442 count: usize,
443) -> Vec<VisualCluster> {
444 let span = (to - from).max(1);
445 let mut clusters = BTreeMap::<(String, String, usize), Vec<&PositionedEntry>>::new();
446 for entry in entries {
447 let index = (((entry.position - from) * count as i128) / span)
448 .clamp(0, count.saturating_sub(1) as i128) as usize;
449 for (dimension, scope_id) in &entry.labels {
450 clusters
451 .entry((dimension.clone(), scope_id.clone(), index))
452 .or_default()
453 .push(entry);
454 }
455 }
456 clusters
457 .into_iter()
458 .map(|((dimension, scope_id, _), entries)| {
459 let mut by_kind = BTreeMap::new();
460 let mut refs = Vec::new();
461 for entry in &entries {
462 *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
463 refs.push(entry.entry.ref_id.clone());
464 }
465 VisualCluster {
466 dimension,
467 scope_id,
468 from: entries
469 .first()
470 .map(|entry| entry.position_text.clone())
471 .unwrap_or_default(),
472 to: entries
473 .last()
474 .map(|entry| entry.position_text.clone())
475 .unwrap_or_default(),
476 total: entries.len(),
477 refs,
478 by_kind,
479 }
480 })
481 .collect()
482}
483
484fn visual_relations(
485 temporal: &TemporalMemoryResult,
486 refs: &BTreeSet<String>,
487) -> Vec<VisualRelation> {
488 temporal
489 .source_bundle
490 .relationships()
491 .iter()
492 .filter(|relation| {
493 relation.explanation().semantic_class()
494 != &kmp_domain::RelationSemanticClass::Structural
495 && ((refs.contains(relation.source_node_id())
496 && refs.contains(relation.target_node_id()))
497 || (relation.relationship_type() == "supports"
498 && refs.contains(relation.target_node_id())))
499 })
500 .map(|relation| VisualRelation {
501 from: relation.source_node_id().to_string(),
502 to: relation.target_node_id().to_string(),
503 rel: relation.relationship_type().to_string(),
504 class: relation.explanation().semantic_class().as_str().to_string(),
505 why: relation.explanation().rationale().map(ToString::to_string),
506 evidence: relation.explanation().evidence().map(ToString::to_string),
507 confidence: relation.explanation().confidence().map(ToString::to_string),
508 })
509 .collect()
510}
511
512fn axis_time(coordinate: &TemporalCoordinate, axis: TemporalAxis) -> Option<&str> {
513 match axis {
514 TemporalAxis::Default => coordinate
515 .occurred_at()
516 .or(coordinate.valid_from())
517 .or(coordinate.observed_at())
518 .or(coordinate.ingested_at()),
519 TemporalAxis::Occurred => coordinate.occurred_at(),
520 TemporalAxis::Observed => coordinate.observed_at(),
521 TemporalAxis::Ingested => coordinate.ingested_at(),
522 TemporalAxis::Validity => coordinate.valid_from().or(coordinate.valid_until()),
523 }
524}
525
526fn selection_hash(query: &VisualProjectionQuery, revision: u64, content_hash: &str) -> String {
527 let mut hasher = Sha256::new();
528 hasher.update(query.about.as_bytes());
529 hasher.update(query.from.as_bytes());
530 hasher.update(query.to.as_bytes());
531 hasher.update(format!("{:?}", query.axis).as_bytes());
532 hasher.update(format!("{:?}", query.dimensions).as_bytes());
533 hasher.update(format!("{:?}", query.level_of_detail).as_bytes());
534 hasher.update(query.bin_count.to_le_bytes());
535 hasher.update(revision.to_le_bytes());
536 hasher.update(content_hash.as_bytes());
537 format!("{:x}", hasher.finalize())[..24].to_string()
538}
539
540fn cursor_offset(cursor: Option<&str>, selection_hash: &str) -> Result<usize, ApplicationError> {
541 let Some(cursor) = cursor else {
542 return Ok(0);
543 };
544 let mut parts = cursor.split(':');
545 let valid = parts.next() == Some("kmp-visual-v1")
546 && parts.next() == Some(selection_hash)
547 && parts.clone().count() == 1;
548 let offset = parts.next().and_then(|value| value.parse::<usize>().ok());
549 if !valid || offset.is_none() {
550 return Err(ApplicationError::Validation(
551 "visual projection cursor is malformed or belongs to another selection".to_string(),
552 ));
553 }
554 Ok(offset.unwrap_or_default())
555}
556
557fn ratio(numerator: usize, denominator: usize) -> f64 {
558 if denominator == 0 {
559 0.0
560 } else {
561 numerator as f64 / denominator as f64
562 }
563}
564
565fn causal_relation_count(relations: &[VisualRelation]) -> usize {
566 relations
567 .iter()
568 .filter(|relation| relation.class == "causal")
569 .count()
570}
571
572fn timestamp_nanos(value: &str) -> Option<i128> {
573 if let Some(value) = value.strip_prefix("unix:") {
574 let (seconds, nanos) = value.split_once(':')?;
575 let seconds = seconds.parse::<i128>().ok()? - 100_000_000_000i128;
576 let nanos = nanos.parse::<i128>().ok()?;
577 return Some(seconds * 1_000_000_000 + nanos);
578 }
579 basic_rfc3339_nanos(value)
580}
581
582fn nanos_timestamp(value: i128) -> String {
583 let seconds = value.div_euclid(1_000_000_000);
584 let nanos = value.rem_euclid(1_000_000_000);
585 format!("unix:{:012}:{:09}", seconds + 100_000_000_000i128, nanos)
586}
587
588fn basic_rfc3339_nanos(value: &str) -> Option<i128> {
589 let value = value.trim();
590 if value.len() < 20 {
591 return None;
592 }
593 let number = |from: usize, to: usize| -> Option<i64> { value.get(from..to)?.parse().ok() };
594 let year = number(0, 4)?;
595 let month = number(5, 7)?;
596 let day = number(8, 10)?;
597 let hour = number(11, 13)?;
598 let minute = number(14, 16)?;
599 let second = number(17, 19)?;
600 if value.get(4..5)? != "-"
601 || value.get(7..8)? != "-"
602 || value.get(10..11)? != "T"
603 || value.get(13..14)? != ":"
604 || value.get(16..17)? != ":"
605 {
606 return None;
607 }
608 let tail = value.get(19..)?;
609 let timezone_start = tail
610 .char_indices()
611 .find_map(|(index, character)| matches!(character, 'Z' | '+' | '-').then_some(index))?;
612 let fraction = tail.get(..timezone_start)?;
613 let timezone = tail.get(timezone_start..)?;
614 let nanos = match fraction.strip_prefix('.') {
615 Some(digits) if !digits.is_empty() && digits.len() <= 9 => {
616 let padded = format!("{digits:0<9}");
617 padded.parse::<i128>().ok()?
618 }
619 None if fraction.is_empty() => 0,
620 _ => return None,
621 };
622 let offset_seconds = match timezone {
623 "Z" => 0,
624 offset if offset.len() == 6 && offset.get(3..4) == Some(":") => {
625 let sign = match offset.get(..1)? {
626 "+" => 1,
627 "-" => -1,
628 _ => return None,
629 };
630 let hours = offset.get(1..3)?.parse::<i64>().ok()?;
631 let minutes = offset.get(4..6)?.parse::<i64>().ok()?;
632 if hours > 23 || minutes > 59 {
633 return None;
634 }
635 sign * (hours * 3_600 + minutes * 60)
636 }
637 _ => return None,
638 };
639 let seconds = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second
640 - offset_seconds;
641 Some(seconds as i128 * 1_000_000_000 + nanos)
642}
643
644fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
645 let year = if month <= 2 { year - 1 } else { year };
646 let era = if year >= 0 { year } else { year - 399 } / 400;
647 let year_of_era = year - era * 400;
648 let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
649 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
650 era * 146_097 + day_of_era - 719_468
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn sortable_and_rfc3339_timestamps_share_one_coordinate_space() {
659 assert_eq!(
660 timestamp_nanos("2026-08-27T12:00:00Z"),
661 timestamp_nanos("unix:101787832000:000000000")
662 );
663 assert_eq!(
664 timestamp_nanos("2026-08-27T14:00:00.125+02:00"),
665 timestamp_nanos("unix:101787832000:125000000")
666 );
667 }
668
669 #[test]
670 fn projection_cursor_is_bound_to_its_selection() {
671 assert_eq!(cursor_offset(None, "abc").expect("first page"), 0);
672 assert_eq!(
673 cursor_offset(Some("kmp-visual-v1:abc:12"), "abc").expect("same selection"),
674 12
675 );
676 assert!(cursor_offset(Some("kmp-visual-v1:def:12"), "abc").is_err());
677 }
678
679 #[test]
680 fn visual_causal_density_does_not_count_other_explanatory_classes() {
681 let relation = |class: &str| VisualRelation {
682 from: "a".to_string(),
683 to: "b".to_string(),
684 rel: "rel".to_string(),
685 class: class.to_string(),
686 why: None,
687 evidence: None,
688 confidence: None,
689 };
690 let relations = [
691 relation("causal"),
692 relation("evidential"),
693 relation("motivational"),
694 ];
695
696 assert_eq!(causal_relation_count(&relations), 1);
697 }
698}