1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct VisualMeasurements {
43 pub headless_pixel_mismatches: u64,
45 pub headless_max_channel_delta: u64,
47 pub geometry_upload_bytes: u64,
49 pub render_uniform_upload_bytes: u64,
51 pub unexpected_readback_bytes: u64,
53 pub screenshot_readback_bytes: u64,
55 pub peak_lod_host_bytes: u64,
57 pub peak_lod_gpu_bytes: u64,
59 pub inflight_lod_chunks: u64,
61 pub browser_requested_bytes: u64,
63 pub python_copy_bytes: u64,
65 pub state_roundtrip_mismatches: u64,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct VisualReceiptEvidence {
72 pub id: String,
74 pub captured_unix_days: u64,
76}
77
78#[derive(Clone, Debug)]
80pub struct VisualReleaseEvidence {
81 pub conformance: ConformanceReport,
83 pub security: SecurityChecklist,
85 pub measurements: VisualMeasurements,
87 pub candidate_unix_days: u64,
89 pub receipts: Vec<VisualReceiptEvidence>,
91 pub verified_examples: Vec<String>,
93 pub migration_policy: String,
95}
96
97#[derive(Clone, Copy, Debug, Default)]
99pub struct VisualReleaseGate;
100
101impl VisualReleaseGate {
102 pub const fn required_conformance_cases() -> &'static [&'static str] {
104 REQUIRED_CASES
105 }
106
107 pub const fn required_receipts() -> &'static [&'static str] {
109 REQUIRED_RECEIPTS
110 }
111
112 pub const fn required_examples() -> &'static [&'static str] {
114 REQUIRED_EXAMPLES
115 }
116
117 pub const fn max_receipt_age_days() -> u64 {
119 MAX_RECEIPT_AGE_DAYS
120 }
121
122 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 #[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}