rustev_eval/explain/
render.rs1use super::*;
3use serde_json::Value;
4use std::fmt::Write;
5
6fn escape(s: &str) -> String {
8 s.replace('&', "&")
9 .replace('<', "<")
10 .replace('>', ">")
11 .replace('|', "|")
12 .replace('`', "`")
13 .replace('\n', " ")
14 .replace('\r', " ")
15}
16fn 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}
141pub 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}