1use std::fmt::Write as _;
2use std::fs;
3use std::path::Path;
4
5use crate::error::Result;
6use crate::frame::{BoundingBox, Color, ImageFrame, ScalarField};
7use crate::metrics::{AblationEntry, AggregateRunScore, DemoASuiteMetrics, ScenarioReport};
8use crate::report::TrustDiagnostics;
9use crate::sampling::{BudgetCurve, DemoBScenarioReport, DemoBScenarioRun};
10use crate::scaling::ResolutionScalingMetrics;
11use crate::sensitivity::ParameterSensitivityMetrics;
12
13pub struct ScenarioMosaicEntry<'a> {
14 pub scenario_title: &'a str,
15 pub baseline: &'a ImageFrame,
16 pub heuristic: &'a ImageFrame,
17 pub host_realistic: &'a ImageFrame,
18 pub focus_bbox: BoundingBox,
19}
20
21pub fn write_system_diagram(path: &Path) -> Result<()> {
22 if let Some(parent) = path.parent() {
23 fs::create_dir_all(parent)?;
24 }
25
26 let labels = [
27 "Host Buffers",
28 "Residuals",
29 "Proxies",
30 "State / Grammar",
31 "Trust",
32 "Alpha / Budget",
33 ];
34 let fills = [
35 "#11324d", "#204a68", "#2f5d7c", "#457b9d", "#4d9078", "#a44a3f",
36 ];
37 let mut boxes = String::new();
38 for (index, (label, fill)) in labels.iter().zip(fills.iter()).enumerate() {
39 let x = 40 + index as i32 * 172;
40 let arrow_x = x + 134;
41 let _ = write!(
42 boxes,
43 r##"<rect x="{x}" y="86" rx="18" ry="18" width="134" height="72" fill="{fill}" stroke="#f4f7fb" stroke-width="2"/>"##
44 );
45 let _ = write!(
46 boxes,
47 r##"<text x="{}" y="130" text-anchor="middle" font-size="24" font-family="Arial, Helvetica, sans-serif" fill="#f8fbff">{label}</text>"##,
48 x + 67
49 );
50 if index + 1 < labels.len() {
51 let _ = write!(
52 boxes,
53 r##"<line x1="{arrow_x}" y1="122" x2="{}" y2="122" stroke="#f4f7fb" stroke-width="4" marker-end="url(#arrow)"/>"##,
54 x + 172
55 );
56 }
57 }
58
59 let svg = format!(
60 r##"<svg xmlns="http://www.w3.org/2000/svg" width="1110" height="240" viewBox="0 0 1110 240">
61<defs>
62 <marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
63 <path d="M0,0 L12,6 L0,12 z" fill="#f4f7fb"/>
64 </marker>
65</defs>
66<rect width="1110" height="240" fill="#0b1320"/>
67<text x="40" y="42" font-size="32" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">DSFB Supervisory Flow and Integration Surface</text>
68<text x="40" y="68" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#b9c6d3">Inputs → Residuals → Proxies → Grammar → Trust → Intervention / Modulation</text>
69{boxes}
70</svg>"##
71 );
72
73 fs::write(path, svg)?;
74 Ok(())
75}
76
77pub fn write_trust_map_figure(
78 current_frame: &ImageFrame,
79 trust: &ScalarField,
80 focus_bbox: BoundingBox,
81 path: &Path,
82) -> Result<()> {
83 if let Some(parent) = path.parent() {
84 fs::create_dir_all(parent)?;
85 }
86
87 let base_png = png_data_uri(¤t_frame.encode_png()?);
88 let overlay_png = png_data_uri(&trust_overlay_image(trust).encode_png()?);
89 let x = focus_bbox.min_x as f32 * 4.0 + 36.0;
90 let y = focus_bbox.min_y as f32 * 4.0 + 72.0;
91 let width = focus_bbox.width() as f32 * 4.0;
92 let height = focus_bbox.height() as f32 * 4.0;
93
94 let svg = format!(
95 r##"<svg xmlns="http://www.w3.org/2000/svg" width="960" height="500" viewBox="0 0 960 500">
96<rect width="960" height="500" fill="#0b1320"/>
97<text x="36" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Canonical Trust Map</text>
98<text x="36" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Low trust is overlaid on the actual host-realistic reveal frame.</text>
99<image href="{base_png}" x="36" y="72" width="640" height="384" preserveAspectRatio="none"/>
100<image href="{overlay_png}" x="36" y="72" width="640" height="384" opacity="0.72" preserveAspectRatio="none"/>
101<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="none" stroke="#f4f7fb" stroke-width="2.5" stroke-dasharray="10 7"/>
102<text x="720" y="112" font-size="22" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Legend</text>
103<rect x="720" y="138" width="28" height="220" rx="10" fill="url(#trustRamp)" stroke="#f4f7fb" stroke-width="1.5"/>
104<defs>
105 <linearGradient id="trustRamp" x1="0%" y1="0%" x2="0%" y2="100%">
106 <stop offset="0%" stop-color="#0d1b2a"/>
107 <stop offset="40%" stop-color="#ffb703"/>
108 <stop offset="100%" stop-color="#ef476f"/>
109 </linearGradient>
110</defs>
111<text x="760" y="156" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">high trust</text>
112<text x="760" y="354" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">low trust</text>
113<text x="720" y="406" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">The experiment is intended to demonstrate</text>
114<text x="720" y="428" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">behavioral differences rather than establish</text>
115<text x="720" y="450" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">optimal performance.</text>
116</svg>"##
117 );
118
119 fs::write(path, svg)?;
120 Ok(())
121}
122
123pub fn write_before_after_figure(
124 baseline_frame: &ImageFrame,
125 strong_heuristic_frame: &ImageFrame,
126 host_realistic_frame: &ImageFrame,
127 focus_bbox: BoundingBox,
128 path: &Path,
129) -> Result<()> {
130 if let Some(parent) = path.parent() {
131 fs::create_dir_all(parent)?;
132 }
133
134 let baseline_png = png_data_uri(&baseline_frame.encode_png()?);
135 let strong_png = png_data_uri(&strong_heuristic_frame.encode_png()?);
136 let host_png = png_data_uri(&host_realistic_frame.encode_png()?);
137 let crop = focus_bbox.expand(baseline_frame.width(), baseline_frame.height(), 2);
138 let baseline_crop = png_data_uri(&baseline_frame.crop(crop).encode_png()?);
139 let strong_crop = png_data_uri(&strong_heuristic_frame.crop(crop).encode_png()?);
140 let host_crop = png_data_uri(&host_realistic_frame.crop(crop).encode_png()?);
141 let box_x = crop.min_x as f32 * 2.2 + 30.0;
142 let box_y = crop.min_y as f32 * 2.2 + 90.0;
143 let box_w = crop.width() as f32 * 2.2;
144 let box_h = crop.height() as f32 * 2.2;
145
146 let svg = format!(
147 r##"<svg xmlns="http://www.w3.org/2000/svg" width="1340" height="760" viewBox="0 0 1340 760">
148<rect width="1340" height="760" fill="#0b1320"/>
149<text x="30" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Canonical Baseline Comparison</text>
150<text x="30" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Fixed alpha, strong heuristic, and host-realistic DSFB on the same deterministic frame.</text>
151<text x="110" y="88" font-size="20" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Fixed alpha</text>
152<text x="520" y="88" font-size="20" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Strong heuristic</text>
153<text x="936" y="88" font-size="20" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Host-realistic DSFB</text>
154<image href="{baseline_png}" x="30" y="96" width="360" height="220" preserveAspectRatio="none"/>
155<image href="{strong_png}" x="450" y="96" width="360" height="220" preserveAspectRatio="none"/>
156<image href="{host_png}" x="870" y="96" width="360" height="220" preserveAspectRatio="none"/>
157<rect x="{box_x}" y="{box_y}" width="{box_w}" height="{box_h}" fill="none" stroke="#f4f7fb" stroke-width="2" stroke-dasharray="8 6"/>
158<rect x="{box_x2}" y="{box_y}" width="{box_w}" height="{box_h}" fill="none" stroke="#f4f7fb" stroke-width="2" stroke-dasharray="8 6"/>
159<rect x="{box_x3}" y="{box_y}" width="{box_w}" height="{box_h}" fill="none" stroke="#f4f7fb" stroke-width="2" stroke-dasharray="8 6"/>
160<text x="32" y="360" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">ROI zooms</text>
161<image href="{baseline_crop}" x="30" y="382" width="360" height="180" preserveAspectRatio="none"/>
162<image href="{strong_crop}" x="450" y="382" width="360" height="180" preserveAspectRatio="none"/>
163<image href="{host_crop}" x="870" y="382" width="360" height="180" preserveAspectRatio="none"/>
164<text x="30" y="626" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">The experiment is intended to demonstrate behavioral differences rather than establish optimal performance.</text>
165</svg>"##,
166 box_x2 = box_x + 420.0,
167 box_x3 = box_x + 840.0,
168 );
169
170 fs::write(path, svg)?;
171 Ok(())
172}
173
174pub fn write_trust_vs_error_figure(scenario: &ScenarioReport, path: &Path) -> Result<()> {
175 if let Some(parent) = path.parent() {
176 fs::create_dir_all(parent)?;
177 }
178
179 let fixed = scenario
180 .runs
181 .iter()
182 .find(|run| run.summary.run_id == "fixed_alpha")
183 .expect("fixed_alpha run required");
184 let host = scenario
185 .runs
186 .iter()
187 .find(|run| run.summary.run_id == "dsfb_host_realistic")
188 .expect("dsfb_host_realistic run required");
189 let trust_values = host
190 .frame_metrics
191 .iter()
192 .map(|frame| frame.trust_roi_mean.unwrap_or(1.0))
193 .collect::<Vec<_>>();
194 let width = 960.0f32;
195 let height = 540.0f32;
196 let left = 88.0f32;
197 let right = 820.0f32;
198 let top = 78.0f32;
199 let bottom = 460.0f32;
200 let inner_width = right - left;
201 let inner_height = bottom - top;
202 let max_error = fixed
203 .frame_metrics
204 .iter()
205 .chain(host.frame_metrics.iter())
206 .map(|frame| frame.roi_mae)
207 .fold(0.05f32, f32::max);
208 let frame_count = fixed.frame_metrics.len().max(2);
209 let x_scale = inner_width / (frame_count.saturating_sub(1)) as f32;
210
211 let fixed_path = polyline(
212 &fixed
213 .frame_metrics
214 .iter()
215 .enumerate()
216 .map(|(index, frame)| {
217 (
218 left + index as f32 * x_scale,
219 bottom - (frame.roi_mae / max_error) * inner_height,
220 )
221 })
222 .collect::<Vec<_>>(),
223 );
224 let host_path = polyline(
225 &host
226 .frame_metrics
227 .iter()
228 .enumerate()
229 .map(|(index, frame)| {
230 (
231 left + index as f32 * x_scale,
232 bottom - (frame.roi_mae / max_error) * inner_height,
233 )
234 })
235 .collect::<Vec<_>>(),
236 );
237 let trust_path = polyline(
238 &trust_values
239 .iter()
240 .enumerate()
241 .map(|(index, trust)| {
242 (
243 left + index as f32 * x_scale,
244 bottom - trust.clamp(0.0, 1.0) * inner_height,
245 )
246 })
247 .collect::<Vec<_>>(),
248 );
249
250 let svg = format!(
251 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
252<rect width="{width}" height="{height}" fill="#0b1320"/>
253<text x="36" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Canonical ROI Error vs Trust</text>
254<text x="36" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">x-axis = frame index, left y-axis = ROI MAE, right y-axis = host-realistic trust.</text>
255<line x1="{left}" y1="{top}" x2="{left}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2"/>
256<line x1="{left}" y1="{bottom}" x2="{right}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2"/>
257<line x1="{right}" y1="{top}" x2="{right}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2"/>
258<path d="{fixed_path}" fill="none" stroke="#ef476f" stroke-width="3.5"/>
259<path d="{host_path}" fill="none" stroke="#4cc9f0" stroke-width="3.5"/>
260<path d="{trust_path}" fill="none" stroke="#8bd450" stroke-width="3.5" stroke-dasharray="10 8"/>
261<line x1="{onset_x}" y1="{top}" x2="{onset_x}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2" stroke-dasharray="8 6"/>
262<text x="560" y="110" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Fixed alpha ROI error</text>
263<line x1="500" y1="104" x2="548" y2="104" stroke="#ef476f" stroke-width="3.5"/>
264<text x="560" y="138" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Host-realistic DSFB ROI error</text>
265<line x1="500" y1="132" x2="548" y2="132" stroke="#4cc9f0" stroke-width="3.5"/>
266<text x="560" y="166" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Host-realistic trust</text>
267<line x1="500" y1="160" x2="548" y2="160" stroke="#8bd450" stroke-width="3.5" stroke-dasharray="10 8"/>
268<text x="36" y="515" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">frame index</text>
269<text x="18" y="88" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">error</text>
270<text x="840" y="88" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">trust</text>
271<text x="500" y="236" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">onset frame: {onset_frame}</text>
272<text x="500" y="260" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">host vs fixed cumulative ROI gain: {roi_gain:.5}</text>
273</svg>"##,
274 onset_x = left + scenario.onset_frame as f32 * x_scale,
275 onset_frame = scenario.onset_frame,
276 roi_gain = scenario.host_realistic_vs_fixed_alpha_cumulative_roi_gain,
277 );
278
279 fs::write(path, svg)?;
280 Ok(())
281}
282
283pub fn write_intervention_alpha_figure(
284 current_frame: &ImageFrame,
285 intervention: &ScalarField,
286 alpha: &ScalarField,
287 focus_bbox: BoundingBox,
288 path: &Path,
289) -> Result<()> {
290 if let Some(parent) = path.parent() {
291 fs::create_dir_all(parent)?;
292 }
293 let base_png = png_data_uri(¤t_frame.encode_png()?);
294 let intervention_png =
295 png_data_uri(&field_to_image(intervention, intervention_palette).encode_png()?);
296 let alpha_png = png_data_uri(&field_to_image(alpha, alpha_palette).encode_png()?);
297 let x = focus_bbox.min_x as f32 * 2.4 + 30.0;
298 let y = focus_bbox.min_y as f32 * 2.4 + 86.0;
299 let width = focus_bbox.width() as f32 * 2.4;
300 let height = focus_bbox.height() as f32 * 2.4;
301
302 let svg = format!(
303 r##"<svg xmlns="http://www.w3.org/2000/svg" width="1040" height="560" viewBox="0 0 1040 560">
304<rect width="1040" height="560" fill="#0b1320"/>
305<text x="30" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Intervention and Alpha Maps</text>
306<text x="30" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Current frame, intervention field, and alpha field for the canonical host-realistic onset frame.</text>
307<text x="112" y="86" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Current frame</text>
308<text x="454" y="86" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Intervention</text>
309<text x="790" y="86" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Alpha</text>
310<image href="{base_png}" x="30" y="94" width="300" height="180" preserveAspectRatio="none"/>
311<image href="{intervention_png}" x="370" y="94" width="300" height="180" preserveAspectRatio="none"/>
312<image href="{alpha_png}" x="710" y="94" width="300" height="180" preserveAspectRatio="none"/>
313<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="none" stroke="#f4f7fb" stroke-width="2" stroke-dasharray="8 6"/>
314<rect x="{x2}" y="{y}" width="{width}" height="{height}" fill="none" stroke="#f4f7fb" stroke-width="2" stroke-dasharray="8 6"/>
315<rect x="{x3}" y="{y}" width="{width}" height="{height}" fill="none" stroke="#f4f7fb" stroke-width="2" stroke-dasharray="8 6"/>
316</svg>"##,
317 x2 = x + 340.0,
318 x3 = x + 680.0,
319 );
320
321 fs::write(path, svg)?;
322 Ok(())
323}
324
325pub fn write_ablation_bar_figure(entries: &[AblationEntry], path: &Path) -> Result<()> {
326 if let Some(parent) = path.parent() {
327 fs::create_dir_all(parent)?;
328 }
329 let width = 980.0;
330 let height = 520.0;
331 let left = 210.0;
332 let top = 84.0;
333 let bar_height = 28.0;
334 let row_gap = 18.0;
335 let max_value = entries
336 .iter()
337 .map(|entry| entry.canonical_cumulative_roi_mae)
338 .fold(0.01f32, f32::max);
339 let mut rows = String::new();
340 for (index, entry) in entries.iter().enumerate() {
341 let y = top + index as f32 * (bar_height + row_gap);
342 let width_px = 620.0 * (entry.canonical_cumulative_roi_mae / max_value);
343 let _ = write!(
344 rows,
345 r##"<text x="18" y="{}" font-size="15" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">{}</text>
346<rect x="{left}" y="{y}" width="{width_px}" height="{bar_height}" rx="8" fill="#4cc9f0"/>
347<text x="{}" y="{}" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{:.5}</text>"##,
348 y + 20.0,
349 entry.label,
350 left + width_px + 10.0,
351 y + 19.0,
352 entry.canonical_cumulative_roi_mae
353 );
354 }
355
356 let svg = format!(
357 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
358<rect width="{width}" height="{height}" fill="#0b1320"/>
359<text x="18" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Canonical Ablation Comparison</text>
360<text x="18" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Bar length = cumulative ROI MAE on the canonical scenario. Lower is better.</text>
361{rows}
362</svg>"##
363 );
364 fs::write(path, svg)?;
365 Ok(())
366}
367
368pub fn write_roi_nonroi_error_figure(scenario: &ScenarioReport, path: &Path) -> Result<()> {
369 if let Some(parent) = path.parent() {
370 fs::create_dir_all(parent)?;
371 }
372 let fixed = scenario
373 .runs
374 .iter()
375 .find(|run| run.summary.run_id == "fixed_alpha")
376 .expect("fixed_alpha run required");
377 let host = scenario
378 .runs
379 .iter()
380 .find(|run| run.summary.run_id == "dsfb_host_realistic")
381 .expect("dsfb_host_realistic run required");
382 let width = 960.0f32;
383 let height = 520.0f32;
384 let left = 80.0f32;
385 let right = 860.0f32;
386 let top = 80.0f32;
387 let bottom = 440.0f32;
388 let inner_width = right - left;
389 let inner_height = bottom - top;
390 let max_value = fixed
391 .frame_metrics
392 .iter()
393 .chain(host.frame_metrics.iter())
394 .map(|frame| frame.roi_mae.max(frame.non_roi_mae))
395 .fold(0.05f32, f32::max);
396 let frame_count = fixed.frame_metrics.len().max(2);
397 let x_scale = inner_width / (frame_count.saturating_sub(1)) as f32;
398 let fixed_roi = polyline(
399 &fixed
400 .frame_metrics
401 .iter()
402 .enumerate()
403 .map(|(index, frame)| {
404 (
405 left + index as f32 * x_scale,
406 bottom - (frame.roi_mae / max_value) * inner_height,
407 )
408 })
409 .collect::<Vec<_>>(),
410 );
411 let host_roi = polyline(
412 &host
413 .frame_metrics
414 .iter()
415 .enumerate()
416 .map(|(index, frame)| {
417 (
418 left + index as f32 * x_scale,
419 bottom - (frame.roi_mae / max_value) * inner_height,
420 )
421 })
422 .collect::<Vec<_>>(),
423 );
424 let host_non_roi = polyline(
425 &host
426 .frame_metrics
427 .iter()
428 .enumerate()
429 .map(|(index, frame)| {
430 (
431 left + index as f32 * x_scale,
432 bottom - (frame.non_roi_mae / max_value) * inner_height,
433 )
434 })
435 .collect::<Vec<_>>(),
436 );
437 let svg = format!(
438 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
439<rect width="{width}" height="{height}" fill="#0b1320"/>
440<text x="28" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">ROI vs Non-ROI Error</text>
441<text x="28" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Canonical scenario. This makes the non-target stability tradeoff visible.</text>
442<line x1="{left}" y1="{top}" x2="{left}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2"/>
443<line x1="{left}" y1="{bottom}" x2="{right}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2"/>
444<path d="{fixed_roi}" fill="none" stroke="#ef476f" stroke-width="3.5"/>
445<path d="{host_roi}" fill="none" stroke="#4cc9f0" stroke-width="3.5"/>
446<path d="{host_non_roi}" fill="none" stroke="#ffd166" stroke-width="3.5" stroke-dasharray="10 8"/>
447<text x="540" y="110" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Fixed alpha ROI</text>
448<line x1="484" y1="104" x2="530" y2="104" stroke="#ef476f" stroke-width="3.5"/>
449<text x="540" y="138" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Host-realistic ROI</text>
450<line x1="484" y1="132" x2="530" y2="132" stroke="#4cc9f0" stroke-width="3.5"/>
451<text x="540" y="166" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Host-realistic non-ROI</text>
452<line x1="484" y1="160" x2="530" y2="160" stroke="#ffd166" stroke-width="3.5" stroke-dasharray="10 8"/>
453</svg>"##
454 );
455 fs::write(path, svg)?;
456 Ok(())
457}
458
459pub fn write_leaderboard_figure(entries: &[AggregateRunScore], path: &Path) -> Result<()> {
460 if let Some(parent) = path.parent() {
461 fs::create_dir_all(parent)?;
462 }
463 let mut rows = String::new();
464 let mut y = 110.0f32;
465 for (rank, entry) in entries.iter().take(10).enumerate() {
466 let _ = write!(
467 rows,
468 r##"<text x="24" y="{y}" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">{rank_label}</text>
469<text x="80" y="{y}" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">{label}</text>
470<text x="420" y="{y}" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{mean_rank:.2}</text>
471<text x="560" y="{y}" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{roi_mae:.5}</text>
472<text x="720" y="{y}" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{non_roi_mae:.5}</text>
473<text x="870" y="{y}" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{wins}</text>"##,
474 rank_label = rank + 1,
475 label = entry.label,
476 mean_rank = entry.mean_rank,
477 roi_mae = entry.mean_cumulative_roi_mae,
478 non_roi_mae = entry.mean_non_roi_mae,
479 wins = entry.benefit_scenarios_won,
480 );
481 y += 34.0;
482 }
483
484 let svg = format!(
485 r##"<svg xmlns="http://www.w3.org/2000/svg" width="980" height="520" viewBox="0 0 980 520">
486<rect width="980" height="520" fill="#0b1320"/>
487<text x="24" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Aggregate Scenario Leaderboard</text>
488<text x="24" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Mean rank uses scenario-appropriate primary metrics. Lower is better.</text>
489<text x="24" y="94" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Rank</text>
490<text x="80" y="94" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Run</text>
491<text x="420" y="94" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Mean rank</text>
492<text x="560" y="94" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Mean ROI MAE</text>
493<text x="720" y="94" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Mean non-ROI MAE</text>
494<text x="870" y="94" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Wins</text>
495{rows}
496</svg>"##
497 );
498 fs::write(path, svg)?;
499 Ok(())
500}
501
502pub fn write_scenario_mosaic_figure(
503 entries: &[ScenarioMosaicEntry<'_>],
504 path: &Path,
505) -> Result<()> {
506 if let Some(parent) = path.parent() {
507 fs::create_dir_all(parent)?;
508 }
509
510 let mut body = String::new();
511 let tile_width = 300.0;
512 let tile_height = 180.0;
513 let row_height = 245.0;
514 for (row, entry) in entries.iter().enumerate() {
515 let y = 92.0 + row as f32 * row_height;
516 let baseline_png = png_data_uri(&entry.baseline.encode_png()?);
517 let heuristic_png = png_data_uri(&entry.heuristic.encode_png()?);
518 let host_png = png_data_uri(&entry.host_realistic.encode_png()?);
519 let _ = write!(
520 body,
521 r##"<text x="24" y="{}" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">{}</text>
522<image href="{baseline_png}" x="24" y="{}" width="{tile_width}" height="{tile_height}" preserveAspectRatio="none"/>
523<image href="{heuristic_png}" x="348" y="{}" width="{tile_width}" height="{tile_height}" preserveAspectRatio="none"/>
524<image href="{host_png}" x="672" y="{}" width="{tile_width}" height="{tile_height}" preserveAspectRatio="none"/>"##,
525 y - 10.0,
526 entry.scenario_title,
527 y,
528 y,
529 y,
530 );
531 }
532
533 let height = 100.0 + entries.len() as f32 * row_height;
534 let svg = format!(
535 r##"<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="{height}" viewBox="0 0 1000 {height}">
536<rect width="1000" height="{height}" fill="#0b1320"/>
537<text x="24" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Per-Scenario Comparison Mosaic</text>
538<text x="24" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Each row shows fixed alpha, strong heuristic, and host-realistic DSFB on a different scenario.</text>
539<text x="120" y="88" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Fixed alpha</text>
540<text x="430" y="88" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Strong heuristic</text>
541<text x="746" y="88" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Host-realistic DSFB</text>
542{body}
543</svg>"##
544 );
545 fs::write(path, svg)?;
546 Ok(())
547}
548
549pub fn write_demo_b_sampling_figure(
550 scenario_report: &DemoBScenarioReport,
551 scenario_run: &DemoBScenarioRun,
552 path: &Path,
553) -> Result<()> {
554 if let Some(parent) = path.parent() {
555 fs::create_dir_all(parent)?;
556 }
557
558 let uniform = scenario_run
559 .policy_runs
560 .iter()
561 .find(|run| run.policy_id == crate::sampling::AllocationPolicyId::Uniform)
562 .expect("uniform run required");
563 let imported = scenario_run
564 .policy_runs
565 .iter()
566 .find(|run| run.policy_id == crate::sampling::AllocationPolicyId::ImportedTrust)
567 .expect("imported trust run required");
568 let combined = scenario_run
569 .policy_runs
570 .iter()
571 .find(|run| run.policy_id == crate::sampling::AllocationPolicyId::CombinedHeuristic)
572 .expect("combined heuristic run required");
573 let reference_png = png_data_uri(&scenario_run.reference_frame.encode_png()?);
574 let uniform_png = png_data_uri(&uniform.frame.encode_png()?);
575 let combined_png = png_data_uri(&combined.frame.encode_png()?);
576 let imported_png = png_data_uri(&imported.frame.encode_png()?);
577 let combined_spp = png_data_uri(
578 &field_to_image(&combined.spp, |value| {
579 allocation_palette(value, combined.metrics.max_spp as f32)
580 })
581 .encode_png()?,
582 );
583 let imported_spp = png_data_uri(
584 &field_to_image(&imported.spp, |value| {
585 allocation_palette(value, imported.metrics.max_spp as f32)
586 })
587 .encode_png()?,
588 );
589
590 let svg = format!(
591 r##"<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="760" viewBox="0 0 1180 760">
592<rect width="1180" height="760" fill="#0b1320"/>
593<text x="24" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Demo B Policy Comparison</text>
594<text x="24" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{}</text>
595<text x="60" y="94" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Reference</text>
596<text x="346" y="94" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Uniform</text>
597<text x="628" y="94" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Combined heuristic</text>
598<text x="896" y="94" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Imported trust</text>
599<image href="{reference_png}" x="24" y="102" width="260" height="156" preserveAspectRatio="none"/>
600<image href="{uniform_png}" x="308" y="102" width="260" height="156" preserveAspectRatio="none"/>
601<image href="{combined_png}" x="592" y="102" width="260" height="156" preserveAspectRatio="none"/>
602<image href="{imported_png}" x="876" y="102" width="260" height="156" preserveAspectRatio="none"/>
603<text x="320" y="306" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Combined heuristic spp</text>
604<text x="888" y="306" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Imported trust spp</text>
605<image href="{combined_spp}" x="308" y="314" width="260" height="156" preserveAspectRatio="none"/>
606<image href="{imported_spp}" x="876" y="314" width="260" height="156" preserveAspectRatio="none"/>
607<text x="24" y="536" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Uniform ROI MAE: {uniform_roi:.5}</text>
608<text x="24" y="560" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Combined ROI MAE: {combined_roi:.5}</text>
609<text x="24" y="584" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Imported trust ROI MAE: {imported_roi:.5}</text>
610</svg>"##,
611 scenario_report.headline,
612 uniform_roi = uniform.metrics.roi_mae,
613 combined_roi = combined.metrics.roi_mae,
614 imported_roi = imported.metrics.roi_mae,
615 );
616 fs::write(path, svg)?;
617 Ok(())
618}
619
620pub fn write_demo_b_budget_efficiency_figure(curves: &[BudgetCurve], path: &Path) -> Result<()> {
621 if let Some(parent) = path.parent() {
622 fs::create_dir_all(parent)?;
623 }
624 let relevant = curves
625 .iter()
626 .filter(|curve| curve.scenario_id == "thin_reveal")
627 .collect::<Vec<_>>();
628 let width = 960.0;
629 let height = 520.0;
630 let left = 90.0;
631 let right = 860.0;
632 let top = 80.0;
633 let bottom = 430.0;
634 let inner_width = right - left;
635 let inner_height = bottom - top;
636 let max_x = relevant
637 .iter()
638 .flat_map(|curve| curve.points.iter().map(|point| point.average_spp))
639 .fold(1.0f32, f32::max);
640 let max_y = relevant
641 .iter()
642 .flat_map(|curve| curve.points.iter().map(|point| point.roi_mae))
643 .fold(0.05f32, f32::max);
644 let colors = [
645 ("uniform", "#ef476f"),
646 ("combined_heuristic", "#ffd166"),
647 ("imported_trust", "#4cc9f0"),
648 ("hybrid_trust_variance", "#8bd450"),
649 ];
650 let mut paths = String::new();
651 let mut legend_y = 118.0;
652 for (policy_id, color) in colors {
653 if let Some(curve) = relevant.iter().find(|curve| curve.policy_id == policy_id) {
654 let polyline_path = polyline(
655 &curve
656 .points
657 .iter()
658 .map(|point| {
659 (
660 left + (point.average_spp / max_x) * inner_width,
661 bottom - (point.roi_mae / max_y) * inner_height,
662 )
663 })
664 .collect::<Vec<_>>(),
665 );
666 let _ = write!(
667 paths,
668 r##"<path d="{polyline_path}" fill="none" stroke="{color}" stroke-width="3.5"/>
669<line x1="620" y1="{legend_y}" x2="664" y2="{legend_y}" stroke="{color}" stroke-width="3.5"/>
670<text x="674" y="{}" font-size="16" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">{policy_id}</text>"##,
671 legend_y + 5.0
672 );
673 legend_y += 28.0;
674 }
675 }
676 let svg = format!(
677 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
678<rect width="{width}" height="{height}" fill="#0b1320"/>
679<text x="28" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Demo B Budget Efficiency</text>
680<text x="28" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Canonical scenario. x-axis = mean spp, y-axis = ROI MAE.</text>
681<line x1="{left}" y1="{top}" x2="{left}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2"/>
682<line x1="{left}" y1="{bottom}" x2="{right}" y2="{bottom}" stroke="#f4f7fb" stroke-width="2"/>
683{paths}
684</svg>"##
685 );
686 fs::write(path, svg)?;
687 Ok(())
688}
689
690pub fn write_trust_histogram_figure(diagnostics: &TrustDiagnostics, path: &Path) -> Result<()> {
691 if let Some(parent) = path.parent() {
692 fs::create_dir_all(parent)?;
693 }
694 let entry = diagnostics
695 .scenarios
696 .iter()
697 .find(|entry| {
698 entry.scenario_id == "motion_bias_band" && entry.run_id == "dsfb_host_realistic"
699 })
700 .or_else(|| diagnostics.scenarios.first())
701 .expect("trust diagnostics required");
702 let max_count = entry
703 .histogram
704 .iter()
705 .map(|bin| bin.sample_count)
706 .max()
707 .unwrap_or(1) as f32;
708 let mut bars = String::new();
709 for (index, bin) in entry.histogram.iter().enumerate() {
710 let x = 70.0 + index as f32 * 52.0;
711 let height = 220.0 * (bin.sample_count as f32 / max_count.max(1.0));
712 let y = 340.0 - height;
713 let _ = write!(
714 bars,
715 r##"<rect x="{x}" y="{y}" width="34" height="{height}" rx="4" fill="#4cc9f0"/>
716<text x="{label_x}" y="362" font-size="12" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{lower:.1}</text>"##,
717 label_x = x - 2.0,
718 lower = bin.lower,
719 );
720 }
721 let mut calibration = String::new();
722 for (index, bin) in entry.calibration_bins.iter().enumerate() {
723 let x = 650.0 + index as f32 * 60.0;
724 let error_height = 120.0 * (bin.mean_error / 0.25).clamp(0.0, 1.0);
725 let y = 320.0 - error_height;
726 let _ = write!(
727 calibration,
728 r##"<rect x="{x}" y="{y}" width="28" height="{error_height}" rx="4" fill="#ffd166"/>
729<text x="{label_x}" y="342" font-size="12" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{trust:.2}</text>"##,
730 label_x = x - 6.0,
731 trust = bin.mean_trust,
732 );
733 }
734 let svg = format!(
735 r##"<svg xmlns="http://www.w3.org/2000/svg" width="980" height="420" viewBox="0 0 980 420">
736<rect width="980" height="420" fill="#0b1320"/>
737<text x="28" y="36" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Trust Histogram and Calibration Bins</text>
738<text x="28" y="60" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{scenario} / {run}</text>
739<text x="70" y="94" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Histogram</text>
740<line x1="60" y1="340" x2="590" y2="340" stroke="#f4f7fb" stroke-width="2"/>
741{bars}
742<text x="650" y="94" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Calibration bins</text>
743<line x1="640" y1="320" x2="940" y2="320" stroke="#f4f7fb" stroke-width="2"/>
744{calibration}
745<text x="650" y="374" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Degenerate correlation hidden from headlines when bin occupancy is weak.</text>
746</svg>"##,
747 scenario = entry.scenario_id,
748 run = entry.run_id,
749 );
750 fs::write(path, svg)?;
751 Ok(())
752}
753
754pub fn write_roi_taxonomy_figure(demo_a: &DemoASuiteMetrics, path: &Path) -> Result<()> {
755 if let Some(parent) = path.parent() {
756 fs::create_dir_all(parent)?;
757 }
758 let max_pixels = demo_a
759 .scenarios
760 .iter()
761 .map(|scenario| scenario.target_pixels)
762 .max()
763 .unwrap_or(1) as f32;
764 let mut rows = String::new();
765 for (index, scenario) in demo_a.scenarios.iter().enumerate() {
766 let y = 96.0 + index as f32 * 44.0;
767 let width = 420.0 * (scenario.target_pixels as f32 / max_pixels.max(1.0));
768 let color = match scenario.support_category {
769 crate::scene::ScenarioSupportCategory::PointLikeRoi => "#ef476f",
770 crate::scene::ScenarioSupportCategory::RegionRoi => "#4cc9f0",
771 crate::scene::ScenarioSupportCategory::NegativeControl => "#8bd450",
772 };
773 let _ = write!(
774 rows,
775 r##"<text x="24" y="{y}" font-size="15" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">{label}</text>
776<rect x="320" y="{bar_y}" width="{width}" height="20" rx="6" fill="{color}"/>
777<text x="{value_x}" y="{y}" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">{pixels}</text>"##,
778 label = scenario.scenario_id,
779 bar_y = y - 14.0,
780 value_x = 750.0,
781 pixels = scenario.target_pixels,
782 );
783 }
784 let svg = format!(
785 r##"<svg xmlns="http://www.w3.org/2000/svg" width="900" height="460" viewBox="0 0 900 460">
786<rect width="900" height="460" fill="#0b1320"/>
787<text x="24" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">ROI Size and Scenario Taxonomy</text>
788<text x="24" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Point-like ROI, region ROI, and negative-control scenarios are separated explicitly.</text>
789{rows}
790<text x="24" y="420" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Red = point-like ROI, blue = region ROI, green = negative control.</text>
791</svg>"##
792 );
793 fs::write(path, svg)?;
794 Ok(())
795}
796
797pub fn write_parameter_sensitivity_figure(
798 sensitivity: &ParameterSensitivityMetrics,
799 path: &Path,
800) -> Result<()> {
801 if let Some(parent) = path.parent() {
802 fs::create_dir_all(parent)?;
803 }
804 let points = sensitivity.sweep_points.iter().take(18).collect::<Vec<_>>();
805 let max_value = points
806 .iter()
807 .map(|point| point.region_mean_cumulative_roi_mae)
808 .fold(0.01f32, f32::max);
809 let mut bars = String::new();
810 for (index, point) in points.iter().enumerate() {
811 let x = 56.0 + index as f32 * 46.0;
812 let height = 220.0 * (point.region_mean_cumulative_roi_mae / max_value);
813 let y = 340.0 - height;
814 let fill = if point.robust_corridor_member {
815 "#4cc9f0"
816 } else {
817 "#ef476f"
818 };
819 let _ = write!(
820 bars,
821 r##"<rect x="{x}" y="{y}" width="28" height="{height}" rx="4" fill="{fill}"/>
822<text x="{label_x}" y="362" font-size="10" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd" transform="rotate(45 {label_x} 362)">{label}</text>"##,
823 label_x = x - 2.0,
824 label = point.parameter_id,
825 );
826 }
827 let svg = format!(
828 r##"<svg xmlns="http://www.w3.org/2000/svg" width="960" height="420" viewBox="0 0 960 420">
829<rect width="960" height="420" fill="#0b1320"/>
830<text x="24" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Parameter Sensitivity Corridor</text>
831<text x="24" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Blue sweep points stay within the report's robustness corridor. Red points are fragile.</text>
832<line x1="44" y1="340" x2="920" y2="340" stroke="#f4f7fb" stroke-width="2"/>
833{bars}
834</svg>"##
835 );
836 fs::write(path, svg)?;
837 Ok(())
838}
839
840pub fn write_resolution_scaling_figure(
841 scaling: &ResolutionScalingMetrics,
842 path: &Path,
843) -> Result<()> {
844 if let Some(parent) = path.parent() {
845 fs::create_dir_all(parent)?;
846 }
847 let relevant = scaling
848 .entries
849 .iter()
850 .filter(|entry| {
851 matches!(
852 entry.scenario_id.as_str(),
853 "thin_reveal" | "reveal_band" | "motion_bias_band"
854 )
855 })
856 .collect::<Vec<_>>();
857 let max_gain = relevant
858 .iter()
859 .map(|entry| entry.host_realistic_vs_fixed_alpha_gain.abs())
860 .fold(0.01f32, f32::max);
861 let mut bars = String::new();
862 for (index, entry) in relevant.iter().enumerate() {
863 let x = 52.0 + index as f32 * 58.0;
864 let height = 190.0 * (entry.host_realistic_vs_fixed_alpha_gain.abs() / max_gain);
865 let y = if entry.host_realistic_vs_fixed_alpha_gain >= 0.0 {
866 250.0 - height
867 } else {
868 250.0
869 };
870 let fill = if entry.host_realistic_vs_fixed_alpha_gain >= 0.0 {
871 "#4cc9f0"
872 } else {
873 "#ef476f"
874 };
875 let _ = write!(
876 bars,
877 r##"<rect x="{x}" y="{y}" width="32" height="{height}" rx="4" fill="{fill}"/>
878<text x="{label_x}" y="388" font-size="10" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd" transform="rotate(45 {label_x} 388)">{label}</text>"##,
879 label_x = x - 8.0,
880 label = format!("{}@{}x{}", entry.scenario_id, entry.width, entry.height),
881 );
882 }
883 let svg = format!(
884 r##"<svg xmlns="http://www.w3.org/2000/svg" width="980" height="440" viewBox="0 0 980 440">
885<rect width="980" height="440" fill="#0b1320"/>
886<text x="24" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Resolution Scaling Gain</text>
887<text x="24" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Positive bars mean host-realistic DSFB beats fixed alpha on cumulative ROI MAE.</text>
888<line x1="40" y1="250" x2="944" y2="250" stroke="#f4f7fb" stroke-width="2"/>
889{bars}
890</svg>"##
891 );
892 fs::write(path, svg)?;
893 Ok(())
894}
895
896pub fn write_motion_relevance_figure(demo_a: &DemoASuiteMetrics, path: &Path) -> Result<()> {
897 if let Some(parent) = path.parent() {
898 fs::create_dir_all(parent)?;
899 }
900 let scenarios = demo_a
901 .scenarios
902 .iter()
903 .filter(|scenario| {
904 matches!(
905 scenario.scenario_id.as_str(),
906 "fast_pan" | "motion_bias_band" | "reveal_band"
907 )
908 })
909 .collect::<Vec<_>>();
910 let mut bars = String::new();
911 for (index, scenario) in scenarios.iter().enumerate() {
912 let host = scenario
913 .runs
914 .iter()
915 .find(|run| run.summary.run_id == "dsfb_host_realistic")
916 .expect("host run required");
917 let motion = scenario
918 .runs
919 .iter()
920 .find(|run| run.summary.run_id == "dsfb_motion_augmented")
921 .expect("motion run required");
922 let gain = host.summary.cumulative_roi_mae - motion.summary.cumulative_roi_mae;
923 let height = 160.0 * (gain.abs() / 0.5).clamp(0.0, 1.0);
924 let x = 120.0 + index as f32 * 220.0;
925 let y = if gain >= 0.0 { 270.0 - height } else { 270.0 };
926 let color = if gain >= 0.0 { "#4cc9f0" } else { "#ef476f" };
927 let _ = write!(
928 bars,
929 r##"<rect x="{x}" y="{y}" width="68" height="{height}" rx="6" fill="{color}"/>
930<text x="{text_x}" y="320" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">{label}</text>"##,
931 text_x = x - 10.0,
932 label = scenario.scenario_id,
933 );
934 }
935 let svg = format!(
936 r##"<svg xmlns="http://www.w3.org/2000/svg" width="860" height="380" viewBox="0 0 860 380">
937<rect width="860" height="380" fill="#0b1320"/>
938<text x="24" y="40" font-size="30" font-family="Arial, Helvetica, sans-serif" fill="#f4f7fb">Motion Relevance</text>
939<text x="24" y="64" font-size="18" font-family="Arial, Helvetica, sans-serif" fill="#c6d2dd">Positive bars mean the optional motion-augmented path improved over the minimum host path.</text>
940<line x1="70" y1="270" x2="810" y2="270" stroke="#f4f7fb" stroke-width="2"/>
941{bars}
942</svg>"##
943 );
944 fs::write(path, svg)?;
945 Ok(())
946}
947
948fn trust_overlay_image(trust: &ScalarField) -> ImageFrame {
949 let mut frame = ImageFrame::new(trust.width(), trust.height());
950 for y in 0..trust.height() {
951 for x in 0..trust.width() {
952 let hazard = (1.0 - trust.get(x, y)).clamp(0.0, 1.0);
953 frame.set(
954 x,
955 y,
956 if hazard <= 0.02 {
957 Color::rgb(0.0, 0.0, 0.0)
958 } else {
959 Color::rgb(0.95 * hazard, 0.15 + 0.65 * hazard, 0.10 * hazard)
960 },
961 );
962 }
963 }
964 frame
965}
966
967fn field_to_image(field: &ScalarField, mapper: impl Fn(f32) -> Color) -> ImageFrame {
968 let mut frame = ImageFrame::new(field.width(), field.height());
969 for y in 0..field.height() {
970 for x in 0..field.width() {
971 frame.set(x, y, mapper(field.get(x, y)));
972 }
973 }
974 frame
975}
976
977fn intervention_palette(value: f32) -> Color {
978 let normalized = value.clamp(0.0, 1.0);
979 Color::rgb(
980 0.12 + 0.86 * normalized,
981 0.18 + 0.55 * (1.0 - normalized),
982 0.12,
983 )
984}
985
986fn alpha_palette(value: f32) -> Color {
987 let normalized = value.clamp(0.0, 1.0);
988 Color::rgb(
989 0.20 + 0.72 * normalized,
990 0.16 + 0.22 * normalized,
991 0.34 + 0.40 * (1.0 - normalized),
992 )
993}
994
995fn allocation_palette(value: f32, max_value: f32) -> Color {
996 let normalized = if max_value <= f32::EPSILON {
997 0.0
998 } else {
999 (value / max_value).clamp(0.0, 1.0)
1000 };
1001 Color::rgb(
1002 0.10 + 0.88 * normalized,
1003 0.20 + 0.55 * (1.0 - normalized),
1004 0.30 + 0.50 * normalized,
1005 )
1006}
1007
1008fn polyline(points: &[(f32, f32)]) -> String {
1009 let mut path = String::new();
1010 if let Some((x, y)) = points.first().copied() {
1011 let _ = write!(path, "M{x:.2},{y:.2}");
1012 }
1013 for (x, y) in points.iter().skip(1) {
1014 let _ = write!(path, " L{x:.2},{y:.2}");
1015 }
1016 path
1017}
1018
1019fn png_data_uri(bytes: &[u8]) -> String {
1020 format!("data:image/png;base64,{}", base64_encode(bytes))
1021}
1022
1023fn base64_encode(bytes: &[u8]) -> String {
1024 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1025 let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
1026 let mut chunks = bytes.chunks_exact(3);
1027
1028 for chunk in chunks.by_ref() {
1029 let combined = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
1030 output.push(TABLE[((combined >> 18) & 0x3f) as usize] as char);
1031 output.push(TABLE[((combined >> 12) & 0x3f) as usize] as char);
1032 output.push(TABLE[((combined >> 6) & 0x3f) as usize] as char);
1033 output.push(TABLE[(combined & 0x3f) as usize] as char);
1034 }
1035
1036 let remainder = chunks.remainder();
1037 if !remainder.is_empty() {
1038 let first = remainder[0] as u32;
1039 let second = remainder.get(1).copied().unwrap_or(0) as u32;
1040 let combined = (first << 16) | (second << 8);
1041 output.push(TABLE[((combined >> 18) & 0x3f) as usize] as char);
1042 output.push(TABLE[((combined >> 12) & 0x3f) as usize] as char);
1043 if remainder.len() == 2 {
1044 output.push(TABLE[((combined >> 6) & 0x3f) as usize] as char);
1045 output.push('=');
1046 } else {
1047 output.push('=');
1048 output.push('=');
1049 }
1050 }
1051
1052 output
1053}