Skip to main content

dejadb_context/
assembly.rs

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