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 };
224 adjusted_priority(base, grain, hit)
225 }
226}
227
228fn 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#[cfg(test)]
246mod tests {
247 use super::*;
248 use areev_core::format::header::MgHeader;
249 use std::collections::HashMap;
250
251 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, },
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 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 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); }
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}