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