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 pub from: String,
148 pub to: String,
149 pub rel: String,
150 pub class: String,
151 pub why: Option<String>,
152 pub evidence: Option<String>,
153 pub confidence: Option<String>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub method: Option<String>,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize)]
159pub struct VisualMetric {
160 pub name: String,
161 pub value: f64,
162 pub unit: String,
163 pub scope: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
167pub struct VisualProjectionPage {
168 pub returned: usize,
169 pub total: usize,
170 pub has_more: bool,
171 pub next_cursor: Option<String>,
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize)]
175pub struct VisualProjectionResult {
176 pub contract: String,
177 pub about: String,
178 pub axis: TemporalAxisView,
179 pub level_of_detail: VisualLevelOfDetail,
180 pub range: VisualRange,
181 pub bins: Vec<VisualBin>,
182 pub clusters: Vec<VisualCluster>,
183 pub entries: Vec<VisualEntry>,
184 pub by_kind: BTreeMap<String, usize>,
187 pub relations: Vec<VisualRelation>,
188 pub metrics: Vec<VisualMetric>,
189 pub labels: Vec<VisualLabel>,
194 pub included_dimensions: Vec<String>,
195 pub missing_dimensions: Vec<String>,
196 pub revision: u64,
197 pub content_hash: String,
198 pub page: VisualProjectionPage,
199 pub truncated: bool,
200 pub missing: Vec<String>,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
204#[serde(rename_all = "snake_case")]
205pub enum TemporalAxisView {
206 Default,
207 Occurred,
208 Observed,
209 Ingested,
210 Validity,
211}
212
213impl From<TemporalAxis> for TemporalAxisView {
214 fn from(value: TemporalAxis) -> Self {
215 match value {
216 TemporalAxis::Default => Self::Default,
217 TemporalAxis::Occurred => Self::Occurred,
218 TemporalAxis::Observed => Self::Observed,
219 TemporalAxis::Ingested => Self::Ingested,
220 TemporalAxis::Validity => Self::Validity,
221 }
222 }
223}
224
225#[derive(Debug, Clone)]
226struct PositionedEntry {
227 entry: VisualEntry,
228 position: i128,
229 position_text: String,
230 labels: BTreeSet<(String, String)>,
232}
233
234pub fn build_visual_projection(
238 query: &VisualProjectionQuery,
239 temporal: TemporalMemoryResult,
240 catalogue: Vec<VisualLabel>,
241) -> Result<VisualProjectionResult, ApplicationError> {
242 let from = timestamp_nanos(&query.from).ok_or_else(|| {
243 ApplicationError::Validation("visual projection `from` is not a timestamp".to_string())
244 })?;
245 let to = timestamp_nanos(&query.to).ok_or_else(|| {
246 ApplicationError::Validation("visual projection `to` is not a timestamp".to_string())
247 })?;
248 if to <= from {
249 return Err(ApplicationError::Validation(
250 "visual projection range requires `to` after `from`".to_string(),
251 ));
252 }
253 let bin_count = query.bin_count.clamp(1, MAX_VISUAL_BINS);
254 let page_entries = query.page_entries.clamp(1, MAX_VISUAL_PAGE_ENTRIES);
255 let revision = temporal.source_bundle.metadata().revision;
256 let content_hash = temporal.source_bundle.metadata().content_hash.clone();
257 let selection_hash = selection_hash(query, revision, &content_hash);
258 let offset = cursor_offset(query.cursor.as_deref(), &selection_hash)?;
259
260 let mut positioned = temporal
261 .traversal
262 .entries()
263 .iter()
264 .filter_map(|entry| {
265 let (position, position_text) = entry
266 .coordinates()
267 .iter()
268 .filter_map(|coordinate| {
269 let value = axis_time(coordinate, query.axis)?;
270 Some((timestamp_nanos(value)?, value.to_string()))
271 })
272 .min_by_key(|(position, _)| *position)?;
273 if position < from || position >= to {
274 return None;
275 }
276 let labels = entry
277 .coordinates()
278 .iter()
279 .map(|coordinate| {
280 (
281 coordinate.dimension().to_string(),
282 coordinate.scope_id().to_string(),
283 )
284 })
285 .collect();
286 Some(PositionedEntry {
287 entry: VisualEntry {
288 ref_id: entry.ref_id().to_string(),
289 kind: entry.kind().to_string(),
290 text: entry.text().to_string(),
291 coordinates: entry
292 .coordinates()
293 .iter()
294 .map(TemporalCoordinateView::from)
295 .collect(),
296 },
297 position,
298 position_text,
299 labels,
300 })
301 })
302 .collect::<Vec<_>>();
303 positioned.sort_by(|left, right| {
304 left.position
305 .cmp(&right.position)
306 .then_with(|| left.entry.ref_id.cmp(&right.entry.ref_id))
307 });
308
309 let by_kind = positioned
310 .iter()
311 .fold(BTreeMap::new(), |mut counts, entry| {
312 *counts.entry(entry.entry.kind.clone()).or_default() += 1;
313 counts
314 });
315 let bins = visual_bins(&positioned, from, to, bin_count);
316 let clusters = if query.level_of_detail == VisualLevelOfDetail::Episode {
317 visual_clusters(&positioned, from, to, bin_count)
318 } else {
319 Vec::new()
320 };
321 let total = positioned.len();
322 let end = offset.saturating_add(page_entries).min(total);
323 let entries = if query.level_of_detail == VisualLevelOfDetail::Moment {
324 positioned[offset.min(total)..end]
325 .iter()
326 .map(|entry| entry.entry.clone())
327 .collect()
328 } else {
329 Vec::new()
330 };
331 let page_returned = if query.level_of_detail == VisualLevelOfDetail::Moment {
332 entries.len()
333 } else {
334 total
335 };
336 let has_more = query.level_of_detail == VisualLevelOfDetail::Moment && end < total;
337 let next_cursor = has_more.then(|| format!("kmp-visual-v1:{selection_hash}:{end}"));
338 let page_refs = entries
339 .iter()
340 .map(|entry| entry.ref_id.clone())
341 .collect::<BTreeSet<_>>();
342 let relations = if query.level_of_detail == VisualLevelOfDetail::Moment {
343 visual_relations(&temporal, &page_refs)
344 } else {
345 Vec::new()
346 };
347 let included_dimensions = positioned
348 .iter()
349 .flat_map(|entry| entry.labels.iter().map(|(dimension, _)| dimension.clone()))
350 .collect::<BTreeSet<_>>()
351 .into_iter()
352 .collect::<Vec<_>>();
353 let labels = VisualLabel::counted(
354 catalogue,
355 positioned.iter().flat_map(|entry| entry.labels.iter()),
356 );
357 let causal = causal_relation_count(&relations);
358 let relation_count = relations.len();
359 let source_truncated = temporal.traversal.page().has_more();
360 let missing = if source_truncated {
361 vec!["visual_source_entries".to_string()]
362 } else {
363 Vec::new()
364 };
365
366 Ok(VisualProjectionResult {
367 contract: "kmp.visual.projection.v1".to_string(),
368 about: query.about.clone(),
369 axis: query.axis.into(),
370 level_of_detail: query.level_of_detail,
371 range: VisualRange {
372 from: query.from.clone(),
373 to: query.to.clone(),
374 },
375 bins,
376 clusters,
377 entries,
378 by_kind,
379 relations,
380 labels,
381 metrics: vec![
382 VisualMetric {
383 name: "entry_count".to_string(),
384 value: total as f64,
385 unit: "entries".to_string(),
386 scope: "selected_range".to_string(),
387 },
388 VisualMetric {
389 name: "relation_count".to_string(),
390 value: relation_count as f64,
391 unit: "relations".to_string(),
392 scope: "selected_subgraph".to_string(),
393 },
394 VisualMetric {
395 name: "causal_density".to_string(),
396 value: ratio(causal, relation_count),
397 unit: "ratio".to_string(),
398 scope: "selected_subgraph".to_string(),
399 },
400 ],
401 included_dimensions,
402 missing_dimensions: temporal.traversal.missing_dimensions().to_vec(),
403 revision,
404 content_hash,
405 page: VisualProjectionPage {
406 returned: page_returned,
407 total,
408 has_more,
409 next_cursor,
410 },
411 truncated: source_truncated || has_more,
412 missing,
413 })
414}
415
416fn visual_bins(entries: &[PositionedEntry], from: i128, to: i128, count: usize) -> Vec<VisualBin> {
417 let span = (to - from).max(1);
418 let mut bins = BTreeMap::<(String, String, usize), (usize, BTreeMap<String, usize>)>::new();
419 for entry in entries {
420 let index = (((entry.position - from) * count as i128) / span)
421 .clamp(0, count.saturating_sub(1) as i128) as usize;
422 for (dimension, scope_id) in &entry.labels {
423 let (total, by_kind) = bins
424 .entry((dimension.clone(), scope_id.clone(), index))
425 .or_default();
426 *total += 1;
427 *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
428 }
429 }
430 bins.into_iter()
431 .map(
432 |((dimension, scope_id, index), (total, by_kind))| VisualBin {
433 dimension,
434 scope_id,
435 from: nanos_timestamp(from + span * index as i128 / count as i128),
436 to: nanos_timestamp(from + span * (index + 1) as i128 / count as i128),
437 total,
438 by_kind,
439 },
440 )
441 .collect()
442}
443
444fn visual_clusters(
445 entries: &[PositionedEntry],
446 from: i128,
447 to: i128,
448 count: usize,
449) -> Vec<VisualCluster> {
450 let span = (to - from).max(1);
451 let mut clusters = BTreeMap::<(String, String, usize), Vec<&PositionedEntry>>::new();
452 for entry in entries {
453 let index = (((entry.position - from) * count as i128) / span)
454 .clamp(0, count.saturating_sub(1) as i128) as usize;
455 for (dimension, scope_id) in &entry.labels {
456 clusters
457 .entry((dimension.clone(), scope_id.clone(), index))
458 .or_default()
459 .push(entry);
460 }
461 }
462 clusters
463 .into_iter()
464 .map(|((dimension, scope_id, _), entries)| {
465 let mut by_kind = BTreeMap::new();
466 let mut refs = Vec::new();
467 for entry in &entries {
468 *by_kind.entry(entry.entry.kind.clone()).or_default() += 1;
469 refs.push(entry.entry.ref_id.clone());
470 }
471 VisualCluster {
472 dimension,
473 scope_id,
474 from: entries
475 .first()
476 .map(|entry| entry.position_text.clone())
477 .unwrap_or_default(),
478 to: entries
479 .last()
480 .map(|entry| entry.position_text.clone())
481 .unwrap_or_default(),
482 total: entries.len(),
483 refs,
484 by_kind,
485 }
486 })
487 .collect()
488}
489
490fn visual_relations(
491 temporal: &TemporalMemoryResult,
492 refs: &BTreeSet<String>,
493) -> Vec<VisualRelation> {
494 temporal
495 .source_bundle
496 .relationships()
497 .iter()
498 .filter(|relation| {
499 relation.explanation().semantic_class()
500 != &kmp_domain::RelationSemanticClass::Structural
501 && ((refs.contains(relation.source_node_id())
502 && refs.contains(relation.target_node_id()))
503 || (relation.relationship_type() == "supports"
504 && refs.contains(relation.target_node_id())))
505 })
506 .map(visual_relation)
507 .collect()
508}
509
510fn visual_relation(relation: &BundleRelationship) -> VisualRelation {
511 VisualRelation {
512 from: relation.source_node_id().to_string(),
513 to: relation.target_node_id().to_string(),
514 rel: relation.relationship_type().to_string(),
515 class: relation.explanation().semantic_class().as_str().to_string(),
516 why: relation.explanation().rationale().map(ToString::to_string),
517 evidence: relation.explanation().evidence().map(ToString::to_string),
518 confidence: relation.explanation().confidence().map(ToString::to_string),
519 method: relation.explanation().method().map(ToString::to_string),
520 }
521}
522
523pub(super) fn declared_equivalences(bundle: &KmpBundle) -> Vec<VisualRelation> {
526 bundle
527 .relationships()
528 .iter()
529 .filter(|relation| {
530 let proof = relation.explanation();
531 MemoryRelationType::new(relation.relationship_type())
532 .is_ok_and(|kind| kind.may_cross_abouts())
533 && proof.semantic_class() == &RelationSemanticClass::Evidential
534 && proof
535 .method()
536 .is_some_and(|method| method.starts_with(DECLARED_FROM_RELATE_METHOD))
537 && proof.rationale().is_some_and(|why| !why.trim().is_empty())
538 && proof
539 .evidence()
540 .is_some_and(|evidence| !evidence.trim().is_empty())
541 })
542 .map(visual_relation)
543 .collect()
544}
545
546pub(super) fn include_owned_declarations(
547 projection: &mut VisualProjectionResult,
548 declarations: Vec<VisualRelation>,
549) {
550 let refs: BTreeSet<_> = projection
551 .entries
552 .iter()
553 .map(|entry| entry.ref_id.as_str())
554 .collect();
555 let mut keys: BTreeSet<_> = projection
556 .relations
557 .iter()
558 .map(|edge| (edge.from.clone(), edge.rel.clone(), edge.to.clone()))
559 .collect();
560 for relation in declarations {
561 if refs.contains(relation.from.as_str())
562 && keys.insert((
563 relation.from.clone(),
564 relation.rel.clone(),
565 relation.to.clone(),
566 ))
567 {
568 projection.relations.push(relation);
569 }
570 }
571 for metric in &mut projection.metrics {
572 match metric.name.as_str() {
573 "relation_count" => metric.value = projection.relations.len() as f64,
574 "causal_density" => {
575 metric.value = ratio(
576 causal_relation_count(&projection.relations),
577 projection.relations.len(),
578 )
579 }
580 _ => {}
581 }
582 }
583}
584
585fn axis_time(coordinate: &TemporalCoordinate, axis: TemporalAxis) -> Option<&str> {
586 match axis {
587 TemporalAxis::Default => coordinate
588 .occurred_at()
589 .or(coordinate.valid_from())
590 .or(coordinate.observed_at())
591 .or(coordinate.ingested_at()),
592 TemporalAxis::Occurred => coordinate.occurred_at(),
593 TemporalAxis::Observed => coordinate.observed_at(),
594 TemporalAxis::Ingested => coordinate.ingested_at(),
595 TemporalAxis::Validity => coordinate.valid_from().or(coordinate.valid_until()),
596 }
597}
598
599fn selection_hash(query: &VisualProjectionQuery, revision: u64, content_hash: &str) -> String {
600 let mut hasher = Sha256::new();
601 hasher.update(query.about.as_bytes());
602 hasher.update(query.from.as_bytes());
603 hasher.update(query.to.as_bytes());
604 hasher.update(format!("{:?}", query.axis).as_bytes());
605 hasher.update(format!("{:?}", query.dimensions).as_bytes());
606 hasher.update(format!("{:?}", query.level_of_detail).as_bytes());
607 hasher.update(query.bin_count.to_le_bytes());
608 hasher.update(revision.to_le_bytes());
609 hasher.update(content_hash.as_bytes());
610 format!("{:x}", hasher.finalize())[..24].to_string()
611}
612
613fn cursor_offset(cursor: Option<&str>, selection_hash: &str) -> Result<usize, ApplicationError> {
614 let Some(cursor) = cursor else {
615 return Ok(0);
616 };
617 let mut parts = cursor.split(':');
618 let valid = parts.next() == Some("kmp-visual-v1")
619 && parts.next() == Some(selection_hash)
620 && parts.clone().count() == 1;
621 let offset = parts.next().and_then(|value| value.parse::<usize>().ok());
622 if !valid || offset.is_none() {
623 return Err(ApplicationError::Validation(
624 "visual projection cursor is malformed or belongs to another selection".to_string(),
625 ));
626 }
627 Ok(offset.unwrap_or_default())
628}
629
630fn ratio(numerator: usize, denominator: usize) -> f64 {
631 if denominator == 0 {
632 0.0
633 } else {
634 numerator as f64 / denominator as f64
635 }
636}
637
638fn causal_relation_count(relations: &[VisualRelation]) -> usize {
639 relations
640 .iter()
641 .filter(|relation| relation.class == "causal")
642 .count()
643}
644
645fn timestamp_nanos(value: &str) -> Option<i128> {
646 if let Some(value) = value.strip_prefix("unix:") {
647 let (seconds, nanos) = value.split_once(':')?;
648 let seconds = seconds.parse::<i128>().ok()? - 100_000_000_000i128;
649 let nanos = nanos.parse::<i128>().ok()?;
650 return Some(seconds * 1_000_000_000 + nanos);
651 }
652 basic_rfc3339_nanos(value)
653}
654
655fn nanos_timestamp(value: i128) -> String {
656 let seconds = value.div_euclid(1_000_000_000);
657 let nanos = value.rem_euclid(1_000_000_000);
658 format!("unix:{:012}:{:09}", seconds + 100_000_000_000i128, nanos)
659}
660
661fn basic_rfc3339_nanos(value: &str) -> Option<i128> {
662 let value = value.trim();
663 if value.len() < 20 {
664 return None;
665 }
666 let number = |from: usize, to: usize| -> Option<i64> { value.get(from..to)?.parse().ok() };
667 let year = number(0, 4)?;
668 let month = number(5, 7)?;
669 let day = number(8, 10)?;
670 let hour = number(11, 13)?;
671 let minute = number(14, 16)?;
672 let second = number(17, 19)?;
673 if value.get(4..5)? != "-"
674 || value.get(7..8)? != "-"
675 || value.get(10..11)? != "T"
676 || value.get(13..14)? != ":"
677 || value.get(16..17)? != ":"
678 {
679 return None;
680 }
681 let tail = value.get(19..)?;
682 let timezone_start = tail
683 .char_indices()
684 .find_map(|(index, character)| matches!(character, 'Z' | '+' | '-').then_some(index))?;
685 let fraction = tail.get(..timezone_start)?;
686 let timezone = tail.get(timezone_start..)?;
687 let nanos = match fraction.strip_prefix('.') {
688 Some(digits) if !digits.is_empty() && digits.len() <= 9 => {
689 let padded = format!("{digits:0<9}");
690 padded.parse::<i128>().ok()?
691 }
692 None if fraction.is_empty() => 0,
693 _ => return None,
694 };
695 let offset_seconds = match timezone {
696 "Z" => 0,
697 offset if offset.len() == 6 && offset.get(3..4) == Some(":") => {
698 let sign = match offset.get(..1)? {
699 "+" => 1,
700 "-" => -1,
701 _ => return None,
702 };
703 let hours = offset.get(1..3)?.parse::<i64>().ok()?;
704 let minutes = offset.get(4..6)?.parse::<i64>().ok()?;
705 if hours > 23 || minutes > 59 {
706 return None;
707 }
708 sign * (hours * 3_600 + minutes * 60)
709 }
710 _ => return None,
711 };
712 let seconds = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second
713 - offset_seconds;
714 Some(seconds as i128 * 1_000_000_000 + nanos)
715}
716
717fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
718 let year = if month <= 2 { year - 1 } else { year };
719 let era = if year >= 0 { year } else { year - 399 } / 400;
720 let year_of_era = year - era * 400;
721 let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
722 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
723 era * 146_097 + day_of_era - 719_468
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn sortable_and_rfc3339_timestamps_share_one_coordinate_space() {
732 assert_eq!(
733 timestamp_nanos("2026-08-27T12:00:00Z"),
734 timestamp_nanos("unix:101787832000:000000000")
735 );
736 assert_eq!(
737 timestamp_nanos("2026-08-27T14:00:00.125+02:00"),
738 timestamp_nanos("unix:101787832000:125000000")
739 );
740 }
741
742 #[test]
743 fn projection_cursor_is_bound_to_its_selection() {
744 assert_eq!(cursor_offset(None, "abc").expect("first page"), 0);
745 assert_eq!(
746 cursor_offset(Some("kmp-visual-v1:abc:12"), "abc").expect("same selection"),
747 12
748 );
749 assert!(cursor_offset(Some("kmp-visual-v1:def:12"), "abc").is_err());
750 }
751
752 #[test]
753 fn visual_causal_density_does_not_count_other_explanatory_classes() {
754 let relation = |class: &str| VisualRelation {
755 from: "a".to_string(),
756 to: "b".to_string(),
757 rel: "rel".to_string(),
758 class: class.to_string(),
759 why: None,
760 evidence: None,
761 confidence: None,
762 method: None,
763 };
764 let relations = [
765 relation("causal"),
766 relation("evidential"),
767 relation("motivational"),
768 ];
769
770 assert_eq!(causal_relation_count(&relations), 1);
771 }
772}