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