1use super::super::*;
2
3fn format_reasons(value: Option<&Value>) -> String {
4 let values = string_array(value);
5 if values.is_empty() {
6 "none".to_string()
7 } else {
8 values.join(", ")
9 }
10}
11
12fn render_relationship_brief(value: &Value) -> String {
13 format!(
14 "- {} [{}] {} -> {} (score={:.3})",
15 string(value.get("id")),
16 string(value.get("kind")),
17 string(value.get("source_path")),
18 string(value.get("target_path")),
19 number(value.get("evidence_score"))
20 )
21}
22
23fn render_cluster_brief(value: &Value) -> String {
24 let candidate_type = string(value.get("candidate_type"));
25 format!(
26 "- {} [{}] members={} roots={}{}",
27 string(value.get("id")),
28 string(value.get("kind")),
29 usize_value(value.get("member_count")).max(string_array(value.get("member_paths")).len()),
30 {
31 let roots = string_array(value.get("top_level_roots"));
32 if roots.is_empty() {
33 "none".to_string()
34 } else {
35 roots.join(", ")
36 }
37 },
38 if candidate_type.is_empty() {
39 String::new()
40 } else {
41 format!(" candidate_type={candidate_type}")
42 },
43 )
44}
45
46fn title_case_bool(value: bool) -> &'static str {
47 if value { "True" } else { "False" }
48}
49
50fn shell_quote(value: &str) -> String {
51 format!("'{}'", value.replace('\'', "'\"'\"'"))
52}
53
54fn render_record_cost_lines(
55 label: &str,
56 fallback_path: &str,
57 record: Option<&Value>,
58) -> Vec<String> {
59 let path = string(record.and_then(|value| value.get("path")));
60 let path = if path.is_empty() {
61 fallback_path
62 } else {
63 path.as_str()
64 };
65 let costs = record.and_then(|value| value.get("costs"));
66 vec![
67 format!(
68 "- {label}={path} slop={} slop_score={:.1} context={}",
69 string(record.and_then(|value| value.get("slop_band"))),
70 number(record.and_then(|value| value.get("slop_score"))),
71 string(record.and_then(|value| value.get("context_band"))),
72 ),
73 format!(
74 " - load: tokens={}, folder_tokens={}, pressure={:.3}",
75 integer(value_at(
76 costs.unwrap_or(&Value::Null),
77 &["load", "file_token_count"]
78 )),
79 integer(value_at(
80 costs.unwrap_or(&Value::Null),
81 &["load", "folder_token_count"]
82 )),
83 number(value_at(
84 costs.unwrap_or(&Value::Null),
85 &["load", "load_pressure"]
86 )),
87 ),
88 format!(
89 " - volatility: commits={}, relative_token_churn={:.3}, pressure={:.3}",
90 json_scalar_text(value_at(
91 costs.unwrap_or(&Value::Null),
92 &["volatility", "commit_count_window"]
93 )),
94 number(value_at(
95 costs.unwrap_or(&Value::Null),
96 &["volatility", "relative_token_churn"]
97 )),
98 number(value_at(
99 costs.unwrap_or(&Value::Null),
100 &["volatility", "volatility_pressure"]
101 )),
102 ),
103 format!(
104 " - coordination: diffusion={:.3}, degree={}, pressure={:.3}",
105 number(value_at(
106 costs.unwrap_or(&Value::Null),
107 &["coordination", "change_diffusion"]
108 )),
109 json_scalar_text(value_at(
110 costs.unwrap_or(&Value::Null),
111 &["coordination", "cochange_degree"]
112 )),
113 number(value_at(
114 costs.unwrap_or(&Value::Null),
115 &["coordination", "coordination_pressure"]
116 )),
117 ),
118 ]
119}
120
121fn render_overlay_lines(overlays: Option<&Value>) -> Vec<String> {
122 let Some(overlays) = overlays.and_then(Value::as_object) else {
123 return vec!["- none".to_string()];
124 };
125 if overlays.is_empty() {
126 return vec!["- none".to_string()];
127 }
128 let organization = overlays.get("organization_health");
129 let verification = overlays.get("verification");
130 let navigation = overlays.get("navigation");
131 let blast = overlays.get("blast_radius");
132 let stewardship = overlays.get("stewardship");
133 let drift = overlays.get("semantic_drift");
134 vec![
135 format!(
136 "- organization_health: duplication={:.3}, diffusion={:.3}, coupling={:.3}, boundary={:.3}, clusters={}",
137 number(organization.and_then(|value| value.get("duplication_pressure"))),
138 number(organization.and_then(|value| value.get("diffusion_pressure"))),
139 number(organization.and_then(|value| value.get("coupling_pressure"))),
140 number(organization.and_then(|value| value.get("boundary_pressure"))),
141 string_array(organization.and_then(|value| value.get("cluster_ids"))).len(),
142 ),
143 format!(
144 "- verification: gap={:.3}, adjacency={:.3}, test_cochange={:.3}, hotspot_without_nearby_tests={}",
145 number(verification.and_then(|value| value.get("verification_gap"))),
146 number(verification.and_then(|value| value.get("test_adjacency_score"))),
147 number(verification.and_then(|value| value.get("test_cochange_ratio"))),
148 title_case_bool(
149 verification
150 .and_then(|value| value.get("hotspot_without_nearby_tests"))
151 .and_then(Value::as_bool)
152 .unwrap_or(false),
153 ),
154 ),
155 format!(
156 "- navigation: pressure={:.3}, ambiguity={:.3}, path_depth={}",
157 number(navigation.and_then(|value| value.get("navigation_pressure"))),
158 number(navigation.and_then(|value| value.get("search_ambiguity"))),
159 json_scalar_text(navigation.and_then(|value| value.get("path_depth"))),
160 ),
161 format!(
162 "- blast_radius: pressure={:.3}, degree={}, cross_folder={:.3}",
163 number(blast.and_then(|value| value.get("blast_radius_pressure"))),
164 json_scalar_text(blast.and_then(|value| value.get("cochange_degree"))),
165 number(blast.and_then(|value| value.get("cross_folder_coupling"))),
166 ),
167 format!(
168 "- stewardship: pressure={:.3}, authors={}, top_author_share={:.3}",
169 number(stewardship.and_then(|value| value.get("stewardship_pressure"))),
170 json_scalar_text(stewardship.and_then(|value| value.get("author_count_window"))),
171 number(stewardship.and_then(|value| value.get("top_author_share"))),
172 ),
173 format!(
174 "- semantic_drift: pressure={:.3}, terms={}",
175 number(drift.and_then(|value| value.get("semantic_drift_pressure"))),
176 {
177 let terms = string_array(drift.and_then(|value| value.get("drift_terms")));
178 if terms.is_empty() {
179 "none".to_string()
180 } else {
181 terms.into_iter().take(5).collect::<Vec<_>>().join(", ")
182 }
183 },
184 ),
185 ]
186}
187
188fn render_evidence_summary(payload: &Value) -> Vec<String> {
189 let summary = payload.get("evidence_summary").unwrap_or(&Value::Null);
190 let joined = |key: &str| {
191 let values = string_array(summary.get(key));
192 if values.is_empty() {
193 "none".to_string()
194 } else {
195 values.join("; ")
196 }
197 };
198 let relationships = string_array(value_at(
199 summary,
200 &["supporting_evidence", "relationship_ids"],
201 ));
202 let clusters = string_array(value_at(summary, &["supporting_evidence", "cluster_ids"]));
203 vec![
204 "Evidence Summary".to_string(),
205 format!("- strongest detector costs: {}", joined("detector_cost")),
206 format!("- strongest overlays: {}", joined("strongest_overlays")),
207 format!(
208 "- supporting relationships: {}",
209 if relationships.is_empty() {
210 "none".to_string()
211 } else {
212 relationships.join(", ")
213 }
214 ),
215 format!(
216 "- supporting clusters: {}",
217 if clusters.is_empty() {
218 "none".to_string()
219 } else {
220 clusters.join(", ")
221 }
222 ),
223 format!(
224 "- interpretation: {}",
225 string(summary.get("interpretation"))
226 ),
227 ]
228}
229
230fn render_top_explain(payload: &Value) -> String {
231 let count = usize_value(value_at(payload, &["target", "count"]));
232 let mut lines = vec![format!("Explain: top {count} hotspots"), String::new()];
233 for (index, item) in array_at(payload, &["items"]).iter().enumerate() {
234 let target = item.get("target").unwrap_or(&Value::Null);
235 lines.push(format!(
236 "{}. {} [{}] slop_score={:.1} context={}",
237 index + 1,
238 string(target.get("path")),
239 string(target.get("slop_band")),
240 number(target.get("slop_score")),
241 string(target.get("context_band"))
242 ));
243 lines.push(format!(
244 " cost: load={:.3} volatility={:.3} coordination={:.3}",
245 number(value_at(item, &["cost_summary", "load", "load_pressure"])),
246 number(value_at(
247 item,
248 &["cost_summary", "volatility", "volatility_pressure"]
249 )),
250 number(value_at(
251 item,
252 &["cost_summary", "coordination", "coordination_pressure"]
253 ))
254 ));
255 let pressures = strongest_pressures(item.get("overlay_summary"), 3);
256 lines.push(format!(
257 " overlays: {}",
258 if pressures.is_empty() {
259 "none".to_string()
260 } else {
261 pressures
262 .iter()
263 .map(|(label, value)| format!("{label}={value:.3}"))
264 .collect::<Vec<_>>()
265 .join(", ")
266 }
267 ));
268 let relationships: Vec<String> = array_at(item, &["supporting_relationships"])
269 .iter()
270 .take(2)
271 .map(|value| string(value.get("id")))
272 .collect();
273 let clusters: Vec<String> = array_at(item, &["supporting_clusters"])
274 .iter()
275 .take(2)
276 .map(|value| string(value.get("id")))
277 .collect();
278 lines.push(format!(
279 " relationships: {}",
280 if relationships.is_empty() {
281 "none".to_string()
282 } else {
283 relationships.join(", ")
284 }
285 ));
286 lines.push(format!(
287 " clusters: {}",
288 if clusters.is_empty() {
289 "none".to_string()
290 } else {
291 clusters.join(", ")
292 }
293 ));
294 lines.push(String::new());
295 }
296 lines.extend(render_evidence_summary(payload));
297 lines.push(String::new());
298 lines.push(string(payload.get("boundary_note")));
299 format!("{}\n", lines.join("\n"))
300}
301
302pub fn render_explain_text(payload: &Value) -> String {
303 if string(value_at(payload, &["selector", "kind"])) == "top" {
304 return render_top_explain(payload);
305 }
306 let target = payload.get("target").unwrap_or(&Value::Null);
307 let target_kind = string(target.get("kind"));
308 let target_label = match target_kind.as_str() {
309 "path" => string(target.get("path")),
310 _ => string(target.get("id")),
311 };
312 let target_detail = match target_kind.as_str() {
313 "path" => string(target.get("record_type")),
314 "relationship" => string(target.get("relationship_kind")),
315 "cluster" => string(target.get("cluster_kind")),
316 _ => String::new(),
317 };
318 let mut lines = vec![
319 format!("Explain: {target_kind} {target_label} [{target_detail}]"),
320 String::new(),
321 ];
322
323 if target_kind == "relationship" {
324 lines.push("Hotspot Cost".to_string());
325 lines.extend(render_record_cost_lines(
326 "source",
327 &string(target.get("source_path")),
328 value_at(payload, &["cost_summary", "source"]),
329 ));
330 lines.extend(render_record_cost_lines(
331 "target",
332 &string(target.get("target_path")),
333 value_at(payload, &["cost_summary", "target"]),
334 ));
335 lines.push(String::new());
336 lines.push("Overlay Evidence".to_string());
337 let organization = value_at(payload, &["overlay_summary", "organization_health"]);
338 lines.push(format!(
339 "- organization_health: evidence_score={:.3}, crosses_top_level_boundary={}",
340 number(organization.and_then(|value| value.get("evidence_score"))),
341 title_case_bool(
342 organization
343 .and_then(|value| value.get("crosses_top_level_boundary"))
344 .and_then(Value::as_bool)
345 .unwrap_or(false)
346 ),
347 ));
348 lines.push("- source overlays:".to_string());
349 lines.extend(render_overlay_lines(value_at(
350 payload,
351 &["overlay_summary", "source_overlays"],
352 )));
353 lines.push("- target overlays:".to_string());
354 lines.extend(render_overlay_lines(value_at(
355 payload,
356 &["overlay_summary", "target_overlays"],
357 )));
358 } else if target_kind == "cluster" {
359 lines.push("Hotspot Cost".to_string());
360 let roots = string_array(target.get("top_level_roots"));
361 lines.push(format!(
362 "- members={} roots={} candidate_type={}",
363 usize_value(value_at(payload, &["cost_summary", "member_count"])),
364 if roots.is_empty() {
365 "none".to_string()
366 } else {
367 roots.join(", ")
368 },
369 string_or(target.get("candidate_type"), "n/a"),
370 ));
371 lines.push("- member hotspots:".to_string());
372 for item in array_at(payload, &["cost_summary", "member_hotspots"]) {
373 lines.push(format!(
374 " - {} slop={} slop_score={:.1} context={}",
375 string(item.get("path")),
376 string(item.get("slop_band")),
377 number(item.get("slop_score")),
378 string(item.get("context_band"))
379 ));
380 }
381 lines.push(String::new());
382 lines.push("Overlay Evidence".to_string());
383 let organization = value_at(payload, &["overlay_summary", "organization_health"]);
384 lines.push(format!(
385 "- organization_health: candidate_type={}, evidence_score={:.3}",
386 string_or(
387 organization.and_then(|value| value.get("candidate_type")),
388 &string(organization.and_then(|value| value.get("kind"))),
389 ),
390 number(organization.and_then(|value| value.get("evidence_score"))),
391 ));
392 let maxima = value_at(payload, &["overlay_summary", "member_overlay_maxima"]);
393 if maxima.is_some() {
394 lines.push(format!(
395 "- member overlay maxima: organization.diffusion={:.3}, verification={:.3}, navigation={:.3}, blast_radius={:.3}, semantic_drift={:.3}",
396 number(value_at(maxima.unwrap_or(&Value::Null), &["organization_health", "diffusion_pressure"])),
397 number(value_at(maxima.unwrap_or(&Value::Null), &["verification", "verification_gap"])),
398 number(value_at(maxima.unwrap_or(&Value::Null), &["navigation", "navigation_pressure"])),
399 number(value_at(maxima.unwrap_or(&Value::Null), &["blast_radius", "blast_radius_pressure"])),
400 number(value_at(maxima.unwrap_or(&Value::Null), &["semantic_drift", "semantic_drift_pressure"])),
401 ));
402 }
403 } else {
404 let is_folder = string(target.get("record_type")) == "folder";
405 lines.push("Hotspot Cost".to_string());
406 lines.push(format!(
407 "- slop: {} ({:.1}) context={} reasons={}",
408 string(target.get("slop_band")),
409 number(target.get("slop_score")),
410 string(target.get("context_band")),
411 format_reasons(target.get("reason_codes"))
412 ));
413 lines.push(format!(
414 "- load: {}={}, folder_tokens={}, pressure={:.3}",
415 if is_folder {
416 "max_file_tokens"
417 } else {
418 "tokens"
419 },
420 integer(value_at(
421 payload,
422 &["cost_summary", "load", "file_token_count"]
423 )),
424 integer(value_at(
425 payload,
426 &["cost_summary", "load", "folder_token_count"]
427 )),
428 number(value_at(
429 payload,
430 &["cost_summary", "load", "load_pressure"]
431 ))
432 ));
433 lines.push(format!(
434 "- volatility: commits={}, relative_token_churn={:.3}, pressure={:.3}",
435 json_scalar_text(value_at(
436 payload,
437 &["cost_summary", "volatility", "commit_count_window"]
438 )),
439 number(value_at(
440 payload,
441 &["cost_summary", "volatility", "relative_token_churn"]
442 )),
443 number(value_at(
444 payload,
445 &["cost_summary", "volatility", "volatility_pressure"]
446 ))
447 ));
448 lines.push(format!(
449 "- coordination: diffusion={:.3}, degree={}, pressure={:.3}",
450 number(value_at(
451 payload,
452 &["cost_summary", "coordination", "change_diffusion"]
453 )),
454 value_at(
455 payload,
456 &["cost_summary", "coordination", "cochange_degree"]
457 )
458 .map(ToString::to_string)
459 .unwrap_or_else(|| "0".to_string()),
460 number(value_at(
461 payload,
462 &["cost_summary", "coordination", "coordination_pressure"]
463 ))
464 ));
465 let descendants = array_at(payload, &["cost_summary", "descendant_hotspots"]);
466 if is_folder {
467 lines.push("- descendant hotspots:".to_string());
468 if descendants.is_empty() {
469 lines.push(" - none".to_string());
470 } else {
471 for record in descendants.iter().take(5) {
472 lines.push(format!(
473 " - {} slop={} slop_score={:.1} context={}",
474 string(record.get("path")),
475 string(record.get("slop_band")),
476 number(record.get("slop_score")),
477 string(record.get("context_band"))
478 ));
479 }
480 }
481 }
482 lines.push(String::new());
483 lines.push("Overlay Evidence".to_string());
484 lines.extend(render_overlay_lines(payload.get("overlay_summary")));
485 if is_folder
486 && payload
487 .pointer("/overlay_summary/descendant_overlay_maxima")
488 .is_some()
489 {
490 lines.push(format!(
491 "- descendant overlay maxima: organization.diffusion={:.3}, verification={:.3}, navigation={:.3}, blast_radius={:.3}, semantic_drift={:.3}",
492 number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "organization_health", "diffusion_pressure"])),
493 number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "verification", "verification_gap"])),
494 number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "navigation", "navigation_pressure"])),
495 number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "blast_radius", "blast_radius_pressure"])),
496 number(value_at(payload, &["overlay_summary", "descendant_overlay_maxima", "semantic_drift", "semantic_drift_pressure"])),
497 ));
498 }
499 }
500 lines.push(String::new());
501 lines.push("Supporting Relationships".to_string());
502 let relationships = array_at(payload, &["supporting_relationships"]);
503 let path_is_folder = target_kind == "path" && string(target.get("record_type")) == "folder";
504 let relationship_limit = if path_is_folder {
505 3
506 } else {
507 relationships.len()
508 };
509 if relationships.is_empty() {
510 lines.push("- none".to_string());
511 } else {
512 lines.extend(
513 relationships
514 .iter()
515 .take(relationship_limit)
516 .map(render_relationship_brief),
517 );
518 }
519 lines.push(String::new());
520 lines.push("Supporting Clusters".to_string());
521 let clusters = array_at(payload, &["supporting_clusters"]);
522 let cluster_limit = if path_is_folder { 3 } else { clusters.len() };
523 if clusters.is_empty() {
524 lines.push("- none".to_string());
525 } else {
526 lines.extend(
527 clusters
528 .iter()
529 .take(cluster_limit)
530 .map(render_cluster_brief),
531 );
532 }
533 lines.push(String::new());
534 lines.extend(render_evidence_summary(payload));
535 if target_kind == "relationship" {
536 lines.push(String::new());
537 lines.push("Next Commands".to_string());
538 lines.push(format!(
539 "- Inspect source: git-slop show {}",
540 shell_quote(&string(target.get("source_path")))
541 ));
542 lines.push(format!(
543 "- Inspect target: git-slop show {}",
544 shell_quote(&string(target.get("target_path")))
545 ));
546 lines.push(format!(
547 "- Draft a bounded plan: git-slop plan --relationship {}",
548 shell_quote(&string(target.get("id")))
549 ));
550 } else if target_kind == "cluster" {
551 lines.push(String::new());
552 lines.push("Next Commands".to_string());
553 for member in string_array(target.get("member_paths"))
554 .into_iter()
555 .take(MAX_SLICE_FILES)
556 {
557 lines.push(format!(
558 "- Inspect member: git-slop show {}",
559 shell_quote(&member)
560 ));
561 }
562 lines.push(format!(
563 "- Draft a bounded plan: git-slop plan --cluster {}",
564 shell_quote(&string(target.get("id")))
565 ));
566 }
567 lines.push(String::new());
568 lines.push(string(payload.get("boundary_note")));
569 format!("{}\n", lines.join("\n"))
570}