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 }
1718}
1719
1720fn plural_name(gt: &GrainType) -> &'static str {
1722 section_tag(gt)
1723}
1724
1725fn capitalize(s: &str) -> String {
1727 let mut chars = s.chars();
1728 match chars.next() {
1729 None => String::new(),
1730 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
1731 }
1732}
1733
1734fn grain_separator(format: &OutputFormat) -> &'static str {
1736 match format {
1737 OutputFormat::Sml => "\n",
1738 OutputFormat::Toon => "\n",
1739 OutputFormat::Markdown => "\n",
1740 OutputFormat::PlainText => "\n",
1741 OutputFormat::Json => ",\n",
1742 }
1743}
1744
1745fn expansion_section_header(format: &OutputFormat) -> &'static str {
1747 match format {
1748 OutputFormat::Markdown => "## Additional Context (expansion)",
1749 OutputFormat::PlainText => "=== Additional Context (expansion) ===",
1750 OutputFormat::Toon => "--- expansion ---",
1751 OutputFormat::Sml | OutputFormat::Json => "",
1753 }
1754}
1755
1756fn section_separator(format: &OutputFormat) -> &'static str {
1758 match format {
1759 OutputFormat::Sml => "\n",
1760 OutputFormat::Toon => "\n\n",
1761 OutputFormat::Markdown => "\n\n",
1762 OutputFormat::PlainText => "\n\n",
1763 OutputFormat::Json => ",\n",
1766 }
1767}
1768
1769fn merge_json_census(primary: &str, census: &str) -> String {
1775 let p = serde_json::from_str::<serde_json::Value>(primary);
1776 let c = serde_json::from_str::<serde_json::Value>(census);
1777 match (p, c) {
1778 (Ok(serde_json::Value::Array(mut pa)), Ok(serde_json::Value::Array(ca))) => {
1779 pa.extend(ca);
1780 serde_json::to_string(&serde_json::Value::Array(pa)).unwrap_or_else(|_| primary.to_string())
1781 }
1782 (Ok(serde_json::Value::Object(mut po)), Ok(ca)) => {
1783 po.insert("census".to_string(), ca);
1784 serde_json::to_string(&serde_json::Value::Object(po)).unwrap_or_else(|_| primary.to_string())
1785 }
1786 _ => primary.to_string(),
1787 }
1788}
1789
1790fn estimate_default_tokens(hit: &SearchHit) -> usize {
1792 let chars: usize = hit
1793 .grain
1794 .fields
1795 .iter()
1796 .map(|(k, v)| k.len() + v.to_string().len() + 4)
1797 .sum();
1798 (chars + 30) / 4
1799}
1800
1801fn format_epoch_date(epoch_sec: u32) -> String {
1803 use chrono::{TimeZone, Utc};
1804 match Utc.timestamp_opt(epoch_sec as i64, 0) {
1805 chrono::offset::LocalResult::Single(dt) => dt.format("%Y-%m-%d").to_string(),
1806 _ => format!("epoch:{}", epoch_sec),
1807 }
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812 use super::*;
1813 use crate::policy::{MetadataLevel, OutputFormat};
1814 use areev_core::error::Hash;
1815 use areev_core::format::deserialize::DeserializedGrain;
1816 use areev_core::format::header::MgHeader;
1817 use std::collections::HashMap;
1818
1819 fn make_hit(gt: GrainType, fields: Vec<(&str, &str)>, score: f64) -> SearchHit {
1820 let header = MgHeader {
1821 version: 1,
1822 flags: 0,
1823 grain_type: gt.type_byte(),
1824 ns_hash: 0,
1825 created_at_sec: 1740700000,
1826 };
1827 let mut field_map = HashMap::new();
1828 for (k, v) in fields {
1829 field_map.insert(k.to_string(), serde_json::Value::String(v.to_string()));
1830 }
1831 SearchHit {
1832 grain: DeserializedGrain {
1833 header,
1834 grain_type: gt,
1835 fields: field_map,
1836 hash: Hash::from_bytes(&[0u8; 32]),
1837 },
1838 score,
1839 hash: Hash::from_bytes(&[0u8; 32]),
1840 score_breakdown: None,
1841 #[cfg(feature = "rerank")]
1842 rerank_score: None,
1843 #[cfg(feature = "llm-rerank")]
1844 llm_rerank_score: None,
1845 explanation: None,
1846 scope_depth: None,
1847 source_namespace: None,
1848 relative_time: None,
1849 conflict_status: None,
1850 supersession_status: None,
1851 superseded_by_hash: None,
1852 recall_source: None,
1853 }
1854 }
1855
1856 #[test]
1857 fn test_format_empty_hits() {
1858 let assembler = ContextAssembler::new();
1859 let policy = FormatPolicy::default();
1860 let ctx = assembler.format(&[], &policy);
1861 assert_eq!(ctx.text, "");
1862 assert_eq!(ctx.included_count, 0);
1863 assert_eq!(ctx.omitted_count, 0);
1864 assert!(!ctx.truncated);
1865 }
1866
1867 #[test]
1868 fn test_format_single_fact_plaintext() {
1869 let assembler = ContextAssembler::new();
1870 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
1871 let hits = vec![make_hit(
1872 GrainType::Fact,
1873 vec![
1874 ("subject", "john"),
1875 ("relation", "likes"),
1876 ("object", "coffee"),
1877 ],
1878 0.9,
1879 )];
1880 let ctx = assembler.format(&hits, &policy);
1881 assert!(ctx.text.contains("john"));
1882 assert!(ctx.text.contains("likes"));
1883 assert!(ctx.text.contains("coffee"));
1884 assert_eq!(ctx.included_count, 1);
1885 assert!(!ctx.truncated);
1886 }
1887
1888 #[test]
1889 fn test_format_single_fact_sml() {
1890 let assembler = ContextAssembler::new();
1891 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
1892 let hits = vec![make_hit(
1893 GrainType::Fact,
1894 vec![
1895 ("subject", "john"),
1896 ("relation", "likes"),
1897 ("object", "coffee"),
1898 ],
1899 0.9,
1900 )];
1901 let ctx = assembler.format(&hits, &policy);
1902 assert!(ctx.text.contains("<fact>"));
1903 assert!(ctx.text.contains("</fact>"));
1904 assert!(ctx.text.contains("john"));
1905 assert_eq!(ctx.included_count, 1);
1906 }
1907
1908 #[test]
1909 fn test_format_single_fact_toon() {
1910 let assembler = ContextAssembler::new();
1911 let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::None);
1912 let hits = vec![make_hit(
1913 GrainType::Fact,
1914 vec![
1915 ("subject", "john"),
1916 ("relation", "likes"),
1917 ("object", "coffee"),
1918 ],
1919 0.9,
1920 )];
1921 let ctx = assembler.format(&hits, &policy);
1922 assert!(
1924 ctx.text.contains("facts[1]{subject,content,confidence}:"),
1925 "TOON should have tabular header, got: {}",
1926 ctx.text
1927 );
1928 assert!(
1929 ctx.text.contains("john,likes coffee"),
1930 "TOON should have CSV row with john, got: {}",
1931 ctx.text
1932 );
1933 assert!(
1935 !ctx.text.contains("<fact>"),
1936 "TOON should not contain SML tags"
1937 );
1938 assert!(
1939 !ctx.text.contains(" - "),
1940 "TOON should not contain list-item markers"
1941 );
1942 assert_eq!(ctx.included_count, 1);
1943 }
1944
1945 #[test]
1946 fn test_format_multiple_grains_flat() {
1947 let assembler = ContextAssembler::new();
1948 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
1949 let hits = vec![
1950 make_hit(
1951 GrainType::Fact,
1952 vec![
1953 ("subject", "john"),
1954 ("relation", "likes"),
1955 ("object", "coffee"),
1956 ],
1957 0.9,
1958 ),
1959 make_hit(
1960 GrainType::Goal,
1961 vec![("description", "Deploy v2"), ("goal_state", "active")],
1962 0.8,
1963 ),
1964 ];
1965 let ctx = assembler.format(&hits, &policy);
1966 assert!(ctx.text.contains("john"));
1967 assert!(ctx.text.contains("Deploy v2"));
1968 assert_eq!(ctx.included_count, 2);
1969 }
1970
1971 #[test]
1972 fn test_format_grouped_by_type_sml() {
1973 let assembler = ContextAssembler::new();
1974 let policy = FormatPolicy::new(OutputFormat::Sml)
1975 .metadata(MetadataLevel::None)
1976 .group_by_type();
1977 let hits = vec![
1978 make_hit(
1979 GrainType::Fact,
1980 vec![
1981 ("subject", "john"),
1982 ("relation", "likes"),
1983 ("object", "coffee"),
1984 ],
1985 0.9,
1986 ),
1987 make_hit(
1988 GrainType::Goal,
1989 vec![("description", "Deploy v2"), ("goal_state", "active")],
1990 0.8,
1991 ),
1992 make_hit(
1993 GrainType::Fact,
1994 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
1995 0.7,
1996 ),
1997 ];
1998 let ctx = assembler.format(&hits, &policy);
1999 assert!(ctx.text.contains("<facts>"));
2001 assert!(ctx.text.contains("</facts>"));
2002 assert!(ctx.text.contains("<goals>"));
2003 assert!(ctx.text.contains("</goals>"));
2004 assert_eq!(ctx.included_count, 3);
2005 }
2006
2007 #[test]
2008 fn test_format_grouped_by_type_markdown() {
2009 let assembler = ContextAssembler::new();
2010 let policy = FormatPolicy::new(OutputFormat::Markdown)
2011 .metadata(MetadataLevel::None)
2012 .group_by_type();
2013 let hits = vec![
2014 make_hit(
2015 GrainType::State,
2016 vec![("context", "session running")],
2017 0.9,
2018 ),
2019 make_hit(
2020 GrainType::Goal,
2021 vec![("description", "Deploy v2"), ("goal_state", "active")],
2022 0.8,
2023 ),
2024 ];
2025 let ctx = assembler.format(&hits, &policy);
2026 assert!(ctx.text.contains("## States"));
2027 assert!(ctx.text.contains("## Goals"));
2028 }
2029
2030 #[test]
2031 fn test_budget_truncation() {
2032 let assembler = ContextAssembler::new();
2033 let policy = FormatPolicy::new(OutputFormat::PlainText)
2034 .metadata(MetadataLevel::None)
2035 .token_budget(10); let hits = vec![
2037 make_hit(
2038 GrainType::Fact,
2039 vec![
2040 ("subject", "john"),
2041 ("relation", "likes"),
2042 (
2043 "object",
2044 "coffee with extra long description that should cause truncation",
2045 ),
2046 ],
2047 0.9,
2048 ),
2049 make_hit(
2050 GrainType::Fact,
2051 vec![
2052 ("subject", "bob"),
2053 ("relation", "likes"),
2054 ("object", "another long value to exceed budget"),
2055 ],
2056 0.5,
2057 ),
2058 ];
2059 let ctx = assembler.format(&hits, &policy);
2060 assert!(ctx.truncated);
2061 assert!(ctx.omitted_count > 0);
2062 }
2063
2064 #[test]
2065 fn test_grain_type_override_exclude() {
2066 let assembler = ContextAssembler::new();
2067 use crate::policy::GrainTypeOverride;
2068 let policy = FormatPolicy::new(OutputFormat::PlainText)
2069 .metadata(MetadataLevel::None)
2070 .grain_override(
2071 GrainType::Event,
2072 GrainTypeOverride {
2073 include: false,
2074 max_count: None,
2075 },
2076 );
2077 let hits = vec![
2078 make_hit(
2079 GrainType::Fact,
2080 vec![
2081 ("subject", "john"),
2082 ("relation", "likes"),
2083 ("object", "coffee"),
2084 ],
2085 0.9,
2086 ),
2087 make_hit(
2088 GrainType::Event,
2089 vec![("content", "something happened")],
2090 0.8,
2091 ),
2092 ];
2093 let ctx = assembler.format(&hits, &policy);
2094 assert!(!ctx.text.contains("something happened"));
2096 assert_eq!(ctx.included_count, 1);
2097 }
2098
2099 #[test]
2100 fn test_grain_type_override_max_count() {
2101 let assembler = ContextAssembler::new();
2102 use crate::policy::GrainTypeOverride;
2103 let policy = FormatPolicy::new(OutputFormat::PlainText)
2104 .metadata(MetadataLevel::None)
2105 .grain_override(
2106 GrainType::Fact,
2107 GrainTypeOverride {
2108 include: true,
2109 max_count: Some(1),
2110 },
2111 );
2112 let hits = vec![
2113 make_hit(
2114 GrainType::Fact,
2115 vec![
2116 ("subject", "john"),
2117 ("relation", "likes"),
2118 ("object", "coffee"),
2119 ],
2120 0.9,
2121 ),
2122 make_hit(
2123 GrainType::Fact,
2124 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2125 0.8,
2126 ),
2127 ];
2128 let ctx = assembler.format(&hits, &policy);
2129 assert!(ctx.text.contains("john"));
2131 assert!(!ctx.text.contains("bob"));
2132 assert_eq!(ctx.included_count, 1);
2133 }
2134
2135 #[test]
2136 fn test_chronological_ordering() {
2137 let assembler = ContextAssembler::new();
2138 let policy = FormatPolicy::new(OutputFormat::PlainText)
2139 .metadata(MetadataLevel::None)
2140 .ordering(Ordering::Chronological);
2141 let mut hits = vec![
2142 make_hit(
2143 GrainType::Fact,
2144 vec![
2145 ("subject", "newer"),
2146 ("relation", "is"),
2147 ("object", "second"),
2148 ],
2149 0.9,
2150 ),
2151 make_hit(
2152 GrainType::Fact,
2153 vec![
2154 ("subject", "older"),
2155 ("relation", "is"),
2156 ("object", "first"),
2157 ],
2158 0.8,
2159 ),
2160 ];
2161 hits[0].grain.header.created_at_sec = 1740700100;
2163 hits[1].grain.header.created_at_sec = 1740700000;
2164
2165 let ctx = assembler.format(&hits, &policy);
2166 let older_pos = ctx.text.find("older").unwrap();
2168 let newer_pos = ctx.text.find("newer").unwrap();
2169 assert!(
2170 older_pos < newer_pos,
2171 "Chronological: older should come first"
2172 );
2173 }
2174
2175 #[test]
2176 fn test_json_flat_format() {
2177 let assembler = ContextAssembler::new();
2178 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
2179 let hits = vec![make_hit(
2180 GrainType::Fact,
2181 vec![
2182 ("subject", "john"),
2183 ("relation", "likes"),
2184 ("object", "coffee"),
2185 ],
2186 0.9,
2187 )];
2188 let ctx = assembler.format(&hits, &policy);
2189 assert!(ctx.text.starts_with('['));
2190 assert!(ctx.text.ends_with(']'));
2191 assert!(ctx.text.contains("\"grain_type\":\"fact\""));
2193 assert!(ctx.text.contains("\"subject\":\"john\""));
2194 }
2195
2196 #[test]
2197 fn test_json_grouped_format() {
2198 let assembler = ContextAssembler::new();
2199 let policy = FormatPolicy::new(OutputFormat::Json)
2200 .metadata(MetadataLevel::None)
2201 .group_by_type();
2202 let hits = vec![
2203 make_hit(
2204 GrainType::Fact,
2205 vec![
2206 ("subject", "john"),
2207 ("relation", "likes"),
2208 ("object", "coffee"),
2209 ],
2210 0.9,
2211 ),
2212 make_hit(
2213 GrainType::Goal,
2214 vec![("description", "Deploy v2"), ("goal_state", "active")],
2215 0.8,
2216 ),
2217 ];
2218 let ctx = assembler.format(&hits, &policy);
2219 assert!(ctx.text.starts_with('{'));
2220 assert!(ctx.text.ends_with('}'));
2221 assert!(ctx.text.contains("\"facts\":"));
2222 assert!(ctx.text.contains("\"goals\":"));
2223 }
2224
2225 #[test]
2226 fn test_formatted_context_serializable() {
2227 let ctx = FormattedContext {
2228 text: "test".to_string(),
2229 estimated_tokens: 1,
2230 included_count: 1,
2231 omitted_count: 0,
2232 truncated: false,
2233 };
2234 let json = serde_json::to_string(&ctx).unwrap();
2235 assert!(json.contains("\"text\":\"test\""));
2236 assert!(json.contains("\"truncated\":false"));
2237 }
2238
2239 #[test]
2240 fn test_all_grains_budget_omitted() {
2241 let assembler = ContextAssembler::new();
2242 let policy = FormatPolicy::new(OutputFormat::PlainText)
2244 .metadata(MetadataLevel::None)
2245 .token_budget(1)
2246 .no_grain_type_diversity();
2247 let hits = vec![
2248 make_hit(
2249 GrainType::Fact,
2250 vec![
2251 ("subject", "john"),
2252 ("relation", "likes"),
2253 ("object", "coffee"),
2254 ],
2255 0.9,
2256 ),
2257 make_hit(
2258 GrainType::Goal,
2259 vec![("description", "Deploy v2"), ("goal_state", "active")],
2260 0.8,
2261 ),
2262 ];
2263 let ctx = assembler.format(&hits, &policy);
2264 assert_eq!(ctx.included_count, 0);
2265 assert_eq!(ctx.omitted_count, 2);
2266 assert!(ctx.truncated);
2267 assert!(ctx.text.is_empty());
2268 }
2269
2270 #[test]
2271 fn test_fallback_renderer_for_unknown_type() {
2272 let assembler = ContextAssembler::new();
2276 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2277 let all_types = [
2278 GrainType::Fact,
2279 GrainType::Event,
2280 GrainType::State,
2281 GrainType::Workflow,
2282 GrainType::Tool,
2283 GrainType::Observation,
2284 GrainType::Goal,
2285 GrainType::Reasoning,
2286 GrainType::Consensus,
2287 GrainType::Consent,
2288 ];
2289 for gt in &all_types {
2290 let hits = vec![make_hit(*gt, vec![("content", "test")], 0.5)];
2291 let ctx = assembler.format(&hits, &policy);
2292 assert_eq!(ctx.included_count, 1, "Failed for {:?}", gt);
2293 assert!(!ctx.text.is_empty(), "Empty render for {:?}", gt);
2294 }
2295 }
2296
2297 #[test]
2298 fn test_tight_budget_rendering() {
2299 let assembler = ContextAssembler::new();
2300 let policy = FormatPolicy::new(OutputFormat::PlainText)
2302 .metadata(MetadataLevel::None)
2303 .token_budget(100);
2304 let hits = vec![
2305 make_hit(
2306 GrainType::Fact,
2307 vec![
2308 ("subject", "john"),
2309 ("relation", "likes"),
2310 ("object", "coffee"),
2311 ],
2312 0.9,
2313 ),
2314 make_hit(
2315 GrainType::Fact,
2316 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2317 0.5,
2318 ),
2319 ];
2320 let ctx = assembler.format(&hits, &policy);
2321 assert!(ctx.included_count >= 1);
2323 }
2324
2325 fn make_census_hit(
2326 gt: GrainType,
2327 fields: Vec<(&str, &str)>,
2328 score: f64,
2329 session: &str,
2330 ) -> SearchHit {
2331 let mut hit = make_hit(gt, fields, score);
2332 hit.recall_source = Some(RecallSource::Census);
2333 hit.source_namespace = Some(session.to_string());
2334 hit
2335 }
2336
2337 fn make_dated_hit(
2342 gt: GrainType,
2343 fields: Vec<(&str, &str)>,
2344 score: f64,
2345 epoch_secs: u32,
2346 ) -> SearchHit {
2347 let mut hit = make_hit(gt, fields, score);
2348 hit.grain.header.created_at_sec = epoch_secs;
2349 hit
2350 }
2351
2352 #[test]
2353 fn test_census_grains_tagged() {
2354 let hit = make_census_hit(
2355 GrainType::Fact,
2356 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2357 0.5,
2358 "session-7",
2359 );
2360 assert_eq!(hit.recall_source, Some(RecallSource::Census));
2361 assert_eq!(hit.source_namespace.as_deref(), Some("session-7"));
2362 }
2363
2364 #[test]
2365 fn test_census_section_sml() {
2366 let assembler = ContextAssembler::new();
2367 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
2368 let hits = vec![
2369 make_hit(
2370 GrainType::Fact,
2371 vec![
2372 ("subject", "john"),
2373 ("relation", "likes"),
2374 ("object", "coffee"),
2375 ],
2376 0.9,
2377 ),
2378 make_census_hit(
2379 GrainType::Fact,
2380 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2381 0.4,
2382 "session-7",
2383 ),
2384 ];
2385 let ctx = assembler.format(&hits, &policy);
2386 assert!(
2387 ctx.text.contains("<census_results>"),
2388 "SML should have census_results tag, got: {}",
2389 ctx.text
2390 );
2391 assert!(
2392 ctx.text.contains("</census_results>"),
2393 "SML should close census_results tag"
2394 );
2395 assert!(
2396 ctx.text.contains("session-7"),
2397 "SML census should show session"
2398 );
2399 assert!(ctx.text.contains("john"), "Primary grain should be present");
2400 assert!(ctx.text.contains("bob"), "Census grain should be present");
2401 assert_eq!(ctx.included_count, 2);
2402 }
2403
2404 #[test]
2405 fn test_context_expansion_section_plaintext() {
2406 let assembler = ContextAssembler::new();
2407 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2408 let mut primary = make_hit(
2409 GrainType::Fact,
2410 vec![
2411 ("subject", "john"),
2412 ("relation", "likes"),
2413 ("object", "coffee"),
2414 ],
2415 0.9,
2416 );
2417 primary.recall_source = Some(areev_cal::store_types::RecallSource::Primary);
2418
2419 let mut expansion = make_hit(
2420 GrainType::Fact,
2421 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2422 0.4,
2423 );
2424 expansion.recall_source = Some(areev_cal::store_types::RecallSource::Expansion);
2425
2426 let hits = vec![primary, expansion];
2427 let ctx = assembler.format(&hits, &policy);
2428 assert!(ctx.text.contains("john"));
2429 assert!(ctx.text.contains("Additional Context (expansion)"));
2430 assert!(ctx.text.contains("bob"));
2431 assert_eq!(ctx.included_count, 2);
2432 }
2433
2434 #[test]
2435 fn test_census_section_markdown() {
2436 let assembler = ContextAssembler::new();
2437 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
2438 let hits = vec![
2439 make_hit(
2440 GrainType::Fact,
2441 vec![
2442 ("subject", "john"),
2443 ("relation", "likes"),
2444 ("object", "coffee"),
2445 ],
2446 0.9,
2447 ),
2448 make_census_hit(
2449 GrainType::Fact,
2450 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2451 0.4,
2452 "session-7",
2453 ),
2454 ];
2455 let ctx = assembler.format(&hits, &policy);
2456 assert!(
2457 ctx.text.contains("## Additional Sessions (census)"),
2458 "Markdown should have census header, got: {}",
2459 ctx.text
2460 );
2461 assert!(
2462 ctx.text.contains("(from session-7)"),
2463 "Markdown census should show session origin"
2464 );
2465 }
2466
2467 fn make_supersession_pair(
2474 subject: &str,
2475 old_object: &str,
2476 new_object: &str,
2477 old_ts: u32,
2478 new_ts: u32,
2479 ) -> (SearchHit, SearchHit) {
2480 let old_hash = Hash::from_bytes(&[1u8; 32]);
2481 let new_hash = Hash::from_bytes(&[2u8; 32]);
2482
2483 let old_header = MgHeader {
2484 version: 1,
2485 flags: 0,
2486 grain_type: GrainType::Fact.type_byte(),
2487 ns_hash: 0,
2488 created_at_sec: old_ts,
2489 };
2490 let new_header = MgHeader {
2491 version: 1,
2492 flags: 0,
2493 grain_type: GrainType::Fact.type_byte(),
2494 ns_hash: 0,
2495 created_at_sec: new_ts,
2496 };
2497
2498 let mut old_fields = HashMap::new();
2499 old_fields.insert("subject".to_string(), serde_json::json!(subject));
2500 old_fields.insert("relation".to_string(), serde_json::json!("has"));
2501 old_fields.insert("object".to_string(), serde_json::json!(old_object));
2502
2503 let mut new_fields = HashMap::new();
2504 new_fields.insert("subject".to_string(), serde_json::json!(subject));
2505 new_fields.insert("relation".to_string(), serde_json::json!("has"));
2506 new_fields.insert("object".to_string(), serde_json::json!(new_object));
2507
2508 let old_hit = SearchHit {
2509 grain: DeserializedGrain {
2510 header: old_header,
2511 grain_type: GrainType::Fact,
2512 fields: old_fields,
2513 hash: old_hash,
2514 },
2515 score: 0.8,
2516 hash: old_hash,
2517 score_breakdown: None,
2518 #[cfg(feature = "rerank")]
2519 rerank_score: None,
2520 #[cfg(feature = "llm-rerank")]
2521 llm_rerank_score: None,
2522 explanation: None,
2523 scope_depth: None,
2524 source_namespace: None,
2525 relative_time: None,
2526 conflict_status: None,
2527 recall_source: None,
2528 supersession_status: Some(SupersessionStatus::Superseded),
2529 superseded_by_hash: Some(new_hash),
2530 };
2531
2532 let new_hit = SearchHit {
2533 grain: DeserializedGrain {
2534 header: new_header,
2535 grain_type: GrainType::Fact,
2536 fields: new_fields,
2537 hash: new_hash,
2538 },
2539 score: 0.9,
2540 hash: new_hash,
2541 score_breakdown: None,
2542 #[cfg(feature = "rerank")]
2543 rerank_score: None,
2544 #[cfg(feature = "llm-rerank")]
2545 llm_rerank_score: None,
2546 explanation: None,
2547 scope_depth: None,
2548 source_namespace: None,
2549 relative_time: None,
2550 conflict_status: None,
2551 recall_source: None,
2552 supersession_status: Some(SupersessionStatus::Current),
2553 superseded_by_hash: None,
2554 };
2555
2556 (old_hit, new_hit)
2557 }
2558
2559 #[test]
2560 fn test_ku_chain_plaintext() {
2561 let assembler = ContextAssembler::new();
2562 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2563 let (old_hit, new_hit) = make_supersession_pair(
2565 "therapy_frequency",
2566 "every two weeks",
2567 "every week",
2568 1675987200,
2569 1681516800,
2570 );
2571 let hits = vec![old_hit, new_hit];
2572 let ctx = assembler.format(&hits, &policy);
2573
2574 assert!(
2575 ctx.text.contains("=== Knowledge Updates ==="),
2576 "Should have KU header, got: {}",
2577 ctx.text
2578 );
2579 assert!(
2580 ctx.text.contains("therapy_frequency"),
2581 "Should mention subject, got: {}",
2582 ctx.text
2583 );
2584 assert!(
2585 ctx.text.contains("every two weeks"),
2586 "Should mention old value, got: {}",
2587 ctx.text
2588 );
2589 assert!(
2590 ctx.text.contains("every week"),
2591 "Should mention new value, got: {}",
2592 ctx.text
2593 );
2594 assert!(
2595 ctx.text.contains("[CURRENT]"),
2596 "Should have CURRENT tag, got: {}",
2597 ctx.text
2598 );
2599 assert!(
2600 ctx.text.contains("→"),
2601 "Should have arrow between old and new, got: {}",
2602 ctx.text
2603 );
2604 }
2605
2606 #[test]
2607 fn test_ku_chain_markdown() {
2608 let assembler = ContextAssembler::new();
2609 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
2610 let (old_hit, new_hit) = make_supersession_pair(
2611 "therapy_frequency",
2612 "every two weeks",
2613 "every week",
2614 1675987200,
2615 1681516800,
2616 );
2617 let hits = vec![old_hit, new_hit];
2618 let ctx = assembler.format(&hits, &policy);
2619
2620 assert!(
2621 ctx.text.contains("## Knowledge Updates"),
2622 "Should have markdown KU header, got: {}",
2623 ctx.text
2624 );
2625 assert!(
2626 ctx.text.contains("~~every two weeks~~"),
2627 "Should have strikethrough old value, got: {}",
2628 ctx.text
2629 );
2630 assert!(
2631 ctx.text.contains("**every week**"),
2632 "Should have bold new value, got: {}",
2633 ctx.text
2634 );
2635 assert!(
2636 ctx.text.contains("[CURRENT]"),
2637 "Should have CURRENT tag, got: {}",
2638 ctx.text
2639 );
2640 }
2641
2642 #[test]
2643 fn test_ku_chain_sml() {
2644 let assembler = ContextAssembler::new();
2645 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
2646 let (old_hit, new_hit) = make_supersession_pair(
2647 "therapy_frequency",
2648 "every two weeks",
2649 "every week",
2650 1675987200,
2651 1681516800,
2652 );
2653 let hits = vec![old_hit, new_hit];
2654 let ctx = assembler.format(&hits, &policy);
2655
2656 assert!(
2657 ctx.text.contains("<knowledge_updates>"),
2658 "Should have SML KU opening tag, got: {}",
2659 ctx.text
2660 );
2661 assert!(
2662 ctx.text.contains("</knowledge_updates>"),
2663 "Should have SML KU closing tag, got: {}",
2664 ctx.text
2665 );
2666 assert!(
2667 ctx.text.contains("subject=\"therapy_frequency\""),
2668 "Should have subject attr, got: {}",
2669 ctx.text
2670 );
2671 assert!(
2672 ctx.text.contains("old=\"every two weeks\""),
2673 "Should have old attr, got: {}",
2674 ctx.text
2675 );
2676 assert!(
2677 ctx.text.contains("new=\"every week\""),
2678 "Should have new attr, got: {}",
2679 ctx.text
2680 );
2681 }
2682
2683 #[test]
2684 fn test_ku_chain_json() {
2685 let assembler = ContextAssembler::new();
2686 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
2687 let (old_hit, new_hit) = make_supersession_pair(
2688 "therapy_frequency",
2689 "every two weeks",
2690 "every week",
2691 1675987200,
2692 1681516800,
2693 );
2694 let hits = vec![old_hit, new_hit];
2695 let ctx = assembler.format(&hits, &policy);
2696
2697 assert!(
2698 ctx.text.contains("\"knowledge_updates\""),
2699 "Should have knowledge_updates key, got: {}",
2700 ctx.text
2701 );
2702 assert!(
2703 ctx.text.contains("\"subject\":\"therapy_frequency\""),
2704 "Should have subject in JSON, got: {}",
2705 ctx.text
2706 );
2707 assert!(
2708 ctx.text.contains("\"old_value\":\"every two weeks\""),
2709 "Should have old_value in JSON, got: {}",
2710 ctx.text
2711 );
2712 assert!(
2713 ctx.text.contains("\"new_value\":\"every week\""),
2714 "Should have new_value in JSON, got: {}",
2715 ctx.text
2716 );
2717 assert!(
2718 ctx.text.contains("\"context\""),
2719 "Should have context key, got: {}",
2720 ctx.text
2721 );
2722 }
2723
2724 #[test]
2725 fn test_ku_recency_suppression() {
2726 let assembler = ContextAssembler::new();
2727 let policy = FormatPolicy::new(OutputFormat::PlainText)
2728 .metadata(MetadataLevel::None)
2729 .query_text("what is the therapy frequency currently?");
2730 let (old_hit, new_hit) = make_supersession_pair(
2731 "therapy_frequency",
2732 "every two weeks",
2733 "every week",
2734 1675987200,
2735 1681516800,
2736 );
2737 let hits = vec![old_hit, new_hit];
2738 let ctx = assembler.format(&hits, &policy);
2739
2740 assert!(
2741 ctx.text.contains("=== Knowledge Updates ==="),
2742 "Should have KU header, got: {}",
2743 ctx.text
2744 );
2745 assert!(
2747 ctx.text.contains("every week"),
2748 "Should contain current value, got: {}",
2749 ctx.text
2750 );
2751 assert!(
2753 !ctx.text.contains("every two weeks"),
2754 "Should NOT contain old value in recency mode, got: {}",
2755 ctx.text
2756 );
2757 assert!(
2758 ctx.text.contains("[CURRENT]"),
2759 "Should have CURRENT tag, got: {}",
2760 ctx.text
2761 );
2762 }
2763
2764 #[test]
2765 fn test_census_section_plaintext() {
2766 let assembler = ContextAssembler::new();
2767 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2768 let hits = vec![
2769 make_hit(
2770 GrainType::Fact,
2771 vec![
2772 ("subject", "john"),
2773 ("relation", "likes"),
2774 ("object", "coffee"),
2775 ],
2776 0.9,
2777 ),
2778 make_census_hit(
2779 GrainType::Fact,
2780 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2781 0.4,
2782 "session-7",
2783 ),
2784 ];
2785 let ctx = assembler.format(&hits, &policy);
2786 assert!(
2787 ctx.text.contains("=== Additional Sessions (census) ==="),
2788 "PlainText should have census header, got: {}",
2789 ctx.text
2790 );
2791 assert!(
2792 ctx.text.contains("[session: session-7]"),
2793 "PlainText census should show session origin"
2794 );
2795 }
2796
2797 #[test]
2798 fn test_census_empty_suppression() {
2799 let assembler = ContextAssembler::new();
2800 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2801 let hits = vec![make_hit(
2803 GrainType::Fact,
2804 vec![
2805 ("subject", "john"),
2806 ("relation", "likes"),
2807 ("object", "coffee"),
2808 ],
2809 0.9,
2810 )];
2811 let ctx = assembler.format(&hits, &policy);
2812 assert!(
2813 !ctx.text.contains("census"),
2814 "No census grains -> no census section"
2815 );
2816 }
2817
2818 #[test]
2819 fn test_ku_no_chain_when_no_supersession() {
2820 let assembler = ContextAssembler::new();
2821 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2822 let hits = vec![
2824 make_hit(
2825 GrainType::Fact,
2826 vec![
2827 ("subject", "john"),
2828 ("relation", "likes"),
2829 ("object", "coffee"),
2830 ],
2831 0.9,
2832 ),
2833 make_hit(
2834 GrainType::Fact,
2835 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2836 0.8,
2837 ),
2838 ];
2839 let ctx = assembler.format(&hits, &policy);
2840
2841 assert!(
2843 !ctx.text.contains("Knowledge Updates"),
2844 "Should NOT have KU section without supersession, got: {}",
2845 ctx.text
2846 );
2847 assert!(ctx.text.contains("john"), "Should contain john");
2849 assert!(ctx.text.contains("bob"), "Should contain bob");
2850 }
2851
2852 #[test]
2853 fn test_ku_grains_removed_from_main_context() {
2854 let assembler = ContextAssembler::new();
2855 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
2856 let (old_hit, new_hit) = make_supersession_pair(
2857 "therapy_frequency",
2858 "every two weeks",
2859 "every week",
2860 1675987200,
2861 1681516800,
2862 );
2863
2864 let extra = make_hit(
2866 GrainType::Fact,
2867 vec![
2868 ("subject", "john"),
2869 ("relation", "likes"),
2870 ("object", "coffee"),
2871 ],
2872 0.7,
2873 );
2874 let hits = vec![old_hit, new_hit, extra];
2875 let ctx = assembler.format(&hits, &policy);
2876
2877 assert!(
2879 ctx.text.contains("=== Knowledge Updates ==="),
2880 "Should have KU header, got: {}",
2881 ctx.text
2882 );
2883 assert!(
2885 ctx.text.contains("john"),
2886 "Extra grain should be in main context, got: {}",
2887 ctx.text
2888 );
2889 let ku_count = ctx.text.matches("therapy_frequency").count();
2892 assert_eq!(
2893 ku_count, 1,
2894 "therapy_frequency should appear exactly once (in KU section), found {}",
2895 ku_count
2896 );
2897 }
2898
2899 #[test]
2900 fn test_census_json_has_recall_source() {
2901 let assembler = ContextAssembler::new();
2902 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
2903 let hits = vec![
2904 make_hit(
2905 GrainType::Fact,
2906 vec![
2907 ("subject", "john"),
2908 ("relation", "likes"),
2909 ("object", "coffee"),
2910 ],
2911 0.9,
2912 ),
2913 make_census_hit(
2914 GrainType::Fact,
2915 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
2916 0.4,
2917 "session-7",
2918 ),
2919 ];
2920 let ctx = assembler.format(&hits, &policy);
2921 assert!(
2922 ctx.text.contains("\"recall_source\":\"census\""),
2923 "JSON should include recall_source for census grains, got: {}",
2924 ctx.text
2925 );
2926 }
2927
2928 #[test]
2929 fn test_ku_toon_format() {
2930 let assembler = ContextAssembler::new();
2931 let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::None);
2932 let (old_hit, new_hit) = make_supersession_pair(
2933 "therapy_frequency",
2934 "every two weeks",
2935 "every week",
2936 1675987200,
2937 1681516800,
2938 );
2939 let hits = vec![old_hit, new_hit];
2940 let ctx = assembler.format(&hits, &policy);
2941
2942 assert!(
2943 ctx.text.contains("--- knowledge updates ---"),
2944 "Should have TOON KU header, got: {}",
2945 ctx.text
2946 );
2947 assert!(
2948 ctx.text.contains("therapy_frequency"),
2949 "Should mention subject in TOON, got: {}",
2950 ctx.text
2951 );
2952 assert!(
2953 ctx.text.contains("\"every two weeks\""),
2954 "Should have old value in TOON, got: {}",
2955 ctx.text
2956 );
2957 assert!(
2958 ctx.text.contains("\"every week\""),
2959 "Should have new value in TOON, got: {}",
2960 ctx.text
2961 );
2962 }
2963
2964 #[test]
2965 fn test_census_score_cap() {
2966 let mut hit = make_hit(
2970 GrainType::Fact,
2971 vec![
2972 ("subject", "test"),
2973 ("relation", "is"),
2974 ("object", "census"),
2975 ],
2976 0.8,
2977 );
2978 hit.recall_source = Some(RecallSource::Census);
2979 assert_eq!(hit.recall_source, Some(RecallSource::Census));
2980
2981 let mut primary = make_hit(
2983 GrainType::Fact,
2984 vec![
2985 ("subject", "test"),
2986 ("relation", "is"),
2987 ("object", "primary"),
2988 ],
2989 0.9,
2990 );
2991 primary.recall_source = Some(RecallSource::Primary);
2992 assert_eq!(primary.recall_source, Some(RecallSource::Primary));
2993 }
2994
2995 #[test]
2996 fn test_recall_source_serde_roundtrip() {
2997 let sources = vec![
2998 RecallSource::Primary,
2999 RecallSource::Expansion,
3000 RecallSource::Census,
3001 ];
3002 for source in &sources {
3003 let json = serde_json::to_string(source).unwrap();
3004 let back: RecallSource = serde_json::from_str(&json).unwrap();
3005 assert_eq!(&back, source);
3006 }
3007 assert_eq!(
3008 serde_json::to_string(&RecallSource::Census).unwrap(),
3009 "\"census\""
3010 );
3011 assert_eq!(
3012 serde_json::to_string(&RecallSource::Primary).unwrap(),
3013 "\"primary\""
3014 );
3015 }
3016
3017 #[test]
3018 fn test_context_expansion_section_markdown() {
3019 let assembler = ContextAssembler::new();
3020 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
3021 let mut primary = make_hit(
3022 GrainType::Fact,
3023 vec![
3024 ("subject", "john"),
3025 ("relation", "likes"),
3026 ("object", "coffee"),
3027 ],
3028 0.9,
3029 );
3030 primary.recall_source = Some(areev_cal::store_types::RecallSource::Primary);
3031
3032 let mut expansion = make_hit(
3033 GrainType::Fact,
3034 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
3035 0.4,
3036 );
3037 expansion.recall_source = Some(areev_cal::store_types::RecallSource::Expansion);
3038
3039 let hits = vec![primary, expansion];
3040 let ctx = assembler.format(&hits, &policy);
3041 assert!(ctx.text.contains("## Additional Context (expansion)"));
3042 }
3043
3044 #[test]
3045 fn test_context_expansion_section_sml() {
3046 let assembler = ContextAssembler::new();
3047 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
3048 let mut primary = make_hit(
3049 GrainType::Fact,
3050 vec![
3051 ("subject", "john"),
3052 ("relation", "likes"),
3053 ("object", "coffee"),
3054 ],
3055 0.9,
3056 );
3057 primary.recall_source = Some(areev_cal::store_types::RecallSource::Primary);
3058
3059 let mut expansion = make_hit(
3060 GrainType::Fact,
3061 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
3062 0.4,
3063 );
3064 expansion.recall_source = Some(areev_cal::store_types::RecallSource::Expansion);
3065
3066 let hits = vec![primary, expansion];
3067 let ctx = assembler.format(&hits, &policy);
3068 assert!(ctx.text.contains("<expansion_results>"));
3069 }
3070
3071 #[test]
3072 fn test_context_no_expansion_when_all_primary() {
3073 let assembler = ContextAssembler::new();
3074 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3075 let mut primary = make_hit(
3076 GrainType::Fact,
3077 vec![
3078 ("subject", "john"),
3079 ("relation", "likes"),
3080 ("object", "coffee"),
3081 ],
3082 0.9,
3083 );
3084 primary.recall_source = Some(areev_cal::store_types::RecallSource::Primary);
3085
3086 let hits = vec![primary];
3087 let ctx = assembler.format(&hits, &policy);
3088 assert!(!ctx.text.contains("Additional Context"));
3089 assert!(ctx.text.contains("john"));
3090 }
3091
3092 #[test]
3093 fn test_temporal_pattern_detection() {
3094 let patterns = vec![
3095 "what is the order of events?",
3096 "in what order did things happen?",
3097 "sequence of actions taken",
3098 "from earliest to latest",
3099 "from latest event",
3100 "from first to last",
3101 "how many days between events?",
3102 "how long between the two meetings?",
3103 "time between signup and purchase",
3104 "which came first, A or B?",
3105 "which came last in the list?",
3106 "which was earlier, the call or the email?",
3107 "which was later?",
3108 "was it before or after the meeting?",
3109 "earlier or later than Tuesday?",
3110 "when did they join?",
3111 "what date was the event?",
3112 "what time did it start?",
3113 "show events in chronological order",
3114 ];
3115
3116 for pattern in &patterns {
3117 let hints = RenderingHints {
3118 entity_count: None,
3119 entities: None,
3120 has_temporal_expr: false,
3121 has_time_range: false,
3122 query_text: Some(pattern.to_string()),
3123 };
3124 let hits = [
3125 make_dated_hit(GrainType::Event, vec![("content", "A")], 0.9, 1_700_000_000),
3126 make_dated_hit(GrainType::Event, vec![("content", "B")], 0.8, 1_700_100_000),
3127 ];
3128 let refs: Vec<&SearchHit> = hits.iter().collect();
3129 assert!(
3130 detect_temporal_intent(&hints, &refs),
3131 "Pattern '{}' should trigger temporal intent",
3132 pattern
3133 );
3134 }
3135 }
3136
3137 #[test]
3138 fn test_non_temporal_patterns_do_not_trigger() {
3139 let hints = RenderingHints {
3140 entity_count: None,
3141 entities: None,
3142 has_temporal_expr: false,
3143 has_time_range: false,
3144 query_text: Some("what does john like?".to_string()),
3145 };
3146 let hits = [
3148 make_dated_hit(
3149 GrainType::Fact,
3150 vec![("subject", "john")],
3151 0.9,
3152 1_700_000_000,
3153 ),
3154 make_dated_hit(
3155 GrainType::Fact,
3156 vec![("subject", "bob")],
3157 0.8,
3158 1_700_000_100,
3159 ),
3160 ];
3161 let refs: Vec<&SearchHit> = hits.iter().collect();
3162 assert!(
3163 !detect_temporal_intent(&hints, &refs),
3164 "Non-temporal query within same day should not trigger"
3165 );
3166 }
3167
3168 #[test]
3169 fn test_temporal_flags_trigger() {
3170 let hints_expr = RenderingHints {
3171 entity_count: None,
3172 entities: None,
3173 has_temporal_expr: true,
3174 has_time_range: false,
3175 query_text: None,
3176 };
3177 let hints_range = RenderingHints {
3178 entity_count: None,
3179 entities: None,
3180 has_temporal_expr: false,
3181 has_time_range: true,
3182 query_text: None,
3183 };
3184 let refs: Vec<&SearchHit> = Vec::new();
3185 assert!(detect_temporal_intent(&hints_expr, &refs));
3186 assert!(detect_temporal_intent(&hints_range, &refs));
3187 }
3188
3189 #[test]
3190 fn test_delta_formatting() {
3191 assert_eq!(format_time_delta(0), "same day");
3192 assert_eq!(format_time_delta(86400), "1 day");
3193 assert_eq!(format_time_delta(86400 * 5), "5 days");
3194 assert_eq!(format_time_delta(86400 * 89), "89 days");
3195 assert_eq!(format_time_delta(86400 * 90), "3 months");
3196 assert_eq!(format_time_delta(86400 * 180), "6 months");
3197 assert_eq!(format_time_delta(86400 * 364), "12 months");
3198 assert_eq!(format_time_delta(86400 * 365), "1 year");
3199 assert_eq!(format_time_delta(86400 * 500), "1 year, 4 months");
3200 assert_eq!(format_time_delta(86400 * 730), "2 years");
3201 assert_eq!(format_time_delta(86400 * 800), "2 years, 2 months");
3202 }
3203
3204 #[test]
3205 fn test_timeline_with_deltas_plaintext() {
3206 let assembler = ContextAssembler::new();
3207 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3208 let hints = RenderingHints {
3209 entity_count: None,
3210 entities: None,
3211 has_temporal_expr: false,
3212 has_time_range: false,
3213 query_text: Some("in what order did events happen?".to_string()),
3214 };
3215 let hits = vec![
3217 make_dated_hit(
3218 GrainType::Event,
3219 vec![("content", "Joined volleyball league")],
3220 0.9,
3221 1_673_740_800,
3222 ),
3223 make_dated_hit(
3224 GrainType::Event,
3225 vec![("content", "Completed charity 5K run")],
3226 0.8,
3227 1_682_294_400,
3228 ),
3229 make_dated_hit(
3230 GrainType::Event,
3231 vec![("content", "Started yoga classes")],
3232 0.7,
3233 1_694_304_000,
3234 ),
3235 ];
3236
3237 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3238
3239 assert!(
3241 ctx.text.contains("Timeline (earliest to latest)"),
3242 "Missing timeline header, got:\n{}",
3243 ctx.text
3244 );
3245 assert!(
3247 ctx.text.contains("Joined volleyball league"),
3248 "Missing event 1"
3249 );
3250 assert!(
3251 ctx.text.contains("Completed charity 5K run"),
3252 "Missing event 2"
3253 );
3254 assert!(ctx.text.contains("Started yoga classes"), "Missing event 3");
3255 assert!(
3257 ctx.text.contains("\u{2193}"),
3258 "Missing delta arrows, got:\n{}",
3259 ctx.text
3260 );
3261 assert_eq!(ctx.included_count, 3);
3263 }
3264
3265 #[test]
3266 fn test_timeline_with_deltas_markdown() {
3267 let assembler = ContextAssembler::new();
3268 let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
3269 let hints = RenderingHints {
3270 entity_count: None,
3271 entities: None,
3272 has_temporal_expr: false,
3273 has_time_range: false,
3274 query_text: Some("when did things happen?".to_string()),
3275 };
3276 let hits = vec![
3277 make_dated_hit(
3278 GrainType::Event,
3279 vec![("content", "Event A")],
3280 0.9,
3281 1_673_740_800,
3282 ),
3283 make_dated_hit(
3284 GrainType::Event,
3285 vec![("content", "Event B")],
3286 0.8,
3287 1_682_294_400,
3288 ),
3289 ];
3290
3291 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3292 assert!(
3294 ctx.text.contains("*"),
3295 "Markdown timeline should use italic deltas, got:\n{}",
3296 ctx.text
3297 );
3298 assert!(ctx.text.contains("## Timeline"));
3299 }
3300
3301 #[test]
3302 fn test_timeline_with_deltas_sml() {
3303 let assembler = ContextAssembler::new();
3304 let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
3305 let hints = RenderingHints {
3306 entity_count: None,
3307 entities: None,
3308 has_temporal_expr: true,
3309 has_time_range: false,
3310 query_text: None,
3311 };
3312 let hits = vec![
3313 make_dated_hit(
3314 GrainType::Event,
3315 vec![("content", "First")],
3316 0.9,
3317 1_673_740_800,
3318 ),
3319 make_dated_hit(
3320 GrainType::Event,
3321 vec![("content", "Second")],
3322 0.8,
3323 1_682_294_400,
3324 ),
3325 ];
3326
3327 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3328 assert!(ctx.text.contains("<timeline"), "Missing timeline tag");
3329 assert!(
3330 ctx.text.contains("</timeline>"),
3331 "Missing closing timeline tag"
3332 );
3333 assert!(ctx.text.contains("<delta"), "Missing delta element");
3334 }
3335
3336 #[test]
3337 fn test_timeline_with_deltas_json() {
3338 let assembler = ContextAssembler::new();
3339 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
3340 let hints = RenderingHints {
3341 entity_count: None,
3342 entities: None,
3343 has_temporal_expr: true,
3344 has_time_range: false,
3345 query_text: None,
3346 };
3347 let hits = vec![
3348 make_dated_hit(
3349 GrainType::Event,
3350 vec![("content", "Alpha")],
3351 0.9,
3352 1_673_740_800,
3353 ),
3354 make_dated_hit(
3355 GrainType::Event,
3356 vec![("content", "Beta")],
3357 0.8,
3358 1_682_294_400,
3359 ),
3360 ];
3361
3362 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3363 assert!(
3364 ctx.text.contains("\"timeline\""),
3365 "Missing timeline key in JSON"
3366 );
3367 assert!(
3368 ctx.text.contains("\"delta_to_next\""),
3369 "Missing delta_to_next in JSON"
3370 );
3371 }
3372
3373 #[test]
3374 fn test_timeline_priority_over_relevance() {
3375 let assembler = ContextAssembler::new();
3377 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3378 let hints = RenderingHints {
3379 entity_count: None,
3380 entities: None,
3381 has_temporal_expr: false,
3382 has_time_range: false,
3383 query_text: Some("what is the chronological order?".to_string()),
3384 };
3385
3386 let mut hits = Vec::new();
3387 for i in 0..15 {
3388 hits.push(make_dated_hit(
3389 GrainType::Event,
3390 vec![("content", &format!("Event {}", i))],
3391 0.9 - (i as f64) * 0.05,
3392 1_673_740_800 + (i as u32) * 86400 * 30, ));
3394 }
3395 let hit_contents: Vec<String> = (0..15).map(|i| format!("Event {}", i)).collect();
3397 let mut hits = Vec::new();
3398 for (i, content) in hit_contents.iter().enumerate() {
3399 hits.push(make_dated_hit(
3400 GrainType::Event,
3401 vec![("content", content.as_str())],
3402 0.9 - (i as f64) * 0.05,
3403 1_673_740_800 + (i as u32) * 86400 * 30,
3404 ));
3405 }
3406
3407 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3408
3409 assert!(
3411 ctx.text.contains("Timeline (earliest to latest)"),
3412 "With >10 grains and temporal intent, timeline should take priority, got:\n{}",
3413 ctx.text
3414 );
3415 assert!(ctx.included_count > 10, "Should include all grains");
3416 }
3417
3418 #[test]
3419 fn test_timeline_chronological_order() {
3420 let assembler = ContextAssembler::new();
3422 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3423 let hints = RenderingHints {
3424 entity_count: None,
3425 entities: None,
3426 has_temporal_expr: false,
3427 has_time_range: false,
3428 query_text: Some("in what order did these happen?".to_string()),
3429 };
3430 let hits = vec![
3432 make_dated_hit(
3433 GrainType::Event,
3434 vec![("content", "Third event")],
3435 0.9,
3436 1_694_304_000,
3437 ),
3438 make_dated_hit(
3439 GrainType::Event,
3440 vec![("content", "First event")],
3441 0.8,
3442 1_673_740_800,
3443 ),
3444 make_dated_hit(
3445 GrainType::Event,
3446 vec![("content", "Second event")],
3447 0.7,
3448 1_682_294_400,
3449 ),
3450 ];
3451
3452 let ctx = assembler.format_with_hints(&hits, &policy, &hints);
3453
3454 let first_pos = ctx.text.find("First event").unwrap();
3455 let second_pos = ctx.text.find("Second event").unwrap();
3456 let third_pos = ctx.text.find("Third event").unwrap();
3457 assert!(
3458 first_pos < second_pos && second_pos < third_pos,
3459 "Events should be in chronological order, got:\n{}",
3460 ctx.text
3461 );
3462 }
3463
3464 #[test]
3465 fn test_format_epoch_date() {
3466 assert_eq!(format_epoch_date(0), "1970-01-01");
3467 assert_eq!(format_epoch_date(1_673_740_800), "2023-01-15");
3468 assert_eq!(format_epoch_date(1_682_294_400), "2023-04-24");
3469 assert_eq!(format_epoch_date(1_694_304_000), "2023-09-10");
3470 }
3471
3472 #[test]
3473 fn test_format_with_hints_no_temporal_falls_through() {
3474 let assembler = ContextAssembler::new();
3476 let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
3477 let hints = RenderingHints::default();
3478 let hits = vec![make_hit(
3479 GrainType::Fact,
3480 vec![
3481 ("subject", "john"),
3482 ("relation", "likes"),
3483 ("object", "coffee"),
3484 ],
3485 0.9,
3486 )];
3487
3488 let ctx_hints = assembler.format_with_hints(&hits, &policy, &hints);
3489 let ctx_plain = assembler.format(&hits, &policy);
3490
3491 assert_eq!(ctx_hints.text, ctx_plain.text);
3492 assert_eq!(ctx_hints.included_count, ctx_plain.included_count);
3493 }
3494
3495 #[test]
3496 fn test_is_recency_query() {
3497 assert!(is_recency_query(Some(
3498 "what is the therapy frequency currently?"
3499 )));
3500 assert!(is_recency_query(Some("What's the latest status?")));
3501 assert!(is_recency_query(Some("tell me right now")));
3502 assert!(is_recency_query(Some("what is my address")));
3503 assert!(is_recency_query(Some("most recently updated")));
3504 assert!(is_recency_query(Some("at this point in time")));
3505 assert!(!is_recency_query(Some("tell me about therapy")));
3506 assert!(!is_recency_query(Some("history of changes")));
3507 assert!(!is_recency_query(None));
3508 }
3509
3510 #[test]
3511 fn test_format_timestamp() {
3512 assert_eq!(format_timestamp(1675987200), Some("2023-02-10".to_string()));
3514 assert_eq!(format_timestamp(1681516800), Some("2023-04-15".to_string()));
3516 assert_eq!(format_timestamp(0), None);
3518 }
3519
3520 #[test]
3525 fn census_json_is_valid_and_keeps_recall_source() {
3526 let assembler = ContextAssembler::new();
3527 let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
3528 let mut census = make_hit(
3529 GrainType::Fact,
3530 vec![("subject", "bob"), ("relation", "likes"), ("object", "tea")],
3531 0.4,
3532 );
3533 census.recall_source = Some(RecallSource::Census);
3534 let hits = vec![
3535 make_hit(
3536 GrainType::Fact,
3537 vec![("subject", "john"), ("relation", "likes"), ("object", "coffee")],
3538 0.9,
3539 ),
3540 census,
3541 ];
3542 let ctx = assembler.format(&hits, &policy);
3543 let v: serde_json::Value = serde_json::from_str(&ctx.text)
3544 .unwrap_or_else(|e| panic!("census+JSON must be valid JSON: {e}\n{}", ctx.text));
3545 let arr = v.as_array().expect("one top-level array");
3546 assert_eq!(arr.len(), 2, "primary + census grains in one array");
3547 assert!(ctx.text.contains("\"recall_source\":\"census\""), "census marker preserved");
3548 }
3549
3550 #[test]
3553 fn toon_row_has_no_status_prefix() {
3554 for status in [SupersessionStatus::Current, SupersessionStatus::Superseded] {
3555 let mut hit = make_hit(
3556 GrainType::Fact,
3557 vec![("subject", "john"), ("relation", "likes"), ("object", "coffee")],
3558 0.9,
3559 );
3560 hit.supersession_status = Some(status);
3561 let ctx = ContextAssembler::new().format(
3562 &[hit],
3563 &FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::Minimal),
3564 );
3565 assert!(!ctx.text.contains("[CURRENT]"), "no marker in TOON column:\n{}", ctx.text);
3566 assert!(!ctx.text.contains("[OUTDATED]"), "no marker in TOON column:\n{}", ctx.text);
3567 assert!(ctx.text.contains("john"), "subject column intact:\n{}", ctx.text);
3568 }
3569 }
3570
3571 #[test]
3574 fn census_section_honors_budget() {
3575 let assembler = ContextAssembler::new();
3576 let policy = FormatPolicy::new(OutputFormat::PlainText)
3577 .metadata(MetadataLevel::None)
3578 .token_budget(40); let mut hits = Vec::new();
3580 for i in 0..6 {
3581 let mut h = make_hit(
3582 GrainType::Fact,
3583 vec![("subject", "s"), ("relation", "notes"), ("object", "x")],
3584 0.5,
3585 );
3586 h.grain
3587 .fields
3588 .insert("object".into(), serde_json::Value::String(format!("censusobject{i}")));
3589 h.recall_source = Some(RecallSource::Census);
3590 hits.push(h);
3591 }
3592 let ctx = assembler.format(&hits, &policy);
3593 let rendered = (0..6).filter(|i| ctx.text.contains(&format!("censusobject{i}"))).count();
3594 assert!(rendered < 6, "census must honor its budget (rendered {rendered}/6):\n{}", ctx.text);
3595 assert!(rendered > 0, "but some census hits still render");
3596 assert!(rendered <= ctx.included_count, "text renders no more grains than included_count reports");
3597 }
3598}