Skip to main content

dsfb_computer_graphics/
plots.rs

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(&current_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(&current_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}