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