Skip to main content

areev_context/
assembly.rs

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