Skip to main content

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