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