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::types::GrainType;
17
18use super::policy::{FormatPolicy, MetadataLevel, OutputFormat};
19
20pub trait GrainRenderer: Send + Sync {
24 fn grain_type(&self) -> GrainType;
26
27 fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String;
29
30 fn render_summary(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
32 self.render(grain, policy)
33 }
34
35 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 fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32;
44}
45
46pub 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
57const 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 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 pub fn get(&self, gt: GrainType) -> Option<&dyn GrainRenderer> {
88 self.renderers.get(>).map(|r| r.as_ref())
89 }
90
91 pub fn register(&mut self, renderer: Box<dyn GrainRenderer>) {
93 self.renderers.insert(renderer.grain_type(), renderer);
94 }
95}
96
97pub fn toon_columns(gt: &GrainType) -> &'static [&'static str] {
100 areev_core::types::registry::meta(*gt).toon_columns
101}
102
103fn 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
115fn 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
136struct 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 OutputFormat::Sml => {
169 let tag = view.grain_type;
170 format!("<{tag}>{}</{tag}>", shared::sml_escape(&summary))
171 }
172 OutputFormat::Markdown => format!("- {summary}"),
174 _ => 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 GrainType::State => 0.9,
188 GrainType::Workflow => 0.5,
189 GrainType::Tool => {
190 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 GrainType::Consent => 0.95,
218 GrainType::Skill => 0.6,
219 GrainType::Recommendation => 0.7,
223 GrainType::Trigger => 0.55,
227 };
228 adjusted_priority(base, grain, hit)
229 }
230}
231
232fn 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#[cfg(test)]
250mod tests {
251 use super::*;
252 use areev_core::format::header::MgHeader;
253 use std::collections::HashMap;
254
255 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, },
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 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 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); }
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}