1use std::collections::{HashMap, HashSet};
21
22use dejadb_cal::store_types::{RecallSource, SearchHit, SupersessionStatus};
23use dejadb_core::types::GrainType;
24
25use super::budget::{self, Allocation, ScoredEntry};
26use super::policy::{FormatPolicy, Ordering, OutputFormat};
27use super::render::{GrainRenderer, RendererRegistry};
28
29#[derive(Debug, Clone, Default)]
36pub struct RenderingHints {
37 pub entity_count: Option<usize>,
39 pub entities: Option<Vec<String>>,
41 pub has_temporal_expr: bool,
43 pub has_time_range: bool,
45 pub query_text: Option<String>,
47}
48
49#[derive(Debug, Clone, serde::Serialize)]
51pub struct FormattedContext {
52 pub text: String,
54 pub estimated_tokens: usize,
56 pub included_count: usize,
58 pub omitted_count: usize,
60 pub truncated: bool,
62}
63
64struct SupersessionChain {
70 old_index: usize,
72 new_index: usize,
74 subject: String,
76 old_value: String,
78 old_date: Option<String>,
80 new_value: String,
82 new_date: Option<String>,
84}
85
86fn is_recency_query(query_text: Option<&str>) -> bool {
89 if let Some(q) = query_text {
90 let q = q.to_lowercase();
91 let patterns = [
92 "currently",
93 "right now",
94 "most recently",
95 "at this point",
96 "latest",
97 "what is",
98 "what's",
99 ];
100 patterns.iter().any(|p| q.contains(p))
101 } else {
102 false
103 }
104}
105
106fn extract_supersession_chains(hits: &[SearchHit]) -> Vec<SupersessionChain> {
114 let hash_to_index: HashMap<_, _> = hits.iter().enumerate().map(|(i, h)| (h.hash, i)).collect();
116
117 let mut chains = Vec::new();
118 let mut consumed: HashSet<usize> = HashSet::new();
120
121 for (i, hit) in hits.iter().enumerate() {
122 if consumed.contains(&i) {
123 continue;
124 }
125 if hit.supersession_status != Some(SupersessionStatus::Superseded) {
126 continue;
127 }
128 let Some(superseder_hash) = &hit.superseded_by_hash else {
129 continue;
130 };
131
132 let mut current_hash = *superseder_hash;
134 let mut intermediaries = Vec::new();
135 let mut terminal_index = None;
136
137 for _ in 0..10 {
138 if let Some(&idx) = hash_to_index.get(¤t_hash) {
140 if hits[idx].supersession_status == Some(SupersessionStatus::Current) {
141 terminal_index = Some(idx);
142 break;
143 } else if hits[idx].supersession_status == Some(SupersessionStatus::Superseded) {
144 intermediaries.push(idx);
146 if let Some(next) = &hits[idx].superseded_by_hash {
147 current_hash = *next;
148 } else {
149 break;
150 }
151 } else {
152 break;
153 }
154 } else {
155 break;
156 }
157 }
158
159 if let Some(new_idx) = terminal_index {
160 let subject = field_str(&hits[i].grain.fields, "subject")
162 .or_else(|| field_str(&hits[i].grain.fields, "description"))
163 .unwrap_or_default();
164 let old_value = field_str(&hits[i].grain.fields, "object")
165 .or_else(|| field_str(&hits[i].grain.fields, "content"))
166 .or_else(|| field_str(&hits[i].grain.fields, "context_data"))
167 .unwrap_or_default();
168 let new_value = field_str(&hits[new_idx].grain.fields, "object")
169 .or_else(|| field_str(&hits[new_idx].grain.fields, "content"))
170 .or_else(|| field_str(&hits[new_idx].grain.fields, "context_data"))
171 .unwrap_or_default();
172
173 let old_date = format_timestamp(hits[i].grain.header.created_at_sec);
174 let new_date = format_timestamp(hits[new_idx].grain.header.created_at_sec);
175
176 consumed.insert(i);
177 consumed.insert(new_idx);
178 for &mid in &intermediaries {
179 consumed.insert(mid);
180 }
181
182 chains.push(SupersessionChain {
183 old_index: i,
184 new_index: new_idx,
185 subject,
186 old_value,
187 old_date,
188 new_value,
189 new_date,
190 });
191 }
192 }
193
194 chains
195}
196
197fn field_str(
199 fields: &std::collections::HashMap<String, serde_json::Value>,
200 key: &str,
201) -> Option<String> {
202 fields.get(key).and_then(|v| v.as_str()).map(String::from)
203}
204
205fn format_timestamp(secs: u32) -> Option<String> {
207 if secs == 0 {
208 return None;
209 }
210 let total_days = secs as i64 / 86400;
212 let mut year = 1970i32;
214 let mut remaining = total_days;
215 loop {
216 let days_in_year = if is_leap(year) { 366 } else { 365 };
217 if remaining < days_in_year {
218 break;
219 }
220 remaining -= days_in_year;
221 year += 1;
222 }
223 let leap = is_leap(year);
224 let month_days = if leap {
225 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
226 } else {
227 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
228 };
229 let mut month = 1u32;
230 for &md in &month_days {
231 if remaining < md {
232 break;
233 }
234 remaining -= md;
235 month += 1;
236 }
237 let day = remaining + 1;
238 Some(format!("{year:04}-{month:02}-{day:02}"))
239}
240
241fn is_leap(y: i32) -> bool {
242 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
243}
244
245pub struct ContextAssembler {
247 registry: RendererRegistry,
248}
249
250impl Default for ContextAssembler {
251 fn default() -> Self {
252 Self::new()
253 }
254}
255
256impl ContextAssembler {
257 pub fn new() -> Self {
258 Self {
259 registry: RendererRegistry::new(),
260 }
261 }
262
263 pub fn with_renderer(mut self, renderer: Box<dyn GrainRenderer>) -> Self {
265 self.registry.register(renderer);
266 self
267 }
268
269 pub fn format(&self, hits: &[SearchHit], policy: &FormatPolicy) -> FormattedContext {
274 self.format_with_hints(hits, policy, &RenderingHints::default())
275 }
276
277 pub fn format_with_hints(
289 &self,
290 hits: &[SearchHit],
291 policy: &FormatPolicy,
292 hints: &RenderingHints,
293 ) -> FormattedContext {
294 if hits.is_empty() {
295 return FormattedContext {
296 text: String::new(),
297 estimated_tokens: 0,
298 included_count: 0,
299 omitted_count: 0,
300 truncated: false,
301 };
302 }
303
304 let hit_refs: Vec<&SearchHit> = hits.iter().collect();
307 if detect_temporal_intent(hints, &hit_refs) && hits.len() >= 2 {
308 return self.format_timeline_pass(hits, policy);
309 }
310
311 let has_census = hits
313 .iter()
314 .any(|h| h.recall_source == Some(RecallSource::Census));
315
316 if !has_census {
317 return self.format_single_pass(hits, policy);
318 }
319
320 let mut primary_indices: Vec<usize> = Vec::new();
322 let mut census_indices: Vec<usize> = Vec::new();
323 for (i, hit) in hits.iter().enumerate() {
324 if hit.recall_source == Some(RecallSource::Census) {
325 census_indices.push(i);
326 } else {
327 primary_indices.push(i);
328 }
329 }
330
331 let (primary_budget, census_budget) = match policy.token_budget {
333 Some(total) => (Some((total * 4) / 5), Some(total / 5)),
334 None => (None, None),
335 };
336
337 let primary_policy = match primary_budget {
339 Some(b) => {
340 let mut p = policy.clone();
341 p.token_budget = Some(b);
342 p
343 }
344 None => policy.clone(),
345 };
346 let primary_hits: Vec<&SearchHit> = primary_indices.iter().map(|&i| &hits[i]).collect();
347 let primary_ctx = if primary_hits.is_empty() {
348 FormattedContext {
349 text: String::new(),
350 estimated_tokens: 0,
351 included_count: 0,
352 omitted_count: 0,
353 truncated: false,
354 }
355 } else {
356 let primary_owned: Vec<SearchHit> = primary_hits.iter().map(|h| (*h).clone()).collect();
358 self.format_single_pass(&primary_owned, &primary_policy)
359 };
360
361 let census_policy = match census_budget {
363 Some(b) => {
364 let mut p = policy.clone();
365 p.token_budget = Some(b);
366 p
367 }
368 None => policy.clone(),
369 };
370 let census_hits: Vec<SearchHit> = census_indices.iter().map(|&i| hits[i].clone()).collect();
371 let census_ctx = if census_hits.is_empty() {
372 None
373 } else {
374 let ctx = self.format_census_section(&census_hits, &census_policy);
375 if ctx.text.is_empty() {
376 None
377 } else {
378 Some(ctx)
379 }
380 };
381
382 let mut text = primary_ctx.text;
384 let mut included_count = primary_ctx.included_count;
385 let mut omitted_count = primary_ctx.omitted_count;
386
387 if let Some(ref census) = census_ctx {
388 let sep = section_separator(&policy.format);
389 if !text.is_empty() {
390 text.push_str(sep);
391 }
392 text.push_str(&census.text);
393 included_count += census.included_count;
394 omitted_count += census.omitted_count;
395 }
396
397 let estimated_tokens = text.len() / 4;
398
399 FormattedContext {
400 text,
401 estimated_tokens,
402 included_count,
403 omitted_count,
404 truncated: omitted_count > 0,
405 }
406 }
407
408 fn format_single_pass(&self, hits: &[SearchHit], policy: &FormatPolicy) -> FormattedContext {
410 if hits.is_empty() {
411 return FormattedContext {
412 text: String::new(),
413 estimated_tokens: 0,
414 included_count: 0,
415 omitted_count: 0,
416 truncated: false,
417 };
418 }
419
420 let chains = extract_supersession_chains(hits);
423 let recency = is_recency_query(policy.query_text.as_deref());
424 let ku_section = self.render_knowledge_updates(&chains, &policy.format, recency);
425
426 let ku_consumed: HashSet<usize> = chains
429 .iter()
430 .flat_map(|c| {
431 if recency {
432 vec![c.old_index, c.new_index]
435 } else {
436 vec![c.old_index, c.new_index]
438 }
439 })
440 .collect();
441
442 let filtered: Vec<usize> = self
444 .apply_overrides(hits, policy)
445 .into_iter()
446 .filter(|idx| !ku_consumed.contains(idx))
447 .collect();
448
449 let filtered = if policy.metadata != super::policy::MetadataLevel::Full {
452 self.exclude_superseded_pairs(hits, &filtered)
453 } else {
454 filtered
455 };
456
457 let mut scored: Vec<ScoredEntry> = filtered
459 .iter()
460 .enumerate()
461 .map(|(i, &idx)| {
462 let hit = &hits[idx];
463 let gt = hit.grain.grain_type;
464 let renderer = self.registry.get(gt);
465 let (priority, full_tokens) = match renderer {
466 Some(r) => (
467 r.context_priority(&hit.grain, hit),
468 r.token_estimate(&hit.grain, policy),
469 ),
470 None => (0.5, estimate_default_tokens(hit)),
471 };
472 let summary_tokens = (full_tokens / 3).max(1);
474 ScoredEntry {
475 priority,
476 full_tokens,
477 summary_tokens,
478 original_index: i,
479 grain_type: gt,
480 }
481 })
482 .collect();
483
484 let allocations = match policy.grain_type_diversity {
486 Some(ref diversity) => {
487 budget::allocate_with_diversity(&mut scored, policy.token_budget, diversity)
488 }
489 None => budget::allocate(&mut scored, policy.token_budget),
490 };
491
492 let included: Vec<(usize, Allocation)> = allocations
494 .iter()
495 .enumerate()
496 .filter(|(_, alloc)| **alloc != Allocation::Omit)
497 .map(|(i, alloc)| (filtered[i], *alloc))
498 .collect();
499
500 let omitted_count = allocations
501 .iter()
502 .filter(|a| **a == Allocation::Omit)
503 .count();
504
505 let has_expansion = included.iter().any(|(idx, _)| {
507 hits[*idx].recall_source == Some(dejadb_cal::store_types::RecallSource::Expansion)
508 });
509
510 let (primary_included, expansion_included): (Vec<_>, Vec<_>) = if has_expansion {
511 included.iter().partition(|(idx, _)| {
512 hits[*idx].recall_source != Some(dejadb_cal::store_types::RecallSource::Expansion)
513 })
514 } else {
515 (included, Vec::new())
516 };
517
518 let mut primary_ordered = primary_included;
520 self.apply_ordering(&mut primary_ordered, hits, policy);
521
522 let primary_text = if policy.sections.group_by_type {
524 self.render_grouped(&primary_ordered, hits, policy)
525 } else {
526 self.render_flat(&primary_ordered, hits, policy)
527 };
528
529 let expansion_count = expansion_included.len();
531 let main_text = if !expansion_included.is_empty() {
532 let mut exp_ordered = expansion_included;
533 self.apply_ordering(&mut exp_ordered, hits, policy);
534 let sep = section_separator(&policy.format);
535 let expansion_section = match policy.format {
536 OutputFormat::Sml => {
537 let inner = self.render_flat(&exp_ordered, hits, policy);
538 format!("<expansion_results>\n{inner}\n</expansion_results>")
539 }
540 OutputFormat::Json => {
541 let mut parts = Vec::new();
542 for (idx, _alloc) in &exp_ordered {
543 let hit = &hits[*idx];
544 let gt = hit.grain.grain_type;
545 let renderer = self.registry.get(gt);
546 let rendered = match renderer {
547 Some(r) => r.render(&hit.grain, policy),
548 None => format!("[{}]", gt.as_str()),
549 };
550 if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(&rendered) {
551 if let Some(map) = obj.as_object_mut() {
552 map.insert(
553 "recall_source".to_string(),
554 serde_json::Value::String("expansion".to_string()),
555 );
556 }
557 parts.push(obj.to_string());
558 } else {
559 parts.push(rendered);
560 }
561 }
562 format!("[{}]", parts.join(",\n"))
563 }
564 _ => {
565 let header = expansion_section_header(&policy.format);
566 let inner = self.render_flat(&exp_ordered, hits, policy);
567 format!("{header}{sep}{inner}")
568 }
569 };
570 format!("{primary_text}{sep}{expansion_section}")
571 } else {
572 primary_text
573 };
574
575 let ku_grain_count = chains.len() * 2;
577 let text = match (&ku_section, &policy.format) {
578 (Some(ku), OutputFormat::Json) => {
579 format!("{{\"knowledge_updates\":{ku},\"context\":{main_text}}}",)
581 }
582 (Some(ku), _) => {
583 if main_text.is_empty() {
584 ku.clone()
585 } else {
586 let sep = section_separator(&policy.format);
587 format!("{ku}{sep}{main_text}")
588 }
589 }
590 (None, _) => main_text,
591 };
592
593 let estimated_tokens = text.len() / 4;
594
595 FormattedContext {
596 text,
597 estimated_tokens,
598 included_count: primary_ordered.len() + expansion_count + ku_grain_count,
599 omitted_count,
600 truncated: omitted_count > 0,
601 }
602 }
603
604 fn format_timeline_pass(&self, hits: &[SearchHit], policy: &FormatPolicy) -> FormattedContext {
609 let filtered = self.apply_overrides(hits, policy);
611 let filtered = if policy.metadata != super::policy::MetadataLevel::Full {
612 self.exclude_superseded_pairs(hits, &filtered)
613 } else {
614 filtered
615 };
616
617 let mut scored: Vec<ScoredEntry> = filtered
618 .iter()
619 .enumerate()
620 .map(|(i, &idx)| {
621 let hit = &hits[idx];
622 let gt = hit.grain.grain_type;
623 let renderer = self.registry.get(gt);
624 let (priority, full_tokens) = match renderer {
625 Some(r) => (
626 r.context_priority(&hit.grain, hit),
627 r.token_estimate(&hit.grain, policy),
628 ),
629 None => (0.5, estimate_default_tokens(hit)),
630 };
631 let summary_tokens = (full_tokens / 3).max(1);
632 ScoredEntry {
633 priority,
634 full_tokens,
635 summary_tokens,
636 original_index: i,
637 grain_type: gt,
638 }
639 })
640 .collect();
641
642 let allocations = match policy.grain_type_diversity {
643 Some(ref diversity) => {
644 budget::allocate_with_diversity(&mut scored, policy.token_budget, diversity)
645 }
646 None => budget::allocate(&mut scored, policy.token_budget),
647 };
648 let mut included: Vec<(usize, Allocation)> = allocations
649 .iter()
650 .enumerate()
651 .filter(|(_, alloc)| **alloc != Allocation::Omit)
652 .map(|(i, alloc)| (filtered[i], *alloc))
653 .collect();
654
655 let omitted_count = allocations
656 .iter()
657 .filter(|a| **a == Allocation::Omit)
658 .count();
659
660 included.sort_by_key(|(idx, _)| hits[*idx].grain.header.created_at_sec);
662
663 let text = self.render_timeline(&included, hits, policy);
664 let estimated_tokens = text.len() / 4;
665
666 FormattedContext {
667 text,
668 estimated_tokens,
669 included_count: included.len(),
670 omitted_count,
671 truncated: omitted_count > 0,
672 }
673 }
674
675 fn format_census_section(
677 &self,
678 census_hits: &[SearchHit],
679 policy: &FormatPolicy,
680 ) -> FormattedContext {
681 let inner = self.format_single_pass(census_hits, policy);
683 if inner.text.is_empty() {
684 return inner;
685 }
686
687 let text = match policy.format {
689 OutputFormat::Sml => {
690 let mut rendered_grains = Vec::new();
691 for hit in census_hits {
692 let session = hit.source_namespace.as_deref().unwrap_or("unknown");
693 let gt = hit.grain.grain_type;
694 let renderer = self.registry.get(gt);
695 let content = match renderer {
696 Some(r) => r.render(&hit.grain, policy),
697 None => format!("[{}]", gt.as_str()),
698 };
699 let tag = gt.as_str();
700 if content.starts_with(&format!("<{}>", tag)) {
701 rendered_grains.push(content.replacen(
702 &format!("<{}>", tag),
703 &format!("<{} session=\"{}\">", tag, session),
704 1,
705 ));
706 } else {
707 rendered_grains.push(content);
708 }
709 }
710 format!(
711 "<census_results>\n{}\n</census_results>",
712 rendered_grains.join("\n")
713 )
714 }
715 OutputFormat::Markdown => {
716 let mut lines = vec!["## Additional Sessions (census)".to_string()];
717 for hit in census_hits {
718 let session = hit.source_namespace.as_deref().unwrap_or("unknown");
719 let gt = hit.grain.grain_type;
720 let renderer = self.registry.get(gt);
721 let content = match renderer {
722 Some(r) => r.render(&hit.grain, policy),
723 None => format!("[{}]", gt.as_str()),
724 };
725 lines.push(format!("{} (from {})", content, session));
726 }
727 lines.join("\n")
728 }
729 OutputFormat::PlainText => {
730 let mut lines = vec!["=== Additional Sessions (census) ===".to_string()];
731 for hit in census_hits {
732 let session = hit.source_namespace.as_deref().unwrap_or("unknown");
733 let gt = hit.grain.grain_type;
734 let renderer = self.registry.get(gt);
735 let content = match renderer {
736 Some(r) => r.render(&hit.grain, policy),
737 None => format!("[{}]", gt.as_str()),
738 };
739 lines.push(format!("{} [session: {}]", content, session));
740 }
741 lines.join("\n")
742 }
743 OutputFormat::Json => {
744 let mut parts = Vec::new();
747 for hit in census_hits {
748 let gt = hit.grain.grain_type;
749 let renderer = self.registry.get(gt);
750 let rendered = match renderer {
751 Some(r) => r.render(&hit.grain, policy),
752 None => format!("[{}]", gt.as_str()),
753 };
754 if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(&rendered) {
755 if let Some(map) = obj.as_object_mut() {
756 map.insert(
757 "hash".to_string(),
758 serde_json::Value::String(hit.grain.hash.to_hex()),
759 );
760 map.insert(
761 "recall_source".to_string(),
762 serde_json::Value::String("census".to_string()),
763 );
764 if let Some(ref ns) = hit.source_namespace {
765 map.insert(
766 "source_session".to_string(),
767 serde_json::Value::String(ns.clone()),
768 );
769 }
770 }
771 parts.push(obj.to_string());
772 } else {
773 parts.push(rendered);
774 }
775 }
776 format!("[{}]", parts.join(",\n"))
777 }
778 OutputFormat::Toon => {
779 let mut sections = Vec::new();
781 let mut groups: HashMap<GrainType, Vec<&SearchHit>> = HashMap::new();
782 let mut type_order: Vec<GrainType> = Vec::new();
783 for hit in census_hits {
784 let gt = hit.grain.grain_type;
785 if !groups.contains_key(>) {
786 type_order.push(gt);
787 }
788 groups.entry(gt).or_default().push(hit);
789 }
790 for gt in &type_order {
791 if let Some(entries) = groups.get(gt) {
792 let mut rows = Vec::new();
793 for hit in entries {
794 let renderer = self.registry.get(*gt);
795 let rendered = match renderer {
796 Some(r) => r.render(&hit.grain, policy),
797 None => format!("[{}]", gt.as_str()),
798 };
799 if !rendered.is_empty() {
800 rows.push(rendered);
801 }
802 }
803 if !rows.is_empty() {
804 let name = plural_name(gt);
805 let cols = super::render::toon_columns(gt).join(",");
806 let header = format!("census_{}[{}]{{{}}}:", name, rows.len(), cols);
807 let mut section = header;
808 for row in &rows {
809 section.push('\n');
810 section.push_str(row);
811 }
812 sections.push(section);
813 }
814 }
815 }
816 sections.join("\n\n")
817 }
818 };
819
820 FormattedContext {
821 text,
822 estimated_tokens: inner.estimated_tokens,
823 included_count: inner.included_count,
824 omitted_count: inner.omitted_count,
825 truncated: inner.truncated,
826 }
827 }
828
829 fn apply_overrides(&self, hits: &[SearchHit], policy: &FormatPolicy) -> Vec<usize> {
835 let mut type_counts: HashMap<GrainType, usize> = HashMap::new();
836 let mut result = Vec::with_capacity(hits.len());
837
838 for (i, hit) in hits.iter().enumerate() {
839 let gt = hit.grain.grain_type;
840
841 if let Some(ovr) = policy.grain_overrides.get(>) {
843 if !ovr.include {
844 continue;
845 }
846 if let Some(max) = ovr.max_count {
847 let count = type_counts.entry(gt).or_insert(0);
848 if *count >= max {
849 continue;
850 }
851 *count += 1;
852 }
853 }
854
855 result.push(i);
856 }
857
858 result
859 }
860
861 fn exclude_superseded_pairs(&self, hits: &[SearchHit], indices: &[usize]) -> Vec<usize> {
867 use std::collections::HashSet;
868
869 let current_hashes: HashSet<dejadb_core::error::Hash> = indices
870 .iter()
871 .filter(|&&i| {
872 hits[i].supersession_status
873 == Some(dejadb_cal::store_types::SupersessionStatus::Current)
874 })
875 .map(|&i| hits[i].hash)
876 .collect();
877
878 if current_hashes.is_empty() {
879 return indices.to_vec();
880 }
881
882 indices
883 .iter()
884 .filter(|&&i| {
885 match &hits[i].supersession_status {
886 Some(dejadb_cal::store_types::SupersessionStatus::Superseded) => {
887 !hits[i]
889 .superseded_by_hash
890 .as_ref()
891 .map(|sbh| current_hashes.contains(sbh))
892 .unwrap_or(false)
893 }
894 _ => true,
895 }
896 })
897 .copied()
898 .collect()
899 }
900
901 fn apply_ordering(
903 &self,
904 included: &mut [(usize, Allocation)],
905 hits: &[SearchHit],
906 policy: &FormatPolicy,
907 ) {
908 match policy.ordering {
909 Ordering::ByRelevance => {
910 }
912 Ordering::Chronological => {
913 included.sort_by_key(|(idx, _)| hits[*idx].grain.header.created_at_sec);
914 }
915 Ordering::ReverseChronological => {
916 included.sort_by(|(a, _), (b, _)| {
917 hits[*b]
918 .grain
919 .header
920 .created_at_sec
921 .cmp(&hits[*a].grain.header.created_at_sec)
922 });
923 }
924 Ordering::ByEntity => {
925 included.sort_by(|(a, _), (b, _)| {
927 let subj_a = hits[*a].grain.get_str("subject").unwrap_or("");
928 let subj_b = hits[*b].grain.get_str("subject").unwrap_or("");
929 subj_a.cmp(subj_b).then_with(|| {
930 hits[*b]
931 .score
932 .partial_cmp(&hits[*a].score)
933 .unwrap_or(std::cmp::Ordering::Equal)
934 })
935 });
936 }
937 }
938 }
939
940 fn render_flat(
942 &self,
943 included: &[(usize, Allocation)],
944 hits: &[SearchHit],
945 policy: &FormatPolicy,
946 ) -> String {
947 match policy.format {
948 OutputFormat::Json => self.render_flat_json(included, hits, policy),
949 OutputFormat::Toon => self.render_flat_toon(included, hits, policy),
950 _ => {
951 let separator = grain_separator(&policy.format);
952 let mut parts: Vec<String> = Vec::with_capacity(included.len());
953 for &(idx, alloc) in included {
954 let hit = &hits[idx];
955 let rendered = self.render_one(hit, alloc, policy);
956 if !rendered.is_empty() {
957 parts.push(rendered);
958 }
959 }
960 parts.join(separator)
961 }
962 }
963 }
964
965 fn render_grouped(
967 &self,
968 included: &[(usize, Allocation)],
969 hits: &[SearchHit],
970 policy: &FormatPolicy,
971 ) -> String {
972 let mut groups: HashMap<GrainType, Vec<(usize, Allocation)>> = HashMap::new();
974 for &(idx, alloc) in included {
975 groups
976 .entry(hits[idx].grain.grain_type)
977 .or_default()
978 .push((idx, alloc));
979 }
980
981 let type_order = if policy.sections.type_order.is_empty() {
982 default_type_order()
983 } else {
984 policy.sections.type_order.clone()
985 };
986
987 match policy.format {
988 OutputFormat::Json => self.render_grouped_json(&groups, &type_order, hits, policy),
989 OutputFormat::Toon => self.render_grouped_toon(&groups, &type_order, hits, policy),
990 _ => {
991 let mut sections: Vec<String> = Vec::new();
992 for gt in &type_order {
993 if let Some(entries) = groups.get(gt) {
994 let header = section_header(&policy.format, gt);
995 let separator = grain_separator(&policy.format);
996 let mut parts: Vec<String> = Vec::with_capacity(entries.len() + 1);
997 parts.push(header);
998 for &(idx, alloc) in entries {
999 let rendered = self.render_one(&hits[idx], alloc, policy);
1000 if !rendered.is_empty() {
1001 parts.push(rendered);
1002 }
1003 }
1004 if policy.format == OutputFormat::Sml {
1006 parts.push(format!("</{}>", section_tag(gt)));
1007 }
1008 sections.push(parts.join(separator));
1009 }
1010 }
1011 let section_sep = section_separator(&policy.format);
1012 sections.join(section_sep)
1013 }
1014 }
1015 }
1016
1017 fn render_one(&self, hit: &SearchHit, alloc: Allocation, policy: &FormatPolicy) -> String {
1020 let gt = hit.grain.grain_type;
1021 let rendered = match self.registry.get(gt) {
1022 Some(renderer) => match alloc {
1023 Allocation::Full => renderer.render(&hit.grain, policy),
1024 Allocation::Summary => renderer.render_summary(&hit.grain, policy),
1025 Allocation::Omit => return String::new(),
1026 },
1027 None => {
1028 format!("[{}]", gt.as_str())
1030 }
1031 };
1032 let conflict_prefix = if policy.metadata != super::policy::MetadataLevel::None {
1034 match &hit.conflict_status {
1035 Some(dejadb_cal::store_types::ConflictStatus::Outdated) => "[OUTDATED] ",
1036 Some(dejadb_cal::store_types::ConflictStatus::Current) => "[CURRENT] ",
1037 None => "",
1038 }
1039 } else {
1040 ""
1041 };
1042 let supersession_prefix = if policy.metadata != super::policy::MetadataLevel::None {
1044 match &hit.supersession_status {
1045 Some(dejadb_cal::store_types::SupersessionStatus::Superseded) => "[OUTDATED] ",
1046 Some(dejadb_cal::store_types::SupersessionStatus::Current) => "[CURRENT] ",
1047 None => "",
1048 }
1049 } else {
1050 ""
1051 };
1052 let status_prefix = if !supersession_prefix.is_empty() {
1053 supersession_prefix
1054 } else {
1055 conflict_prefix
1056 };
1057 match &policy.format {
1058 OutputFormat::Json => {
1059 let hash_hex = hit.grain.hash.to_hex();
1060 if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(&rendered) {
1061 if let Some(map) = obj.as_object_mut() {
1062 map.insert("hash".to_string(), serde_json::Value::String(hash_hex));
1063 if let Some(ref cs) = hit.conflict_status {
1064 let status_str = match cs {
1065 dejadb_cal::store_types::ConflictStatus::Current => "current",
1066 dejadb_cal::store_types::ConflictStatus::Outdated => "outdated",
1067 };
1068 map.insert(
1069 "conflict_status".to_string(),
1070 serde_json::Value::String(status_str.to_string()),
1071 );
1072 }
1073 if let Some(ref ss) = hit.supersession_status {
1074 let status_str = match ss {
1075 dejadb_cal::store_types::SupersessionStatus::Current => "current",
1076 dejadb_cal::store_types::SupersessionStatus::Superseded => {
1077 "superseded"
1078 }
1079 };
1080 map.insert(
1081 "supersession_status".to_string(),
1082 serde_json::Value::String(status_str.to_string()),
1083 );
1084 }
1085 }
1086 obj.to_string()
1087 } else {
1088 rendered
1089 }
1090 }
1091 _ => format!("{status_prefix}{rendered}"),
1092 }
1093 }
1094
1095 fn render_flat_json(
1097 &self,
1098 included: &[(usize, Allocation)],
1099 hits: &[SearchHit],
1100 policy: &FormatPolicy,
1101 ) -> String {
1102 let mut parts: Vec<String> = Vec::with_capacity(included.len());
1103 for &(idx, alloc) in included {
1104 let rendered = self.render_one(&hits[idx], alloc, policy);
1105 if !rendered.is_empty() {
1106 parts.push(rendered);
1107 }
1108 }
1109 format!("[{}]", parts.join(",\n"))
1110 }
1111
1112 fn render_grouped_json(
1114 &self,
1115 groups: &HashMap<GrainType, Vec<(usize, Allocation)>>,
1116 type_order: &[GrainType],
1117 hits: &[SearchHit],
1118 policy: &FormatPolicy,
1119 ) -> String {
1120 let mut sections: Vec<String> = Vec::new();
1121 for gt in type_order {
1122 if let Some(entries) = groups.get(gt) {
1123 let mut parts: Vec<String> = Vec::with_capacity(entries.len());
1124 for &(idx, alloc) in entries {
1125 let rendered = self.render_one(&hits[idx], alloc, policy);
1126 if !rendered.is_empty() {
1127 parts.push(rendered);
1128 }
1129 }
1130 let key = plural_name(gt);
1131 sections.push(format!("\"{}\":[{}]", key, parts.join(",\n")));
1132 }
1133 }
1134 format!("{{{}}}", sections.join(",\n"))
1135 }
1136
1137 fn render_flat_toon(
1142 &self,
1143 included: &[(usize, Allocation)],
1144 hits: &[SearchHit],
1145 policy: &FormatPolicy,
1146 ) -> String {
1147 let mut groups: HashMap<GrainType, Vec<(usize, Allocation)>> = HashMap::new();
1149 let mut type_order: Vec<GrainType> = Vec::new();
1150 for &(idx, alloc) in included {
1151 let gt = hits[idx].grain.grain_type;
1152 if !groups.contains_key(>) {
1153 type_order.push(gt);
1154 }
1155 groups.entry(gt).or_default().push((idx, alloc));
1156 }
1157
1158 let mut sections = Vec::new();
1159 for gt in &type_order {
1160 if let Some(entries) = groups.get(gt) {
1161 let mut rows = Vec::new();
1162 for &(idx, alloc) in entries {
1163 let rendered = self.render_one(&hits[idx], alloc, policy);
1164 if !rendered.is_empty() {
1165 rows.push(rendered);
1166 }
1167 }
1168 if !rows.is_empty() {
1169 let name = plural_name(gt);
1170 let cols = super::render::toon_columns(gt).join(",");
1171 let header = format!("{}[{}]{{{}}}:", name, rows.len(), cols);
1172 let mut section = header;
1173 for row in &rows {
1174 section.push('\n');
1175 section.push_str(row);
1176 }
1177 sections.push(section);
1178 }
1179 }
1180 }
1181 sections.join("\n\n")
1182 }
1183
1184 fn render_grouped_toon(
1189 &self,
1190 groups: &HashMap<GrainType, Vec<(usize, Allocation)>>,
1191 type_order: &[GrainType],
1192 hits: &[SearchHit],
1193 policy: &FormatPolicy,
1194 ) -> String {
1195 let mut sections = Vec::new();
1196 for gt in type_order {
1197 if let Some(entries) = groups.get(gt) {
1198 let mut rows = Vec::new();
1199 for &(idx, alloc) in entries {
1200 let rendered = self.render_one(&hits[idx], alloc, policy);
1201 if !rendered.is_empty() {
1202 rows.push(rendered);
1203 }
1204 }
1205 if !rows.is_empty() {
1206 let name = plural_name(gt);
1207 let cols = super::render::toon_columns(gt).join(",");
1208 let header = format!("{}[{}]{{{}}}:", name, rows.len(), cols);
1209 let mut section = header;
1210 for row in &rows {
1211 section.push('\n');
1212 section.push_str(" ");
1214 section.push_str(row);
1215 }
1216 sections.push(section);
1217 }
1218 }
1219 }
1220 sections.join("\n\n")
1221 }
1222
1223 fn render_timeline(
1228 &self,
1229 included: &[(usize, Allocation)],
1230 hits: &[SearchHit],
1231 policy: &FormatPolicy,
1232 ) -> String {
1233 match policy.format {
1234 OutputFormat::Json => self.render_timeline_json(included, hits, policy),
1235 OutputFormat::Toon => self.render_timeline_toon(included, hits, policy),
1236 OutputFormat::Sml => self.render_timeline_sml(included, hits, policy),
1237 OutputFormat::Markdown => self.render_timeline_text(included, hits, policy, true),
1238 OutputFormat::PlainText => self.render_timeline_text(included, hits, policy, false),
1239 }
1240 }
1241
1242 fn render_timeline_text(
1244 &self,
1245 included: &[(usize, Allocation)],
1246 hits: &[SearchHit],
1247 policy: &FormatPolicy,
1248 markdown: bool,
1249 ) -> String {
1250 let header = if markdown {
1251 "## Timeline (earliest to latest)".to_string()
1252 } else {
1253 "=== Timeline (earliest to latest) ===".to_string()
1254 };
1255
1256 let mut parts = vec![header];
1257 for (i, &(idx, alloc)) in included.iter().enumerate() {
1258 let hit = &hits[idx];
1259 let date = format_epoch_date(hit.grain.header.created_at_sec);
1260 let rendered = self.render_one(hit, alloc, policy);
1261 if rendered.is_empty() {
1262 continue;
1263 }
1264 parts.push(format!("{}. {}: {}", i + 1, date, rendered));
1265
1266 if i + 1 < included.len() {
1268 let next_idx = included[i + 1].0;
1269 let next_ts = hits[next_idx].grain.header.created_at_sec;
1270 let curr_ts = hit.grain.header.created_at_sec;
1271 if next_ts > curr_ts {
1272 let delta_secs = (next_ts - curr_ts) as u64;
1273 let label = format_time_delta(delta_secs);
1274 if markdown {
1275 parts.push(format!(" \u{2193} *{}*", label));
1276 } else {
1277 parts.push(format!(" \u{2193} {}", label));
1278 }
1279 }
1280 }
1281 }
1282 parts.join("\n")
1283 }
1284
1285 fn render_timeline_sml(
1287 &self,
1288 included: &[(usize, Allocation)],
1289 hits: &[SearchHit],
1290 policy: &FormatPolicy,
1291 ) -> String {
1292 let mut parts = vec!["<timeline order=\"chronological\">".to_string()];
1293 for (i, &(idx, alloc)) in included.iter().enumerate() {
1294 let hit = &hits[idx];
1295 let rendered = self.render_one(hit, alloc, policy);
1296 if rendered.is_empty() {
1297 continue;
1298 }
1299 parts.push(rendered);
1300
1301 if i + 1 < included.len() {
1303 let next_idx = included[i + 1].0;
1304 let next_ts = hits[next_idx].grain.header.created_at_sec;
1305 let curr_ts = hit.grain.header.created_at_sec;
1306 if next_ts > curr_ts {
1307 let delta_secs = (next_ts - curr_ts) as u64;
1308 let days = delta_secs / 86400;
1309 let label = format_time_delta(delta_secs);
1310 parts.push(format!("<delta days=\"{}\" label=\"{}\"/>", days, label));
1311 }
1312 }
1313 }
1314 parts.push("</timeline>".to_string());
1315 parts.join("\n")
1316 }
1317
1318 fn render_timeline_json(
1320 &self,
1321 included: &[(usize, Allocation)],
1322 hits: &[SearchHit],
1323 policy: &FormatPolicy,
1324 ) -> String {
1325 let mut entries: Vec<String> = Vec::with_capacity(included.len());
1326 for (i, &(idx, alloc)) in included.iter().enumerate() {
1327 let hit = &hits[idx];
1328 let rendered = self.render_one(hit, alloc, policy);
1329 if rendered.is_empty() {
1330 continue;
1331 }
1332 if i + 1 < included.len() {
1334 let next_idx = included[i + 1].0;
1335 let next_ts = hits[next_idx].grain.header.created_at_sec;
1336 let curr_ts = hit.grain.header.created_at_sec;
1337 if next_ts > curr_ts {
1338 let delta_secs = (next_ts - curr_ts) as u64;
1339 let days = delta_secs / 86400;
1340 let label = format_time_delta(delta_secs);
1341 if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(&rendered) {
1342 if let Some(map) = obj.as_object_mut() {
1343 let delta = serde_json::json!({
1344 "days": days,
1345 "label": label,
1346 });
1347 map.insert("delta_to_next".to_string(), delta);
1348 }
1349 entries.push(obj.to_string());
1350 continue;
1351 }
1352 }
1353 }
1354 entries.push(rendered);
1355 }
1356 format!("{{\"timeline\":[{}]}}", entries.join(",\n"))
1357 }
1358
1359 fn render_timeline_toon(
1361 &self,
1362 included: &[(usize, Allocation)],
1363 hits: &[SearchHit],
1364 policy: &FormatPolicy,
1365 ) -> String {
1366 let mut parts = vec![format!("timeline[{}]:", included.len())];
1367 for (i, &(idx, alloc)) in included.iter().enumerate() {
1368 let hit = &hits[idx];
1369 let date = format_epoch_date(hit.grain.header.created_at_sec);
1370 let rendered = self.render_one(hit, alloc, policy);
1371 if rendered.is_empty() {
1372 continue;
1373 }
1374 parts.push(format!(" {}: {}", date, rendered));
1375
1376 if i + 1 < included.len() {
1378 let next_idx = included[i + 1].0;
1379 let next_ts = hits[next_idx].grain.header.created_at_sec;
1380 let curr_ts = hit.grain.header.created_at_sec;
1381 if next_ts > curr_ts {
1382 let delta_secs = (next_ts - curr_ts) as u64;
1383 let label = format_time_delta(delta_secs);
1384 parts.push(format!(" \u{2193} {}", label));
1385 }
1386 }
1387 }
1388 parts.join("\n")
1389 }
1390
1391 fn render_knowledge_updates(
1396 &self,
1397 chains: &[SupersessionChain],
1398 format: &OutputFormat,
1399 recency: bool,
1400 ) -> Option<String> {
1401 if chains.is_empty() {
1402 return None;
1403 }
1404
1405 match format {
1406 OutputFormat::PlainText => {
1407 let mut lines = vec!["=== Knowledge Updates ===".to_string()];
1408 for chain in chains {
1409 let old_date_str = chain
1410 .old_date
1411 .as_deref()
1412 .map(|d| format!(" ({d})"))
1413 .unwrap_or_default();
1414 let new_date_str = chain
1415 .new_date
1416 .as_deref()
1417 .map(|d| format!(" ({d})"))
1418 .unwrap_or_default();
1419 if recency {
1420 lines.push(format!(
1421 "{}: \"{}\" [CURRENT]",
1422 chain.subject, chain.new_value,
1423 ));
1424 } else {
1425 lines.push(format!(
1426 "{}: \"{}\"{} → \"{}\"{} [CURRENT]",
1427 chain.subject,
1428 chain.old_value,
1429 old_date_str,
1430 chain.new_value,
1431 new_date_str,
1432 ));
1433 }
1434 }
1435 Some(lines.join("\n"))
1436 }
1437 OutputFormat::Markdown => {
1438 let mut lines = vec!["## Knowledge Updates".to_string()];
1439 for chain in chains {
1440 let old_date_str = chain
1441 .old_date
1442 .as_deref()
1443 .map(|d| format!(" ({d})"))
1444 .unwrap_or_default();
1445 let new_date_str = chain
1446 .new_date
1447 .as_deref()
1448 .map(|d| format!(" ({d})"))
1449 .unwrap_or_default();
1450 if recency {
1451 lines.push(format!(
1452 "**{}**: **{}**{} [CURRENT]",
1453 chain.subject, chain.new_value, new_date_str,
1454 ));
1455 } else {
1456 lines.push(format!(
1457 "**{}**: ~~{}~~{} → **{}**{} [CURRENT]",
1458 chain.subject,
1459 chain.old_value,
1460 old_date_str,
1461 chain.new_value,
1462 new_date_str,
1463 ));
1464 }
1465 }
1466 Some(lines.join("\n"))
1467 }
1468 OutputFormat::Sml => {
1469 let mut lines = vec!["<knowledge_updates>".to_string()];
1470 for chain in chains {
1471 let old_date_attr = chain
1472 .old_date
1473 .as_deref()
1474 .map(|d| format!(" old_date=\"{d}\""))
1475 .unwrap_or_default();
1476 let new_date_attr = chain
1477 .new_date
1478 .as_deref()
1479 .map(|d| format!(" new_date=\"{d}\""))
1480 .unwrap_or_default();
1481 if recency {
1482 lines.push(format!(
1483 "<update subject=\"{}\" new=\"{}\"{} />",
1484 chain.subject, chain.new_value, new_date_attr,
1485 ));
1486 } else {
1487 lines.push(format!(
1488 "<update subject=\"{}\" old=\"{}\"{} new=\"{}\"{} />",
1489 chain.subject,
1490 chain.old_value,
1491 old_date_attr,
1492 chain.new_value,
1493 new_date_attr,
1494 ));
1495 }
1496 }
1497 lines.push("</knowledge_updates>".to_string());
1498 Some(lines.join("\n"))
1499 }
1500 OutputFormat::Json => {
1501 let updates: Vec<serde_json::Value> = chains
1502 .iter()
1503 .map(|chain| {
1504 if recency {
1505 serde_json::json!({
1506 "subject": chain.subject,
1507 "new_value": chain.new_value,
1508 "new_date": chain.new_date,
1509 })
1510 } else {
1511 serde_json::json!({
1512 "subject": chain.subject,
1513 "old_value": chain.old_value,
1514 "old_date": chain.old_date,
1515 "new_value": chain.new_value,
1516 "new_date": chain.new_date,
1517 })
1518 }
1519 })
1520 .collect();
1521 Some(serde_json::to_string(&updates).unwrap_or_else(|_| "[]".to_string()))
1522 }
1523 OutputFormat::Toon => {
1524 let mut lines = vec!["--- knowledge updates ---".to_string()];
1525 for chain in chains {
1526 if recency {
1527 lines.push(format!(
1528 "{}: \"{}\" [CURRENT]",
1529 chain.subject, chain.new_value,
1530 ));
1531 } else {
1532 lines.push(format!(
1533 "{}: \"{}\" → \"{}\" [CURRENT]",
1534 chain.subject, chain.old_value, chain.new_value,
1535 ));
1536 }
1537 }
1538 Some(lines.join("\n"))
1539 }
1540 }
1541 }
1542}
1543
1544fn detect_temporal_intent(hints: &RenderingHints, _hits: &[&SearchHit]) -> bool {
1555 if hints.has_temporal_expr || hints.has_time_range {
1557 return true;
1558 }
1559
1560 if let Some(ref query) = hints.query_text {
1562 let q = query.to_lowercase();
1563 let temporal_patterns = [
1564 "order of",
1565 "in what order",
1566 "sequence of",
1567 "from earliest",
1568 "from latest",
1569 "from first",
1570 "from last",
1571 "how many days",
1572 "how long between",
1573 "time between",
1574 "which came first",
1575 "which came last",
1576 "which was earlier",
1577 "which was later",
1578 "before or after",
1579 "earlier or later",
1580 "when did",
1581 "what date",
1582 "what time",
1583 "chronolog",
1584 ];
1585 if temporal_patterns.iter().any(|p| q.contains(p)) {
1586 return true;
1587 }
1588 }
1589
1590 false
1591}
1592
1593fn format_time_delta(seconds: u64) -> String {
1599 let days = seconds / 86400;
1600 if days == 0 {
1601 return "same day".to_string();
1602 }
1603 if days < 90 {
1604 if days == 1 {
1605 "1 day".to_string()
1606 } else {
1607 format!("{} days", days)
1608 }
1609 } else if days < 365 {
1610 let months = days / 30;
1611 if months == 1 {
1612 "1 month".to_string()
1613 } else {
1614 format!("{} months", months)
1615 }
1616 } else {
1617 let years = days / 365;
1618 let remaining_months = (days % 365) / 30;
1619 if remaining_months > 0 {
1620 if years == 1 {
1621 format!("1 year, {} months", remaining_months)
1622 } else {
1623 format!("{} years, {} months", years, remaining_months)
1624 }
1625 } else if years == 1 {
1626 "1 year".to_string()
1627 } else {
1628 format!("{} years", years)
1629 }
1630 }
1631}
1632
1633fn default_type_order() -> Vec<GrainType> {
1635 vec![
1636 GrainType::State,
1637 GrainType::Goal,
1638 GrainType::Fact,
1639 GrainType::Tool,
1640 GrainType::Event,
1641 GrainType::Observation,
1642 GrainType::Reasoning,
1643 GrainType::Workflow,
1644 GrainType::Consensus,
1645 GrainType::Consent,
1646 GrainType::Skill,
1647 ]
1648}
1649
1650fn section_header(format: &OutputFormat, gt: &GrainType) -> String {
1652 let name = plural_name(gt);
1653 match format {
1654 OutputFormat::Sml => format!("<{}>", section_tag(gt)),
1655 OutputFormat::Markdown => format!("## {}", capitalize(name)),
1656 OutputFormat::PlainText => format!("=== {} ===", name.to_uppercase()),
1657 OutputFormat::Json | OutputFormat::Toon => unreachable!("handled by dedicated methods"),
1660 }
1661}
1662
1663fn section_tag(gt: &GrainType) -> &'static str {
1665 match gt {
1666 GrainType::Fact => "facts",
1667 GrainType::Event => "events",
1668 GrainType::State => "states",
1669 GrainType::Workflow => "workflows",
1670 GrainType::Tool => "tools",
1671 GrainType::Observation => "observations",
1672 GrainType::Goal => "goals",
1673 GrainType::Reasoning => "reasoning",
1674 GrainType::Consensus => "consensus",
1675 GrainType::Consent => "consents",
1676 GrainType::Skill => "skills",
1677 }
1678}
1679
1680fn plural_name(gt: &GrainType) -> &'static str {
1682 section_tag(gt)
1683}
1684
1685fn capitalize(s: &str) -> String {
1687 let mut chars = s.chars();
1688 match chars.next() {
1689 None => String::new(),
1690 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
1691 }
1692}
1693
1694fn grain_separator(format: &OutputFormat) -> &'static str {
1696 match format {
1697 OutputFormat::Sml => "\n",
1698 OutputFormat::Toon => "\n",
1699 OutputFormat::Markdown => "\n",
1700 OutputFormat::PlainText => "\n",
1701 OutputFormat::Json => ",\n",
1702 }
1703}
1704
1705fn expansion_section_header(format: &OutputFormat) -> &'static str {
1707 match format {
1708 OutputFormat::Markdown => "## Additional Context (expansion)",
1709 OutputFormat::PlainText => "=== Additional Context (expansion) ===",
1710 OutputFormat::Toon => "--- expansion ---",
1711 OutputFormat::Sml | OutputFormat::Json => "",
1713 }
1714}
1715
1716fn section_separator(format: &OutputFormat) -> &'static str {
1718 match format {
1719 OutputFormat::Sml => "\n",
1720 OutputFormat::Toon => "\n\n",
1721 OutputFormat::Markdown => "\n\n",
1722 OutputFormat::PlainText => "\n\n",
1723 OutputFormat::Json => ",\n",
1724 }
1725}
1726
1727fn estimate_default_tokens(hit: &SearchHit) -> usize {
1729 let chars: usize = hit
1730 .grain
1731 .fields
1732 .iter()
1733 .map(|(k, v)| k.len() + v.to_string().len() + 4)
1734 .sum();
1735 (chars + 30) / 4
1736}
1737
1738fn format_epoch_date(epoch_sec: u32) -> String {
1740 use chrono::{TimeZone, Utc};
1741 match Utc.timestamp_opt(epoch_sec as i64, 0) {
1742 chrono::offset::LocalResult::Single(dt) => dt.format("%Y-%m-%d").to_string(),
1743 _ => format!("epoch:{}", epoch_sec),
1744 }
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749 use super::*;
1750 use crate::policy::{MetadataLevel, OutputFormat};
1751 use dejadb_core::error::Hash;
1752 use dejadb_core::format::deserialize::DeserializedGrain;
1753 use dejadb_core::format::header::MgHeader;
1754 use std::collections::HashMap;
1755
1756 fn make_hit(gt: GrainType, fields: Vec<(&str, &str)>, score: f64) -> SearchHit {
1757 let header = MgHeader {
1758 version: 1,
1759 flags: 0,
1760 grain_type: gt.type_byte(),
1761 ns_hash: 0,
1762 created_at_sec: 1740700000,
1763 };
1764 let mut field_map = HashMap::new();
1765 for (k, v) in fields {
1766 field_map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
1767 }
1768 SearchHit {
1769 grain: DeserializedGrain {
1770 header,
1771 grain_type: gt,
1772 fields: field_map,
1773 hash: Hash::from_bytes(&[0u8; 32]),
1774 },
1775 score,
1776 hash: Hash::from_bytes(&[0u8; 32]),
1777 score_breakdown: None,
1778 #[cfg(feature = "rerank")]
1779 rerank_score: None,
1780 #[cfg(feature = "llm-rerank")]
1781 llm_rerank_score: None,
1782 explanation: None,
1783 scope_depth: None,
1784 source_namespace: None,
1785 relative_time: None,
1786 conflict_status: None,
1787 supersession_status: None,
1788 superseded_by_hash: None,
1789 recall_source: None,
1790 }
1791 }
1792
1793 #[test]
1794 fn test_format_empty_hits() {
1795 let assembler = ContextAssembler::new();
1796 let policy = FormatPolicy::default();
1797 let ctx = assembler.format(&[], &policy);
1798 assert_eq!(ctx.text, "");
1799 assert_eq!(ctx.included_count, 0);
1800 assert_eq!(ctx.omitted_count, 0);
1801 assert!(!ctx.truncated);
1802 }
1803
1804 #[test]
1805 fn test_format_single_fact_plaintext() {
1806 let assembler = ContextAssembler::new();
1807 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
1808 let hits = vec![make_hit(
1809 GrainType::Fact,
1810 vec![
1811 ("subject", "john"),
1812 ("relation", "likes"),
1813 ("object", "coffee"),
1814 ],
1815 0.9,
1816 )];
1817 let ctx = assembler.format(&hits, &policy);
1818 assert!(ctx.text.contains("john"));
1819 assert!(ctx.text.contains("likes"));
1820 assert!(ctx.text.contains("coffee"));
1821 assert_eq!(ctx.included_count, 1);
1822 assert!(!ctx.truncated);
1823 }
1824
1825 #[test]
1826 fn test_format_single_fact_sml() {
1827 let assembler = ContextAssembler::new();
1828 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
1829 let hits = vec![make_hit(
1830 GrainType::Fact,
1831 vec![
1832 ("subject", "john"),
1833 ("relation", "likes"),
1834 ("object", "coffee"),
1835 ],
1836 0.9,
1837 )];
1838 let ctx = assembler.format(&hits, &policy);
1839 assert!(ctx.text.contains("<fact>"));
1840 assert!(ctx.text.contains("</fact>"));
1841 assert!(ctx.text.contains("john"));
1842 assert_eq!(ctx.included_count, 1);
1843 }
1844
1845 #[test]
1846 fn test_format_single_fact_toon() {
1847 let assembler = ContextAssembler::new();
1848 let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::None);
1849 let hits = vec![make_hit(
1850 GrainType::Fact,
1851 vec![
1852 ("subject", "john"),
1853 ("relation", "likes"),
1854 ("object", "coffee"),
1855 ],
1856 0.9,
1857 )];
1858 let ctx = assembler.format(&hits, &policy);
1859 assert!(
1861 ctx.text.contains("facts[1]{subject,content,confidence}:"),
1862 "TOON should have tabular header, got: {}",
1863 ctx.text
1864 );
1865 assert!(
1866 ctx.text.contains("john,likes coffee"),
1867 "TOON should have CSV row with john, got: {}",
1868 ctx.text
1869 );
1870 assert!(
1872 !ctx.text.contains("<fact>"),
1873 "TOON should not contain SML tags"
1874 );
1875 assert!(
1876 !ctx.text.contains(" - "),
1877 "TOON should not contain list-item markers"
1878 );
1879 assert_eq!(ctx.included_count, 1);
1880 }
1881
1882 #[test]
1883 fn test_format_multiple_grains_flat() {
1884 let assembler = ContextAssembler::new();
1885 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
1886 let hits = vec![
1887 make_hit(
1888 GrainType::Fact,
1889 vec![
1890 ("subject", "john"),
1891 ("relation", "likes"),
1892 ("object", "coffee"),
1893 ],
1894 0.9,
1895 ),
1896 make_hit(
1897 GrainType::Goal,
1898 vec![("description", "Deploy v2"), ("goal_state", "active")],
1899 0.8,
1900 ),
1901 ];
1902 let ctx = assembler.format(&hits, &policy);
1903 assert!(ctx.text.contains("john"));
1904 assert!(ctx.text.contains("Deploy v2"));
1905 assert_eq!(ctx.included_count, 2);
1906 }
1907
1908 #[test]
1909 fn test_format_grouped_by_type_sml() {
1910 let assembler = ContextAssembler::new();
1911 let policy = FormatPolicy::new(OutputFormat::Sml)
1912 .metadata(MetadataLevel::None)
1913 .group_by_type();
1914 let hits = vec![
1915 make_hit(
1916 GrainType::Fact,
1917 vec![
1918 ("subject", "john"),
1919 ("relation", "likes"),
1920 ("object", "coffee"),
1921 ],
1922 0.9,
1923 ),
1924 make_hit(
1925 GrainType::Goal,
1926 vec![("description", "Deploy v2"), ("goal_state", "active")],
1927 0.8,
1928 ),
1929 make_hit(
1930 GrainType::Fact,
1931 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
1932 0.7,
1933 ),
1934 ];
1935 let ctx = assembler.format(&hits, &policy);
1936 assert!(ctx.text.contains("<facts>"));
1938 assert!(ctx.text.contains("</facts>"));
1939 assert!(ctx.text.contains("<goals>"));
1940 assert!(ctx.text.contains("</goals>"));
1941 assert_eq!(ctx.included_count, 3);
1942 }
1943
1944 #[test]
1945 fn test_format_grouped_by_type_markdown() {
1946 let assembler = ContextAssembler::new();
1947 let policy = FormatPolicy::new(OutputFormat::Markdown)
1948 .metadata(MetadataLevel::None)
1949 .group_by_type();
1950 let hits = vec![
1951 make_hit(
1952 GrainType::State,
1953 vec![("context_data", "session running")],
1954 0.9,
1955 ),
1956 make_hit(
1957 GrainType::Goal,
1958 vec![("description", "Deploy v2"), ("goal_state", "active")],
1959 0.8,
1960 ),
1961 ];
1962 let ctx = assembler.format(&hits, &policy);
1963 assert!(ctx.text.contains("## States"));
1964 assert!(ctx.text.contains("## Goals"));
1965 }
1966
1967 #[test]
1968 fn test_budget_truncation() {
1969 let assembler = ContextAssembler::new();
1970 let policy = FormatPolicy::new(OutputFormat::PlainText)
1971 .metadata(MetadataLevel::None)
1972 .token_budget(10); let hits = vec![
1974 make_hit(
1975 GrainType::Fact,
1976 vec![
1977 ("subject", "john"),
1978 ("relation", "likes"),
1979 (
1980 "object",
1981 "coffee with extra long description that should cause truncation",
1982 ),
1983 ],
1984 0.9,
1985 ),
1986 make_hit(
1987 GrainType::Fact,
1988 vec![
1989 ("subject", "bob"),
1990 ("relation", "likes"),
1991 ("object", "another long value to exceed budget"),
1992 ],
1993 0.5,
1994 ),
1995 ];
1996 let ctx = assembler.format(&hits, &policy);
1997 assert!(ctx.truncated);
1998 assert!(ctx.omitted_count > 0);
1999 }
2000
2001 #[test]
2002 fn test_grain_type_override_exclude() {
2003 let assembler = ContextAssembler::new();
2004 use crate::policy::GrainTypeOverride;
2005 let policy = FormatPolicy::new(OutputFormat::PlainText)
2006 .metadata(MetadataLevel::None)
2007 .grain_override(
2008 GrainType::Event,
2009 GrainTypeOverride {
2010 include: false,
2011 max_count: None,
2012 },
2013 );
2014 let hits = vec![
2015 make_hit(
2016 GrainType::Fact,
2017 vec![
2018 ("subject", "john"),
2019 ("relation", "likes"),
2020 ("object", "coffee"),
2021 ],
2022 0.9,
2023 ),
2024 make_hit(
2025 GrainType::Event,
2026 vec![("content", "something happened")],
2027 0.8,
2028 ),
2029 ];
2030 let ctx = assembler.format(&hits, &policy);
2031 assert!(!ctx.text.contains("something happened"));
2033 assert_eq!(ctx.included_count, 1);
2034 }
2035
2036 #[test]
2037 fn test_grain_type_override_max_count() {
2038 let assembler = ContextAssembler::new();
2039 use crate::policy::GrainTypeOverride;
2040 let policy = FormatPolicy::new(OutputFormat::PlainText)
2041 .metadata(MetadataLevel::None)
2042 .grain_override(
2043 GrainType::Fact,
2044 GrainTypeOverride {
2045 include: true,
2046 max_count: Some(1),
2047 },
2048 );
2049 let hits = vec![
2050 make_hit(
2051 GrainType::Fact,
2052 vec![
2053 ("subject", "john"),
2054 ("relation", "likes"),
2055 ("object", "coffee"),
2056 ],
2057 0.9,
2058 ),
2059 make_hit(
2060 GrainType::Fact,
2061 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2062 0.8,
2063 ),
2064 ];
2065 let ctx = assembler.format(&hits, &policy);
2066 assert!(ctx.text.contains("john"));
2068 assert!(!ctx.text.contains("bob"));
2069 assert_eq!(ctx.included_count, 1);
2070 }
2071
2072 #[test]
2073 fn test_chronological_ordering() {
2074 let assembler = ContextAssembler::new();
2075 let policy = FormatPolicy::new(OutputFormat::PlainText)
2076 .metadata(MetadataLevel::None)
2077 .ordering(Ordering::Chronological);
2078 let mut hits = vec![
2079 make_hit(
2080 GrainType::Fact,
2081 vec![
2082 ("subject", "newer"),
2083 ("relation", "is"),
2084 ("object", "second"),
2085 ],
2086 0.9,
2087 ),
2088 make_hit(
2089 GrainType::Fact,
2090 vec![
2091 ("subject", "older"),
2092 ("relation", "is"),
2093 ("object", "first"),
2094 ],
2095 0.8,
2096 ),
2097 ];
2098 hits[0].grain.header.created_at_sec = 1740700100;
2100 hits[1].grain.header.created_at_sec = 1740700000;
2101
2102 let ctx = assembler.format(&hits, &policy);
2103 let older_pos = ctx.text.find("older").unwrap();
2105 let newer_pos = ctx.text.find("newer").unwrap();
2106 assert!(
2107 older_pos < newer_pos,
2108 "Chronological: older should come first"
2109 );
2110 }
2111
2112 #[test]
2113 fn test_json_flat_format() {
2114 let assembler = ContextAssembler::new();
2115 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
2116 let hits = vec![make_hit(
2117 GrainType::Fact,
2118 vec![
2119 ("subject", "john"),
2120 ("relation", "likes"),
2121 ("object", "coffee"),
2122 ],
2123 0.9,
2124 )];
2125 let ctx = assembler.format(&hits, &policy);
2126 assert!(ctx.text.starts_with('['));
2127 assert!(ctx.text.ends_with(']'));
2128 assert!(ctx.text.contains("\"type\":\"fact\""));
2129 }
2130
2131 #[test]
2132 fn test_json_grouped_format() {
2133 let assembler = ContextAssembler::new();
2134 let policy = FormatPolicy::new(OutputFormat::Json)
2135 .metadata(MetadataLevel::None)
2136 .group_by_type();
2137 let hits = vec![
2138 make_hit(
2139 GrainType::Fact,
2140 vec![
2141 ("subject", "john"),
2142 ("relation", "likes"),
2143 ("object", "coffee"),
2144 ],
2145 0.9,
2146 ),
2147 make_hit(
2148 GrainType::Goal,
2149 vec![("description", "Deploy v2"), ("goal_state", "active")],
2150 0.8,
2151 ),
2152 ];
2153 let ctx = assembler.format(&hits, &policy);
2154 assert!(ctx.text.starts_with('{'));
2155 assert!(ctx.text.ends_with('}'));
2156 assert!(ctx.text.contains("\"facts\":"));
2157 assert!(ctx.text.contains("\"goals\":"));
2158 }
2159
2160 #[test]
2161 fn test_formatted_context_serializable() {
2162 let ctx = FormattedContext {
2163 text: "test".to_string(),
2164 estimated_tokens: 1,
2165 included_count: 1,
2166 omitted_count: 0,
2167 truncated: false,
2168 };
2169 let json = serde_json::to_string(&ctx).unwrap();
2170 assert!(json.contains("\"text\":\"test\""));
2171 assert!(json.contains("\"truncated\":false"));
2172 }
2173
2174 #[test]
2175 fn test_all_grains_budget_omitted() {
2176 let assembler = ContextAssembler::new();
2177 let policy = FormatPolicy::new(OutputFormat::PlainText)
2179 .metadata(MetadataLevel::None)
2180 .token_budget(1)
2181 .no_grain_type_diversity();
2182 let hits = vec![
2183 make_hit(
2184 GrainType::Fact,
2185 vec![
2186 ("subject", "john"),
2187 ("relation", "likes"),
2188 ("object", "coffee"),
2189 ],
2190 0.9,
2191 ),
2192 make_hit(
2193 GrainType::Goal,
2194 vec![("description", "Deploy v2"), ("goal_state", "active")],
2195 0.8,
2196 ),
2197 ];
2198 let ctx = assembler.format(&hits, &policy);
2199 assert_eq!(ctx.included_count, 0);
2200 assert_eq!(ctx.omitted_count, 2);
2201 assert!(ctx.truncated);
2202 assert!(ctx.text.is_empty());
2203 }
2204
2205 #[test]
2206 fn test_fallback_renderer_for_unknown_type() {
2207 let assembler = ContextAssembler::new();
2211 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2212 let all_types = [
2213 GrainType::Fact,
2214 GrainType::Event,
2215 GrainType::State,
2216 GrainType::Workflow,
2217 GrainType::Tool,
2218 GrainType::Observation,
2219 GrainType::Goal,
2220 GrainType::Reasoning,
2221 GrainType::Consensus,
2222 GrainType::Consent,
2223 ];
2224 for gt in &all_types {
2225 let hits = vec![make_hit(*gt, vec![("content", "test")], 0.5)];
2226 let ctx = assembler.format(&hits, &policy);
2227 assert_eq!(ctx.included_count, 1, "Failed for {:?}", gt);
2228 assert!(!ctx.text.is_empty(), "Empty render for {:?}", gt);
2229 }
2230 }
2231
2232 #[test]
2233 fn test_tight_budget_rendering() {
2234 let assembler = ContextAssembler::new();
2235 let policy = FormatPolicy::new(OutputFormat::PlainText)
2237 .metadata(MetadataLevel::None)
2238 .token_budget(100);
2239 let hits = vec![
2240 make_hit(
2241 GrainType::Fact,
2242 vec![
2243 ("subject", "john"),
2244 ("relation", "likes"),
2245 ("object", "coffee"),
2246 ],
2247 0.9,
2248 ),
2249 make_hit(
2250 GrainType::Fact,
2251 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2252 0.5,
2253 ),
2254 ];
2255 let ctx = assembler.format(&hits, &policy);
2256 assert!(ctx.included_count >= 1);
2258 }
2259
2260 fn make_census_hit(
2261 gt: GrainType,
2262 fields: Vec<(&str, &str)>,
2263 score: f64,
2264 session: &str,
2265 ) -> SearchHit {
2266 let mut hit = make_hit(gt, fields, score);
2267 hit.recall_source = Some(RecallSource::Census);
2268 hit.source_namespace = Some(session.to_string());
2269 hit
2270 }
2271
2272 fn make_dated_hit(
2277 gt: GrainType,
2278 fields: Vec<(&str, &str)>,
2279 score: f64,
2280 epoch_secs: u32,
2281 ) -> SearchHit {
2282 let mut hit = make_hit(gt, fields, score);
2283 hit.grain.header.created_at_sec = epoch_secs;
2284 hit
2285 }
2286
2287 #[test]
2288 fn test_census_grains_tagged() {
2289 let hit = make_census_hit(
2290 GrainType::Fact,
2291 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2292 0.5,
2293 "session-7",
2294 );
2295 assert_eq!(hit.recall_source, Some(RecallSource::Census));
2296 assert_eq!(hit.source_namespace.as_deref(), Some("session-7"));
2297 }
2298
2299 #[test]
2300 fn test_census_section_sml() {
2301 let assembler = ContextAssembler::new();
2302 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
2303 let hits = vec![
2304 make_hit(
2305 GrainType::Fact,
2306 vec![
2307 ("subject", "john"),
2308 ("relation", "likes"),
2309 ("object", "coffee"),
2310 ],
2311 0.9,
2312 ),
2313 make_census_hit(
2314 GrainType::Fact,
2315 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2316 0.4,
2317 "session-7",
2318 ),
2319 ];
2320 let ctx = assembler.format(&hits, &policy);
2321 assert!(
2322 ctx.text.contains("<census_results>"),
2323 "SML should have census_results tag, got: {}",
2324 ctx.text
2325 );
2326 assert!(
2327 ctx.text.contains("</census_results>"),
2328 "SML should close census_results tag"
2329 );
2330 assert!(
2331 ctx.text.contains("session-7"),
2332 "SML census should show session"
2333 );
2334 assert!(ctx.text.contains("john"), "Primary grain should be present");
2335 assert!(ctx.text.contains("bob"), "Census grain should be present");
2336 assert_eq!(ctx.included_count, 2);
2337 }
2338
2339 #[test]
2340 fn test_context_expansion_section_plaintext() {
2341 let assembler = ContextAssembler::new();
2342 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2343 let mut primary = make_hit(
2344 GrainType::Fact,
2345 vec![
2346 ("subject", "john"),
2347 ("relation", "likes"),
2348 ("object", "coffee"),
2349 ],
2350 0.9,
2351 );
2352 primary.recall_source = Some(dejadb_cal::store_types::RecallSource::Primary);
2353
2354 let mut expansion = make_hit(
2355 GrainType::Fact,
2356 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2357 0.4,
2358 );
2359 expansion.recall_source = Some(dejadb_cal::store_types::RecallSource::Expansion);
2360
2361 let hits = vec![primary, expansion];
2362 let ctx = assembler.format(&hits, &policy);
2363 assert!(ctx.text.contains("john"));
2364 assert!(ctx.text.contains("Additional Context (expansion)"));
2365 assert!(ctx.text.contains("bob"));
2366 assert_eq!(ctx.included_count, 2);
2367 }
2368
2369 #[test]
2370 fn test_census_section_markdown() {
2371 let assembler = ContextAssembler::new();
2372 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
2373 let hits = vec![
2374 make_hit(
2375 GrainType::Fact,
2376 vec![
2377 ("subject", "john"),
2378 ("relation", "likes"),
2379 ("object", "coffee"),
2380 ],
2381 0.9,
2382 ),
2383 make_census_hit(
2384 GrainType::Fact,
2385 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2386 0.4,
2387 "session-7",
2388 ),
2389 ];
2390 let ctx = assembler.format(&hits, &policy);
2391 assert!(
2392 ctx.text.contains("## Additional Sessions (census)"),
2393 "Markdown should have census header, got: {}",
2394 ctx.text
2395 );
2396 assert!(
2397 ctx.text.contains("(from session-7)"),
2398 "Markdown census should show session origin"
2399 );
2400 }
2401
2402 fn make_supersession_pair(
2409 subject: &str,
2410 old_object: &str,
2411 new_object: &str,
2412 old_ts: u32,
2413 new_ts: u32,
2414 ) -> (SearchHit, SearchHit) {
2415 let old_hash = Hash::from_bytes(&[1u8; 32]);
2416 let new_hash = Hash::from_bytes(&[2u8; 32]);
2417
2418 let old_header = MgHeader {
2419 version: 1,
2420 flags: 0,
2421 grain_type: GrainType::Fact.type_byte(),
2422 ns_hash: 0,
2423 created_at_sec: old_ts,
2424 };
2425 let new_header = MgHeader {
2426 version: 1,
2427 flags: 0,
2428 grain_type: GrainType::Fact.type_byte(),
2429 ns_hash: 0,
2430 created_at_sec: new_ts,
2431 };
2432
2433 let mut old_fields = HashMap::new();
2434 old_fields.insert("subject".to_string(), serde_json::json!(subject));
2435 old_fields.insert("relation".to_string(), serde_json::json!("has"));
2436 old_fields.insert("object".to_string(), serde_json::json!(old_object));
2437
2438 let mut new_fields = HashMap::new();
2439 new_fields.insert("subject".to_string(), serde_json::json!(subject));
2440 new_fields.insert("relation".to_string(), serde_json::json!("has"));
2441 new_fields.insert("object".to_string(), serde_json::json!(new_object));
2442
2443 let old_hit = SearchHit {
2444 grain: DeserializedGrain {
2445 header: old_header,
2446 grain_type: GrainType::Fact,
2447 fields: old_fields,
2448 hash: old_hash,
2449 },
2450 score: 0.8,
2451 hash: old_hash,
2452 score_breakdown: None,
2453 #[cfg(feature = "rerank")]
2454 rerank_score: None,
2455 #[cfg(feature = "llm-rerank")]
2456 llm_rerank_score: None,
2457 explanation: None,
2458 scope_depth: None,
2459 source_namespace: None,
2460 relative_time: None,
2461 conflict_status: None,
2462 recall_source: None,
2463 supersession_status: Some(SupersessionStatus::Superseded),
2464 superseded_by_hash: Some(new_hash),
2465 };
2466
2467 let new_hit = SearchHit {
2468 grain: DeserializedGrain {
2469 header: new_header,
2470 grain_type: GrainType::Fact,
2471 fields: new_fields,
2472 hash: new_hash,
2473 },
2474 score: 0.9,
2475 hash: new_hash,
2476 score_breakdown: None,
2477 #[cfg(feature = "rerank")]
2478 rerank_score: None,
2479 #[cfg(feature = "llm-rerank")]
2480 llm_rerank_score: None,
2481 explanation: None,
2482 scope_depth: None,
2483 source_namespace: None,
2484 relative_time: None,
2485 conflict_status: None,
2486 recall_source: None,
2487 supersession_status: Some(SupersessionStatus::Current),
2488 superseded_by_hash: None,
2489 };
2490
2491 (old_hit, new_hit)
2492 }
2493
2494 #[test]
2495 fn test_ku_chain_plaintext() {
2496 let assembler = ContextAssembler::new();
2497 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2498 let (old_hit, new_hit) = make_supersession_pair(
2500 "therapy_frequency",
2501 "every two weeks",
2502 "every week",
2503 1675987200,
2504 1681516800,
2505 );
2506 let hits = vec![old_hit, new_hit];
2507 let ctx = assembler.format(&hits, &policy);
2508
2509 assert!(
2510 ctx.text.contains("=== Knowledge Updates ==="),
2511 "Should have KU header, got: {}",
2512 ctx.text
2513 );
2514 assert!(
2515 ctx.text.contains("therapy_frequency"),
2516 "Should mention subject, got: {}",
2517 ctx.text
2518 );
2519 assert!(
2520 ctx.text.contains("every two weeks"),
2521 "Should mention old value, got: {}",
2522 ctx.text
2523 );
2524 assert!(
2525 ctx.text.contains("every week"),
2526 "Should mention new value, got: {}",
2527 ctx.text
2528 );
2529 assert!(
2530 ctx.text.contains("[CURRENT]"),
2531 "Should have CURRENT tag, got: {}",
2532 ctx.text
2533 );
2534 assert!(
2535 ctx.text.contains("→"),
2536 "Should have arrow between old and new, got: {}",
2537 ctx.text
2538 );
2539 }
2540
2541 #[test]
2542 fn test_ku_chain_markdown() {
2543 let assembler = ContextAssembler::new();
2544 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
2545 let (old_hit, new_hit) = make_supersession_pair(
2546 "therapy_frequency",
2547 "every two weeks",
2548 "every week",
2549 1675987200,
2550 1681516800,
2551 );
2552 let hits = vec![old_hit, new_hit];
2553 let ctx = assembler.format(&hits, &policy);
2554
2555 assert!(
2556 ctx.text.contains("## Knowledge Updates"),
2557 "Should have markdown KU header, got: {}",
2558 ctx.text
2559 );
2560 assert!(
2561 ctx.text.contains("~~every two weeks~~"),
2562 "Should have strikethrough old value, got: {}",
2563 ctx.text
2564 );
2565 assert!(
2566 ctx.text.contains("**every week**"),
2567 "Should have bold new value, got: {}",
2568 ctx.text
2569 );
2570 assert!(
2571 ctx.text.contains("[CURRENT]"),
2572 "Should have CURRENT tag, got: {}",
2573 ctx.text
2574 );
2575 }
2576
2577 #[test]
2578 fn test_ku_chain_sml() {
2579 let assembler = ContextAssembler::new();
2580 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
2581 let (old_hit, new_hit) = make_supersession_pair(
2582 "therapy_frequency",
2583 "every two weeks",
2584 "every week",
2585 1675987200,
2586 1681516800,
2587 );
2588 let hits = vec![old_hit, new_hit];
2589 let ctx = assembler.format(&hits, &policy);
2590
2591 assert!(
2592 ctx.text.contains("<knowledge_updates>"),
2593 "Should have SML KU opening tag, got: {}",
2594 ctx.text
2595 );
2596 assert!(
2597 ctx.text.contains("</knowledge_updates>"),
2598 "Should have SML KU closing tag, got: {}",
2599 ctx.text
2600 );
2601 assert!(
2602 ctx.text.contains("subject=\"therapy_frequency\""),
2603 "Should have subject attr, got: {}",
2604 ctx.text
2605 );
2606 assert!(
2607 ctx.text.contains("old=\"every two weeks\""),
2608 "Should have old attr, got: {}",
2609 ctx.text
2610 );
2611 assert!(
2612 ctx.text.contains("new=\"every week\""),
2613 "Should have new attr, got: {}",
2614 ctx.text
2615 );
2616 }
2617
2618 #[test]
2619 fn test_ku_chain_json() {
2620 let assembler = ContextAssembler::new();
2621 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
2622 let (old_hit, new_hit) = make_supersession_pair(
2623 "therapy_frequency",
2624 "every two weeks",
2625 "every week",
2626 1675987200,
2627 1681516800,
2628 );
2629 let hits = vec![old_hit, new_hit];
2630 let ctx = assembler.format(&hits, &policy);
2631
2632 assert!(
2633 ctx.text.contains("\"knowledge_updates\""),
2634 "Should have knowledge_updates key, got: {}",
2635 ctx.text
2636 );
2637 assert!(
2638 ctx.text.contains("\"subject\":\"therapy_frequency\""),
2639 "Should have subject in JSON, got: {}",
2640 ctx.text
2641 );
2642 assert!(
2643 ctx.text.contains("\"old_value\":\"every two weeks\""),
2644 "Should have old_value in JSON, got: {}",
2645 ctx.text
2646 );
2647 assert!(
2648 ctx.text.contains("\"new_value\":\"every week\""),
2649 "Should have new_value in JSON, got: {}",
2650 ctx.text
2651 );
2652 assert!(
2653 ctx.text.contains("\"context\""),
2654 "Should have context key, got: {}",
2655 ctx.text
2656 );
2657 }
2658
2659 #[test]
2660 fn test_ku_recency_suppression() {
2661 let assembler = ContextAssembler::new();
2662 let policy = FormatPolicy::new(OutputFormat::PlainText)
2663 .metadata(MetadataLevel::None)
2664 .query_text("what is the therapy frequency currently?");
2665 let (old_hit, new_hit) = make_supersession_pair(
2666 "therapy_frequency",
2667 "every two weeks",
2668 "every week",
2669 1675987200,
2670 1681516800,
2671 );
2672 let hits = vec![old_hit, new_hit];
2673 let ctx = assembler.format(&hits, &policy);
2674
2675 assert!(
2676 ctx.text.contains("=== Knowledge Updates ==="),
2677 "Should have KU header, got: {}",
2678 ctx.text
2679 );
2680 assert!(
2682 ctx.text.contains("every week"),
2683 "Should contain current value, got: {}",
2684 ctx.text
2685 );
2686 assert!(
2688 !ctx.text.contains("every two weeks"),
2689 "Should NOT contain old value in recency mode, got: {}",
2690 ctx.text
2691 );
2692 assert!(
2693 ctx.text.contains("[CURRENT]"),
2694 "Should have CURRENT tag, got: {}",
2695 ctx.text
2696 );
2697 }
2698
2699 #[test]
2700 fn test_census_section_plaintext() {
2701 let assembler = ContextAssembler::new();
2702 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2703 let hits = vec![
2704 make_hit(
2705 GrainType::Fact,
2706 vec![
2707 ("subject", "john"),
2708 ("relation", "likes"),
2709 ("object", "coffee"),
2710 ],
2711 0.9,
2712 ),
2713 make_census_hit(
2714 GrainType::Fact,
2715 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2716 0.4,
2717 "session-7",
2718 ),
2719 ];
2720 let ctx = assembler.format(&hits, &policy);
2721 assert!(
2722 ctx.text.contains("=== Additional Sessions (census) ==="),
2723 "PlainText should have census header, got: {}",
2724 ctx.text
2725 );
2726 assert!(
2727 ctx.text.contains("[session: session-7]"),
2728 "PlainText census should show session origin"
2729 );
2730 }
2731
2732 #[test]
2733 fn test_census_empty_suppression() {
2734 let assembler = ContextAssembler::new();
2735 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2736 let hits = vec![make_hit(
2738 GrainType::Fact,
2739 vec![
2740 ("subject", "john"),
2741 ("relation", "likes"),
2742 ("object", "coffee"),
2743 ],
2744 0.9,
2745 )];
2746 let ctx = assembler.format(&hits, &policy);
2747 assert!(
2748 !ctx.text.contains("census"),
2749 "No census grains -> no census section"
2750 );
2751 }
2752
2753 #[test]
2754 fn test_ku_no_chain_when_no_supersession() {
2755 let assembler = ContextAssembler::new();
2756 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2757 let hits = vec![
2759 make_hit(
2760 GrainType::Fact,
2761 vec![
2762 ("subject", "john"),
2763 ("relation", "likes"),
2764 ("object", "coffee"),
2765 ],
2766 0.9,
2767 ),
2768 make_hit(
2769 GrainType::Fact,
2770 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2771 0.8,
2772 ),
2773 ];
2774 let ctx = assembler.format(&hits, &policy);
2775
2776 assert!(
2778 !ctx.text.contains("Knowledge Updates"),
2779 "Should NOT have KU section without supersession, got: {}",
2780 ctx.text
2781 );
2782 assert!(ctx.text.contains("john"), "Should contain john");
2784 assert!(ctx.text.contains("bob"), "Should contain bob");
2785 }
2786
2787 #[test]
2788 fn test_ku_grains_removed_from_main_context() {
2789 let assembler = ContextAssembler::new();
2790 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2791 let (old_hit, new_hit) = make_supersession_pair(
2792 "therapy_frequency",
2793 "every two weeks",
2794 "every week",
2795 1675987200,
2796 1681516800,
2797 );
2798
2799 let extra = make_hit(
2801 GrainType::Fact,
2802 vec![
2803 ("subject", "john"),
2804 ("relation", "likes"),
2805 ("object", "coffee"),
2806 ],
2807 0.7,
2808 );
2809 let hits = vec![old_hit, new_hit, extra];
2810 let ctx = assembler.format(&hits, &policy);
2811
2812 assert!(
2814 ctx.text.contains("=== Knowledge Updates ==="),
2815 "Should have KU header, got: {}",
2816 ctx.text
2817 );
2818 assert!(
2820 ctx.text.contains("john"),
2821 "Extra grain should be in main context, got: {}",
2822 ctx.text
2823 );
2824 let ku_count = ctx.text.matches("therapy_frequency").count();
2827 assert_eq!(
2828 ku_count, 1,
2829 "therapy_frequency should appear exactly once (in KU section), found {}",
2830 ku_count
2831 );
2832 }
2833
2834 #[test]
2835 fn test_census_json_has_recall_source() {
2836 let assembler = ContextAssembler::new();
2837 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
2838 let hits = vec![
2839 make_hit(
2840 GrainType::Fact,
2841 vec![
2842 ("subject", "john"),
2843 ("relation", "likes"),
2844 ("object", "coffee"),
2845 ],
2846 0.9,
2847 ),
2848 make_census_hit(
2849 GrainType::Fact,
2850 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2851 0.4,
2852 "session-7",
2853 ),
2854 ];
2855 let ctx = assembler.format(&hits, &policy);
2856 assert!(
2857 ctx.text.contains("\"recall_source\":\"census\""),
2858 "JSON should include recall_source for census grains, got: {}",
2859 ctx.text
2860 );
2861 }
2862
2863 #[test]
2864 fn test_ku_toon_format() {
2865 let assembler = ContextAssembler::new();
2866 let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::None);
2867 let (old_hit, new_hit) = make_supersession_pair(
2868 "therapy_frequency",
2869 "every two weeks",
2870 "every week",
2871 1675987200,
2872 1681516800,
2873 );
2874 let hits = vec![old_hit, new_hit];
2875 let ctx = assembler.format(&hits, &policy);
2876
2877 assert!(
2878 ctx.text.contains("--- knowledge updates ---"),
2879 "Should have TOON KU header, got: {}",
2880 ctx.text
2881 );
2882 assert!(
2883 ctx.text.contains("therapy_frequency"),
2884 "Should mention subject in TOON, got: {}",
2885 ctx.text
2886 );
2887 assert!(
2888 ctx.text.contains("\"every two weeks\""),
2889 "Should have old value in TOON, got: {}",
2890 ctx.text
2891 );
2892 assert!(
2893 ctx.text.contains("\"every week\""),
2894 "Should have new value in TOON, got: {}",
2895 ctx.text
2896 );
2897 }
2898
2899 #[test]
2900 fn test_census_score_cap() {
2901 let mut hit = make_hit(
2905 GrainType::Fact,
2906 vec![
2907 ("subject", "test"),
2908 ("relation", "is"),
2909 ("object", "census"),
2910 ],
2911 0.8,
2912 );
2913 hit.recall_source = Some(RecallSource::Census);
2914 assert_eq!(hit.recall_source, Some(RecallSource::Census));
2915
2916 let mut primary = make_hit(
2918 GrainType::Fact,
2919 vec![
2920 ("subject", "test"),
2921 ("relation", "is"),
2922 ("object", "primary"),
2923 ],
2924 0.9,
2925 );
2926 primary.recall_source = Some(RecallSource::Primary);
2927 assert_eq!(primary.recall_source, Some(RecallSource::Primary));
2928 }
2929
2930 #[test]
2931 fn test_recall_source_serde_roundtrip() {
2932 let sources = vec![
2933 RecallSource::Primary,
2934 RecallSource::Expansion,
2935 RecallSource::Census,
2936 ];
2937 for source in &sources {
2938 let json = serde_json::to_string(source).unwrap();
2939 let back: RecallSource = serde_json::from_str(&json).unwrap();
2940 assert_eq!(&back, source);
2941 }
2942 assert_eq!(
2943 serde_json::to_string(&RecallSource::Census).unwrap(),
2944 "\"census\""
2945 );
2946 assert_eq!(
2947 serde_json::to_string(&RecallSource::Primary).unwrap(),
2948 "\"primary\""
2949 );
2950 }
2951
2952 #[test]
2953 fn test_context_expansion_section_markdown() {
2954 let assembler = ContextAssembler::new();
2955 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
2956 let mut primary = make_hit(
2957 GrainType::Fact,
2958 vec![
2959 ("subject", "john"),
2960 ("relation", "likes"),
2961 ("object", "coffee"),
2962 ],
2963 0.9,
2964 );
2965 primary.recall_source = Some(dejadb_cal::store_types::RecallSource::Primary);
2966
2967 let mut expansion = make_hit(
2968 GrainType::Fact,
2969 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2970 0.4,
2971 );
2972 expansion.recall_source = Some(dejadb_cal::store_types::RecallSource::Expansion);
2973
2974 let hits = vec![primary, expansion];
2975 let ctx = assembler.format(&hits, &policy);
2976 assert!(ctx.text.contains("## Additional Context (expansion)"));
2977 }
2978
2979 #[test]
2980 fn test_context_expansion_section_sml() {
2981 let assembler = ContextAssembler::new();
2982 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
2983 let mut primary = make_hit(
2984 GrainType::Fact,
2985 vec![
2986 ("subject", "john"),
2987 ("relation", "likes"),
2988 ("object", "coffee"),
2989 ],
2990 0.9,
2991 );
2992 primary.recall_source = Some(dejadb_cal::store_types::RecallSource::Primary);
2993
2994 let mut expansion = make_hit(
2995 GrainType::Fact,
2996 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2997 0.4,
2998 );
2999 expansion.recall_source = Some(dejadb_cal::store_types::RecallSource::Expansion);
3000
3001 let hits = vec![primary, expansion];
3002 let ctx = assembler.format(&hits, &policy);
3003 assert!(ctx.text.contains("<expansion_results>"));
3004 }
3005
3006 #[test]
3007 fn test_context_no_expansion_when_all_primary() {
3008 let assembler = ContextAssembler::new();
3009 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3010 let mut primary = make_hit(
3011 GrainType::Fact,
3012 vec![
3013 ("subject", "john"),
3014 ("relation", "likes"),
3015 ("object", "coffee"),
3016 ],
3017 0.9,
3018 );
3019 primary.recall_source = Some(dejadb_cal::store_types::RecallSource::Primary);
3020
3021 let hits = vec![primary];
3022 let ctx = assembler.format(&hits, &policy);
3023 assert!(!ctx.text.contains("Additional Context"));
3024 assert!(ctx.text.contains("john"));
3025 }
3026
3027 #[test]
3028 fn test_temporal_pattern_detection() {
3029 let patterns = vec![
3030 "what is the order of events?",
3031 "in what order did things happen?",
3032 "sequence of actions taken",
3033 "from earliest to latest",
3034 "from latest event",
3035 "from first to last",
3036 "how many days between events?",
3037 "how long between the two meetings?",
3038 "time between signup and purchase",
3039 "which came first, A or B?",
3040 "which came last in the list?",
3041 "which was earlier, the call or the email?",
3042 "which was later?",
3043 "was it before or after the meeting?",
3044 "earlier or later than Tuesday?",
3045 "when did they join?",
3046 "what date was the event?",
3047 "what time did it start?",
3048 "show events in chronological order",
3049 ];
3050
3051 for pattern in &patterns {
3052 let hints = RenderingHints {
3053 entity_count: None,
3054 entities: None,
3055 has_temporal_expr: false,
3056 has_time_range: false,
3057 query_text: Some(pattern.to_string()),
3058 };
3059 let hits = [
3060 make_dated_hit(GrainType::Event, vec![("content", "A")], 0.9, 1_700_000_000),
3061 make_dated_hit(GrainType::Event, vec![("content", "B")], 0.8, 1_700_100_000),
3062 ];
3063 let refs: Vec<&SearchHit> = hits.iter().collect();
3064 assert!(
3065 detect_temporal_intent(&hints, &refs),
3066 "Pattern '{}' should trigger temporal intent",
3067 pattern
3068 );
3069 }
3070 }
3071
3072 #[test]
3073 fn test_non_temporal_patterns_do_not_trigger() {
3074 let hints = RenderingHints {
3075 entity_count: None,
3076 entities: None,
3077 has_temporal_expr: false,
3078 has_time_range: false,
3079 query_text: Some("what does john like?".to_string()),
3080 };
3081 let hits = [
3083 make_dated_hit(
3084 GrainType::Fact,
3085 vec![("subject", "john")],
3086 0.9,
3087 1_700_000_000,
3088 ),
3089 make_dated_hit(
3090 GrainType::Fact,
3091 vec![("subject", "bob")],
3092 0.8,
3093 1_700_000_100,
3094 ),
3095 ];
3096 let refs: Vec<&SearchHit> = hits.iter().collect();
3097 assert!(
3098 !detect_temporal_intent(&hints, &refs),
3099 "Non-temporal query within same day should not trigger"
3100 );
3101 }
3102
3103 #[test]
3104 fn test_temporal_flags_trigger() {
3105 let hints_expr = RenderingHints {
3106 entity_count: None,
3107 entities: None,
3108 has_temporal_expr: true,
3109 has_time_range: false,
3110 query_text: None,
3111 };
3112 let hints_range = RenderingHints {
3113 entity_count: None,
3114 entities: None,
3115 has_temporal_expr: false,
3116 has_time_range: true,
3117 query_text: None,
3118 };
3119 let refs: Vec<&SearchHit> = Vec::new();
3120 assert!(detect_temporal_intent(&hints_expr, &refs));
3121 assert!(detect_temporal_intent(&hints_range, &refs));
3122 }
3123
3124 #[test]
3125 fn test_delta_formatting() {
3126 assert_eq!(format_time_delta(0), "same day");
3127 assert_eq!(format_time_delta(86400), "1 day");
3128 assert_eq!(format_time_delta(86400 * 5), "5 days");
3129 assert_eq!(format_time_delta(86400 * 89), "89 days");
3130 assert_eq!(format_time_delta(86400 * 90), "3 months");
3131 assert_eq!(format_time_delta(86400 * 180), "6 months");
3132 assert_eq!(format_time_delta(86400 * 364), "12 months");
3133 assert_eq!(format_time_delta(86400 * 365), "1 year");
3134 assert_eq!(format_time_delta(86400 * 500), "1 year, 4 months");
3135 assert_eq!(format_time_delta(86400 * 730), "2 years");
3136 assert_eq!(format_time_delta(86400 * 800), "2 years, 2 months");
3137 }
3138
3139 #[test]
3140 fn test_timeline_with_deltas_plaintext() {
3141 let assembler = ContextAssembler::new();
3142 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3143 let hints = RenderingHints {
3144 entity_count: None,
3145 entities: None,
3146 has_temporal_expr: false,
3147 has_time_range: false,
3148 query_text: Some("in what order did events happen?".to_string()),
3149 };
3150 let hits = vec![
3152 make_dated_hit(
3153 GrainType::Event,
3154 vec![("content", "Joined volleyball league")],
3155 0.9,
3156 1_673_740_800,
3157 ),
3158 make_dated_hit(
3159 GrainType::Event,
3160 vec![("content", "Completed charity 5K run")],
3161 0.8,
3162 1_682_294_400,
3163 ),
3164 make_dated_hit(
3165 GrainType::Event,
3166 vec![("content", "Started yoga classes")],
3167 0.7,
3168 1_694_304_000,
3169 ),
3170 ];
3171
3172 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3173
3174 assert!(
3176 ctx.text.contains("Timeline (earliest to latest)"),
3177 "Missing timeline header, got:\n{}",
3178 ctx.text
3179 );
3180 assert!(
3182 ctx.text.contains("Joined volleyball league"),
3183 "Missing event 1"
3184 );
3185 assert!(
3186 ctx.text.contains("Completed charity 5K run"),
3187 "Missing event 2"
3188 );
3189 assert!(ctx.text.contains("Started yoga classes"), "Missing event 3");
3190 assert!(
3192 ctx.text.contains("\u{2193}"),
3193 "Missing delta arrows, got:\n{}",
3194 ctx.text
3195 );
3196 assert_eq!(ctx.included_count, 3);
3198 }
3199
3200 #[test]
3201 fn test_timeline_with_deltas_markdown() {
3202 let assembler = ContextAssembler::new();
3203 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
3204 let hints = RenderingHints {
3205 entity_count: None,
3206 entities: None,
3207 has_temporal_expr: false,
3208 has_time_range: false,
3209 query_text: Some("when did things happen?".to_string()),
3210 };
3211 let hits = vec![
3212 make_dated_hit(
3213 GrainType::Event,
3214 vec![("content", "Event A")],
3215 0.9,
3216 1_673_740_800,
3217 ),
3218 make_dated_hit(
3219 GrainType::Event,
3220 vec![("content", "Event B")],
3221 0.8,
3222 1_682_294_400,
3223 ),
3224 ];
3225
3226 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3227 assert!(
3229 ctx.text.contains("*"),
3230 "Markdown timeline should use italic deltas, got:\n{}",
3231 ctx.text
3232 );
3233 assert!(ctx.text.contains("## Timeline"));
3234 }
3235
3236 #[test]
3237 fn test_timeline_with_deltas_sml() {
3238 let assembler = ContextAssembler::new();
3239 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
3240 let hints = RenderingHints {
3241 entity_count: None,
3242 entities: None,
3243 has_temporal_expr: true,
3244 has_time_range: false,
3245 query_text: None,
3246 };
3247 let hits = vec![
3248 make_dated_hit(
3249 GrainType::Event,
3250 vec![("content", "First")],
3251 0.9,
3252 1_673_740_800,
3253 ),
3254 make_dated_hit(
3255 GrainType::Event,
3256 vec![("content", "Second")],
3257 0.8,
3258 1_682_294_400,
3259 ),
3260 ];
3261
3262 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3263 assert!(ctx.text.contains("<timeline"), "Missing timeline tag");
3264 assert!(
3265 ctx.text.contains("</timeline>"),
3266 "Missing closing timeline tag"
3267 );
3268 assert!(ctx.text.contains("<delta"), "Missing delta element");
3269 }
3270
3271 #[test]
3272 fn test_timeline_with_deltas_json() {
3273 let assembler = ContextAssembler::new();
3274 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
3275 let hints = RenderingHints {
3276 entity_count: None,
3277 entities: None,
3278 has_temporal_expr: true,
3279 has_time_range: false,
3280 query_text: None,
3281 };
3282 let hits = vec![
3283 make_dated_hit(
3284 GrainType::Event,
3285 vec![("content", "Alpha")],
3286 0.9,
3287 1_673_740_800,
3288 ),
3289 make_dated_hit(
3290 GrainType::Event,
3291 vec![("content", "Beta")],
3292 0.8,
3293 1_682_294_400,
3294 ),
3295 ];
3296
3297 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3298 assert!(
3299 ctx.text.contains("\"timeline\""),
3300 "Missing timeline key in JSON"
3301 );
3302 assert!(
3303 ctx.text.contains("\"delta_to_next\""),
3304 "Missing delta_to_next in JSON"
3305 );
3306 }
3307
3308 #[test]
3309 fn test_timeline_priority_over_relevance() {
3310 let assembler = ContextAssembler::new();
3312 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3313 let hints = RenderingHints {
3314 entity_count: None,
3315 entities: None,
3316 has_temporal_expr: false,
3317 has_time_range: false,
3318 query_text: Some("what is the chronological order?".to_string()),
3319 };
3320
3321 let mut hits = Vec::new();
3322 for i in 0..15 {
3323 hits.push(make_dated_hit(
3324 GrainType::Event,
3325 vec![("content", &format!("Event {}", i))],
3326 0.9 - (i as f64) * 0.05,
3327 1_673_740_800 + (i as u32) * 86400 * 30, ));
3329 }
3330 let hit_contents: Vec<String> = (0..15).map(|i| format!("Event {}", i)).collect();
3332 let mut hits = Vec::new();
3333 for (i, content) in hit_contents.iter().enumerate() {
3334 hits.push(make_dated_hit(
3335 GrainType::Event,
3336 vec![("content", content.as_str())],
3337 0.9 - (i as f64) * 0.05,
3338 1_673_740_800 + (i as u32) * 86400 * 30,
3339 ));
3340 }
3341
3342 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3343
3344 assert!(
3346 ctx.text.contains("Timeline (earliest to latest)"),
3347 "With >10 grains and temporal intent, timeline should take priority, got:\n{}",
3348 ctx.text
3349 );
3350 assert!(ctx.included_count > 10, "Should include all grains");
3351 }
3352
3353 #[test]
3354 fn test_timeline_chronological_order() {
3355 let assembler = ContextAssembler::new();
3357 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3358 let hints = RenderingHints {
3359 entity_count: None,
3360 entities: None,
3361 has_temporal_expr: false,
3362 has_time_range: false,
3363 query_text: Some("in what order did these happen?".to_string()),
3364 };
3365 let hits = vec![
3367 make_dated_hit(
3368 GrainType::Event,
3369 vec![("content", "Third event")],
3370 0.9,
3371 1_694_304_000,
3372 ),
3373 make_dated_hit(
3374 GrainType::Event,
3375 vec![("content", "First event")],
3376 0.8,
3377 1_673_740_800,
3378 ),
3379 make_dated_hit(
3380 GrainType::Event,
3381 vec![("content", "Second event")],
3382 0.7,
3383 1_682_294_400,
3384 ),
3385 ];
3386
3387 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3388
3389 let first_pos = ctx.text.find("First event").unwrap();
3390 let second_pos = ctx.text.find("Second event").unwrap();
3391 let third_pos = ctx.text.find("Third event").unwrap();
3392 assert!(
3393 first_pos < second_pos && second_pos < third_pos,
3394 "Events should be in chronological order, got:\n{}",
3395 ctx.text
3396 );
3397 }
3398
3399 #[test]
3400 fn test_format_epoch_date() {
3401 assert_eq!(format_epoch_date(0), "1970-01-01");
3402 assert_eq!(format_epoch_date(1_673_740_800), "2023-01-15");
3403 assert_eq!(format_epoch_date(1_682_294_400), "2023-04-24");
3404 assert_eq!(format_epoch_date(1_694_304_000), "2023-09-10");
3405 }
3406
3407 #[test]
3408 fn test_format_with_hints_no_temporal_falls_through() {
3409 let assembler = ContextAssembler::new();
3411 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3412 let hints = RenderingHints::default();
3413 let hits = vec![make_hit(
3414 GrainType::Fact,
3415 vec![
3416 ("subject", "john"),
3417 ("relation", "likes"),
3418 ("object", "coffee"),
3419 ],
3420 0.9,
3421 )];
3422
3423 let ctx_hints = assembler.format_with_hints(&hits, &policy, &hints);
3424 let ctx_plain = assembler.format(&hits, &policy);
3425
3426 assert_eq!(ctx_hints.text, ctx_plain.text);
3427 assert_eq!(ctx_hints.included_count, ctx_plain.included_count);
3428 }
3429
3430 #[test]
3431 fn test_is_recency_query() {
3432 assert!(is_recency_query(Some(
3433 "what is the therapy frequency currently?"
3434 )));
3435 assert!(is_recency_query(Some("What's the latest status?")));
3436 assert!(is_recency_query(Some("tell me right now")));
3437 assert!(is_recency_query(Some("what is my address")));
3438 assert!(is_recency_query(Some("most recently updated")));
3439 assert!(is_recency_query(Some("at this point in time")));
3440 assert!(!is_recency_query(Some("tell me about therapy")));
3441 assert!(!is_recency_query(Some("history of changes")));
3442 assert!(!is_recency_query(None));
3443 }
3444
3445 #[test]
3446 fn test_format_timestamp() {
3447 assert_eq!(format_timestamp(1675987200), Some("2023-02-10".to_string()));
3449 assert_eq!(format_timestamp(1681516800), Some("2023-04-15".to_string()));
3451 assert_eq!(format_timestamp(0), None);
3453 }
3454}