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