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            // A declaration of what the agent is supposed to be doing on its
224            // own is worth surfacing, but it is standing configuration rather
225            // than something that just happened — below an Event, above a plan.
226            GrainType::Trigger => 0.55,
227        };
228        adjusted_priority(base, grain, hit)
229    }
230}
231
232/// Apply priority modifiers common to all grain types.
233fn adjusted_priority(base: f32, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
234    let score_boost = hit.score as f32 * 0.15;
235    let confidence = grain.get_f64("confidence").unwrap_or(1.0) as f32;
236    let confidence_boost = (confidence - 0.5).max(0.0) * 0.1;
237    let verification_penalty = match grain.get_str("verification_status") {
238        Some("retracted") => -0.3,
239        Some("contested") => -0.15,
240        _ => 0.0,
241    };
242    (base + score_boost + confidence_boost + verification_penalty).clamp(0.0, 1.0)
243}
244
245// ---------------------------------------------------------------------------
246// Tests
247// ---------------------------------------------------------------------------
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use areev_core::format::header::MgHeader;
253    use std::collections::HashMap;
254
255    /// Build a test grain with given type and fields.
256    fn test_grain(gt: GrainType, fields: Vec<(&str, serde_json::Value)>) -> DeserializedGrain {
257        let mut map = HashMap::new();
258        for (k, v) in fields {
259            map.insert(k.to_string(), v);
260        }
261        DeserializedGrain {
262            header: MgHeader {
263                version: 1,
264                flags: 0,
265                grain_type: gt.type_byte(),
266                ns_hash: 0,
267                created_at_sec: 1740000000, // 2025-02-19
268            },
269            grain_type: gt,
270            fields: map,
271            hash: areev_core::error::Hash::from_bytes(&[0u8; 32]),
272        }
273    }
274
275    fn test_hit(grain: DeserializedGrain) -> SearchHit {
276        SearchHit {
277            hash: grain.hash,
278            score: 0.85,
279            grain,
280            score_breakdown: None,
281            #[cfg(feature = "rerank")]
282            rerank_score: None,
283            #[cfg(feature = "llm-rerank")]
284            llm_rerank_score: None,
285            explanation: None,
286            scope_depth: None,
287            source_namespace: None,
288            relative_time: None,
289            conflict_status: None,
290            supersession_status: None,
291            superseded_by_hash: None,
292            recall_source: None,
293        }
294    }
295
296    fn fact() -> DeserializedGrain {
297        test_grain(
298            GrainType::Fact,
299            vec![
300                ("subject", serde_json::json!("john")),
301                ("relation", serde_json::json!("likes")),
302                ("object", serde_json::json!("coffee")),
303                ("confidence", serde_json::json!(0.95)),
304            ],
305        )
306    }
307
308    #[test]
309    fn fact_sml_is_semantic_and_identical_to_the_shared_renderer() {
310        let grain = fact();
311        let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::Minimal);
312        let registry = RendererRegistry::new();
313        let via_registry = registry.get(GrainType::Fact).unwrap().render(&grain, &policy);
314        let direct = with_view(&grain, |v| {
315            shared::render_grain_sml(v, shared::MetadataDetail::Minimal)
316        });
317        assert_eq!(via_registry, direct);
318        assert!(via_registry.starts_with("<fact"));
319        assert!(via_registry.contains("confidence=\"0.95\""));
320        assert!(via_registry.contains("john likes coffee"));
321    }
322
323    #[test]
324    fn fact_markdown_matches_the_documented_cal_shape() {
325        let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
326        let registry = RendererRegistry::new();
327        let grain = test_grain(
328            GrainType::Fact,
329            vec![
330                ("subject", serde_json::json!("john")),
331                ("relation", serde_json::json!("likes")),
332                ("object", serde_json::json!("coffee")),
333            ],
334        );
335        let result = registry.get(GrainType::Fact).unwrap().render(&grain, &policy);
336        assert_eq!(result, "- **john** likes coffee *(2025-02-19)*");
337    }
338
339    #[test]
340    fn fact_summary_is_the_bare_triple() {
341        let policy = FormatPolicy::default();
342        let registry = RendererRegistry::new();
343        let result = registry
344            .get(GrainType::Fact)
345            .unwrap()
346            .render_summary(&fact(), &policy);
347        assert_eq!(result, "john likes coffee");
348    }
349
350    #[test]
351    fn json_is_the_envelope() {
352        let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
353        let registry = RendererRegistry::new();
354        let out = registry.get(GrainType::Fact).unwrap().render(&fact(), &policy);
355        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
356        assert_eq!(v["grain_type"], "fact");
357        assert_eq!(v["fields"]["subject"], "john");
358        assert!(v["hash"].as_str().is_some());
359    }
360
361    #[test]
362    fn every_grain_type_has_a_default_renderer() {
363        let registry = RendererRegistry::new();
364        for gt in ALL_TYPES {
365            assert!(registry.get(gt).is_some(), "missing renderer for {gt:?}");
366        }
367    }
368
369    #[test]
370    fn custom_renderer_replaces_the_default() {
371        struct Loud;
372        impl GrainRenderer for Loud {
373            fn grain_type(&self) -> GrainType {
374                GrainType::Fact
375            }
376            fn render(&self, _g: &DeserializedGrain, _p: &FormatPolicy) -> String {
377                "LOUD".into()
378            }
379            fn context_priority(&self, _g: &DeserializedGrain, _h: &SearchHit) -> f32 {
380                1.0
381            }
382        }
383        let mut registry = RendererRegistry::new();
384        registry.register(Box::new(Loud));
385        let policy = FormatPolicy::default();
386        assert_eq!(
387            registry.get(GrainType::Fact).unwrap().render(&fact(), &policy),
388            "LOUD"
389        );
390    }
391
392    #[test]
393    fn priorities_keep_their_per_type_semantics() {
394        let registry = RendererRegistry::new();
395        let policy_hit = |gt: GrainType, fields: Vec<(&str, serde_json::Value)>| {
396            let grain = test_grain(gt, fields);
397            let hit = test_hit(grain);
398            registry
399                .get(gt)
400                .unwrap()
401                .context_priority(&hit.grain, &hit)
402        };
403        // Consent outranks facts; a failed tool call outranks a clean one.
404        let consent = policy_hit(GrainType::Consent, vec![]);
405        let fact_p = policy_hit(GrainType::Fact, vec![]);
406        assert!(consent > fact_p);
407        let tool_ok = policy_hit(GrainType::Tool, vec![("is_error", serde_json::json!(false))]);
408        let tool_err = policy_hit(GrainType::Tool, vec![("is_error", serde_json::json!(true))]);
409        assert!(tool_err > tool_ok);
410        // An active goal outranks a satisfied one.
411        let g_active = policy_hit(
412            GrainType::Goal,
413            vec![("goal_state", serde_json::json!("active"))],
414        );
415        let g_done = policy_hit(
416            GrainType::Goal,
417            vec![("goal_state", serde_json::json!("satisfied"))],
418        );
419        assert!(g_active > g_done);
420    }
421
422    #[test]
423    fn token_estimate_uses_the_shared_estimator() {
424        let registry = RendererRegistry::new();
425        let grain = fact();
426        let none = registry.get(GrainType::Fact).unwrap().token_estimate(
427            &grain,
428            &FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None),
429        );
430        let full = registry.get(GrainType::Fact).unwrap().token_estimate(
431            &grain,
432            &FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::Full),
433        );
434        assert!(none >= 1);
435        assert_eq!(full, none + 20); // 80 chars of metadata overhead / 4
436    }
437
438    #[test]
439    fn event_toon_row_carries_the_header_date() {
440        let registry = RendererRegistry::new();
441        let grain = test_grain(
442            GrainType::Event,
443            vec![
444                ("content", serde_json::json!("hello")),
445                ("role", serde_json::json!("user")),
446            ],
447        );
448        let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::None);
449        let row = registry.get(GrainType::Event).unwrap().render(&grain, &policy);
450        assert_eq!(row, "user,2025-02-19,hello");
451    }
452}