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