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