Skip to main content

areev_context/
render.rs

1//! Grain renderers for context formatting — a thin delegation layer.
2//!
3//! Since the render unification, the
4//! actual per-grain rendering lives in `areev_cal::render` — ONE
5//! implementation shared with CAL's `FORMAT` arms, so a grain renders
6//! identically on every surface. What stays here is orchestration-side:
7//! the `GrainRenderer` trait and its registry (the seam a host can override
8//! with `ContextAssembler::with_renderer`), the per-type context priorities
9//! that feed budget allocation, and the per-type summaries.
10
11use std::collections::HashMap;
12
13use areev_cal::render as shared;
14use areev_cal::store_types::SearchHit;
15use areev_core::format::deserialize::DeserializedGrain;
16use areev_core::types::GrainType;
17
18use super::policy::{FormatPolicy, MetadataLevel, OutputFormat};
19
20/// Renders a single grain type into formatted context strings.
21///
22/// Object-safe: stored in `RendererRegistry` keyed by `GrainType`.
23pub trait GrainRenderer: Send + Sync {
24    /// Which grain type this renderer handles.
25    fn grain_type(&self) -> GrainType;
26
27    /// Full render of the grain's content.
28    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String;
29
30    /// Compact one-line summary for budget-constrained contexts.
31    fn render_summary(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
32        self.render(grain, policy)
33    }
34
35    /// Estimated token count for the full render (~4 chars per token).
36    fn token_estimate(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> usize {
37        with_view(grain, |view| {
38            shared::estimate_tokens(view, detail(policy.metadata))
39        })
40    }
41
42    /// Context priority for this grain [0.0, 1.0].
43    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32;
44}
45
46/// Registry mapping GrainType to its renderer.
47pub struct RendererRegistry {
48    renderers: HashMap<GrainType, Box<dyn GrainRenderer>>,
49}
50
51impl Default for RendererRegistry {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57/// Every grain type, for registry construction.
58const ALL_TYPES: [GrainType; 12] = [
59    GrainType::Fact,
60    GrainType::Event,
61    GrainType::State,
62    GrainType::Workflow,
63    GrainType::Tool,
64    GrainType::Observation,
65    GrainType::Goal,
66    GrainType::Reasoning,
67    GrainType::Consensus,
68    GrainType::Consent,
69    GrainType::Skill,
70    GrainType::Recommendation,
71];
72
73impl RendererRegistry {
74    /// Create with the default (shared-implementation) renderer registered
75    /// for every grain type.
76    pub fn new() -> Self {
77        let mut registry = Self {
78            renderers: HashMap::with_capacity(ALL_TYPES.len()),
79        };
80        for gt in ALL_TYPES {
81            registry.register(Box::new(SharedRenderer { gt }));
82        }
83        registry
84    }
85
86    /// Get renderer for a grain type.
87    pub fn get(&self, gt: GrainType) -> Option<&dyn GrainRenderer> {
88        self.renderers.get(&gt).map(|r| r.as_ref())
89    }
90
91    /// Register a custom renderer (replaces existing for that type).
92    pub fn register(&mut self, renderer: Box<dyn GrainRenderer>) {
93        self.renderers.insert(renderer.grain_type(), renderer);
94    }
95}
96
97/// TOON column names per grain type, per CAL spec Section 10.9.3. Reads from
98/// the grain-type metadata registry — the single source of truth.
99pub fn toon_columns(gt: &GrainType) -> &'static [&'static str] {
100    areev_core::types::registry::meta(*gt).toon_columns
101}
102
103// ---------------------------------------------------------------------------
104// View bridging
105// ---------------------------------------------------------------------------
106
107fn detail(level: MetadataLevel) -> shared::MetadataDetail {
108    match level {
109        MetadataLevel::None => shared::MetadataDetail::None,
110        MetadataLevel::Minimal => shared::MetadataDetail::Minimal,
111        MetadataLevel::Full => shared::MetadataDetail::Full,
112    }
113}
114
115/// Build the shared-renderer view of a deserialized grain and run `f` on it.
116/// The fields map is materialized as a JSON object for the duration of the
117/// call (BTreeMap-backed, so iteration order is deterministic).
118fn with_view<R>(grain: &DeserializedGrain, f: impl FnOnce(&shared::GrainView<'_>) -> R) -> R {
119    let fields = serde_json::Value::Object(
120        grain
121            .fields
122            .iter()
123            .map(|(k, v)| (k.clone(), v.clone()))
124            .collect(),
125    );
126    let hash_hex = grain.hash.to_hex();
127    let view = shared::GrainView {
128        grain_type: grain.grain_type.as_str(),
129        hash: &hash_hex,
130        fields: &fields,
131        created_at_sec: grain.header.created_at_sec,
132    };
133    f(&view)
134}
135
136// ---------------------------------------------------------------------------
137// The default renderer — delegates rendering, keeps allocation semantics
138// ---------------------------------------------------------------------------
139
140/// The default per-type renderer: rendering and summaries delegate to
141/// `areev_cal::render`; context priority (an allocation concern, not a
142/// rendering one) is decided here per type.
143struct SharedRenderer {
144    gt: GrainType,
145}
146
147impl GrainRenderer for SharedRenderer {
148    fn grain_type(&self) -> GrainType {
149        self.gt
150    }
151
152    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
153        with_view(grain, |view| match &policy.format {
154            OutputFormat::Sml => shared::render_grain_sml(view, detail(policy.metadata)),
155            OutputFormat::Markdown => shared::render_grain_markdown(view),
156            OutputFormat::PlainText => shared::render_grain_text_line(view, None),
157            OutputFormat::Json => shared::grain_json(view).to_string(),
158            OutputFormat::Toon => shared::toon_row(view),
159        })
160    }
161
162    fn render_summary(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
163        with_view(grain, |view| {
164            let summary = shared::render_grain_summary(view);
165            match &policy.format {
166                // Keep SML well-formed under budget pressure: the semantic
167                // tag survives, the metadata does not.
168                OutputFormat::Sml => {
169                    let tag = view.grain_type;
170                    format!("<{tag}>{}</{tag}>", shared::sml_escape(&summary))
171                }
172                // A summary is still a list item.
173                OutputFormat::Markdown => format!("- {summary}"),
174                // Structured formats never receive summaries (the assembler
175                // degrades Summary to Omit for them); prose text gets the
176                // bare line.
177                _ => summary,
178            }
179        })
180    }
181
182    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
183        let base = match self.gt {
184            GrainType::Fact => 0.7,
185            GrainType::Event => 0.6,
186            // A state snapshot is what the agent resumes from.
187            GrainType::State => 0.9,
188            GrainType::Workflow => 0.5,
189            GrainType::Tool => {
190                // A failed call is more informative than a successful one.
191                if grain.get_bool("is_error").unwrap_or(false) {
192                    0.65
193                } else {
194                    0.5
195                }
196            }
197            GrainType::Observation => 0.6,
198            GrainType::Goal => {
199                let state_mod = match grain.get_str("goal_state") {
200                    Some("active") => 0.15,
201                    Some("suspended") => 0.05,
202                    Some("satisfied" | "failed") => -0.2,
203                    _ => 0.0,
204                };
205                let priority_mod = match grain.get_str("priority") {
206                    Some("critical") => 0.1,
207                    Some("high") => 0.05,
208                    Some("low") => -0.1,
209                    _ => 0.0,
210                };
211                0.8 + state_mod + priority_mod
212            }
213            GrainType::Reasoning => 0.7,
214            GrainType::Consensus => 0.65,
215            // Consent grains get highest base priority — legally required
216            // context.
217            GrainType::Consent => 0.95,
218            GrainType::Skill => 0.6,
219            // A pending proposal is something a reviewer must act on, so it
220            // ranks above passive records but below the facts it reasons
221            // about.
222            GrainType::Recommendation => 0.7,
223        };
224        adjusted_priority(base, grain, hit)
225    }
226}
227
228/// Apply priority modifiers common to all grain types.
229fn adjusted_priority(base: f32, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
230    let score_boost = hit.score as f32 * 0.15;
231    let confidence = grain.get_f64("confidence").unwrap_or(1.0) as f32;
232    let confidence_boost = (confidence - 0.5).max(0.0) * 0.1;
233    let verification_penalty = match grain.get_str("verification_status") {
234        Some("retracted") => -0.3,
235        Some("contested") => -0.15,
236        _ => 0.0,
237    };
238    (base + score_boost + confidence_boost + verification_penalty).clamp(0.0, 1.0)
239}
240
241// ---------------------------------------------------------------------------
242// Tests
243// ---------------------------------------------------------------------------
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use areev_core::format::header::MgHeader;
249    use std::collections::HashMap;
250
251    /// Build a test grain with given type and fields.
252    fn test_grain(gt: GrainType, fields: Vec<(&str, serde_json::Value)>) -> DeserializedGrain {
253        let mut map = HashMap::new();
254        for (k, v) in fields {
255            map.insert(k.to_string(), v);
256        }
257        DeserializedGrain {
258            header: MgHeader {
259                version: 1,
260                flags: 0,
261                grain_type: gt.type_byte(),
262                ns_hash: 0,
263                created_at_sec: 1740000000, // 2025-02-19
264            },
265            grain_type: gt,
266            fields: map,
267            hash: areev_core::error::Hash::from_bytes(&[0u8; 32]),
268        }
269    }
270
271    fn test_hit(grain: DeserializedGrain) -> SearchHit {
272        SearchHit {
273            hash: grain.hash,
274            score: 0.85,
275            grain,
276            score_breakdown: None,
277            #[cfg(feature = "rerank")]
278            rerank_score: None,
279            #[cfg(feature = "llm-rerank")]
280            llm_rerank_score: None,
281            explanation: None,
282            scope_depth: None,
283            source_namespace: None,
284            relative_time: None,
285            conflict_status: None,
286            supersession_status: None,
287            superseded_by_hash: None,
288            recall_source: None,
289        }
290    }
291
292    fn fact() -> DeserializedGrain {
293        test_grain(
294            GrainType::Fact,
295            vec![
296                ("subject", serde_json::json!("john")),
297                ("relation", serde_json::json!("likes")),
298                ("object", serde_json::json!("coffee")),
299                ("confidence", serde_json::json!(0.95)),
300            ],
301        )
302    }
303
304    #[test]
305    fn fact_sml_is_semantic_and_identical_to_the_shared_renderer() {
306        let grain = fact();
307        let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::Minimal);
308        let registry = RendererRegistry::new();
309        let via_registry = registry.get(GrainType::Fact).unwrap().render(&grain, &policy);
310        let direct = with_view(&grain, |v| {
311            shared::render_grain_sml(v, shared::MetadataDetail::Minimal)
312        });
313        assert_eq!(via_registry, direct);
314        assert!(via_registry.starts_with("<fact"));
315        assert!(via_registry.contains("confidence=\"0.95\""));
316        assert!(via_registry.contains("john likes coffee"));
317    }
318
319    #[test]
320    fn fact_markdown_matches_the_documented_cal_shape() {
321        let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
322        let registry = RendererRegistry::new();
323        let grain = test_grain(
324            GrainType::Fact,
325            vec![
326                ("subject", serde_json::json!("john")),
327                ("relation", serde_json::json!("likes")),
328                ("object", serde_json::json!("coffee")),
329            ],
330        );
331        let result = registry.get(GrainType::Fact).unwrap().render(&grain, &policy);
332        assert_eq!(result, "- **john** likes coffee *(2025-02-19)*");
333    }
334
335    #[test]
336    fn fact_summary_is_the_bare_triple() {
337        let policy = FormatPolicy::default();
338        let registry = RendererRegistry::new();
339        let result = registry
340            .get(GrainType::Fact)
341            .unwrap()
342            .render_summary(&fact(), &policy);
343        assert_eq!(result, "john likes coffee");
344    }
345
346    #[test]
347    fn json_is_the_envelope() {
348        let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
349        let registry = RendererRegistry::new();
350        let out = registry.get(GrainType::Fact).unwrap().render(&fact(), &policy);
351        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
352        assert_eq!(v["grain_type"], "fact");
353        assert_eq!(v["fields"]["subject"], "john");
354        assert!(v["hash"].as_str().is_some());
355    }
356
357    #[test]
358    fn every_grain_type_has_a_default_renderer() {
359        let registry = RendererRegistry::new();
360        for gt in ALL_TYPES {
361            assert!(registry.get(gt).is_some(), "missing renderer for {gt:?}");
362        }
363    }
364
365    #[test]
366    fn custom_renderer_replaces_the_default() {
367        struct Loud;
368        impl GrainRenderer for Loud {
369            fn grain_type(&self) -> GrainType {
370                GrainType::Fact
371            }
372            fn render(&self, _g: &DeserializedGrain, _p: &FormatPolicy) -> String {
373                "LOUD".into()
374            }
375            fn context_priority(&self, _g: &DeserializedGrain, _h: &SearchHit) -> f32 {
376                1.0
377            }
378        }
379        let mut registry = RendererRegistry::new();
380        registry.register(Box::new(Loud));
381        let policy = FormatPolicy::default();
382        assert_eq!(
383            registry.get(GrainType::Fact).unwrap().render(&fact(), &policy),
384            "LOUD"
385        );
386    }
387
388    #[test]
389    fn priorities_keep_their_per_type_semantics() {
390        let registry = RendererRegistry::new();
391        let policy_hit = |gt: GrainType, fields: Vec<(&str, serde_json::Value)>| {
392            let grain = test_grain(gt, fields);
393            let hit = test_hit(grain);
394            registry
395                .get(gt)
396                .unwrap()
397                .context_priority(&hit.grain, &hit)
398        };
399        // Consent outranks facts; a failed tool call outranks a clean one.
400        let consent = policy_hit(GrainType::Consent, vec![]);
401        let fact_p = policy_hit(GrainType::Fact, vec![]);
402        assert!(consent > fact_p);
403        let tool_ok = policy_hit(GrainType::Tool, vec![("is_error", serde_json::json!(false))]);
404        let tool_err = policy_hit(GrainType::Tool, vec![("is_error", serde_json::json!(true))]);
405        assert!(tool_err > tool_ok);
406        // An active goal outranks a satisfied one.
407        let g_active = policy_hit(
408            GrainType::Goal,
409            vec![("goal_state", serde_json::json!("active"))],
410        );
411        let g_done = policy_hit(
412            GrainType::Goal,
413            vec![("goal_state", serde_json::json!("satisfied"))],
414        );
415        assert!(g_active > g_done);
416    }
417
418    #[test]
419    fn token_estimate_uses_the_shared_estimator() {
420        let registry = RendererRegistry::new();
421        let grain = fact();
422        let none = registry.get(GrainType::Fact).unwrap().token_estimate(
423            &grain,
424            &FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None),
425        );
426        let full = registry.get(GrainType::Fact).unwrap().token_estimate(
427            &grain,
428            &FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::Full),
429        );
430        assert!(none >= 1);
431        assert_eq!(full, none + 20); // 80 chars of metadata overhead / 4
432    }
433
434    #[test]
435    fn event_toon_row_carries_the_header_date() {
436        let registry = RendererRegistry::new();
437        let grain = test_grain(
438            GrainType::Event,
439            vec![
440                ("content", serde_json::json!("hello")),
441                ("role", serde_json::json!("user")),
442            ],
443        );
444        let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::None);
445        let row = registry.get(GrainType::Event).unwrap().render(&grain, &policy);
446        assert_eq!(row, "user,2025-02-19,hello");
447    }
448}