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