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 "streaming-linux",
13 "streaming-windows",
14 "streaming-macos",
15 "streaming-memory-fail-closed",
16 "streaming-cancellation-cleanup",
17 "streaming-format-roundtrip",
18 "streaming-copc-range",
19 "streaming-deterministic-voxel",
20 "streaming-rust-cli",
21 "streaming-python-iterator",
22 "streaming-unsafe-audit",
23];
24
25const REQUIRED_RECEIPTS: &[&str] = &[
26 "epic121-streaming-contract",
27 "epic122-bounded-record-stream",
28 "epic123-streaming-io",
29 "epic124-chunk-ops",
30 "epic125-streaming-e2e",
31];
32
33const REQUIRED_EXAMPLES: &[&str] = &[
34 "streaming_receipt",
35 "bounded_record_stream",
36 "bounded_pcd_to_ply",
37 "bounded_voxel",
38 "spatialrust-stream",
39 "streaming_1_2_release_gate",
40];
41
42const MAX_MEMORY_BUDGET_BYTES: u64 = 256 * 1024 * 1024;
43const MAX_SPOOL_LIMIT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
44const MAX_OPEN_SPILL_FILES: u64 = 1025;
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct Streaming12Measurements {
49 pub memory_budget_bytes: u64,
51 pub peak_tracked_bytes: u64,
53 pub spool_limit_bytes: u64,
55 pub spilled_bytes: u64,
57 pub current_bytes_after_finish: u64,
59 pub hidden_host_copy_bytes: u64,
61 pub host_to_device_bytes: u64,
63 pub device_to_host_bytes: u64,
65 pub determinism_mismatches: u64,
67 pub max_open_spill_files: u64,
69}
70
71#[derive(Clone, Debug)]
73pub struct Streaming12ReleaseEvidence {
74 pub conformance: ConformanceReport,
76 pub security: SecurityChecklist,
78 pub measurements: Streaming12Measurements,
80 pub passed_receipts: Vec<String>,
82 pub verified_examples: Vec<String>,
84 pub migration_policy: String,
86}
87
88#[derive(Clone, Copy, Debug, Default)]
90pub struct Streaming12ReleaseGate;
91
92impl Streaming12ReleaseGate {
93 pub const fn required_conformance_cases() -> &'static [&'static str] {
95 REQUIRED_CASES
96 }
97
98 pub const fn required_receipts() -> &'static [&'static str] {
100 REQUIRED_RECEIPTS
101 }
102
103 pub const fn required_examples() -> &'static [&'static str] {
105 REQUIRED_EXAMPLES
106 }
107
108 pub fn evaluate(evidence: &Streaming12ReleaseEvidence) -> ReleaseGateDecision {
110 let base = ReleaseGate {
111 stability: Some(StabilityRegistry::bounded_streaming_v1_2_surface()),
112 conformance: Some(evidence.conformance.clone()),
113 security: Some(evidence.security.clone()),
114 lts: Some(LtsPolicy::spatialrust_v1()),
115 budgets: Some(streaming_budgets(evidence.measurements)),
116 reject_experimental: true,
117 };
118 let mut decision = base.evaluate();
119 require_passing_cases(&mut decision.reasons, &evidence.conformance);
120 require_names(
121 &mut decision.reasons,
122 "receipt",
123 REQUIRED_RECEIPTS,
124 &evidence.passed_receipts,
125 );
126 require_names(
127 &mut decision.reasons,
128 "example",
129 REQUIRED_EXAMPLES,
130 &evidence.verified_examples,
131 );
132 if evidence.migration_policy != "bounded-streaming-1.2" {
133 decision
134 .reasons
135 .push("migration policy `bounded-streaming-1.2` was not acknowledged".into());
136 }
137 let values = evidence.measurements;
138 if values.peak_tracked_bytes > values.memory_budget_bytes {
139 decision.reasons.push(format!(
140 "streaming peak {} exceeds configured memory budget {}",
141 values.peak_tracked_bytes, values.memory_budget_bytes
142 ));
143 }
144 if values.spilled_bytes > values.spool_limit_bytes {
145 decision.reasons.push(format!(
146 "streaming spill {} exceeds configured spool limit {}",
147 values.spilled_bytes, values.spool_limit_bytes
148 ));
149 }
150 decision.allowed = decision.reasons.is_empty();
151 decision
152 }
153
154 #[must_use]
156 pub fn render_markdown(evidence: &Streaming12ReleaseEvidence) -> String {
157 let decision = Self::evaluate(evidence);
158 let mut output = String::from("# SpatialRust 1.2 bounded-streaming release receipt\n\n");
159 let _ = writeln!(
160 output,
161 "Decision: **{}**\n",
162 if decision.allowed { "allowed" } else { "denied" }
163 );
164 output.push_str("| Measurement | Observed | Ceiling |\n");
165 output.push_str("| --- | ---: | ---: |\n");
166 for (label, observed, ceiling) in measurement_rows(evidence.measurements) {
167 let _ = writeln!(output, "| {label} | {observed} | {ceiling} |");
168 }
169 output.push_str("\nRequired receipts:\n\n");
170 for receipt in REQUIRED_RECEIPTS {
171 let present = evidence.passed_receipts.iter().any(|value| value == receipt);
172 let _ = writeln!(output, "- [{}] `{receipt}`", if present { "x" } else { " " });
173 }
174 if !decision.reasons.is_empty() {
175 output.push_str("\nDenial reasons:\n\n");
176 for reason in decision.reasons {
177 let _ = writeln!(output, "- {reason}");
178 }
179 }
180 output
181 }
182}
183
184fn require_passing_cases(reasons: &mut Vec<String>, conformance: &ConformanceReport) {
185 for required in REQUIRED_CASES {
186 let matching =
187 conformance.cases().iter().filter(|case| case.id == *required).collect::<Vec<_>>();
188 match matching.as_slice() {
189 [case] if case.status == ConformanceStatus::Pass => {}
190 [case] => {
191 reasons.push(format!("required conformance `{required}` is {:?}", case.status));
192 }
193 [] => reasons.push(format!("required conformance `{required}` is missing")),
194 _ => reasons.push(format!("required conformance `{required}` is duplicated")),
195 }
196 }
197}
198
199fn require_names(reasons: &mut Vec<String>, kind: &str, required: &[&str], actual: &[String]) {
200 for name in required {
201 let count = actual.iter().filter(|value| value.as_str() == *name).count();
202 match count {
203 1 => {}
204 0 => reasons.push(format!("required {kind} `{name}` is missing")),
205 _ => reasons.push(format!("required {kind} `{name}` is duplicated")),
206 }
207 }
208}
209
210fn measurement_rows(values: Streaming12Measurements) -> [(&'static str, u64, u64); 10] {
211 [
212 ("configured memory budget (bytes)", values.memory_budget_bytes, MAX_MEMORY_BUDGET_BYTES),
213 ("peak tracked memory (bytes)", values.peak_tracked_bytes, MAX_MEMORY_BUDGET_BYTES),
214 ("configured spool limit (bytes)", values.spool_limit_bytes, MAX_SPOOL_LIMIT_BYTES),
215 ("spilled bytes", values.spilled_bytes, MAX_SPOOL_LIMIT_BYTES),
216 ("live bytes after finish", values.current_bytes_after_finish, 0),
217 ("hidden host-copy bytes", values.hidden_host_copy_bytes, 0),
218 ("host-to-device bytes", values.host_to_device_bytes, 0),
219 ("device-to-host bytes", values.device_to_host_bytes, 0),
220 ("determinism mismatches", values.determinism_mismatches, 0),
221 ("open spill files", values.max_open_spill_files, MAX_OPEN_SPILL_FILES),
222 ]
223}
224
225fn streaming_budgets(values: Streaming12Measurements) -> PerformanceBudgetReport {
226 let kinds = [
227 BudgetKind::MemoryBytes,
228 BudgetKind::MemoryBytes,
229 BudgetKind::MemoryBytes,
230 BudgetKind::MemoryBytes,
231 BudgetKind::MemoryBytes,
232 BudgetKind::BytesCopied,
233 BudgetKind::BytesCopied,
234 BudgetKind::BytesCopied,
235 BudgetKind::AllocationCount,
236 BudgetKind::AllocationCount,
237 ];
238 let ids = [
239 "streaming-configured-memory-budget-bytes",
240 "streaming-peak-tracked-memory-bytes",
241 "streaming-configured-spool-limit-bytes",
242 "streaming-spilled-bytes",
243 "streaming-live-bytes-after-finish",
244 "streaming-hidden-host-copy-bytes",
245 "streaming-host-to-device-bytes",
246 "streaming-device-to-host-bytes",
247 "streaming-determinism-mismatches",
248 "streaming-open-spill-files",
249 ];
250 let mut report = PerformanceBudgetReport::new();
251 for (((_, observed, ceiling), kind), id) in
252 measurement_rows(values).into_iter().zip(kinds).zip(ids)
253 {
254 report.declare(PerformanceBudget { id: id.into(), kind, ceiling });
255 report.sample(id, observed);
256 }
257 report
258}
259
260#[cfg(test)]
261mod tests {
262 use super::{Streaming12Measurements, Streaming12ReleaseEvidence, Streaming12ReleaseGate};
263 use crate::{ConformanceReport, ConformanceStatus, SecurityChecklist};
264
265 fn passing() -> Streaming12ReleaseEvidence {
266 let mut conformance = ConformanceReport::new();
267 for &id in Streaming12ReleaseGate::required_conformance_cases() {
268 conformance.record(id, ConformanceStatus::Pass, Some("CI receipt".into()));
269 }
270 Streaming12ReleaseEvidence {
271 conformance,
272 security: SecurityChecklist::north_star_baseline_satisfied(),
273 measurements: Streaming12Measurements {
274 memory_budget_bytes: 1024 * 1024,
275 peak_tracked_bytes: 64 * 1024,
276 spool_limit_bytes: 1024 * 1024,
277 spilled_bytes: 4096,
278 current_bytes_after_finish: 0,
279 hidden_host_copy_bytes: 0,
280 host_to_device_bytes: 0,
281 device_to_host_bytes: 0,
282 determinism_mismatches: 0,
283 max_open_spill_files: 4,
284 },
285 passed_receipts: Streaming12ReleaseGate::required_receipts()
286 .iter()
287 .map(ToString::to_string)
288 .collect(),
289 verified_examples: Streaming12ReleaseGate::required_examples()
290 .iter()
291 .map(ToString::to_string)
292 .collect(),
293 migration_policy: "bounded-streaming-1.2".into(),
294 }
295 }
296
297 #[test]
298 fn streaming_complete_evidence_is_allowed_and_rendered() {
299 let evidence = passing();
300 assert!(Streaming12ReleaseGate::evaluate(&evidence).allowed);
301 let markdown = Streaming12ReleaseGate::render_markdown(&evidence);
302 assert!(markdown.contains("Decision: **allowed**"));
303 assert!(markdown.contains("epic125-streaming-e2e"));
304 }
305
306 #[test]
307 fn streaming_rejects_missing_skipped_and_duplicate_evidence() {
308 let mut evidence = passing();
309 let mut conformance = ConformanceReport::new();
310 for &id in Streaming12ReleaseGate::required_conformance_cases() {
311 if id == "streaming-macos" {
312 conformance.record(id, ConformanceStatus::Skip, None);
313 } else if id == "streaming-rust-cli" {
314 conformance.record(id, ConformanceStatus::Pass, None);
315 conformance.record(id, ConformanceStatus::Pass, None);
316 } else if id != "streaming-python-iterator" {
317 conformance.record(id, ConformanceStatus::Pass, None);
318 }
319 }
320 evidence.conformance = conformance;
321 evidence.passed_receipts.pop();
322 evidence.verified_examples.push("streaming_1_2_release_gate".into());
323 evidence.migration_policy = "vision-2".into();
324 let decision = Streaming12ReleaseGate::evaluate(&evidence);
325 assert!(!decision.allowed);
326 for needle in [
327 "streaming-python-iterator",
328 "streaming-macos",
329 "streaming-rust-cli",
330 "epic125-streaming-e2e",
331 "streaming_1_2_release_gate",
332 "migration policy",
333 ] {
334 assert!(decision.reasons.iter().any(|reason| reason.contains(needle)), "{needle}");
335 }
336 }
337
338 #[test]
339 fn streaming_rejects_each_resource_budget_overrun() {
340 let overruns: &[(&str, fn(&mut Streaming12Measurements))] = &[
341 ("configured-memory", |v| v.memory_budget_bytes = 256 * 1024 * 1024 + 1),
342 ("peak-tracked", |v| v.peak_tracked_bytes = 256 * 1024 * 1024 + 1),
343 ("configured-spool", |v| v.spool_limit_bytes = 2 * 1024 * 1024 * 1024 + 1),
344 ("spilled", |v| v.spilled_bytes = 2 * 1024 * 1024 * 1024 + 1),
345 ("live-bytes", |v| v.current_bytes_after_finish = 1),
346 ("hidden-host-copy", |v| v.hidden_host_copy_bytes = 1),
347 ("host-to-device", |v| v.host_to_device_bytes = 1),
348 ("device-to-host", |v| v.device_to_host_bytes = 1),
349 ("determinism", |v| v.determinism_mismatches = 1),
350 ("open-spill-files", |v| v.max_open_spill_files = 1026),
351 ];
352 for &(budget_id, mutate) in overruns {
353 let mut evidence = passing();
354 mutate(&mut evidence.measurements);
355 let decision = Streaming12ReleaseGate::evaluate(&evidence);
356 assert!(!decision.allowed, "{budget_id}");
357 assert!(
358 decision.reasons.iter().any(|reason| reason.contains(budget_id)),
359 "{budget_id}: {:?}",
360 decision.reasons
361 );
362 }
363 }
364
365 #[test]
366 fn streaming_rejects_peak_and_spill_above_configured_limits() {
367 let mut evidence = passing();
368 evidence.measurements.peak_tracked_bytes = evidence.measurements.memory_budget_bytes + 1;
369 evidence.measurements.spilled_bytes = evidence.measurements.spool_limit_bytes + 1;
370 let decision = Streaming12ReleaseGate::evaluate(&evidence);
371 assert!(!decision.allowed);
372 assert!(decision.reasons.iter().any(|reason| reason.contains("configured memory budget")));
373 assert!(decision.reasons.iter().any(|reason| reason.contains("configured spool limit")));
374 }
375}