Skip to main content

rustev_eval/explain/
render.rs

1//! Deterministic Markdown, using only the explanation document.
2use super::*;
3use serde_json::Value;
4use std::fmt::Write;
5
6// HTML entities cannot terminate a code span or a Markdown table cell.
7fn escape(s: &str) -> String {
8    s.replace('&', "&")
9        .replace('<', "&lt;")
10        .replace('>', "&gt;")
11        .replace('|', "&#124;")
12        .replace('`', "&#96;")
13        .replace('\n', "&#10;")
14        .replace('\r', "&#13;")
15}
16// A code span shows its content literally, so entities would display as
17// typed; only what ends the span or the cell is handled: a fence longer
18// than any backtick run inside, and in a table `\|`, which GFM unescapes
19// inside a code span. Canonical JSON holds no raw line break.
20fn code(s: &str, table: bool) -> String {
21    let s = if table {
22        s.replace('|', "\\|")
23    } else {
24        s.to_string()
25    };
26    let run = s.split(|c| c != '`').map(str::len).max().unwrap_or(0);
27    let fence = "`".repeat(run + 1);
28    let pad = if s.starts_with('`') || s.ends_with('`') {
29        " "
30    } else {
31        ""
32    };
33    format!("{fence}{pad}{s}{pad}{fence}")
34}
35fn json(v: &Value) -> String {
36    span(v, true)
37}
38fn span(v: &Value, table: bool) -> String {
39    let bytes = rustev_contract::canonical::record_canonical_bytes(v).unwrap_or_default();
40    let text = String::from_utf8(bytes).unwrap_or_default();
41    let mut end = text.len().min(256);
42    while !text.is_char_boundary(end) {
43        end -= 1;
44    }
45    let cut = if end < text.len() { "..." } else { "" };
46    code(&format!("{}{cut}", &text[..end]), table)
47}
48fn encoded<T: serde::Serialize>(v: &T) -> String {
49    json(&serde_json::to_value(v).unwrap_or(Value::Null))
50}
51fn label<T: serde::Serialize>(v: &T) -> String {
52    serde_json::to_value(v)
53        .ok()
54        .and_then(|v| v.as_str().map(str::to_string))
55        .unwrap_or_default()
56}
57fn unavailable(u: &Unavailable) -> String {
58    let reason = match u {
59        Unavailable::NotRecorded { facet } => format!(
60            "not recorded{}",
61            facet
62                .map(|f| format!(" (facet {})", label(&f)))
63                .unwrap_or_default()
64        ),
65        Unavailable::PlanNotSupplied {} => "plan not supplied".into(),
66        Unavailable::Retention { item, availability } => {
67            format!("retention {} (item {})", label(availability), item.as_str())
68        }
69        Unavailable::NotProduced {} => "not produced".into(),
70        Unavailable::NotInRecord {} => "not in record".into(),
71        Unavailable::Redacted { policy, action, .. } => format!(
72            "redacted by policy {policy}{}",
73            if *action == WithholdingAction::Digest {
74                " (digest not computed)"
75            } else {
76                ""
77            }
78        ),
79    };
80    format!("not available: {}", escape(&reason))
81}
82fn item(i: &Item) -> String {
83    match i {
84        Item::String(s) => encoded(s),
85        Item::Withheld(w) => unavailable(&w.not_available),
86    }
87}
88fn answer(a: &Answer) -> &'static str {
89    match a {
90        Answer::WhySelected(a) => match a {
91            SelectedAnswer::NotAvailable { .. } => "Selection evidence is not available.",
92            SelectedAnswer::NoProposal { .. } => "The recorded outcome is not a proposal.",
93            SelectedAnswer::NotSelected { .. } => "The item is not selected; see why_excluded.",
94            SelectedAnswer::Selected { .. } => {
95                "The item appears in the recorded proposal parameters."
96            }
97        },
98        Answer::WhyExcluded(a) => match a {
99            ExcludedAnswer::NotAvailable { .. } => "Exclusion evidence is not available.",
100            ExcludedAnswer::NoProposal { .. } => "The recorded outcome is not a proposal.",
101            ExcludedAnswer::Excluded { .. } => "The record states reasons for excluding the item.",
102            ExcludedAnswer::Cut { .. } => "The item was cut from a recorded shortlist.",
103            ExcludedAnswer::Selected { .. } => "The item is selected; see why_selected.",
104            ExcludedAnswer::NotInPool { .. } => "The item is not in the recorded pool.",
105            ExcludedAnswer::Unknown { .. } => {
106                "The evidence does not establish why the item is absent."
107            }
108        },
109        Answer::WhyOrder(a) => match a {
110            OrderAnswer::NotAvailable { .. } => "Ordering evidence is not available.",
111            OrderAnswer::Ordered { .. } => "The recorded scopes state the item ordering.",
112            OrderAnswer::Incomparable(_) => {
113                "The items cannot be compared in a shared recorded ranking."
114            }
115        },
116        Answer::WhyOutcome(a) => match a {
117            OutcomeAnswer::NotAvailable { .. } => "Outcome evidence is not available.",
118            OutcomeAnswer::Proposed(_) => "The recorded outcome is a proposal.",
119            OutcomeAnswer::Escalated(_) => "The recorded outcome is an escalation.",
120            OutcomeAnswer::Unresolved(_) => "The recorded outcome is unresolved.",
121        },
122        Answer::TextOrigin(a) => match a {
123            TextAnswer::NotAvailable { .. } => "Text evidence is not available.",
124            TextAnswer::TextFields { .. } => {
125                "The record contains the listed text fields and origin evidence."
126            }
127            TextAnswer::NoText {} => "The recorded outcome contains no text fields.",
128        },
129        Answer::Models(a) => match a {
130            ModelsAnswer::NotAvailable { .. } => "Model request evidence is not available.",
131            ModelsAnswer::Models { .. } => "The record lists model requests, attempts and costs.",
132            ModelsAnswer::NoModelCalls { .. } => "The record contains no model calls.",
133        },
134        Answer::Diff(a) => match a {
135            DiffAnswer::Identical { .. } => "The compared decision values are identical.",
136            DiffAnswer::Diverged { .. } => "The compared decision values diverged.",
137            DiffAnswer::Withheld { .. } => "The comparison is withheld by policy.",
138        },
139    }
140}
141/// Render a document without consulting evidence or recomputing an answer.
142pub fn render_markdown(doc: &Explanation) -> String {
143    let mut out = format!(
144        "# Explanation: {}\n\n| Side | Form | Decision | Plan | Capture |\n| --- | --- | --- | --- | --- |\n",
145        span(
146            &serde_json::to_value(&doc.query).unwrap_or(Value::Null),
147            false
148        )
149    );
150    for s in &doc.sides {
151        writeln!(
152            out,
153            "| {} | {} | {} | {} | {} |",
154            label(&s.name),
155            label(&s.form),
156            item(&s.decision_id),
157            item(&s.plan_id),
158            encoded(&s.capture)
159        )
160        .unwrap();
161    }
162    writeln!(
163        out,
164        "\n{}\n\n| Claim | Value | Derivation | Lineage | Cites |\n| --- | --- | --- | --- | --- |",
165        answer(&doc.answer)
166    )
167    .unwrap();
168    for f in &doc.facts {
169        let mut value = match &f.availability {
170            Availability::Available => json(&f.value),
171            Availability::NotAvailable(u) => unavailable(u),
172        };
173        if f.availability == Availability::Available
174            && let Some(t) = &f.truncated
175        {
176            write!(
177                value,
178                " (truncated by policy {} to {} bytes)",
179                t.policy, t.bytes
180            )
181            .unwrap();
182        }
183        writeln!(
184            out,
185            "| {} | {} | {} | {} | {} |",
186            label(&f.claim),
187            value,
188            encoded(&f.derivation),
189            encoded(&f.lineage),
190            encoded(&f.cites)
191        )
192        .unwrap();
193    }
194    if let Some(t) = &doc.truncation {
195        writeln!(out, "\ntruncated: {} of {} facts", doc.facts.len(), t.total).unwrap();
196    }
197    out
198}