1use std::collections::{BTreeMap, BTreeSet};
2
3use kmp_domain::{
4 BundleRelationship, DECLARED_FROM_RELATE_METHOD, DimensionSelection, KmpBundle,
5 MemoryRelationType, RelationSemanticClass, TemporalAxis, TemporalCoordinate, TemporalCursor,
6 TemporalDirection, TemporalWindow,
7};
8use serde::Serialize;
9use sha2::{Digest, Sha256};
10
11use crate::ApplicationError;
12
13use super::{TemporalIncludeOptions, TemporalMemoryQuery, TemporalMemoryResult, VisualLabel};
14
15pub const MAX_VISUAL_SOURCE_ENTRIES: usize = 65_536;
16pub const MAX_VISUAL_PAGE_ENTRIES: usize = 2_048;
17pub const MAX_VISUAL_BINS: usize = 512;
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum VisualLevelOfDetail {
22 #[default]
23 Atlas,
24 Episode,
25 Moment,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct VisualProjectionQuery {
30 pub about: String,
31 pub from: String,
32 pub to: String,
33 pub axis: TemporalAxis,
34 pub dimensions: DimensionSelection,
35 pub level_of_detail: VisualLevelOfDetail,
36 pub bin_count: usize,
37 pub page_entries: usize,
38 pub cursor: Option<String>,
39 pub depth: u32,
40}
41
42impl VisualProjectionQuery {
43 pub fn temporal_query(&self) -> Result<TemporalMemoryQuery, ApplicationError> {
44 Ok(TemporalMemoryQuery {
45 entry_selection: None,
46 about: self.about.clone(),
47 direction: TemporalDirection::Goto,
48 axis: self.axis,
49 cursor: Some(TemporalCursor::time(self.to.clone())?),
50 interval: None,
51 dimensions: self.dimensions.clone(),
52 window: TemporalWindow::new(0, 0),
53 limit_entries: Some(MAX_VISUAL_SOURCE_ENTRIES),
54 include: TemporalIncludeOptions {
55 dependencies: false,
56 evidence: false,
57 relations: true,
58 raw_refs: false,
59 },
60 token_budget: 262_144,
61 depth: self.depth.max(1),
62 max_tier: None,
63 })
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct VisualRange {
69 pub from: String,
70 pub to: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct VisualBin {
78 pub dimension: String,
79 pub scope_id: String,
80 pub from: String,
81 pub to: String,
82 pub total: usize,
83 pub by_kind: BTreeMap<String, usize>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct VisualCluster {
88 pub dimension: String,
89 pub scope_id: String,
90 pub from: String,
91 pub to: String,
92 pub total: usize,
93 pub refs: Vec<String>,
94 pub by_kind: BTreeMap<String, usize>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98pub struct VisualEntry {
99 pub ref_id: String,
100 pub kind: String,
101 pub text: String,
102 pub coordinates: Vec<TemporalCoordinateView>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct TemporalCoordinateView {
107 pub dimension: String,
108 pub scope_id: String,
109 pub occurred_at: Option<String>,
110 pub observed_at: Option<String>,
111 pub ingested_at: Option<String>,
112 pub valid_from: Option<String>,
113 pub valid_until: Option<String>,
114 pub sequence: Option<u32>,
115 pub rank: Option<u32>,
116 #[serde(skip_serializing_if = "Option::is_none")]
119 pub method: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub why: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub motivation: Option<String>,
124}
125
126impl From<&TemporalCoordinate> for TemporalCoordinateView {
127 fn from(value: &TemporalCoordinate) -> Self {
128 Self {
129 dimension: value.dimension().to_string(),
130 scope_id: value.scope_id().to_string(),
131 occurred_at: value.occurred_at().map(ToString::to_string),
132 observed_at: value.observed_at().map(ToString::to_string),
133 ingested_at: value.ingested_at().map(ToString::to_string),
134 valid_from: value.valid_from().map(ToString::to_string),
135 valid_until: value.valid_until().map(ToString::to_string),
136 sequence: value.sequence(),
137 rank: value.rank(),
138 method: value.origin().method().map(ToString::to_string),
139 why: value.origin().rationale().map(ToString::to_string),
140 motivation: value.origin().motivation().map(ToString::to_string),
141 }
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
146pub struct VisualRelation {
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub clocks: Option<super::MemoryRelationClocks>,
149 pub from: String,
150 pub to: String,
151 pub rel: String,
152 pub class: String,
153 pub why: Option<String>,
154 pub evidence: Option<String>,
155 pub confidence: Option<String>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub method: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct VisualMetric {
162 pub name: String,
163 pub value: f64,
164 pub unit: String,
165 pub scope: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
169pub struct VisualProjectionPage {
170 pub returned: usize,
171 pub total: usize,
172 pub has_more: bool,
173 pub next_cursor: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize)]
177pub struct VisualProjectionResult {
178 pub contract: String,
179 pub about: String,
180 pub axis: TemporalAxisView,
181 pub level_of_detail: VisualLevelOfDetail,
182 pub range: VisualRange,
183 pub bins: Vec<VisualBin>,
184 pub clusters: Vec<VisualCluster>,
185 pub entries: Vec<VisualEntry>,
186 pub by_kind: BTreeMap<String, usize>,
189 pub relations: Vec<VisualRelation>,
190 pub metrics: Vec<VisualMetric>,
191 pub labels: Vec<VisualLabel>,
196 pub included_dimensions: Vec<String>,
197 pub missing_dimensions: Vec<String>,
198 pub revision: u64,
199 pub content_hash: String,
200 pub page: VisualProjectionPage,
201 pub truncated: bool,
202 pub missing: Vec<String>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
206#[serde(rename_all = "snake_case")]
207pub enum TemporalAxisView {
208 Default,
209 Occurred,
210 Observed,
211 Ingested,
212 Validity,
213}
214
215impl From<TemporalAxis> for TemporalAxisView {
216 fn from(value: TemporalAxis) -> Self {
217 match value {
218 TemporalAxis::Default => Self::Default,
219 TemporalAxis::Occurred => Self::Occurred,
220 TemporalAxis::Observed => Self::Observed,
221 TemporalAxis::Ingested => Self::Ingested,
222 TemporalAxis::Validity => Self::Validity,
223 }
224 }
225}
226
227#[derive(Debug, Clone)]
228struct PositionedEntry {
229 entry: VisualEntry,
230 position: i128,
231 position_text: String,
232 labels: BTreeSet<(String, String)>,
234}
235
236pub fn build_visual_projection(
240 query: &VisualProjectionQuery,
241 temporal: TemporalMemoryResult,
242 catalogue: Vec<VisualLabel>,
243) -> Result<VisualProjectionResult, ApplicationError> {
244 let from = timestamp_nanos(&query.from).ok_or_else(|| {
245 ApplicationError::Validation("visual projection `from` is not a timestamp".to_string())
246 })?;
247 let to = timestamp_nanos(&query.to).ok_or_else(|| {
248 ApplicationError::Validation("visual projection `to` is not a timestamp".to_string())
249 })?;
250 if to <= from {
251 return Err(ApplicationError::Validation(
252 "visual projection range requires `to` after `from`".to_string(),
253 ));
254 }
255 let bin_count = query.bin_count.clamp(1, MAX_VISUAL_BINS);
256 let page_entries = query.page_entries.clamp(1, MAX_VISUAL_PAGE_ENTRIES);
257 let revision = temporal.source_bundle.metadata().revision;
258 let content_hash = temporal.source_bundle.metadata().content_hash.clone();
259 let selection_hash = selection_hash(query, revision, &content_hash);
260 let offset = cursor_offset(query.cursor.as_deref(), &selection_hash)?;
261
262 let missing_axis_entries = missing_axis_entries(&temporal, query);
263 let mut positioned = temporal
264 .traversal
265 .entries()
266 .iter()
267 .filter_map(|entry| {
268 let (position, position_text) = entry
269 .coordinates()
270 .iter()
271 .filter_map(|coordinate| {
272 let value = axis_time(coordinate, query.axis)?;
273 Some((timestamp_nanos(value)?, value.to_string()))
274 })
275 .min_by_key(|(position, _)| *position)?;
276 if position < from || position >= to {
277 return None;
278 }
279 let labels = entry
280 .coordinates()
281 .iter()
282 .map(|coordinate| {
283 (
284 coordinate.dimension().to_string(),
285 coordinate.scope_id().to_string(),
286 )
287 })
288 .collect();
289 Some(PositionedEntry {
290 entry: VisualEntry {
291 ref_id: entry.ref_id().to_string(),
292 kind: entry.kind().to_string(),
293 text: entry.text().to_string(),
294 coordinates: entry
295 .coordinates()
296 .iter()
297 .map(TemporalCoordinateView::from)
298 .collect(),
299 },
300 position,
301 position_text,
302 labels,
303 })
304 })
305 .collect::<Vec<_>>();
306 positioned.sort_by(|left, right| {
307 left.position
308 .cmp(&right.position)
309 .then_with(|| left.entry.ref_id.cmp(&right.entry.ref_id))
310 });
311
312 let by_kind = positioned
313 .iter()
314 .fold(BTreeMap::new(), |mut counts, entry| {
315 *counts.entry(entry.entry.kind.clone()).or_default() += 1;
316 counts
317 });
318 let bins = visual_bins(&positioned, from, to, bin_count);
319 let clusters = if query.level_of_detail == VisualLevelOfDetail::Episode {
320 visual_clusters(&positioned, from, to, bin_count)
321 } else {
322 Vec::new()
323 };
324 let total = positioned.len();
325 let end = offset.saturating_add(page_entries).min(total);
326 let entries = if query.level_of_detail == VisualLevelOfDetail::Moment {
327 positioned[offset.min(total)..end]
328 .iter()
329 .map(|entry| entry.entry.clone())
330 .collect()
331 } else {
332 Vec::new()
333 };
334 let page_returned = if query.level_of_detail == VisualLevelOfDetail::Moment {
335 entries.len()
336 } else {
337 total
338 };
339 let has_more = query.level_of_detail == VisualLevelOfDetail::Moment && end < total;
340 let next_cursor = has_more.then(|| format!("kmp-visual-v1:{selection_hash}:{end}"));
341 let page_refs = entries
342 .iter()
343 .map(|entry| entry.ref_id.clone())
344 .collect::<BTreeSet<_>>();
345 let relations = if query.level_of_detail == VisualLevelOfDetail::Moment {
346 visual_relations(&temporal, &page_refs)
347 } else {
348 Vec::new()
349 };
350 let included_dimensions = positioned
351 .iter()
352 .flat_map(|entry| entry.labels.iter().map(|(dimension, _)| dimension.clone()))
353 .collect::<BTreeSet<_>>()
354 .into_iter()
355 .collect::<Vec<_>>();
356 let labels = VisualLabel::counted(
357 catalogue,
358 positioned.iter().flat_map(|entry| entry.labels.iter()),
359 );
360 let causal = causal_relation_count(&relations);
361 let relation_count = relations.len();
362 let source_truncated = temporal.traversal.page().has_more();
363 let missing = visual_missing(source_truncated, missing_axis_entries);
364
365 Ok(VisualProjectionResult {
366 contract: "kmp.visual.projection.v1".to_string(),
367 about: query.about.clone(),
368 axis: query.axis.into(),
369 level_of_detail: query.level_of_detail,
370 range: VisualRange {
371 from: query.from.clone(),
372 to: query.to.clone(),
373 },
374 bins,
375 clusters,
376 entries,
377 by_kind,
378 relations,
379 labels,
380 metrics: vec![
381 VisualMetric {
382 name: "entry_count".to_string(),
383 value: total as f64,
384 unit: "entries".to_string(),
385 scope: "selected_range".to_string(),
386 },
387 VisualMetric {
388 name: "missing_axis_entries".to_string(),
389 value: missing_axis_entries as f64,
390 unit: "entries".to_string(),
391 scope: "selected_source".to_string(),
392 },
393 VisualMetric {
394 name: "relation_count".to_string(),
395 value: relation_count as f64,
396 unit: "relations".to_string(),
397 scope: "selected_subgraph".to_string(),
398 },
399 VisualMetric {
400 name: "causal_density".to_string(),
401 value: ratio(causal, relation_count),
402 unit: "ratio".to_string(),
403 scope: "selected_subgraph".to_string(),
404 },
405 ],
406 included_dimensions,
407 missing_dimensions: temporal.traversal.missing_dimensions().to_vec(),
408 revision,
409 content_hash,
410 page: VisualProjectionPage {
411 returned: page_returned,
412 total,
413 has_more,
414 next_cursor,
415 },
416 truncated: source_truncated || has_more,
417 missing,
418 })
419}
420
421fn missing_axis_entry(coordinates: &[TemporalCoordinate], axis: TemporalAxis) -> bool {
422 !coordinates
423 .iter()
424 .any(|coordinate| axis_time(coordinate, axis).is_some())
425}
426
427fn missing_axis_entries(temporal: &TemporalMemoryResult, query: &VisualProjectionQuery) -> usize {
428 let mut candidates = BTreeSet::new();
429 let mut coordinates = BTreeMap::<String, Vec<TemporalCoordinate>>::new();
430 for relationship in temporal.source_bundle.relationships() {
431 if relationship.relationship_type() != "contains_entry" {
432 continue;
433 }
434 let explanation = relationship.explanation();
435 if !query.dimensions.includes_coordinate(
436 explanation.dimension().unwrap_or_default(),
437 explanation.scope_id().unwrap_or_default(),
438 ) {
439 continue;
440 }
441 candidates.insert(relationship.target_node_id().to_string());
442 if let Ok(Some(coordinate)) = TemporalCoordinate::from_relation_explanation(explanation) {
443 coordinates
444 .entry(relationship.target_node_id().to_string())
445 .or_default()
446 .push(coordinate);
447 }
448 }
449 candidates
450 .into_iter()
451 .filter(|ref_id| {
452 missing_axis_entry(
453 coordinates.get(ref_id).map_or(&[], Vec::as_slice),
454 query.axis,
455 )
456 })
457 .count()
458}
459
460fn visual_missing(source_truncated: bool, missing_axis_entries: usize) -> Vec<String> {
461 let mut missing = Vec::new();
462 if source_truncated {
463 missing.push("visual_source_entries".to_string());
464 }
465 if missing_axis_entries > 0 {
466 missing.push("temporal_positions".to_string());
467 }
468 missing
469}
470
471fn visual_bins(entries: &[PositionedEntry], from: i128, to: i128, count: usize) -> Vec<VisualBin> {
472 let span = (to - from).max(1);
473 let mut bins = BTreeMap::<(String, String, usize), (usize, BTreeMap<String, usize>)>::new();
474 for entry in entries {
475 let index = (((entry.position - from) * count as i128) / span)
476 .clamp(0, count.saturating_sub(1) as i128) as usize;
477 for (dimension, scope_id) in &entry.labels {
478 let (total, by_kind) = bins
479 .entry((dimension.clone(), scope_id.clone(), index))
480 .or_default();
481 *total += 1;
482 *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
483 }
484 }
485 bins.into_iter()
486 .map(
487 |((dimension, scope_id, index), (total, by_kind))| VisualBin {
488 dimension,
489 scope_id,
490 from: nanos_timestamp(from + span * index as i128 / count as i128),
491 to: nanos_timestamp(from + span * (index + 1) as i128 / count as i128),
492 total,
493 by_kind,
494 },
495 )
496 .collect()
497}
498
499fn visual_clusters(
500 entries: &[PositionedEntry],
501 from: i128,
502 to: i128,
503 count: usize,
504) -> Vec<VisualCluster> {
505 let span = (to - from).max(1);
506 let mut clusters = BTreeMap::<(String, String, usize), Vec<&PositionedEntry>>::new();
507 for entry in entries {
508 let index = (((entry.position - from) * count as i128) / span)
509 .clamp(0, count.saturating_sub(1) as i128) as usize;
510 for (dimension, scope_id) in &entry.labels {
511 clusters
512 .entry((dimension.clone(), scope_id.clone(), index))
513 .or_default()
514 .push(entry);
515 }
516 }
517 clusters
518 .into_iter()
519 .map(|((dimension, scope_id, _), entries)| {
520 let mut by_kind = BTreeMap::new();
521 let mut refs = Vec::new();
522 for entry in &entries {
523 *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
524 refs.push(entry.entry.ref_id.clone());
525 }
526 VisualCluster {
527 dimension,
528 scope_id,
529 from: entries
530 .first()
531 .map(|entry| entry.position_text.clone())
532 .unwrap_or_default(),
533 to: entries
534 .last()
535 .map(|entry| entry.position_text.clone())
536 .unwrap_or_default(),
537 total: entries.len(),
538 refs,
539 by_kind,
540 }
541 })
542 .collect()
543}
544
545fn visual_relations(
546 temporal: &TemporalMemoryResult,
547 refs: &BTreeSet<String>,
548) -> Vec<VisualRelation> {
549 temporal
550 .source_bundle
551 .relationships()
552 .iter()
553 .filter(|relation| {
554 relation.explanation().semantic_class()
555 != &kmp_domain::RelationSemanticClass::Structural
556 && ((refs.contains(relation.source_node_id())
557 && refs.contains(relation.target_node_id()))
558 || (relation.relationship_type() == "supports"
559 && refs.contains(relation.target_node_id())))
560 })
561 .map(visual_relation)
562 .collect()
563}
564
565fn visual_relation(relation: &BundleRelationship) -> VisualRelation {
566 let explanation = relation.explanation();
567 let clocks = super::MemoryRelationClocks {
568 occurred_at: explanation.occurred_at().map(ToString::to_string),
569 observed_at: explanation.observed_at().map(ToString::to_string),
570 ingested_at: explanation.ingested_at().map(ToString::to_string),
571 valid_from: explanation.valid_from().map(ToString::to_string),
572 valid_until: explanation.valid_until().map(ToString::to_string),
573 };
574 VisualRelation {
575 clocks: (clocks != Default::default()).then_some(clocks),
576 from: relation.source_node_id().to_string(),
577 to: relation.target_node_id().to_string(),
578 rel: relation.relationship_type().to_string(),
579 class: relation.explanation().semantic_class().as_str().to_string(),
580 why: relation.explanation().rationale().map(ToString::to_string),
581 evidence: relation.explanation().evidence().map(ToString::to_string),
582 confidence: relation.explanation().confidence().map(ToString::to_string),
583 method: relation.explanation().method().map(ToString::to_string),
584 }
585}
586
587pub(super) fn declared_equivalences(bundle: &KmpBundle) -> Vec<VisualRelation> {
590 bundle
591 .relationships()
592 .iter()
593 .filter(|relation| {
594 let proof = relation.explanation();
595 MemoryRelationType::new(relation.relationship_type())
596 .is_ok_and(|kind| kind.may_cross_abouts())
597 && proof.semantic_class() == &RelationSemanticClass::Evidential
598 && proof
599 .method()
600 .is_some_and(|method| method.starts_with(DECLARED_FROM_RELATE_METHOD))
601 && proof.rationale().is_some_and(|why| !why.trim().is_empty())
602 && proof
603 .evidence()
604 .is_some_and(|evidence| !evidence.trim().is_empty())
605 })
606 .map(visual_relation)
607 .collect()
608}
609
610pub(super) fn include_owned_declarations(
611 projection: &mut VisualProjectionResult,
612 declarations: Vec<VisualRelation>,
613) {
614 let refs: BTreeSet<_> = projection
615 .entries
616 .iter()
617 .map(|entry| entry.ref_id.as_str())
618 .collect();
619 let mut keys: BTreeSet<_> = projection
620 .relations
621 .iter()
622 .map(|edge| (edge.from.clone(), edge.rel.clone(), edge.to.clone()))
623 .collect();
624 for relation in declarations {
625 if refs.contains(relation.from.as_str())
626 && keys.insert((
627 relation.from.clone(),
628 relation.rel.clone(),
629 relation.to.clone(),
630 ))
631 {
632 projection.relations.push(relation);
633 }
634 }
635 for metric in &mut projection.metrics {
636 match metric.name.as_str() {
637 "relation_count" => metric.value = projection.relations.len() as f64,
638 "causal_density" => {
639 metric.value = ratio(
640 causal_relation_count(&projection.relations),
641 projection.relations.len(),
642 )
643 }
644 _ => {}
645 }
646 }
647}
648
649fn axis_time(coordinate: &TemporalCoordinate, axis: TemporalAxis) -> Option<&str> {
650 match axis {
651 TemporalAxis::Default => coordinate
652 .occurred_at()
653 .or(coordinate.valid_from())
654 .or(coordinate.observed_at())
655 .or(coordinate.ingested_at()),
656 TemporalAxis::Occurred => coordinate.occurred_at(),
657 TemporalAxis::Observed => coordinate.observed_at(),
658 TemporalAxis::Ingested => coordinate.ingested_at(),
659 TemporalAxis::Validity => coordinate.valid_from().or(coordinate.valid_until()),
660 }
661}
662
663fn selection_hash(query: &VisualProjectionQuery, revision: u64, content_hash: &str) -> String {
664 let mut hasher = Sha256::new();
665 hasher.update(query.about.as_bytes());
666 hasher.update(query.from.as_bytes());
667 hasher.update(query.to.as_bytes());
668 hasher.update(format!("{:?}", query.axis).as_bytes());
669 hasher.update(format!("{:?}", query.dimensions).as_bytes());
670 hasher.update(format!("{:?}", query.level_of_detail).as_bytes());
671 hasher.update(query.bin_count.to_le_bytes());
672 hasher.update(revision.to_le_bytes());
673 hasher.update(content_hash.as_bytes());
674 format!("{:x}", hasher.finalize())[..24].to_string()
675}
676
677fn cursor_offset(cursor: Option<&str>, selection_hash: &str) -> Result<usize, ApplicationError> {
678 let Some(cursor) = cursor else {
679 return Ok(0);
680 };
681 let mut parts = cursor.split(':');
682 let valid = parts.next() == Some("kmp-visual-v1")
683 && parts.next() == Some(selection_hash)
684 && parts.clone().count() == 1;
685 let offset = parts.next().and_then(|value| value.parse::<usize>().ok());
686 if !valid || offset.is_none() {
687 return Err(ApplicationError::Validation(
688 "visual projection cursor is malformed or belongs to another selection".to_string(),
689 ));
690 }
691 Ok(offset.unwrap_or_default())
692}
693
694fn ratio(numerator: usize, denominator: usize) -> f64 {
695 if denominator == 0 {
696 0.0
697 } else {
698 numerator as f64 / denominator as f64
699 }
700}
701
702fn causal_relation_count(relations: &[VisualRelation]) -> usize {
703 relations
704 .iter()
705 .filter(|relation| relation.class == "causal")
706 .count()
707}
708
709fn timestamp_nanos(value: &str) -> Option<i128> {
710 if let Some(value) = value.strip_prefix("unix:") {
711 let (seconds, nanos) = value.split_once(':')?;
712 let seconds = seconds.parse::<i128>().ok()? - 100_000_000_000i128;
713 let nanos = nanos.parse::<i128>().ok()?;
714 return Some(seconds * 1_000_000_000 + nanos);
715 }
716 basic_rfc3339_nanos(value)
717}
718
719fn nanos_timestamp(value: i128) -> String {
720 let seconds = value.div_euclid(1_000_000_000);
721 let nanos = value.rem_euclid(1_000_000_000);
722 format!("unix:{:012}:{:09}", seconds + 100_000_000_000i128, nanos)
723}
724
725fn basic_rfc3339_nanos(value: &str) -> Option<i128> {
726 let value = value.trim();
727 if value.len() < 20 {
728 return None;
729 }
730 let number = |from: usize, to: usize| -> Option<i64> { value.get(from..to)?.parse().ok() };
731 let year = number(0, 4)?;
732 let month = number(5, 7)?;
733 let day = number(8, 10)?;
734 let hour = number(11, 13)?;
735 let minute = number(14, 16)?;
736 let second = number(17, 19)?;
737 if value.get(4..5)? != "-"
738 || value.get(7..8)? != "-"
739 || value.get(10..11)? != "T"
740 || value.get(13..14)? != ":"
741 || value.get(16..17)? != ":"
742 {
743 return None;
744 }
745 let tail = value.get(19..)?;
746 let timezone_start = tail
747 .char_indices()
748 .find_map(|(index, character)| matches!(character, 'Z' | '+' | '-').then_some(index))?;
749 let fraction = tail.get(..timezone_start)?;
750 let timezone = tail.get(timezone_start..)?;
751 let nanos = match fraction.strip_prefix('.') {
752 Some(digits) if !digits.is_empty() && digits.len() <= 9 => {
753 let padded = format!("{digits:0<9}");
754 padded.parse::<i128>().ok()?
755 }
756 None if fraction.is_empty() => 0,
757 _ => return None,
758 };
759 let offset_seconds = match timezone {
760 "Z" => 0,
761 offset if offset.len() == 6 && offset.get(3..4) == Some(":") => {
762 let sign = match offset.get(..1)? {
763 "+" => 1,
764 "-" => -1,
765 _ => return None,
766 };
767 let hours = offset.get(1..3)?.parse::<i64>().ok()?;
768 let minutes = offset.get(4..6)?.parse::<i64>().ok()?;
769 if hours > 23 || minutes > 59 {
770 return None;
771 }
772 sign * (hours * 3_600 + minutes * 60)
773 }
774 _ => return None,
775 };
776 let seconds = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second
777 - offset_seconds;
778 Some(seconds as i128 * 1_000_000_000 + nanos)
779}
780
781fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
782 let year = if month <= 2 { year - 1 } else { year };
783 let era = if year >= 0 { year } else { year - 399 } / 400;
784 let year_of_era = year - era * 400;
785 let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
786 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
787 era * 146_097 + day_of_era - 719_468
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use kmp_domain::{
794 BundleMetadata, BundleNode, BundleRelationship, CaseId, KmpBundle, RelationExplanation,
795 RelationSemanticClass, Role, TemporalMemoryTraversal, TemporalTraversalRequest,
796 TemporalWindow,
797 };
798 use std::collections::BTreeMap;
799
800 #[test]
801 fn sortable_and_rfc3339_timestamps_share_one_coordinate_space() {
802 assert_eq!(
803 timestamp_nanos("2026-08-27T12:00:00Z"),
804 timestamp_nanos("unix:101787832000:000000000")
805 );
806 assert_eq!(
807 timestamp_nanos("2026-08-27T14:00:00.125+02:00"),
808 timestamp_nanos("unix:101787832000:125000000")
809 );
810 }
811
812 #[test]
813 fn projection_cursor_is_bound_to_its_selection() {
814 assert_eq!(cursor_offset(None, "abc").expect("first page"), 0);
815 assert_eq!(
816 cursor_offset(Some("kmp-visual-v1:abc:12"), "abc").expect("same selection"),
817 12
818 );
819 assert!(cursor_offset(Some("kmp-visual-v1:def:12"), "abc").is_err());
820 }
821
822 #[test]
823 fn visual_causal_density_does_not_count_other_explanatory_classes() {
824 let relation = |class: &str| VisualRelation {
825 clocks: None,
826 from: "a".to_string(),
827 to: "b".to_string(),
828 rel: "rel".to_string(),
829 class: class.to_string(),
830 why: None,
831 evidence: None,
832 confidence: None,
833 method: None,
834 };
835 let relations = [
836 relation("causal"),
837 relation("evidential"),
838 relation("motivational"),
839 ];
840
841 assert_eq!(causal_relation_count(&relations), 1);
842 }
843
844 #[test]
845 fn missing_axis_entry_is_reported_without_using_another_clock() {
846 let observed_only =
847 TemporalCoordinate::cursor_time("2026-08-27T12:00:00Z", TemporalAxis::Observed)
848 .expect("coordinate");
849 let occurred =
850 TemporalCoordinate::cursor_time("2026-08-27T12:00:00Z", TemporalAxis::Occurred)
851 .expect("coordinate");
852
853 assert!(missing_axis_entry(
854 std::slice::from_ref(&observed_only),
855 TemporalAxis::Occurred
856 ));
857 assert!(!missing_axis_entry(
858 std::slice::from_ref(&observed_only),
859 TemporalAxis::Observed
860 ));
861 assert!(!missing_axis_entry(
862 &[observed_only, occurred],
863 TemporalAxis::Occurred
864 ));
865 }
866
867 #[test]
868 fn visual_missing_keeps_source_truncation_and_axis_gap_distinct() {
869 assert_eq!(
870 visual_missing(true, 2),
871 vec!["visual_source_entries", "temporal_positions"]
872 );
873 assert_eq!(visual_missing(false, 0), Vec::<String>::new());
874 }
875
876 #[test]
877 fn projection_counts_missing_axis_entries_and_keeps_range_entries() {
878 let node =
879 |id: &str| BundleNode::new(id, "memory", id, id, "ACTIVE", vec![], BTreeMap::new());
880 let edge = |id: &str, occurred: Option<&str>, observed: Option<&str>| {
881 let mut explanation = RelationExplanation::new(RelationSemanticClass::Structural)
882 .with_dimension("lane")
883 .with_scope_id("scope");
884 if let Some(value) = occurred {
885 explanation = explanation.with_occurred_at(value);
886 }
887 if let Some(value) = observed {
888 explanation = explanation.with_observed_at(value);
889 }
890 BundleRelationship::new("scope", id, "contains_entry", explanation)
891 };
892 let bundle = KmpBundle::new(
893 CaseId::new("about").expect("about"),
894 Role::new("memory").expect("role"),
895 node("about"),
896 vec![
897 node("scope"),
898 node("observed"),
899 node("inside"),
900 node("outside"),
901 ],
902 vec![
903 edge("observed", None, Some("2026-08-15T00:00:00Z")),
904 edge("inside", Some("2026-08-15T00:00:00Z"), None),
905 edge("outside", Some("2026-07-15T00:00:00Z"), None),
906 ],
907 vec![],
908 BundleMetadata::initial("visual-test"),
909 )
910 .expect("bundle");
911 let request = TemporalTraversalRequest::new(
912 TemporalDirection::Goto,
913 TemporalCursor::time("2026-09-01T00:00:00Z").expect("cursor"),
914 )
915 .with_axis(TemporalAxis::Occurred)
916 .with_window(TemporalWindow::new(20, 20));
917 let traversal = TemporalMemoryTraversal::traverse(&bundle, &request).expect("traversal");
918 let temporal = TemporalMemoryResult {
919 traversal,
920 source_bundle: bundle,
921 include: TemporalIncludeOptions::default(),
922 };
923 let query = VisualProjectionQuery {
924 about: "about".to_string(),
925 from: "2026-08-01T00:00:00Z".to_string(),
926 to: "2026-09-01T00:00:00Z".to_string(),
927 axis: TemporalAxis::Occurred,
928 dimensions: DimensionSelection::all(),
929 level_of_detail: VisualLevelOfDetail::Moment,
930 bin_count: 4,
931 page_entries: 20,
932 cursor: None,
933 depth: 1,
934 };
935 let result = build_visual_projection(&query, temporal, vec![]).expect("projection");
936
937 assert_eq!(result.entries.len(), 1);
938 assert_eq!(result.entries[0].ref_id, "inside");
939 assert_eq!(result.page.total, 1);
940 assert_eq!(result.missing, vec!["temporal_positions"]);
941 let missing = result
942 .metrics
943 .iter()
944 .find(|metric| metric.name == "missing_axis_entries")
945 .expect("missing metric");
946 assert_eq!(missing.value, 1.0);
947 assert_eq!(missing.scope, "selected_source");
948 }
949}