Skip to main content

dejadb_context/
render.rs

1//! Grain-type-aware renderers for context formatting.
2//!
3//! Each of the 11 OMS grain types has a renderer implementing `GrainRenderer`.
4//! Renderers produce formatted strings in SML, Markdown, PlainText, or JSON.
5
6use std::collections::HashMap;
7
8use dejadb_cal::store_types::SearchHit;
9use dejadb_core::format::deserialize::DeserializedGrain;
10use dejadb_core::types::GrainType;
11
12use super::policy::{FormatPolicy, MetadataLevel, OutputFormat};
13
14/// Renders a single grain type into formatted context strings.
15///
16/// Object-safe: stored in `RendererRegistry` keyed by `GrainType`.
17pub trait GrainRenderer: Send + Sync {
18    /// Which grain type this renderer handles.
19    fn grain_type(&self) -> GrainType;
20
21    /// Full render of the grain's content.
22    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String;
23
24    /// Compact one-line summary for budget-constrained contexts.
25    fn render_summary(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
26        self.render(grain, policy)
27    }
28
29    /// Estimated token count for the full render (~4 chars per token).
30    fn token_estimate(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> usize {
31        let chars: usize = grain
32            .fields
33            .iter()
34            .map(|(k, v)| k.len() + json_display_len(v) + 4)
35            .sum();
36        let meta_overhead = match policy.metadata {
37            MetadataLevel::None => 0,
38            MetadataLevel::Minimal => 30,
39            MetadataLevel::Full => 80,
40        };
41        (chars + meta_overhead) / 4
42    }
43
44    /// Context priority for this grain [0.0, 1.0].
45    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32;
46}
47
48/// Approximate display length of a JSON value (for token estimation).
49fn json_display_len(v: &serde_json::Value) -> usize {
50    match v {
51        serde_json::Value::Null => 4,
52        serde_json::Value::Bool(b) => {
53            if *b {
54                4
55            } else {
56                5
57            }
58        }
59        serde_json::Value::Number(n) => n.to_string().len(),
60        serde_json::Value::String(s) => s.len(),
61        serde_json::Value::Array(a) => a.iter().map(json_display_len).sum::<usize>() + a.len() * 2,
62        serde_json::Value::Object(o) => o
63            .iter()
64            .map(|(k, v)| k.len() + json_display_len(v) + 4)
65            .sum(),
66    }
67}
68
69/// Registry mapping GrainType to its renderer.
70pub struct RendererRegistry {
71    renderers: HashMap<GrainType, Box<dyn GrainRenderer>>,
72}
73
74impl Default for RendererRegistry {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl RendererRegistry {
81    /// Create with all 10 default renderers registered.
82    pub fn new() -> Self {
83        let mut registry = Self {
84            renderers: HashMap::with_capacity(10),
85        };
86        registry.register(Box::new(FactRenderer));
87        registry.register(Box::new(EventRenderer));
88        registry.register(Box::new(StateRenderer));
89        registry.register(Box::new(WorkflowRenderer));
90        registry.register(Box::new(ToolRenderer));
91        registry.register(Box::new(ObservationRenderer));
92        registry.register(Box::new(GoalRenderer));
93        registry.register(Box::new(ReasoningRenderer));
94        registry.register(Box::new(ConsensusRenderer));
95        registry.register(Box::new(ConsentRenderer));
96        registry.register(Box::new(SkillRenderer));
97        registry
98    }
99
100    /// Get renderer for a grain type.
101    pub fn get(&self, gt: GrainType) -> Option<&dyn GrainRenderer> {
102        self.renderers.get(&gt).map(|r| r.as_ref())
103    }
104
105    /// Register a custom renderer (replaces existing for that type).
106    pub fn register(&mut self, renderer: Box<dyn GrainRenderer>) {
107        self.renderers.insert(renderer.grain_type(), renderer);
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Shared helpers
113// ---------------------------------------------------------------------------
114
115/// Apply priority modifiers common to all grain types.
116fn adjusted_priority(base: f32, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
117    let score_boost = hit.score as f32 * 0.15;
118    let confidence = grain.get_f64("confidence").unwrap_or(1.0) as f32;
119    let confidence_boost = (confidence - 0.5).max(0.0) * 0.1;
120    let verification_penalty = match grain.get_str("verification_status") {
121        Some("retracted") => -0.3,
122        Some("contested") => -0.15,
123        _ => 0.0,
124    };
125    (base + score_boost + confidence_boost + verification_penalty).clamp(0.0, 1.0)
126}
127
128/// Extracted metadata fields.
129struct MetadataFragment {
130    confidence: Option<f64>,
131    created_at_sec: u32,
132    hash_hex: String,
133    tags: Vec<String>,
134    source_type: Option<String>,
135    namespace: Option<String>,
136    verification_status: Option<String>,
137}
138
139fn extract_metadata(grain: &DeserializedGrain, level: MetadataLevel) -> Option<MetadataFragment> {
140    match level {
141        MetadataLevel::None => None,
142        MetadataLevel::Minimal | MetadataLevel::Full => {
143            let confidence = grain.get_f64("confidence");
144            let created_at_sec = grain.header.created_at_sec;
145            let hash_hex = grain.hash.to_hex();
146
147            let (tags, source_type, namespace, verification_status) =
148                if level == MetadataLevel::Full {
149                    let tags = grain
150                        .fields
151                        .get("structural_tags")
152                        .or_else(|| grain.fields.get("tags"))
153                        .and_then(|v| v.as_array())
154                        .map(|arr| {
155                            arr.iter()
156                                .filter_map(|v| v.as_str().map(String::from))
157                                .collect()
158                        })
159                        .unwrap_or_default();
160                    (
161                        tags,
162                        grain.get_str("source_type").map(String::from),
163                        grain.get_str("namespace").map(String::from),
164                        grain.get_str("verification_status").map(String::from),
165                    )
166                } else {
167                    (Vec::new(), None, None, None)
168                };
169
170            Some(MetadataFragment {
171                confidence,
172                created_at_sec,
173                hash_hex,
174                tags,
175                source_type,
176                namespace,
177                verification_status,
178            })
179        }
180    }
181}
182
183/// Format epoch seconds as "YYYY-MM-DD".
184fn format_date(epoch_sec: u32) -> String {
185    let secs = epoch_sec as i64;
186    // Days since epoch
187    let days = secs / 86400;
188    // Compute year/month/day from days since 1970-01-01
189    let (y, m, d) = days_to_ymd(days);
190    format!("{y:04}-{m:02}-{d:02}")
191}
192
193/// Convert days since epoch to (year, month, day).
194fn days_to_ymd(mut days: i64) -> (i64, u32, u32) {
195    // Civil calendar algorithm (Howard Hinnant)
196    days += 719468;
197    let era = if days >= 0 { days } else { days - 146096 } / 146097;
198    let doe = (days - era * 146097) as u32;
199    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
200    let y = yoe as i64 + era * 400;
201    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
202    let mp = (5 * doy + 2) / 153;
203    let d = doy - (153 * mp + 2) / 5 + 1;
204    let m = if mp < 10 { mp + 3 } else { mp - 9 };
205    let y = if m <= 2 { y + 1 } else { y };
206    (y, m, d)
207}
208
209fn format_meta_sml_attrs(meta: &MetadataFragment, level: MetadataLevel) -> String {
210    let mut attrs = Vec::new();
211    if let Some(c) = meta.confidence {
212        attrs.push(format!("confidence=\"{c:.2}\""));
213    }
214    if meta.created_at_sec > 0 {
215        attrs.push(format!("date=\"{}\"", format_date(meta.created_at_sec)));
216    }
217    if level == MetadataLevel::Full {
218        attrs.push(format!("hash=\"{}\"", &meta.hash_hex[..16]));
219        if let Some(ref ns) = meta.namespace {
220            attrs.push(format!("namespace=\"{}\"", sml_escape(ns)));
221        }
222        if let Some(ref vs) = meta.verification_status {
223            attrs.push(format!("status=\"{}\"", sml_escape(vs)));
224        }
225        if !meta.tags.is_empty() {
226            attrs.push(format!("tags=\"{}\"", sml_escape(&meta.tags.join(","))));
227        }
228    }
229    if attrs.is_empty() {
230        String::new()
231    } else {
232        format!(" {}", attrs.join(" "))
233    }
234}
235
236fn format_meta_markdown(meta: &MetadataFragment, level: MetadataLevel) -> String {
237    let mut parts = Vec::new();
238    if let Some(c) = meta.confidence {
239        parts.push(format!("{c:.2}"));
240    }
241    if meta.created_at_sec > 0 {
242        parts.push(format_date(meta.created_at_sec));
243    }
244    if level == MetadataLevel::Full {
245        if let Some(ref vs) = meta.verification_status {
246            parts.push(vs.clone());
247        }
248    }
249    if parts.is_empty() {
250        String::new()
251    } else {
252        format!(" *({})*", parts.join(", "))
253    }
254}
255
256fn format_meta_plain(meta: &MetadataFragment, level: MetadataLevel) -> String {
257    let mut parts = Vec::new();
258    if let Some(c) = meta.confidence {
259        parts.push(format!("{c:.2}"));
260    }
261    if meta.created_at_sec > 0 {
262        parts.push(format_date(meta.created_at_sec));
263    }
264    if level == MetadataLevel::Full {
265        if let Some(ref vs) = meta.verification_status {
266            parts.push(vs.clone());
267        }
268        if let Some(ref ns) = meta.namespace {
269            parts.push(ns.clone());
270        }
271    }
272    if parts.is_empty() {
273        String::new()
274    } else {
275        format!(" [{}]", parts.join(", "))
276    }
277}
278
279/// Escape XML metacharacters (`&`, `<`, `>`, `"`, `'`) for SML output.
280/// Bidi-control codepoints (U+202A–U+202E, U+2066–U+2069, U+200E/F, ZWJ, BOM)
281/// are intentionally passed through — stripping is the LLM-output-layer's
282/// responsibility and is out of scope for the SML escape helper.
283fn sml_escape(s: &str) -> String {
284    s.replace('&', "&amp;")
285        .replace('<', "&lt;")
286        .replace('>', "&gt;")
287        .replace('"', "&quot;")
288        .replace('\'', "&apos;")
289}
290
291/// Render a `content_blocks` JSON array (Event grain chat extension,
292/// OMS 1.2 §8.2) as typed SML tags — `<text>`, `<tool_use>`, and
293/// `<tool_result>`. Never renders block payloads as free text (security
294/// SC1 — prevents injection via tool arguments). Falls back to the
295/// plain Event `content` string when the value is malformed.
296fn render_content_blocks_sml(blocks: &serde_json::Value, fallback: &str) -> String {
297    let Some(arr) = blocks.as_array() else {
298        return sml_escape(fallback);
299    };
300    let mut out = String::new();
301    for block in arr {
302        let Some(obj) = block.as_object() else {
303            continue;
304        };
305        let kind = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
306        match kind {
307            "text" => {
308                let text = obj.get("text").and_then(|v| v.as_str()).unwrap_or("");
309                out.push_str(&format!("<text>{}</text>", sml_escape(text)));
310            }
311            "tool_use" => {
312                let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
313                let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("");
314                let input_json = obj
315                    .get("input")
316                    .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
317                    .unwrap_or_else(|| "{}".into());
318                out.push_str(&format!(
319                    "<tool_use name=\"{}\" id=\"{}\"><args format=\"json\">{}</args></tool_use>",
320                    sml_escape(name),
321                    sml_escape(id),
322                    sml_escape(&input_json)
323                ));
324            }
325            "tool_result" => {
326                let tool_use_id = obj
327                    .get("tool_use_id")
328                    .and_then(|v| v.as_str())
329                    .unwrap_or("");
330                let content = obj.get("content").and_then(|v| v.as_str()).unwrap_or("");
331                let is_error = obj
332                    .get("is_error")
333                    .and_then(|v| v.as_bool())
334                    .unwrap_or(false);
335                out.push_str(&format!(
336                    "<tool_result tool_use_id=\"{}\" is_error=\"{}\">{}</tool_result>",
337                    sml_escape(tool_use_id),
338                    is_error,
339                    sml_escape(content)
340                ));
341            }
342            _ => {}
343        }
344    }
345    if out.is_empty() {
346        sml_escape(fallback)
347    } else {
348        out
349    }
350}
351
352/// Escape/quote a TOON value per TOON spec Section 7.2.
353/// Values are quoted only when necessary (contains special chars, matches keywords, etc.).
354fn toon_escape(s: &str) -> String {
355    if s.is_empty() {
356        return "\"\"".to_string();
357    }
358    let needs_quoting = s != s.trim()
359        || matches!(s, "true" | "false" | "null")
360        || toon_looks_numeric(s)
361        || s.contains(':')
362        || s.contains('"')
363        || s.contains('\\')
364        || s.contains('[')
365        || s.contains(']')
366        || s.contains('{')
367        || s.contains('}')
368        || s.contains(',')
369        || s.contains('\n')
370        || s.contains('\r')
371        || s.contains('\t')
372        || s.starts_with('-');
373    if needs_quoting {
374        let escaped = s
375            .replace('\\', "\\\\")
376            .replace('"', "\\\"")
377            .replace('\n', "\\n")
378            .replace('\r', "\\r")
379            .replace('\t', "\\t");
380        format!("\"{}\"", escaped)
381    } else {
382        s.to_string()
383    }
384}
385
386/// Check if a string looks like a TOON numeric literal.
387fn toon_looks_numeric(s: &str) -> bool {
388    let s = s.strip_prefix('-').unwrap_or(s);
389    if s.is_empty() {
390        return false;
391    }
392    // Leading zeros like "05" are treated as strings in TOON but still need quoting
393    if s.len() > 1
394        && s.starts_with('0')
395        && !s.starts_with("0.")
396        && !s.starts_with("0e")
397        && !s.starts_with("0E")
398    {
399        return true;
400    }
401    s.parse::<f64>().is_ok()
402        && s.chars()
403            .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
404}
405
406/// TOON column names per grain type, per CAL spec Section 10.9.3.
407///
408/// Reads from the grain-type metadata registry (D1) — the single source of
409/// truth for the byte ↔ string ↔ plural mapping plus the per-type TOON column
410/// set — so this is not a third hand-maintained copy of the same data.
411pub fn toon_columns(gt: &GrainType) -> &'static [&'static str] {
412    dejadb_core::types::registry::meta(*gt).toon_columns
413}
414
415/// Canonicalize a number for TOON output.
416/// Per TOON spec: no exponent notation, no trailing fractional zeros, NaN/Infinity → "null".
417fn toon_canonicalize_number(n: f64) -> String {
418    if n.is_nan() || n.is_infinite() {
419        return "null".to_string();
420    }
421    // Rust Display for f64 already gives shortest representation without exponent
422    format!("{}", n)
423}
424
425/// Truncate a string to max_chars at a valid UTF-8 char boundary.
426fn truncate(s: &str, max_chars: usize) -> &str {
427    if s.len() <= max_chars {
428        s
429    } else {
430        let mut end = max_chars.saturating_sub(3).min(s.len());
431        // Walk backwards to find a valid UTF-8 char boundary
432        while end > 0 && !s.is_char_boundary(end) {
433            end -= 1;
434        }
435        &s[..end]
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Fact renderer
441// ---------------------------------------------------------------------------
442
443struct FactRenderer;
444
445impl GrainRenderer for FactRenderer {
446    fn grain_type(&self) -> GrainType {
447        GrainType::Fact
448    }
449
450    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
451        let subject = grain.get_str("subject").unwrap_or("?");
452        let relation = grain.get_str("relation").unwrap_or("?");
453        let object = grain.get_str("object").unwrap_or("?");
454        let meta = extract_metadata(grain, policy.metadata);
455
456        match &policy.format {
457            OutputFormat::Sml => {
458                let attrs = meta
459                    .as_ref()
460                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
461                    .unwrap_or_default();
462                format!(
463                    "<fact{attrs}>{} {} {}</fact>",
464                    sml_escape(subject),
465                    sml_escape(relation),
466                    sml_escape(object)
467                )
468            }
469            OutputFormat::Markdown => {
470                let suffix = meta
471                    .as_ref()
472                    .map(|m| format_meta_markdown(m, policy.metadata))
473                    .unwrap_or_default();
474                format!("**{subject}** {relation} {object}{suffix}")
475            }
476            OutputFormat::PlainText => {
477                let suffix = meta
478                    .as_ref()
479                    .map(|m| format_meta_plain(m, policy.metadata))
480                    .unwrap_or_default();
481                format!("{subject} {relation} {object}{suffix}")
482            }
483            OutputFormat::Json => {
484                let mut obj = serde_json::json!({
485                    "type": "fact",
486                    "subject": subject,
487                    "relation": relation,
488                    "object": object,
489                });
490                if let Some(ref m) = meta {
491                    add_json_metadata(&mut obj, m, policy.metadata);
492                }
493                obj.to_string()
494            }
495            OutputFormat::Toon => {
496                // CAL spec Section 10.9.3: CSV row — subject, content, confidence
497                let content = if !relation.is_empty() && !object.is_empty() {
498                    format!("{relation} {object}")
499                } else if !object.is_empty() {
500                    object.to_string()
501                } else {
502                    relation.to_string()
503                };
504                let confidence_str = grain
505                    .get_f64("confidence")
506                    .map(toon_canonicalize_number)
507                    .unwrap_or_default();
508                format!(
509                    "{},{},{}",
510                    toon_escape(subject),
511                    toon_escape(&content),
512                    confidence_str
513                )
514            }
515        }
516    }
517
518    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
519        let s = grain.get_str("subject").unwrap_or("?");
520        let r = grain.get_str("relation").unwrap_or("?");
521        let o = grain.get_str("object").unwrap_or("?");
522        format!("{s} {r} {o}")
523    }
524
525    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
526        adjusted_priority(0.7, grain, hit)
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Event renderer
532// ---------------------------------------------------------------------------
533
534struct EventRenderer;
535
536impl GrainRenderer for EventRenderer {
537    fn grain_type(&self) -> GrainType {
538        GrainType::Event
539    }
540
541    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
542        let content = grain.get_str("content").unwrap_or("");
543        let display = truncate(content, 500);
544        let meta = extract_metadata(grain, policy.metadata);
545
546        match &policy.format {
547            OutputFormat::Sml => {
548                let attrs = meta
549                    .as_ref()
550                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
551                    .unwrap_or_default();
552                if let Some(blocks_json) = grain.fields.get("content_blocks") {
553                    let inner = render_content_blocks_sml(blocks_json, display);
554                    format!("<event{attrs}>{inner}</event>")
555                } else {
556                    format!("<event{attrs}>{}</event>", sml_escape(display))
557                }
558            }
559            OutputFormat::Markdown => {
560                let suffix = meta
561                    .as_ref()
562                    .map(|m| format_meta_markdown(m, policy.metadata))
563                    .unwrap_or_default();
564                format!("{display}{suffix}")
565            }
566            OutputFormat::PlainText => {
567                let suffix = meta
568                    .as_ref()
569                    .map(|m| format_meta_plain(m, policy.metadata))
570                    .unwrap_or_default();
571                format!("{display}{suffix}")
572            }
573            OutputFormat::Json => {
574                let mut obj = serde_json::json!({
575                    "type": "event",
576                    "content": display,
577                });
578                if let Some(ref m) = meta {
579                    add_json_metadata(&mut obj, m, policy.metadata);
580                }
581                obj.to_string()
582            }
583            OutputFormat::Toon => {
584                // CAL spec Section 10.9.3: CSV row — role, time, content
585                let role = grain
586                    .get_str("role")
587                    .or_else(|| grain.get_str("speaker"))
588                    .unwrap_or("user");
589                let time = if grain.header.created_at_sec > 0 {
590                    format_date(grain.header.created_at_sec)
591                } else {
592                    String::new()
593                };
594                format!(
595                    "{},{},{}",
596                    toon_escape(role),
597                    toon_escape(&time),
598                    toon_escape(display)
599                )
600            }
601        }
602    }
603
604    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
605        let content = grain.get_str("content").unwrap_or("");
606        truncate(content, 80).to_string()
607    }
608
609    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
610        adjusted_priority(0.6, grain, hit)
611    }
612}
613
614// ---------------------------------------------------------------------------
615// State renderer
616// ---------------------------------------------------------------------------
617
618struct StateRenderer;
619
620impl GrainRenderer for StateRenderer {
621    fn grain_type(&self) -> GrainType {
622        GrainType::State
623    }
624
625    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
626        let label = state_label(grain);
627        let meta = extract_metadata(grain, policy.metadata);
628
629        match &policy.format {
630            OutputFormat::Sml => {
631                let attrs = meta
632                    .as_ref()
633                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
634                    .unwrap_or_default();
635                format!("<state{attrs}>{}</state>", sml_escape(&label))
636            }
637            OutputFormat::Markdown => {
638                let suffix = meta
639                    .as_ref()
640                    .map(|m| format_meta_markdown(m, policy.metadata))
641                    .unwrap_or_default();
642                format!("**State**: {label}{suffix}")
643            }
644            OutputFormat::PlainText => {
645                let suffix = meta
646                    .as_ref()
647                    .map(|m| format_meta_plain(m, policy.metadata))
648                    .unwrap_or_default();
649                format!("State: {label}{suffix}")
650            }
651            OutputFormat::Json => {
652                let mut obj = serde_json::json!({
653                    "type": "state",
654                    "label": label,
655                });
656                // Include context_data if present
657                if let Some(ctx) = grain.fields.get("context_data") {
658                    obj["context_data"] = ctx.clone();
659                }
660                if let Some(ref m) = meta {
661                    add_json_metadata(&mut obj, m, policy.metadata);
662                }
663                obj.to_string()
664            }
665            OutputFormat::Toon => {
666                // CAL spec Section 10.9.3: CSV row — context, content
667                let content_summary = if let Some(ctx) = grain.fields.get("context_data") {
668                    if let Some(obj) = ctx.as_object() {
669                        let summary: Vec<String> = obj
670                            .iter()
671                            .filter(|(k, _)| {
672                                !matches!(k.as_str(), "label" | "description" | "title" | "name")
673                            })
674                            .take(3)
675                            .map(|(k, v)| {
676                                let val = match v {
677                                    serde_json::Value::String(s) => s.clone(),
678                                    _ => v.to_string(),
679                                };
680                                format!("{k}={val}")
681                            })
682                            .collect();
683                        if summary.is_empty() {
684                            label.clone()
685                        } else {
686                            summary.join("; ")
687                        }
688                    } else {
689                        label.clone()
690                    }
691                } else {
692                    label.clone()
693                };
694                format!("{},{}", toon_escape(&label), toon_escape(&content_summary))
695            }
696        }
697    }
698
699    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
700        state_label(grain)
701    }
702
703    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
704        adjusted_priority(0.9, grain, hit)
705    }
706}
707
708fn state_label(grain: &DeserializedGrain) -> String {
709    // Search context_data for label/description/title/name
710    if let Some(ctx) = grain.fields.get("context_data") {
711        for key in &["label", "description", "title", "name"] {
712            if let Some(s) = ctx.get(key).and_then(|v| v.as_str()) {
713                if !s.is_empty() {
714                    return s.to_string();
715                }
716            }
717        }
718        // Count keys
719        if let Some(obj) = ctx.as_object() {
720            return format!("{} keys", obj.len());
721        }
722    }
723    "state".to_string()
724}
725
726// ---------------------------------------------------------------------------
727// Workflow renderer
728// ---------------------------------------------------------------------------
729
730struct WorkflowRenderer;
731
732impl GrainRenderer for WorkflowRenderer {
733    fn grain_type(&self) -> GrainType {
734        GrainType::Workflow
735    }
736
737    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
738        let trigger = grain.get_str("trigger").unwrap_or("workflow");
739        let node_count = grain
740            .fields
741            .get("nodes")
742            .and_then(|v| v.as_array())
743            .map(|a| a.len())
744            .unwrap_or(0);
745        let edge_count = grain
746            .fields
747            .get("edges")
748            .and_then(|v| v.as_array())
749            .map(|a| a.len())
750            .unwrap_or(0);
751        let meta = extract_metadata(grain, policy.metadata);
752
753        match &policy.format {
754            OutputFormat::Sml => {
755                let attrs = meta
756                    .as_ref()
757                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
758                    .unwrap_or_default();
759                format!(
760                    "<workflow nodes=\"{node_count}\" edges=\"{edge_count}\"{attrs}>{}</workflow>",
761                    sml_escape(trigger)
762                )
763            }
764            OutputFormat::Markdown => {
765                let suffix = meta
766                    .as_ref()
767                    .map(|m| format_meta_markdown(m, policy.metadata))
768                    .unwrap_or_default();
769                format!("{trigger} ({node_count} nodes, {edge_count} edges){suffix}")
770            }
771            OutputFormat::PlainText => {
772                let suffix = meta
773                    .as_ref()
774                    .map(|m| format_meta_plain(m, policy.metadata))
775                    .unwrap_or_default();
776                format!("{trigger} ({node_count} nodes, {edge_count} edges){suffix}")
777            }
778            OutputFormat::Json => {
779                let mut obj = serde_json::json!({
780                    "type": "workflow",
781                    "trigger": trigger,
782                    "node_count": node_count,
783                    "edge_count": edge_count,
784                });
785                if let Some(ref m) = meta {
786                    add_json_metadata(&mut obj, m, policy.metadata);
787                }
788                obj.to_string()
789            }
790            OutputFormat::Toon => {
791                // CAL spec Section 10.9.3: CSV row — trigger, content
792                let content = format!("{node_count} nodes, {edge_count} edges");
793                format!("{},{}", toon_escape(trigger), toon_escape(&content))
794            }
795        }
796    }
797
798    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
799        let trigger = grain.get_str("trigger").unwrap_or("workflow");
800        let node_count = grain
801            .fields
802            .get("nodes")
803            .and_then(|v| v.as_array())
804            .map(|a| a.len())
805            .unwrap_or(0);
806        let edge_count = grain
807            .fields
808            .get("edges")
809            .and_then(|v| v.as_array())
810            .map(|a| a.len())
811            .unwrap_or(0);
812        format!("{trigger} ({node_count} nodes, {edge_count} edges)")
813    }
814
815    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
816        adjusted_priority(0.5, grain, hit)
817    }
818}
819
820// ---------------------------------------------------------------------------
821// Tool renderer
822// ---------------------------------------------------------------------------
823
824struct ToolRenderer;
825
826impl GrainRenderer for ToolRenderer {
827    fn grain_type(&self) -> GrainType {
828        GrainType::Tool
829    }
830
831    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
832        let tool = grain.get_str("tool_name").unwrap_or("unknown");
833        let is_error = grain.get_bool("is_error").unwrap_or(false);
834        let status = if is_error { "FAIL" } else { "OK" };
835        let content = grain
836            .get_str("tool_content")
837            .or_else(|| grain.get_str("content"))
838            .unwrap_or("");
839        let error_msg = grain.get_str("error");
840        let duration = grain.get_u64("duration_ms");
841        let meta = extract_metadata(grain, policy.metadata);
842
843        let dur_str = duration.map(|d| format!(" ({d}ms)")).unwrap_or_default();
844        let result_str = if is_error {
845            error_msg.unwrap_or(content)
846        } else {
847            content
848        };
849
850        match &policy.format {
851            OutputFormat::Sml => {
852                let attrs = meta
853                    .as_ref()
854                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
855                    .unwrap_or_default();
856                let mut sml = format!(
857                    "<tool tool=\"{}\" status=\"{}\"{attrs}>",
858                    sml_escape(tool),
859                    status.to_lowercase()
860                );
861                if !result_str.is_empty() {
862                    sml.push_str(&sml_escape(truncate(result_str, 300)));
863                }
864                if let Some(d) = duration {
865                    sml.push_str(&format!(" ({d}ms)"));
866                }
867                sml.push_str("</tool>");
868                sml
869            }
870            OutputFormat::Markdown => {
871                let suffix = meta
872                    .as_ref()
873                    .map(|m| format_meta_markdown(m, policy.metadata))
874                    .unwrap_or_default();
875                let result_part = if !result_str.is_empty() {
876                    format!(": {}", truncate(result_str, 200))
877                } else {
878                    String::new()
879                };
880                format!("`{tool}` [{status}]{dur_str}{result_part}{suffix}")
881            }
882            OutputFormat::PlainText => {
883                let suffix = meta
884                    .as_ref()
885                    .map(|m| format_meta_plain(m, policy.metadata))
886                    .unwrap_or_default();
887                let result_part = if !result_str.is_empty() {
888                    format!(": {}", truncate(result_str, 200))
889                } else {
890                    String::new()
891                };
892                format!("{tool} [{status}]{dur_str}{result_part}{suffix}")
893            }
894            OutputFormat::Json => {
895                let mut obj = serde_json::json!({
896                    "type": "tool",
897                    "tool_name": tool,
898                    "status": status.to_lowercase(),
899                });
900                if !result_str.is_empty() {
901                    obj["content"] = serde_json::Value::String(result_str.to_string());
902                }
903                if let Some(d) = duration {
904                    obj["duration_ms"] = serde_json::json!(d);
905                }
906                if let Some(e) = error_msg {
907                    obj["error"] = serde_json::Value::String(e.to_string());
908                }
909                if let Some(ref m) = meta {
910                    add_json_metadata(&mut obj, m, policy.metadata);
911                }
912                obj.to_string()
913            }
914            OutputFormat::Toon => {
915                // CAL spec Section 10.9.3: CSV row — tool, phase, content
916                let phase = status.to_lowercase();
917                let content_val = if !result_str.is_empty() {
918                    truncate(result_str, 300)
919                } else {
920                    ""
921                };
922                format!(
923                    "{},{},{}",
924                    toon_escape(tool),
925                    toon_escape(&phase),
926                    toon_escape(content_val)
927                )
928            }
929        }
930    }
931
932    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
933        let tool = grain.get_str("tool_name").unwrap_or("unknown");
934        let is_error = grain.get_bool("is_error").unwrap_or(false);
935        let status = if is_error { "FAIL" } else { "OK" };
936        format!("{tool} [{status}]")
937    }
938
939    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
940        let is_error = grain.get_bool("is_error").unwrap_or(false);
941        let error_boost = if is_error { 0.15 } else { 0.0 };
942        adjusted_priority(0.5 + error_boost, grain, hit)
943    }
944}
945
946// ---------------------------------------------------------------------------
947// Observation renderer
948// ---------------------------------------------------------------------------
949
950struct ObservationRenderer;
951
952impl GrainRenderer for ObservationRenderer {
953    fn grain_type(&self) -> GrainType {
954        GrainType::Observation
955    }
956
957    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
958        let observer = grain.get_str("observer_id").unwrap_or("?");
959        let subject = grain.get_str("subject").unwrap_or("");
960        let object = grain.get_str("object").unwrap_or("");
961        let meta = extract_metadata(grain, policy.metadata);
962
963        let content = if !subject.is_empty() && !object.is_empty() {
964            format!("{subject}: {object}")
965        } else if !subject.is_empty() {
966            subject.to_string()
967        } else if !object.is_empty() {
968            object.to_string()
969        } else {
970            String::new()
971        };
972
973        match &policy.format {
974            OutputFormat::Sml => {
975                let attrs = meta
976                    .as_ref()
977                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
978                    .unwrap_or_default();
979                format!(
980                    "<observation observer=\"{}\"{attrs}>{}</observation>",
981                    sml_escape(observer),
982                    sml_escape(&content)
983                )
984            }
985            OutputFormat::Markdown => {
986                let suffix = meta
987                    .as_ref()
988                    .map(|m| format_meta_markdown(m, policy.metadata))
989                    .unwrap_or_default();
990                if content.is_empty() {
991                    format!("*{observer}*{suffix}")
992                } else {
993                    format!("*{observer}*: {content}{suffix}")
994                }
995            }
996            OutputFormat::PlainText => {
997                let suffix = meta
998                    .as_ref()
999                    .map(|m| format_meta_plain(m, policy.metadata))
1000                    .unwrap_or_default();
1001                if content.is_empty() {
1002                    format!("{observer}{suffix}")
1003                } else {
1004                    format!("{observer}: {content}{suffix}")
1005                }
1006            }
1007            OutputFormat::Json => {
1008                let mut obj = serde_json::json!({
1009                    "type": "observation",
1010                    "observer_id": observer,
1011                });
1012                if !subject.is_empty() {
1013                    obj["subject"] = serde_json::Value::String(subject.to_string());
1014                }
1015                if !object.is_empty() {
1016                    obj["object"] = serde_json::Value::String(object.to_string());
1017                }
1018                if let Some(ref m) = meta {
1019                    add_json_metadata(&mut obj, m, policy.metadata);
1020                }
1021                obj.to_string()
1022            }
1023            OutputFormat::Toon => {
1024                // CAL spec Section 10.9.3: CSV row — observer, content
1025                format!("{},{}", toon_escape(observer), toon_escape(&content))
1026            }
1027        }
1028    }
1029
1030    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1031        let observer = grain.get_str("observer_id").unwrap_or("?");
1032        let subject = grain.get_str("subject").unwrap_or("");
1033        if subject.is_empty() {
1034            observer.to_string()
1035        } else {
1036            format!("{observer}: {subject}")
1037        }
1038    }
1039
1040    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1041        adjusted_priority(0.6, grain, hit)
1042    }
1043}
1044
1045// ---------------------------------------------------------------------------
1046// Goal renderer
1047// ---------------------------------------------------------------------------
1048
1049struct GoalRenderer;
1050
1051impl GrainRenderer for GoalRenderer {
1052    fn grain_type(&self) -> GrainType {
1053        GrainType::Goal
1054    }
1055
1056    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1057        let desc = grain.get_str("description").unwrap_or("?");
1058        let state = grain.get_str("goal_state").unwrap_or("active");
1059        let priority = grain.get_str("priority").unwrap_or("medium");
1060        let progress = grain.get_f64("progress");
1061        let criteria = grain.get_str("criteria");
1062        let meta = extract_metadata(grain, policy.metadata);
1063
1064        let progress_str = progress
1065            .map(|p| format!(" ({:.0}%)", p * 100.0))
1066            .unwrap_or_default();
1067
1068        match &policy.format {
1069            OutputFormat::Sml => {
1070                let attrs = meta
1071                    .as_ref()
1072                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
1073                    .unwrap_or_default();
1074                let mut sml = format!(
1075                    "<goal state=\"{}\" priority=\"{}\"{attrs}>{}</goal>",
1076                    sml_escape(state),
1077                    sml_escape(priority),
1078                    sml_escape(desc)
1079                );
1080                if let Some(c) = criteria {
1081                    // Insert criteria before closing tag
1082                    sml = sml.replace(
1083                        "</goal>",
1084                        &format!("<criteria>{}</criteria></goal>", sml_escape(c)),
1085                    );
1086                }
1087                sml
1088            }
1089            OutputFormat::Markdown => {
1090                let suffix = meta
1091                    .as_ref()
1092                    .map(|m| format_meta_markdown(m, policy.metadata))
1093                    .unwrap_or_default();
1094                format!("[{priority}/{state}] {desc}{progress_str}{suffix}")
1095            }
1096            OutputFormat::PlainText => {
1097                let suffix = meta
1098                    .as_ref()
1099                    .map(|m| format_meta_plain(m, policy.metadata))
1100                    .unwrap_or_default();
1101                format!("[{priority}/{state}] {desc}{progress_str}{suffix}")
1102            }
1103            OutputFormat::Json => {
1104                let mut obj = serde_json::json!({
1105                    "type": "goal",
1106                    "description": desc,
1107                    "goal_state": state,
1108                    "priority": priority,
1109                });
1110                if let Some(p) = progress {
1111                    obj["progress"] = serde_json::json!(p);
1112                }
1113                if let Some(c) = criteria {
1114                    obj["criteria"] = serde_json::Value::String(c.to_string());
1115                }
1116                if let Some(ref m) = meta {
1117                    add_json_metadata(&mut obj, m, policy.metadata);
1118                }
1119                obj.to_string()
1120            }
1121            OutputFormat::Toon => {
1122                // CAL spec Section 10.9.3: CSV row — subject, content, state
1123                let subject = grain.get_str("subject").unwrap_or(desc);
1124                format!(
1125                    "{},{},{}",
1126                    toon_escape(subject),
1127                    toon_escape(desc),
1128                    toon_escape(state)
1129                )
1130            }
1131        }
1132    }
1133
1134    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1135        let desc = grain.get_str("description").unwrap_or("?");
1136        let state = grain.get_str("goal_state").unwrap_or("active");
1137        let priority = grain.get_str("priority").unwrap_or("medium");
1138        format!("[{priority}/{state}] {desc}")
1139    }
1140
1141    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1142        let state_mod = match grain.get_str("goal_state") {
1143            Some("active") => 0.15,
1144            Some("suspended") => 0.05,
1145            Some("satisfied" | "failed") => -0.2,
1146            _ => 0.0,
1147        };
1148        let priority_mod = match grain.get_str("priority") {
1149            Some("critical") => 0.1,
1150            Some("high") => 0.05,
1151            Some("low") => -0.1,
1152            _ => 0.0,
1153        };
1154        adjusted_priority(0.8 + state_mod + priority_mod, grain, hit)
1155    }
1156}
1157
1158// ---------------------------------------------------------------------------
1159// Reasoning renderer
1160// ---------------------------------------------------------------------------
1161
1162struct ReasoningRenderer;
1163
1164impl GrainRenderer for ReasoningRenderer {
1165    fn grain_type(&self) -> GrainType {
1166        GrainType::Reasoning
1167    }
1168
1169    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1170        let conclusion = grain.get_str("conclusion").unwrap_or("");
1171        let method = grain.get_str("inference_method");
1172        let premise_count = grain
1173            .fields
1174            .get("premises")
1175            .and_then(|v| v.as_array())
1176            .map(|a| a.len())
1177            .unwrap_or(0);
1178        let meta = extract_metadata(grain, policy.metadata);
1179
1180        let method_str = method.map(|m| format!(" ({m})")).unwrap_or_default();
1181
1182        match &policy.format {
1183            OutputFormat::Sml => {
1184                let attrs = meta
1185                    .as_ref()
1186                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
1187                    .unwrap_or_default();
1188                let mut sml = format!("<reasoning{attrs}>");
1189                if !conclusion.is_empty() {
1190                    sml.push_str(&format!(
1191                        "<conclusion>{}</conclusion>",
1192                        sml_escape(conclusion)
1193                    ));
1194                }
1195                if premise_count > 0 {
1196                    sml.push_str(&format!("<premises count=\"{premise_count}\"/>"));
1197                }
1198                sml.push_str("</reasoning>");
1199                sml
1200            }
1201            OutputFormat::Markdown => {
1202                let suffix = meta
1203                    .as_ref()
1204                    .map(|m| format_meta_markdown(m, policy.metadata))
1205                    .unwrap_or_default();
1206                if conclusion.is_empty() {
1207                    format!("Reasoning{method_str} ({premise_count} premises){suffix}")
1208                } else {
1209                    format!("{conclusion}{method_str}{suffix}")
1210                }
1211            }
1212            OutputFormat::PlainText => {
1213                let suffix = meta
1214                    .as_ref()
1215                    .map(|m| format_meta_plain(m, policy.metadata))
1216                    .unwrap_or_default();
1217                if conclusion.is_empty() {
1218                    format!("Reasoning{method_str} ({premise_count} premises){suffix}")
1219                } else {
1220                    format!("{conclusion}{method_str}{suffix}")
1221                }
1222            }
1223            OutputFormat::Json => {
1224                let mut obj = serde_json::json!({
1225                    "type": "reasoning",
1226                    "premise_count": premise_count,
1227                });
1228                if !conclusion.is_empty() {
1229                    obj["conclusion"] = serde_json::Value::String(conclusion.to_string());
1230                }
1231                if let Some(m) = method {
1232                    obj["inference_method"] = serde_json::Value::String(m.to_string());
1233                }
1234                if let Some(ref m) = meta {
1235                    add_json_metadata(&mut obj, m, policy.metadata);
1236                }
1237                obj.to_string()
1238            }
1239            OutputFormat::Toon => {
1240                // CAL spec Section 10.9.3: CSV row — type, content
1241                let type_val = method.unwrap_or("reasoning");
1242                let content_val = if conclusion.is_empty() {
1243                    format!("{} premises", premise_count)
1244                } else {
1245                    conclusion.to_string()
1246                };
1247                format!("{},{}", toon_escape(type_val), toon_escape(&content_val))
1248            }
1249        }
1250    }
1251
1252    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1253        grain
1254            .get_str("conclusion")
1255            .unwrap_or("reasoning")
1256            .to_string()
1257    }
1258
1259    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1260        adjusted_priority(0.7, grain, hit)
1261    }
1262}
1263
1264// ---------------------------------------------------------------------------
1265// Consensus renderer
1266// ---------------------------------------------------------------------------
1267
1268struct ConsensusRenderer;
1269
1270impl GrainRenderer for ConsensusRenderer {
1271    fn grain_type(&self) -> GrainType {
1272        GrainType::Consensus
1273    }
1274
1275    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1276        let agreed = grain.get_str("agreed_content").unwrap_or("");
1277        let agree_count = grain.get_i64("agreement_count").unwrap_or(0);
1278        let dissent_count = grain.get_i64("dissent_count").unwrap_or(0);
1279        let meta = extract_metadata(grain, policy.metadata);
1280
1281        match &policy.format {
1282            OutputFormat::Sml => {
1283                let attrs = meta
1284                    .as_ref()
1285                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
1286                    .unwrap_or_default();
1287                format!(
1288                    "<consensus agreed=\"{agree_count}\" dissent=\"{dissent_count}\"{attrs}>{}</consensus>",
1289                    sml_escape(agreed)
1290                )
1291            }
1292            OutputFormat::Markdown => {
1293                let suffix = meta
1294                    .as_ref()
1295                    .map(|m| format_meta_markdown(m, policy.metadata))
1296                    .unwrap_or_default();
1297                let vote_str = if agree_count > 0 || dissent_count > 0 {
1298                    format!(" ({agree_count} agreed, {dissent_count} dissent)")
1299                } else {
1300                    String::new()
1301                };
1302                if agreed.is_empty() {
1303                    format!("Consensus{vote_str}{suffix}")
1304                } else {
1305                    format!("{agreed}{vote_str}{suffix}")
1306                }
1307            }
1308            OutputFormat::PlainText => {
1309                let suffix = meta
1310                    .as_ref()
1311                    .map(|m| format_meta_plain(m, policy.metadata))
1312                    .unwrap_or_default();
1313                let vote_str = if agree_count > 0 || dissent_count > 0 {
1314                    format!(" ({agree_count}/{dissent_count})")
1315                } else {
1316                    String::new()
1317                };
1318                if agreed.is_empty() {
1319                    format!("Consensus{vote_str}{suffix}")
1320                } else {
1321                    format!("{agreed}{vote_str}{suffix}")
1322                }
1323            }
1324            OutputFormat::Json => {
1325                let mut obj = serde_json::json!({
1326                    "type": "consensus",
1327                    "agreement_count": agree_count,
1328                    "dissent_count": dissent_count,
1329                });
1330                if !agreed.is_empty() {
1331                    obj["agreed_content"] = serde_json::Value::String(agreed.to_string());
1332                }
1333                if let Some(ref m) = meta {
1334                    add_json_metadata(&mut obj, m, policy.metadata);
1335                }
1336                obj.to_string()
1337            }
1338            OutputFormat::Toon => {
1339                // CAL spec Section 10.9.3: CSV row — threshold, count, content
1340                let threshold = grain.get_str("threshold").unwrap_or("-");
1341                format!(
1342                    "{},{},{}",
1343                    toon_escape(threshold),
1344                    agree_count,
1345                    toon_escape(agreed)
1346                )
1347            }
1348        }
1349    }
1350
1351    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1352        grain
1353            .get_str("agreed_content")
1354            .unwrap_or("consensus")
1355            .to_string()
1356    }
1357
1358    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1359        adjusted_priority(0.65, grain, hit)
1360    }
1361}
1362
1363// ---------------------------------------------------------------------------
1364// Consent renderer
1365// ---------------------------------------------------------------------------
1366
1367struct ConsentRenderer;
1368
1369impl GrainRenderer for ConsentRenderer {
1370    fn grain_type(&self) -> GrainType {
1371        GrainType::Consent
1372    }
1373
1374    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1375        let subject = grain.get_str("subject_did").unwrap_or("?");
1376        let is_withdrawal = grain.get_bool("is_withdrawal").unwrap_or(false);
1377        let action = if is_withdrawal { "withdraws" } else { "grants" };
1378        let scope = grain.get_str("scope").unwrap_or("");
1379        let grantee = grain.get_str("grantee_did");
1380        let meta = extract_metadata(grain, policy.metadata);
1381
1382        match &policy.format {
1383            OutputFormat::Sml => {
1384                let attrs = meta
1385                    .as_ref()
1386                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
1387                    .unwrap_or_default();
1388                let mut sml = format!(
1389                    "<consent action=\"{action}\" subject=\"{}\"{attrs}>",
1390                    sml_escape(subject)
1391                );
1392                if !scope.is_empty() {
1393                    sml.push_str(&format!("<scope>{}</scope>", sml_escape(scope)));
1394                }
1395                if let Some(g) = grantee {
1396                    sml.push_str(&format!("<grantee>{}</grantee>", sml_escape(g)));
1397                }
1398                sml.push_str("</consent>");
1399                sml
1400            }
1401            OutputFormat::Markdown => {
1402                let suffix = meta
1403                    .as_ref()
1404                    .map(|m| format_meta_markdown(m, policy.metadata))
1405                    .unwrap_or_default();
1406                let scope_part = if scope.is_empty() {
1407                    String::new()
1408                } else {
1409                    format!(" for {scope}")
1410                };
1411                format!("{subject} {action}{scope_part}{suffix}")
1412            }
1413            OutputFormat::PlainText => {
1414                let suffix = meta
1415                    .as_ref()
1416                    .map(|m| format_meta_plain(m, policy.metadata))
1417                    .unwrap_or_default();
1418                let scope_part = if scope.is_empty() {
1419                    String::new()
1420                } else {
1421                    format!(" for {scope}")
1422                };
1423                format!("{subject} {action}{scope_part}{suffix}")
1424            }
1425            OutputFormat::Json => {
1426                let mut obj = serde_json::json!({
1427                    "type": "consent",
1428                    "subject_did": subject,
1429                    "action": action,
1430                });
1431                if !scope.is_empty() {
1432                    obj["scope"] = serde_json::Value::String(scope.to_string());
1433                }
1434                if let Some(g) = grantee {
1435                    obj["grantee_did"] = serde_json::Value::String(g.to_string());
1436                }
1437                if let Some(ref m) = meta {
1438                    add_json_metadata(&mut obj, m, policy.metadata);
1439                }
1440                obj.to_string()
1441            }
1442            OutputFormat::Toon => {
1443                // CAL spec Section 10.9.3: CSV row — grantor, grantee, action, content
1444                let grantee_val = grantee.unwrap_or("-");
1445                format!(
1446                    "{},{},{},{}",
1447                    toon_escape(subject),
1448                    toon_escape(grantee_val),
1449                    toon_escape(action),
1450                    toon_escape(scope)
1451                )
1452            }
1453        }
1454    }
1455
1456    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1457        let subject = grain.get_str("subject_did").unwrap_or("?");
1458        let is_withdrawal = grain.get_bool("is_withdrawal").unwrap_or(false);
1459        let action = if is_withdrawal { "withdraws" } else { "grants" };
1460        let scope = grain.get_str("scope").unwrap_or("");
1461        if scope.is_empty() {
1462            format!("{subject} {action}")
1463        } else {
1464            format!("{subject} {action} {scope}")
1465        }
1466    }
1467
1468    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1469        // Consent grains get highest base priority — legally required context
1470        adjusted_priority(0.95, grain, hit)
1471    }
1472}
1473
1474// ---------------------------------------------------------------------------
1475// Skill renderer (OMS 1.4) — data projection only (name + description +
1476// domain + proficiency). `instructions`/`when_to_use` are deliberately NOT
1477// rendered raw (design §13 non-blocking note).
1478// ---------------------------------------------------------------------------
1479
1480struct SkillRenderer;
1481
1482impl GrainRenderer for SkillRenderer {
1483    fn grain_type(&self) -> GrainType {
1484        GrainType::Skill
1485    }
1486
1487    fn render(&self, grain: &DeserializedGrain, policy: &FormatPolicy) -> String {
1488        let name = grain.get_str("name").unwrap_or("?");
1489        let description = grain.get_str("description").unwrap_or("");
1490        let domain = grain.get_str("domain");
1491        // proficiency aliases confidence (D3) — present only on held instances.
1492        let proficiency = grain
1493            .get_f64("proficiency")
1494            .or_else(|| grain.get_f64("confidence"));
1495        let meta = extract_metadata(grain, policy.metadata);
1496
1497        match &policy.format {
1498            OutputFormat::Sml => {
1499                let attrs = meta
1500                    .as_ref()
1501                    .map(|m| format_meta_sml_attrs(m, policy.metadata))
1502                    .unwrap_or_default();
1503                let domain_attr = domain
1504                    .map(|d| format!(" domain=\"{}\"", sml_escape(d)))
1505                    .unwrap_or_default();
1506                let mut sml = format!("<skill name=\"{}\"{domain_attr}{attrs}>", sml_escape(name));
1507                if !description.is_empty() {
1508                    sml.push_str(&sml_escape(description));
1509                }
1510                sml.push_str("</skill>");
1511                sml
1512            }
1513            OutputFormat::Markdown => {
1514                let suffix = meta
1515                    .as_ref()
1516                    .map(|m| format_meta_markdown(m, policy.metadata))
1517                    .unwrap_or_default();
1518                let domain_part = domain.map(|d| format!(" [{d}]")).unwrap_or_default();
1519                format!("**{name}**: {description}{domain_part}{suffix}")
1520            }
1521            OutputFormat::PlainText => {
1522                let suffix = meta
1523                    .as_ref()
1524                    .map(|m| format_meta_plain(m, policy.metadata))
1525                    .unwrap_or_default();
1526                let domain_part = domain.map(|d| format!(" [{d}]")).unwrap_or_default();
1527                format!("{name}: {description}{domain_part}{suffix}")
1528            }
1529            OutputFormat::Json => {
1530                let mut obj = serde_json::json!({
1531                    "type": "skill",
1532                    "name": name,
1533                    "description": description,
1534                });
1535                if let Some(d) = domain {
1536                    obj["domain"] = serde_json::Value::String(d.to_string());
1537                }
1538                if let Some(p) = proficiency {
1539                    obj["proficiency"] = serde_json::json!(p);
1540                }
1541                if let Some(ref m) = meta {
1542                    add_json_metadata(&mut obj, m, policy.metadata);
1543                }
1544                obj.to_string()
1545            }
1546            OutputFormat::Toon => {
1547                // CAL spec Section 10.9.3: CSV row — name, domain, proficiency
1548                let prof_str = proficiency
1549                    .map(toon_canonicalize_number)
1550                    .unwrap_or_default();
1551                format!(
1552                    "{},{},{}",
1553                    toon_escape(name),
1554                    toon_escape(domain.unwrap_or("-")),
1555                    toon_escape(&prof_str)
1556                )
1557            }
1558        }
1559    }
1560
1561    fn render_summary(&self, grain: &DeserializedGrain, _policy: &FormatPolicy) -> String {
1562        let name = grain.get_str("name").unwrap_or("?");
1563        match grain.get_str("domain") {
1564            Some(d) => format!("{name} [{d}]"),
1565            None => name.to_string(),
1566        }
1567    }
1568
1569    fn context_priority(&self, grain: &DeserializedGrain, hit: &SearchHit) -> f32 {
1570        adjusted_priority(0.6, grain, hit)
1571    }
1572}
1573
1574// ---------------------------------------------------------------------------
1575// JSON metadata helper
1576// ---------------------------------------------------------------------------
1577
1578fn add_json_metadata(obj: &mut serde_json::Value, meta: &MetadataFragment, level: MetadataLevel) {
1579    if let Some(c) = meta.confidence {
1580        obj["confidence"] = serde_json::json!(c);
1581    }
1582    if meta.created_at_sec > 0 {
1583        obj["created_at"] = serde_json::Value::String(format_date(meta.created_at_sec));
1584    }
1585    if level == MetadataLevel::Full {
1586        obj["hash"] = serde_json::Value::String(meta.hash_hex[..16].to_string());
1587        if let Some(ref ns) = meta.namespace {
1588            obj["namespace"] = serde_json::Value::String(ns.clone());
1589        }
1590        if let Some(ref vs) = meta.verification_status {
1591            obj["verification_status"] = serde_json::Value::String(vs.clone());
1592        }
1593        if !meta.tags.is_empty() {
1594            obj["tags"] = serde_json::json!(meta.tags);
1595        }
1596        if let Some(ref st) = meta.source_type {
1597            obj["source_type"] = serde_json::Value::String(st.clone());
1598        }
1599    }
1600}
1601
1602// ---------------------------------------------------------------------------
1603// Tests
1604// ---------------------------------------------------------------------------
1605
1606#[cfg(test)]
1607mod tests {
1608    use super::*;
1609    use dejadb_core::format::deserialize::DeserializedGrain;
1610    use dejadb_core::format::header::MgHeader;
1611    use std::collections::HashMap;
1612
1613    /// Build a test grain with given type and fields.
1614    fn test_grain(gt: GrainType, fields: Vec<(&str, serde_json::Value)>) -> DeserializedGrain {
1615        let mut map = HashMap::new();
1616        for (k, v) in fields {
1617            map.insert(k.to_string(), v);
1618        }
1619        DeserializedGrain {
1620            header: MgHeader {
1621                version: 1,
1622                flags: 0,
1623                grain_type: gt.type_byte(),
1624                ns_hash: 0,
1625                created_at_sec: 1740000000, // 2025-02-19
1626            },
1627            grain_type: gt,
1628            fields: map,
1629            hash: dejadb_core::error::Hash::from_bytes(&[0u8; 32]),
1630        }
1631    }
1632
1633    fn test_hit(grain: DeserializedGrain) -> SearchHit {
1634        SearchHit {
1635            hash: grain.hash,
1636            score: 0.85,
1637            grain,
1638            score_breakdown: None,
1639            #[cfg(feature = "rerank")]
1640            rerank_score: None,
1641            #[cfg(feature = "llm-rerank")]
1642            llm_rerank_score: None,
1643            explanation: None,
1644            scope_depth: None,
1645            source_namespace: None,
1646            relative_time: None,
1647            conflict_status: None,
1648            supersession_status: None,
1649            superseded_by_hash: None,
1650            recall_source: None,
1651        }
1652    }
1653
1654    #[test]
1655    fn test_fact_sml() {
1656        let grain = test_grain(
1657            GrainType::Fact,
1658            vec![
1659                ("subject", serde_json::json!("john")),
1660                ("relation", serde_json::json!("likes")),
1661                ("object", serde_json::json!("coffee")),
1662                ("confidence", serde_json::json!(0.95)),
1663            ],
1664        );
1665        let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::Minimal);
1666        let registry = RendererRegistry::new();
1667        let renderer = registry.get(GrainType::Fact).unwrap();
1668        let result = renderer.render(&grain, &policy);
1669        assert!(result.starts_with("<fact"));
1670        assert!(result.contains("confidence=\"0.95\""));
1671        assert!(result.contains("john likes coffee"));
1672        assert!(result.ends_with("</fact>"));
1673    }
1674
1675    #[test]
1676    fn test_fact_markdown() {
1677        let grain = test_grain(
1678            GrainType::Fact,
1679            vec![
1680                ("subject", serde_json::json!("john")),
1681                ("relation", serde_json::json!("likes")),
1682                ("object", serde_json::json!("coffee")),
1683            ],
1684        );
1685        let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
1686        let registry = RendererRegistry::new();
1687        let result = registry
1688            .get(GrainType::Fact)
1689            .unwrap()
1690            .render(&grain, &policy);
1691        assert_eq!(result, "**john** likes coffee");
1692    }
1693
1694    #[test]
1695    fn test_fact_summary() {
1696        let grain = test_grain(
1697            GrainType::Fact,
1698            vec![
1699                ("subject", serde_json::json!("john")),
1700                ("relation", serde_json::json!("likes")),
1701                ("object", serde_json::json!("coffee")),
1702            ],
1703        );
1704        let policy = FormatPolicy::default();
1705        let registry = RendererRegistry::new();
1706        let result = registry
1707            .get(GrainType::Fact)
1708            .unwrap()
1709            .render_summary(&grain, &policy);
1710        assert_eq!(result, "john likes coffee");
1711    }
1712
1713    #[test]
1714    fn test_tool_sml() {
1715        let grain = test_grain(
1716            GrainType::Tool,
1717            vec![
1718                ("tool_name", serde_json::json!("search_api")),
1719                ("is_error", serde_json::json!(false)),
1720                ("tool_content", serde_json::json!("12 results")),
1721                ("duration_ms", serde_json::json!(340)),
1722            ],
1723        );
1724        let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
1725        let registry = RendererRegistry::new();
1726        let result = registry
1727            .get(GrainType::Tool)
1728            .unwrap()
1729            .render(&grain, &policy);
1730        assert!(result.contains("tool=\"search_api\""));
1731        assert!(result.contains("status=\"ok\""));
1732        assert!(result.contains("12 results"));
1733        assert!(result.contains("340ms"));
1734    }
1735
1736    #[test]
1737    fn test_goal_plaintext() {
1738        let grain = test_grain(
1739            GrainType::Goal,
1740            vec![
1741                ("description", serde_json::json!("Deploy v2")),
1742                ("goal_state", serde_json::json!("active")),
1743                ("priority", serde_json::json!("high")),
1744                ("progress", serde_json::json!(0.78)),
1745            ],
1746        );
1747        let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
1748        let registry = RendererRegistry::new();
1749        let result = registry
1750            .get(GrainType::Goal)
1751            .unwrap()
1752            .render(&grain, &policy);
1753        assert_eq!(result, "[high/active] Deploy v2 (78%)");
1754    }
1755
1756    #[test]
1757    fn test_consent_markdown() {
1758        let grain = test_grain(
1759            GrainType::Consent,
1760            vec![
1761                ("subject_did", serde_json::json!("did:john")),
1762                ("is_withdrawal", serde_json::json!(false)),
1763                ("scope", serde_json::json!("analytics")),
1764            ],
1765        );
1766        let policy = FormatPolicy::new(OutputFormat::Markdown).metadata(MetadataLevel::None);
1767        let registry = RendererRegistry::new();
1768        let result = registry
1769            .get(GrainType::Consent)
1770            .unwrap()
1771            .render(&grain, &policy);
1772        assert_eq!(result, "did:john grants for analytics");
1773    }
1774
1775    #[test]
1776    fn test_state_label_extraction() {
1777        let grain = test_grain(
1778            GrainType::State,
1779            vec![(
1780                "context_data",
1781                serde_json::json!({"label": "planning_phase", "data": {}}),
1782            )],
1783        );
1784        let policy = FormatPolicy::new(OutputFormat::PlainText).metadata(MetadataLevel::None);
1785        let registry = RendererRegistry::new();
1786        let result = registry
1787            .get(GrainType::State)
1788            .unwrap()
1789            .render(&grain, &policy);
1790        assert_eq!(result, "State: planning_phase");
1791    }
1792
1793    #[test]
1794    fn test_context_priority_consent_highest() {
1795        let grain = test_grain(
1796            GrainType::Consent,
1797            vec![("subject_did", serde_json::json!("did:x"))],
1798        );
1799        let hit = test_hit(grain.clone());
1800        let registry = RendererRegistry::new();
1801        let consent_pri = registry
1802            .get(GrainType::Consent)
1803            .unwrap()
1804            .context_priority(&grain, &hit);
1805
1806        let fact_grain = test_grain(GrainType::Fact, vec![("subject", serde_json::json!("x"))]);
1807        let fact_hit = test_hit(fact_grain.clone());
1808        let fact_pri = registry
1809            .get(GrainType::Fact)
1810            .unwrap()
1811            .context_priority(&fact_grain, &fact_hit);
1812
1813        assert!(
1814            consent_pri > fact_pri,
1815            "Consent ({consent_pri}) should outrank Fact ({fact_pri})"
1816        );
1817    }
1818
1819    #[test]
1820    fn test_json_format_includes_type() {
1821        let grain = test_grain(
1822            GrainType::Fact,
1823            vec![
1824                ("subject", serde_json::json!("john")),
1825                ("relation", serde_json::json!("likes")),
1826                ("object", serde_json::json!("coffee")),
1827            ],
1828        );
1829        let policy = FormatPolicy::new(OutputFormat::Json).metadata(MetadataLevel::None);
1830        let registry = RendererRegistry::new();
1831        let result = registry
1832            .get(GrainType::Fact)
1833            .unwrap()
1834            .render(&grain, &policy);
1835        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1836        assert_eq!(parsed["type"], "fact");
1837        assert_eq!(parsed["subject"], "john");
1838    }
1839
1840    #[test]
1841    fn test_format_date() {
1842        assert_eq!(format_date(1740000000), "2025-02-19");
1843        assert_eq!(format_date(0), "1970-01-01");
1844        assert_eq!(format_date(1609459200), "2021-01-01");
1845    }
1846
1847    #[test]
1848    fn test_sml_escape() {
1849        assert_eq!(sml_escape("a < b & c"), "a &lt; b &amp; c");
1850        assert_eq!(sml_escape("\"hello\""), "&quot;hello&quot;");
1851    }
1852
1853    #[test]
1854    fn test_fact_toon() {
1855        let grain = test_grain(
1856            GrainType::Fact,
1857            vec![
1858                ("subject", serde_json::json!("john")),
1859                ("relation", serde_json::json!("likes")),
1860                ("object", serde_json::json!("coffee")),
1861                ("confidence", serde_json::json!(0.95)),
1862            ],
1863        );
1864        let policy = FormatPolicy::new(OutputFormat::Toon).metadata(MetadataLevel::Minimal);
1865        let registry = RendererRegistry::new();
1866        let renderer = registry.get(GrainType::Fact).unwrap();
1867        let rendered = renderer.render(&grain, &policy);
1868        // TOON tabular: CSV row with subject, content (humanized), confidence
1869        assert_eq!(rendered, "john,likes coffee,0.95");
1870    }
1871
1872    #[test]
1873    fn test_toon_escape() {
1874        assert_eq!(toon_escape("hello"), "hello");
1875        assert_eq!(toon_escape(""), "\"\"");
1876        assert_eq!(toon_escape("true"), "\"true\"");
1877        assert_eq!(toon_escape("has:colon"), "\"has:colon\"");
1878        assert_eq!(toon_escape("has,comma"), "\"has,comma\"");
1879        assert_eq!(toon_escape("has\"quote"), "\"has\\\"quote\"");
1880        assert_eq!(toon_escape("line\nbreak"), "\"line\\nbreak\"");
1881        assert_eq!(toon_escape("42"), "\"42\"");
1882        assert_eq!(toon_escape("simple text"), "simple text");
1883    }
1884
1885    #[test]
1886    fn test_toon_columns() {
1887        assert_eq!(
1888            toon_columns(&GrainType::Fact),
1889            &["subject", "content", "confidence"]
1890        );
1891        assert_eq!(
1892            toon_columns(&GrainType::Event),
1893            &["role", "time", "content"]
1894        );
1895        assert_eq!(
1896            toon_columns(&GrainType::Consent),
1897            &["grantor", "grantee", "action", "content"]
1898        );
1899    }
1900
1901    #[test]
1902    fn test_event_sml_renders_content_blocks_as_typed_tags() {
1903        let blocks = serde_json::json!([
1904            { "type": "text", "text": "hello <world>" },
1905            { "type": "tool_use", "id": "tu_1", "name": "search", "input": {"q": "a&b"} },
1906            { "type": "tool_result", "tool_use_id": "tu_1", "content": "done", "is_error": false },
1907        ]);
1908        let grain = test_grain(
1909            GrainType::Event,
1910            vec![
1911                ("content", serde_json::json!("fallback")),
1912                ("content_blocks", blocks),
1913            ],
1914        );
1915        let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
1916        let registry = RendererRegistry::new();
1917        let out = registry
1918            .get(GrainType::Event)
1919            .unwrap()
1920            .render(&grain, &policy);
1921        assert!(out.starts_with("<event"));
1922        assert!(out.contains("<text>hello &lt;world&gt;</text>"));
1923        assert!(out.contains("<tool_use name=\"search\" id=\"tu_1\">"));
1924        assert!(out.contains("<args format=\"json\">"));
1925        assert!(out.contains("a&amp;b"));
1926        assert!(
1927            out.contains("<tool_result tool_use_id=\"tu_1\" is_error=\"false\">done</tool_result>")
1928        );
1929        // Fallback text is bypassed when content_blocks renders anything.
1930        assert!(!out.contains("fallback"));
1931    }
1932
1933    #[test]
1934    fn test_event_sml_empty_blocks_falls_back_to_content() {
1935        let grain = test_grain(
1936            GrainType::Event,
1937            vec![
1938                ("content", serde_json::json!("plain text")),
1939                ("content_blocks", serde_json::json!([])),
1940            ],
1941        );
1942        let policy = FormatPolicy::new(OutputFormat::Sml).metadata(MetadataLevel::None);
1943        let registry = RendererRegistry::new();
1944        let out = registry
1945            .get(GrainType::Event)
1946            .unwrap()
1947            .render(&grain, &policy);
1948        assert!(out.contains("plain text"));
1949    }
1950
1951    #[test]
1952    fn test_all_renderers_registered() {
1953        let registry = RendererRegistry::new();
1954        let types = [
1955            GrainType::Fact,
1956            GrainType::Event,
1957            GrainType::State,
1958            GrainType::Workflow,
1959            GrainType::Tool,
1960            GrainType::Observation,
1961            GrainType::Goal,
1962            GrainType::Reasoning,
1963            GrainType::Consensus,
1964            GrainType::Consent,
1965        ];
1966        for gt in &types {
1967            assert!(registry.get(*gt).is_some(), "Missing renderer for {:?}", gt);
1968        }
1969    }
1970
1971    /// XML metacharacters must all be escaped; bidi controls pass through
1972    /// (documented scope — stripping is the LLM-output-layer's job).
1973    #[test]
1974    fn test_sml_escape_metacharacters_and_bidi_passthrough() {
1975        assert_eq!(sml_escape("<a>"), "&lt;a&gt;");
1976        assert_eq!(sml_escape("a & b"), "a &amp; b");
1977        assert_eq!(sml_escape("\"q\""), "&quot;q&quot;");
1978        assert_eq!(sml_escape("it's"), "it&apos;s");
1979        // Bidi controls (U+202E right-to-left override, U+2066 LRI) and ZWJ
1980        // must survive verbatim — documented passthrough.
1981        let bidi = "safe\u{202E}evil\u{2066}x\u{200D}y";
1982        assert_eq!(sml_escape(bidi), bidi);
1983    }
1984}