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