1use 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
21pub trait GrainRenderer: Send + Sync {
25 fn grain_type(&self) -> GrainType;
27
28 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String;
30
31 fn render_summary(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
33 self.render(grain, policy)
34 }
35
36 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 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32;
45}
46
47pub 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
58const 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 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 pub fn get(&self, gt: GrainType) -> Option<&dyn GrainRenderer> {
89 self.renderers.get(>).map(|r| r.as_ref())
90 }
91
92 pub fn register(&mut self, renderer: Box<dyn GrainRenderer>) {
94 self.renderers.insert(renderer.grain_type(), renderer);
95 }
96}
97
98pub fn toon_columns(gt: &GrainType) -> &'static [&'static str] {
101 areev_core::types::registry::meta(*gt).toon_columns
102}
103
104fn 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
116fn 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
137struct 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 OutputFormat::Sml => {
170 let tag = view.grain_type;
171 format!("<{tag}>{}</{tag}>", shared::sml_escape(&summary))
172 }
173 OutputFormat::Markdown => format!("- {summary}"),
175 _ => 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 GrainType::State => 0.9,
189 GrainType::Workflow => 0.5,
190 GrainType::Tool => {
191 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 GrainType::Consent => 0.95,
219 GrainType::Skill => 0.6,
220 GrainType::Recommendation => 0.7,
224 GrainType::Trigger => 0.55,
228 };
229 adjusted_priority(base, grain, hit)
230 }
231}
232
233fn 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#[cfg(test)]
248mod tests {
249 use super::*;
250 use areev_core::format::header::MgHeader;
251 use std::collections::HashMap;
252
253 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, },
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 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 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); }
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}