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