1use serde_json::Value;
9
10pub fn render_program(v: &Value) -> Option<String> {
12 match v.get("program").and_then(|p| p.as_str()) {
13 Some("breakdown") => Some(render_breakdown(v)),
14 Some("crosstab") => Some(render_crosstab(v)),
15 Some("rank") => Some(render_rank(v)),
16 Some("structure") => render_structure(v),
17 Some("profile") => Some(render_profile(v)),
18 _ => None,
19 }
20}
21
22fn render_profile(v: &Value) -> String {
24 let sections = v.get("sections").and_then(|s| s.as_array()).cloned().unwrap_or_default();
25 if sections.is_empty() {
26 return "No informative structure found in the matching rows.".to_string();
27 }
28 let scope = v.get("scope_size").and_then(|x| x.as_u64()).unwrap_or(0);
29 let mut out = vec![format!("Profile of {scope} matching situations:")];
30 for s in §ions {
31 if let Some(r) = s.get("result").and_then(render_program) {
32 out.push(format!("\n{r}"));
33 }
34 }
35 out.join("\n")
36}
37
38fn render_breakdown(v: &Value) -> String {
39 let facet = v.get("facet").and_then(|x| x.as_str()).unwrap_or("value");
40 let total = v.get("total").and_then(|x| x.as_u64()).unwrap_or(0);
41 let parts = v.get("partition").and_then(|x| x.as_array()).cloned().unwrap_or_default();
42 if parts.is_empty() {
43 return format!("No {facet} values matched ({total} situations in scope).");
44 }
45 let items: Vec<String> = parts
46 .iter()
47 .map(|p| {
48 let val = p.get("value").and_then(|x| x.as_str()).unwrap_or("?");
49 let n = p.get("count").and_then(|x| x.as_u64()).unwrap_or(0);
50 format!("{val} ({n})")
51 })
52 .collect();
53 let top = parts[0].get("value").and_then(|x| x.as_str()).unwrap_or("?");
54 format!("Breakdown by {facet} across {total} situations: {}. Most common: {top}.", items.join(", "))
55}
56
57fn render_crosstab(v: &Value) -> String {
58 let row_facet = v.get("row_facet").and_then(|x| x.as_str()).unwrap_or("row");
59 let col_facet = v.get("col_facet").and_then(|x| x.as_str()).unwrap_or("col");
60 let total = v.get("total").and_then(|x| x.as_u64()).unwrap_or(0);
61 let matrix = v.get("matrix").and_then(|x| x.as_array()).cloned().unwrap_or_default();
62 if matrix.is_empty() {
63 return format!("No {row_facet} × {col_facet} combinations matched ({total} situations).");
64 }
65 let mut lines = Vec::new();
66 for row in &matrix {
67 let name = row.get("row").and_then(|x| x.as_str()).unwrap_or("?");
68 let cells = row.get("cells").and_then(|x| x.as_array()).cloned().unwrap_or_default();
69 let mut best: Option<(&str, u64)> = None;
71 let parts: Vec<String> = cells
72 .iter()
73 .map(|c| {
74 let col = c.get("col").and_then(|x| x.as_str()).unwrap_or("?");
75 let n = c.get("count").and_then(|x| x.as_u64()).unwrap_or(0);
76 if best.map(|(_, bn)| n > bn).unwrap_or(true) {
77 best = Some((col, n));
78 }
79 format!("{col} {n}")
80 })
81 .collect();
82 let most = best.filter(|(_, n)| *n > 0).map(|(c, _)| format!(" → most common: {c}")).unwrap_or_default();
83 lines.push(format!("- {name}: {}{most}", parts.join(", ")));
84 }
85 format!("{row_facet} × {col_facet} ({total} situations):\n{}", lines.join("\n"))
86}
87
88fn render_rank(v: &Value) -> String {
89 let facet = v.get("facet").and_then(|x| x.as_str()).unwrap_or("value");
90 let ranked = v.get("ranked").and_then(|x| x.as_array()).cloned().unwrap_or_default();
91 if ranked.is_empty() {
92 return format!("No {facet} values to rank.");
93 }
94 let items: Vec<String> = ranked
95 .iter()
96 .take(8)
97 .map(|r| {
98 let tok = r.get("token").and_then(|x| x.as_str()).unwrap_or("?");
99 let leaf = tok.rsplit('/').next().unwrap_or(tok);
100 match r.get("mdus").and_then(|x| x.as_f64()) {
101 Some(m) => format!("{leaf} ({m:.2})"),
102 None => leaf.to_string(),
103 }
104 })
105 .collect();
106 format!("Top {facet} by salience: {}.", items.join(", "))
107}
108
109fn render_structure(v: &Value) -> Option<String> {
110 match v.get("op").and_then(|x| x.as_str()) {
111 Some("cooccurs") => {
112 let focus = v.get("focus").and_then(|x| x.as_str()).unwrap_or("?");
113 let co = v.get("cooccurs").and_then(|x| x.as_array()).cloned().unwrap_or_default();
114 if co.is_empty() {
115 return Some(format!("{focus} has no notable co-occurring tokens."));
116 }
117 let items: Vec<String> = co
118 .iter()
119 .take(8)
120 .map(|c| {
121 let tok = c.get("token").and_then(|x| x.as_str()).unwrap_or("?");
122 let leaf = tok.rsplit('/').next().unwrap_or(tok);
123 let n = c.get("shared").and_then(|x| x.as_u64()).unwrap_or(0);
124 format!("{leaf} ({n})")
125 })
126 .collect();
127 Some(format!("{focus} most co-occurs with: {}.", items.join(", ")))
128 }
129 Some("s_path") => {
130 let (a, b) = (v.get("a").and_then(|x| x.as_str()).unwrap_or("?"), v.get("b").and_then(|x| x.as_str()).unwrap_or("?"));
131 match v.get("path").and_then(|p| p.as_array()) {
132 Some(p) if !p.is_empty() => {
133 let hops: Vec<&str> = p.iter().filter_map(|x| x.as_str()).map(|t| t.rsplit('/').next().unwrap_or(t)).collect();
134 Some(format!("Path {a} → {b}: {}.", hops.join(" → ")))
135 }
136 _ => Some(format!("No connecting path between {a} and {b} at this overlap threshold.")),
137 }
138 }
139 Some("s_clusters") => {
140 let cl = v.get("clusters").and_then(|x| x.as_array()).cloned().unwrap_or_default();
141 if cl.is_empty() {
142 return Some("No multi-token clusters at this overlap threshold.".into());
143 }
144 let lines: Vec<String> = cl
145 .iter()
146 .take(6)
147 .map(|c| {
148 let size = c.get("size").and_then(|x| x.as_u64()).unwrap_or(0);
149 let toks: Vec<&str> = c
150 .get("tokens")
151 .and_then(|t| t.as_array())
152 .map(|a| a.iter().filter_map(|x| x.as_str()).map(|t| t.rsplit('/').next().unwrap_or(t)).take(8).collect())
153 .unwrap_or_default();
154 format!("- [{size}] {}", toks.join(", "))
155 })
156 .collect();
157 Some(format!("{} concept clusters:\n{}", cl.len(), lines.join("\n")))
158 }
159 _ => None,
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use serde_json::json;
167
168 #[test]
169 fn crosstab_reports_argmax_per_row() {
170 let v = json!({
171 "program": "crosstab", "row_facet": "country", "col_facet": "powertrain", "total": 20,
172 "matrix": [
173 {"row": "japan", "cells": [{"col":"electric","count":2},{"col":"diesel","count":1},{"col":"hybrid","count":2}]},
174 {"row": "germany", "cells": [{"col":"electric","count":3},{"col":"diesel","count":2},{"col":"hybrid","count":0}]}
175 ]
176 });
177 let out = render_program(&v).unwrap();
178 assert!(out.contains("country × powertrain"));
179 assert!(out.contains("germany: electric 3, diesel 2, hybrid 0 → most common: electric"));
180 assert!(out.contains("japan:"));
182 }
183
184 #[test]
185 fn breakdown_and_rank_render() {
186 let b = json!({"program":"breakdown","facet":"country","total":20,"partition":[{"value":"usa","count":6},{"value":"japan","count":5}]});
187 assert!(render_program(&b).unwrap().contains("Most common: usa"));
188 let r = json!({"program":"rank","facet":"ent","ranked":[{"token":"ent/connect","mdus":1.0},{"token":"ent/agent","mdus":0.76}]});
189 assert!(render_program(&r).unwrap().contains("connect (1.00)"));
190 }
191
192 #[test]
193 fn non_program_returns_none() {
194 assert!(render_program(&json!({"foo": "bar"})).is_none());
195 }
196}