Skip to main content

areev_context/
assembly.rs

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