Skip to main content

spatialrust_platform/
visual.rs

1//! SpatialRust Visual fail-closed conformance and release gate.
2
3use std::fmt::Write;
4
5use crate::{
6    BudgetKind, ConformanceReport, ConformanceStatus, LtsPolicy, PerformanceBudget,
7    PerformanceBudgetReport, ReleaseGate, ReleaseGateDecision, SecurityChecklist,
8    StabilityRegistry,
9};
10
11const REQUIRED_CASES: &[&str] = &[
12    "visual-headless-linux",
13    "visual-headless-windows",
14    "visual-headless-macos",
15    "visual-native-smoke",
16    "visual-web-wasm",
17    "visual-browser-smoke",
18    "visual-python-38",
19    "visual-python-current",
20    "visual-jupyter-notebook",
21    "visual-lod-budgets",
22    "visual-transfer-ledger",
23    "visual-docs",
24    "visual-unsafe-audit",
25];
26
27const REQUIRED_RECEIPTS: &[&str] = &[
28    "visual-viz-contracts",
29    "visual-wgpu-renderer",
30    "visual-native-debug",
31    "visual-scene-rgbd",
32    "visual-bounded-lod",
33    "visual-web-viewer",
34    "visual-python-jupyter",
35];
36
37const REQUIRED_EXAMPLES: &[&str] = &["visual_release_gate"];
38const MAX_RECEIPT_AGE_DAYS: u64 = 30;
39
40/// Typed canonical measurements consumed by the Visual release gate.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct VisualMeasurements {
43    /// Pixel/channel mismatches in strict canonical headless fixtures.
44    pub headless_pixel_mismatches: u64,
45    /// Maximum absolute channel delta in canonical headless fixtures.
46    pub headless_max_channel_delta: u64,
47    /// Explicit host-to-device geometry bytes for the canonical point source.
48    pub geometry_upload_bytes: u64,
49    /// Explicit render-uniform bytes for the canonical frame.
50    pub render_uniform_upload_bytes: u64,
51    /// Device-to-host bytes before caller-requested readback.
52    pub unexpected_readback_bytes: u64,
53    /// Caller-requested RGBA screenshot readback bytes.
54    pub screenshot_readback_bytes: u64,
55    /// Peak accounted host bytes in the canonical LOD run.
56    pub peak_lod_host_bytes: u64,
57    /// Peak accounted device bytes in the canonical LOD run.
58    pub peak_lod_gpu_bytes: u64,
59    /// Maximum concurrent LOD chunk requests.
60    pub inflight_lod_chunks: u64,
61    /// Total admitted HTTP Range bytes in the browser fixture.
62    pub browser_requested_bytes: u64,
63    /// Explicit bytes copied by the canonical Python fixture.
64    pub python_copy_bytes: u64,
65    /// Portable state mismatches across native, Web, Python, and Jupyter.
66    pub state_roundtrip_mismatches: u64,
67}
68
69/// One dated Visual evidence receipt.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct VisualReceiptEvidence {
72    /// Stable receipt identifier.
73    pub id: String,
74    /// UTC day containing the evidence, expressed as Unix epoch days.
75    pub captured_unix_days: u64,
76}
77
78/// Evidence gathered by CI and release tooling for a Visual candidate.
79#[derive(Clone, Debug)]
80pub struct VisualReleaseEvidence {
81    /// Required image, platform, adapter, audit, and documentation cases.
82    pub conformance: ConformanceReport,
83    /// Satisfied security audit evidence.
84    pub security: SecurityChecklist,
85    /// Typed image, transfer, residency, request, and state values.
86    pub measurements: VisualMeasurements,
87    /// Candidate UTC day, expressed as Unix epoch days.
88    pub candidate_unix_days: u64,
89    /// Required dated implementation receipts.
90    pub receipts: Vec<VisualReceiptEvidence>,
91    /// Cargo examples compiled and exercised by the candidate.
92    pub verified_examples: Vec<String>,
93    /// Migration policy identifier; must equal `visual-1`.
94    pub migration_policy: String,
95}
96
97/// Mandatory SpatialRust Visual release policy.
98#[derive(Clone, Copy, Debug, Default)]
99pub struct VisualReleaseGate;
100
101impl VisualReleaseGate {
102    /// Returns conformance ids that must be present exactly once with `Pass`.
103    pub const fn required_conformance_cases() -> &'static [&'static str] {
104        REQUIRED_CASES
105    }
106
107    /// Returns fresh receipt ids required by the release candidate.
108    pub const fn required_receipts() -> &'static [&'static str] {
109        REQUIRED_RECEIPTS
110    }
111
112    /// Returns runnable examples required by the release candidate.
113    pub const fn required_examples() -> &'static [&'static str] {
114        REQUIRED_EXAMPLES
115    }
116
117    /// Maximum accepted receipt age in whole days.
118    pub const fn max_receipt_age_days() -> u64 {
119        MAX_RECEIPT_AGE_DAYS
120    }
121
122    /// Evaluates every mandatory item and returns all denial reasons.
123    pub fn evaluate(evidence: &VisualReleaseEvidence) -> ReleaseGateDecision {
124        let base = ReleaseGate {
125            stability: Some(StabilityRegistry::visual_surface()),
126            conformance: Some(evidence.conformance.clone()),
127            security: Some(evidence.security.clone()),
128            lts: Some(LtsPolicy::spatialrust_v1()),
129            budgets: Some(visual_budgets(evidence.measurements)),
130            reject_experimental: true,
131        };
132        let mut decision = base.evaluate();
133        require_passing_cases(&mut decision.reasons, &evidence.conformance);
134        require_fresh_receipts(
135            &mut decision.reasons,
136            evidence.candidate_unix_days,
137            &evidence.receipts,
138        );
139        require_names(
140            &mut decision.reasons,
141            "example",
142            REQUIRED_EXAMPLES,
143            &evidence.verified_examples,
144        );
145        if evidence.migration_policy != "visual-1" {
146            decision.reasons.push("migration policy `visual-1` was not acknowledged".into());
147        }
148        decision.allowed = decision.reasons.is_empty();
149        decision
150    }
151
152    /// Generates the auditable Markdown receipt embedded in release docs.
153    #[must_use]
154    pub fn render_markdown(evidence: &VisualReleaseEvidence) -> String {
155        let decision = Self::evaluate(evidence);
156        let mut output = String::from("# Visual release receipt\n\n");
157        let _ = writeln!(
158            output,
159            "Decision: **{}**\n",
160            if decision.allowed { "allowed" } else { "denied" }
161        );
162        let _ = writeln!(output, "Candidate Unix day: `{}`\n", evidence.candidate_unix_days);
163        output.push_str("| Measurement | Observed | Ceiling |\n");
164        output.push_str("| --- | ---: | ---: |\n");
165        for (label, observed, ceiling) in measurement_rows(evidence.measurements) {
166            let _ = writeln!(output, "| {label} | {observed} | {ceiling} |");
167        }
168        output.push_str("\nRequired receipts:\n\n");
169        for receipt in REQUIRED_RECEIPTS {
170            let matching = evidence.receipts.iter().find(|item| item.id == *receipt);
171            let present = matching.is_some();
172            let age = matching
173                .and_then(|item| evidence.candidate_unix_days.checked_sub(item.captured_unix_days))
174                .map_or_else(|| "n/a".into(), |days| format!("{days} day(s)"));
175            let _ = writeln!(output, "- [{}] `{receipt}` ({age})", if present { "x" } else { " " });
176        }
177        if !decision.reasons.is_empty() {
178            output.push_str("\nDenial reasons:\n\n");
179            for reason in decision.reasons {
180                let _ = writeln!(output, "- {reason}");
181            }
182        }
183        output
184    }
185}
186
187fn require_passing_cases(reasons: &mut Vec<String>, conformance: &ConformanceReport) {
188    for required in REQUIRED_CASES {
189        let matching =
190            conformance.cases().iter().filter(|case| case.id == *required).collect::<Vec<_>>();
191        match matching.as_slice() {
192            [case] if case.status == ConformanceStatus::Pass => {}
193            [case] => {
194                reasons.push(format!("required conformance `{required}` is {:?}", case.status))
195            }
196            [] => reasons.push(format!("required conformance `{required}` is missing")),
197            _ => reasons.push(format!("required conformance `{required}` is duplicated")),
198        }
199    }
200}
201
202fn require_fresh_receipts(
203    reasons: &mut Vec<String>,
204    candidate_unix_days: u64,
205    receipts: &[VisualReceiptEvidence],
206) {
207    if candidate_unix_days == 0 {
208        reasons.push("candidate Unix day must be non-zero".into());
209    }
210    for required in REQUIRED_RECEIPTS {
211        let matching =
212            receipts.iter().filter(|receipt| receipt.id == *required).collect::<Vec<_>>();
213        match matching.as_slice() {
214            [receipt] if receipt.captured_unix_days > candidate_unix_days => {
215                reasons.push(format!("required receipt `{required}` is dated after the candidate"))
216            }
217            [receipt]
218                if candidate_unix_days - receipt.captured_unix_days > MAX_RECEIPT_AGE_DAYS =>
219            {
220                reasons.push(format!(
221                    "required receipt `{required}` is stale (older than {MAX_RECEIPT_AGE_DAYS} days)"
222                ));
223            }
224            [_] => {}
225            [] => reasons.push(format!("required receipt `{required}` is missing")),
226            _ => reasons.push(format!("required receipt `{required}` is duplicated")),
227        }
228    }
229}
230
231fn require_names(reasons: &mut Vec<String>, kind: &str, required: &[&str], actual: &[String]) {
232    for name in required {
233        let count = actual.iter().filter(|value| value.as_str() == *name).count();
234        match count {
235            1 => {}
236            0 => reasons.push(format!("required {kind} `{name}` is missing")),
237            _ => reasons.push(format!("required {kind} `{name}` is duplicated")),
238        }
239    }
240}
241
242fn measurement_rows(values: VisualMeasurements) -> [(&'static str, u64, u64); 12] {
243    [
244        ("headless pixel mismatches", values.headless_pixel_mismatches, 0),
245        ("headless maximum channel delta", values.headless_max_channel_delta, 0),
246        ("canonical geometry upload (bytes)", values.geometry_upload_bytes, 8 * 1024 * 1024),
247        ("render uniform upload (bytes)", values.render_uniform_upload_bytes, 112),
248        ("unexpected readback (bytes)", values.unexpected_readback_bytes, 0),
249        ("screenshot readback (bytes)", values.screenshot_readback_bytes, 64 * 64 * 4),
250        ("peak LOD host memory (bytes)", values.peak_lod_host_bytes, 64 * 1024 * 1024),
251        ("peak LOD GPU memory (bytes)", values.peak_lod_gpu_bytes, 64 * 1024 * 1024),
252        ("in-flight LOD chunks", values.inflight_lod_chunks, 8),
253        ("browser requested bytes", values.browser_requested_bytes, 8 * 1024 * 1024),
254        ("Python explicit copy (bytes)", values.python_copy_bytes, 12 * 1024 * 1024),
255        ("state round-trip mismatches", values.state_roundtrip_mismatches, 0),
256    ]
257}
258
259fn visual_budgets(values: VisualMeasurements) -> PerformanceBudgetReport {
260    let kinds = [
261        BudgetKind::AllocationCount,
262        BudgetKind::AllocationCount,
263        BudgetKind::BytesCopied,
264        BudgetKind::BytesCopied,
265        BudgetKind::BytesCopied,
266        BudgetKind::BytesCopied,
267        BudgetKind::MemoryBytes,
268        BudgetKind::MemoryBytes,
269        BudgetKind::ThreadCount,
270        BudgetKind::BytesCopied,
271        BudgetKind::BytesCopied,
272        BudgetKind::AllocationCount,
273    ];
274    let ids = [
275        "visual-headless-pixel-mismatches",
276        "visual-headless-channel-delta",
277        "visual-geometry-upload-bytes",
278        "visual-render-uniform-upload-bytes",
279        "visual-unexpected-readback-bytes",
280        "visual-screenshot-readback-bytes",
281        "visual-peak-lod-host-bytes",
282        "visual-peak-lod-gpu-bytes",
283        "visual-inflight-lod-chunks",
284        "visual-browser-requested-bytes",
285        "visual-python-copy-bytes",
286        "visual-state-roundtrip-mismatches",
287    ];
288    let mut report = PerformanceBudgetReport::new();
289    for (((_, observed, ceiling), kind), id) in
290        measurement_rows(values).into_iter().zip(kinds).zip(ids)
291    {
292        report.declare(PerformanceBudget { id: id.into(), kind, ceiling });
293        report.sample(id, observed);
294    }
295    report
296}
297
298#[cfg(test)]
299mod tests {
300    use super::{
301        VisualMeasurements, VisualReceiptEvidence, VisualReleaseEvidence, VisualReleaseGate,
302    };
303    use crate::{ConformanceReport, ConformanceStatus, SecurityChecklist};
304
305    fn passing() -> VisualReleaseEvidence {
306        let mut conformance = ConformanceReport::new();
307        for &id in VisualReleaseGate::required_conformance_cases() {
308            conformance.record(id, ConformanceStatus::Pass, Some("CI receipt".into()));
309        }
310        VisualReleaseEvidence {
311            conformance,
312            security: SecurityChecklist::north_star_baseline_satisfied(),
313            measurements: VisualMeasurements {
314                headless_pixel_mismatches: 0,
315                headless_max_channel_delta: 0,
316                geometry_upload_bytes: 40,
317                render_uniform_upload_bytes: 112,
318                unexpected_readback_bytes: 0,
319                screenshot_readback_bytes: 64 * 64 * 4,
320                peak_lod_host_bytes: 24 * 1024 * 1024,
321                peak_lod_gpu_bytes: 20 * 1024 * 1024,
322                inflight_lod_chunks: 4,
323                browser_requested_bytes: 2 * 1024 * 1024,
324                python_copy_bytes: 3 * 1024 * 1024,
325                state_roundtrip_mismatches: 0,
326            },
327            candidate_unix_days: 20_662,
328            receipts: VisualReleaseGate::required_receipts()
329                .iter()
330                .map(|id| VisualReceiptEvidence { id: (*id).into(), captured_unix_days: 20_662 })
331                .collect(),
332            verified_examples: VisualReleaseGate::required_examples()
333                .iter()
334                .map(ToString::to_string)
335                .collect(),
336            migration_policy: "visual-1".into(),
337        }
338    }
339
340    #[test]
341    fn complete_visual_evidence_is_allowed_and_rendered() {
342        let evidence = passing();
343        assert!(VisualReleaseGate::evaluate(&evidence).allowed);
344        let markdown = VisualReleaseGate::render_markdown(&evidence);
345        assert!(markdown.contains("Decision: **allowed**"));
346        assert!(markdown.contains("visual-python-jupyter"));
347        assert!(markdown.contains("(0 day(s))"));
348    }
349
350    #[test]
351    fn rejects_missing_skipped_duplicate_and_wrong_migration_evidence() {
352        let mut evidence = passing();
353        let mut conformance = ConformanceReport::new();
354        for &id in VisualReleaseGate::required_conformance_cases() {
355            if id == "visual-headless-macos" {
356                conformance.record(id, ConformanceStatus::Skip, None);
357            } else if id == "visual-docs" {
358                conformance.record(id, ConformanceStatus::Pass, None);
359                conformance.record(id, ConformanceStatus::Pass, None);
360            } else if id != "visual-browser-smoke" {
361                conformance.record(id, ConformanceStatus::Pass, None);
362            }
363        }
364        evidence.conformance = conformance;
365        evidence.receipts.pop();
366        evidence.verified_examples.push("visual_release_gate".into());
367        evidence.migration_policy = "visual-0".into();
368        let decision = VisualReleaseGate::evaluate(&evidence);
369        assert!(!decision.allowed);
370        for needle in [
371            "visual-headless-macos",
372            "visual-docs",
373            "visual-browser-smoke",
374            "visual-python-jupyter",
375            "visual_release_gate",
376            "migration policy",
377        ] {
378            assert!(
379                decision.reasons.iter().any(|reason| reason.contains(needle)),
380                "{needle}: {:?}",
381                decision.reasons
382            );
383        }
384    }
385
386    #[test]
387    fn rejects_stale_future_duplicate_and_undated_receipts() {
388        let mut stale = passing();
389        stale.receipts[0].captured_unix_days =
390            stale.candidate_unix_days - VisualReleaseGate::max_receipt_age_days() - 1;
391        assert!(VisualReleaseGate::evaluate(&stale)
392            .reasons
393            .iter()
394            .any(|reason| reason.contains("stale")));
395
396        let mut future = passing();
397        future.receipts[0].captured_unix_days = future.candidate_unix_days + 1;
398        assert!(VisualReleaseGate::evaluate(&future)
399            .reasons
400            .iter()
401            .any(|reason| reason.contains("after the candidate")));
402
403        let mut duplicate = passing();
404        duplicate.receipts.push(duplicate.receipts[0].clone());
405        assert!(VisualReleaseGate::evaluate(&duplicate)
406            .reasons
407            .iter()
408            .any(|reason| reason.contains("duplicated")));
409
410        let mut undated = passing();
411        undated.candidate_unix_days = 0;
412        assert!(VisualReleaseGate::evaluate(&undated)
413            .reasons
414            .iter()
415            .any(|reason| reason.contains("non-zero")));
416    }
417
418    #[test]
419    fn rejects_each_visual_budget_overrun() {
420        let overruns: &[(&str, fn(&mut VisualMeasurements))] = &[
421            ("pixel-mismatches", |v| v.headless_pixel_mismatches = 1),
422            ("channel-delta", |v| v.headless_max_channel_delta = 1),
423            ("geometry-upload", |v| v.geometry_upload_bytes = 8 * 1024 * 1024 + 1),
424            ("render-uniform-upload", |v| v.render_uniform_upload_bytes = 113),
425            ("unexpected-readback", |v| v.unexpected_readback_bytes = 1),
426            ("screenshot-readback", |v| v.screenshot_readback_bytes = 64 * 64 * 4 + 1),
427            ("peak-lod-host", |v| v.peak_lod_host_bytes = 64 * 1024 * 1024 + 1),
428            ("peak-lod-gpu", |v| v.peak_lod_gpu_bytes = 64 * 1024 * 1024 + 1),
429            ("inflight-lod", |v| v.inflight_lod_chunks = 9),
430            ("browser-requested", |v| v.browser_requested_bytes = 8 * 1024 * 1024 + 1),
431            ("python-copy", |v| v.python_copy_bytes = 12 * 1024 * 1024 + 1),
432            ("state-roundtrip", |v| v.state_roundtrip_mismatches = 1),
433        ];
434        for &(budget_id, mutate) in overruns {
435            let mut evidence = passing();
436            mutate(&mut evidence.measurements);
437            let decision = VisualReleaseGate::evaluate(&evidence);
438            assert!(!decision.allowed, "{budget_id}");
439            assert!(
440                decision.reasons.iter().any(|reason| reason.contains(budget_id)),
441                "{budget_id}: {:?}",
442                decision.reasons
443            );
444        }
445    }
446}