Skip to main content

areev_context/
assembly.rs

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