1use std::collections::{BTreeMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7pub use super::census::{CENSUS_IDENTITY_SCHEMA_VERSION, CensusIdentity};
8use super::conformance::{
9 ConformanceResult, ConformanceStatus, Equivalence, FeatureId, TestArtifact, is_sha256_digest,
10};
11use workshop_rs::catalog::{Catalog, CatalogIdentity, Locale};
12
13pub const LIVE_CAPTURE_SCHEMA_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct LiveCapture {
20 pub schema_version: u32,
21 pub capture_id: String,
22 pub game: String,
23 pub client: String,
25 pub season: String,
26 pub captured_at: String,
27 pub environment: String,
30 pub locale: Locale,
31 pub catalog: CatalogIdentity,
32 pub census: CensusIdentity,
33 pub raw_artifact: TestArtifact,
36 pub results: Vec<ConformanceResult>,
38}
39
40impl LiveCapture {
41 pub fn validate(&self) -> Result<(), LiveCaptureError> {
45 self.validate_structural(None)
46 }
47
48 pub fn validate_against(&self, catalog: &Catalog) -> Result<(), LiveCaptureError> {
50 self.validate_structural(Some(catalog))
51 }
52
53 fn validate_structural(&self, catalog: Option<&Catalog>) -> Result<(), LiveCaptureError> {
54 if self.schema_version != LIVE_CAPTURE_SCHEMA_VERSION {
55 return Err(invalid(format!(
56 "unsupported live capture schema version {}; expected {}",
57 self.schema_version, LIVE_CAPTURE_SCHEMA_VERSION
58 )));
59 }
60 validate_name("captureId", &self.capture_id)?;
61 validate_name("game", &self.game)?;
62 validate_name("client", &self.client)?;
63 validate_name("season", &self.season)?;
64 validate_timestamp(&self.captured_at)?;
65 validate_name("environment", &self.environment)?;
66 validate_name("locale", self.locale.as_str())?;
67 validate_catalog(&self.catalog)?;
68 if normalize_game(&self.catalog.target.game) != normalize_game(&self.game) {
69 return Err(invalid("capture game does not match catalog target game"));
70 }
71 if let Some(catalog) = catalog {
72 if self.catalog != catalog.identity() {
73 return Err(invalid(
74 "capture catalog identity does not match the loaded catalog",
75 ));
76 }
77 }
78 validate_census(&self.census)?;
79 validate_artifact("rawArtifact", &self.raw_artifact, true)?;
80 if self.results.is_empty() {
81 return Err(invalid(
82 "results must contain at least one conformance result",
83 ));
84 }
85
86 let mut case_ids = HashSet::with_capacity(self.results.len());
87 for (index, result) in self.results.iter().enumerate() {
88 let validation = match catalog {
89 Some(catalog) => result.validate_against(catalog),
90 None => result.validate(),
91 };
92 validation.map_err(|error| invalid(format!("results[{index}]: {error}")))?;
93 if !case_ids.insert(&result.case_id) {
94 return Err(invalid(format!(
95 "results[{index}].caseId duplicates another capture result"
96 )));
97 }
98 if result.catalog != self.catalog {
99 return Err(invalid(format!(
100 "results[{index}].catalog does not match capture catalog"
101 )));
102 }
103 if result.locale.as_ref() != Some(&self.locale) {
104 return Err(invalid(format!(
105 "results[{index}].locale does not match capture locale"
106 )));
107 }
108 if result.source != self.raw_artifact {
109 return Err(invalid(format!(
110 "results[{index}].source must pin the capture raw artifact"
111 )));
112 }
113 if result.status == ConformanceStatus::Matched {
114 let observed = result.comparison.observed.as_ref().ok_or_else(|| {
115 invalid(format!(
116 "results[{index}].comparison.observed is required for matched results"
117 ))
118 })?;
119 if observed.sha256 != self.raw_artifact.sha256 {
120 return Err(invalid(format!(
121 "results[{index}].comparison.observed must pin the raw capture digest"
122 )));
123 }
124 }
125 }
126 Ok(())
127 }
128
129 pub fn from_json(json: &str) -> Result<Self, LiveCaptureError> {
131 let capture: Self = serde_json::from_str(json)
132 .map_err(|error| invalid(format!("invalid live capture JSON: {error}")))?;
133 capture.validate()?;
134 Ok(capture)
135 }
136
137 pub fn to_json(&self) -> Result<String, LiveCaptureError> {
139 self.validate()?;
140 serde_json::to_string_pretty(self)
141 .map_err(|error| invalid(format!("cannot serialize live capture: {error}")))
142 }
143
144 pub fn diff(&self, newer: &Self) -> Result<LiveCaptureDiff, LiveCaptureError> {
146 self.validate()?;
147 newer.validate()?;
148
149 let mut changes = Vec::new();
150 if self.locale != newer.locale {
151 changes.push(DiffEntry::metadata(
152 DiffCategory::Locale,
153 format!("locale changed from {} to {}", self.locale, newer.locale),
154 ));
155 }
156 if self.catalog != newer.catalog {
157 changes.push(DiffEntry::metadata(
158 DiffCategory::Catalog,
159 "catalog identity changed",
160 ));
161 }
162 if self.census != newer.census {
163 changes.push(DiffEntry::metadata(
164 DiffCategory::SemanticSchema,
165 "census identity or shard set changed",
166 ));
167 }
168 if self.raw_artifact != newer.raw_artifact {
169 changes.push(DiffEntry::metadata(
170 DiffCategory::Content,
171 "raw client artifact provenance or content changed",
172 ));
173 }
174
175 let prior: BTreeMap<_, _> = self
176 .results
177 .iter()
178 .map(|result| (result.case_id.as_str(), result))
179 .collect();
180 let current: BTreeMap<_, _> = newer
181 .results
182 .iter()
183 .map(|result| (result.case_id.as_str(), result))
184 .collect();
185 let mut all_case_ids: Vec<&str> = prior.keys().chain(current.keys()).copied().collect();
186 all_case_ids.sort_unstable();
187 all_case_ids.dedup();
188
189 let mut runtime_uncertainty = vec![DiffEntry::metadata(
190 DiffCategory::RuntimeUncertainty,
191 "import/export capture does not establish gameplay or runtime behavior",
192 )];
193 for case_id in all_case_ids {
194 match (prior.get(case_id), current.get(case_id)) {
195 (None, Some(result)) => changes.push(DiffEntry::result(
196 DiffCategory::Content,
197 result,
198 "feature-attributed result was added",
199 )),
200 (Some(result), None) => changes.push(DiffEntry::result(
201 DiffCategory::Content,
202 result,
203 "feature-attributed result was removed",
204 )),
205 (Some(previous), Some(current)) => {
206 let features_changed = !same_features(previous, current);
207 if features_changed {
208 changes.push(DiffEntry::result(
209 DiffCategory::SemanticSchema,
210 current,
211 "feature attribution changed",
212 ));
213 }
214
215 if previous.status != current.status {
216 if is_runtime_uncertain(previous) {
217 runtime_uncertainty.push(DiffEntry::result(
218 DiffCategory::RuntimeUncertainty,
219 current,
220 "result is not a comparable semantic match",
221 ));
222 } else {
223 changes.push(DiffEntry::result(
224 DiffCategory::SemanticSchema,
225 current,
226 format!(
227 "conformance status changed from {:?} to {:?}",
228 previous.status, current.status
229 ),
230 ));
231 }
232 } else if is_runtime_uncertain(current) {
233 runtime_uncertainty.push(DiffEntry::result(
234 DiffCategory::RuntimeUncertainty,
235 current,
236 "result is not a comparable semantic match",
237 ));
238 } else if previous.comparison.mode != current.comparison.mode {
239 changes.push(DiffEntry::result(
240 DiffCategory::SemanticSchema,
241 current,
242 "comparison mode changed",
243 ));
244 } else if previous.comparison != current.comparison {
245 changes.push(DiffEntry::result(
246 DiffCategory::Content,
247 current,
248 "expected or observed feature artifact changed",
249 ));
250 }
251 }
252 (None, None) => unreachable!("case ID came from one of the result maps"),
253 }
254 }
255
256 Ok(LiveCaptureDiff {
257 schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
258 prior_capture_id: self.capture_id.clone(),
259 new_capture_id: newer.capture_id.clone(),
260 changes,
261 runtime_uncertainty,
262 })
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
268#[serde(rename_all = "kebab-case")]
269pub enum DiffCategory {
270 Locale,
271 Catalog,
272 Content,
273 SemanticSchema,
274 RuntimeUncertainty,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct DiffEntry {
281 pub category: DiffCategory,
282 pub case_id: Option<String>,
283 pub features: Vec<FeatureId>,
284 pub detail: String,
285}
286
287impl DiffEntry {
288 fn metadata(category: DiffCategory, detail: impl Into<String>) -> Self {
289 Self {
290 category,
291 case_id: None,
292 features: Vec::new(),
293 detail: detail.into(),
294 }
295 }
296
297 fn result(
298 category: DiffCategory,
299 result: &ConformanceResult,
300 detail: impl Into<String>,
301 ) -> Self {
302 Self {
303 category,
304 case_id: Some(result.case_id.clone()),
305 features: result.features.clone(),
306 detail: detail.into(),
307 }
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct LiveCaptureDiff {
315 pub schema_version: u32,
316 pub prior_capture_id: String,
317 pub new_capture_id: String,
318 pub changes: Vec<DiffEntry>,
319 pub runtime_uncertainty: Vec<DiffEntry>,
322}
323
324impl LiveCaptureDiff {
325 pub fn to_json(&self) -> Result<String, LiveCaptureError> {
326 serde_json::to_string_pretty(self)
327 .map_err(|error| invalid(format!("cannot serialize live capture diff: {error}")))
328 }
329
330 pub fn human_summary(&self) -> String {
331 let mut output = format!(
332 "live capture diff schema {}\n{} -> {}\n",
333 self.schema_version, self.prior_capture_id, self.new_capture_id
334 );
335 for entry in self.changes.iter().chain(self.runtime_uncertainty.iter()) {
336 output.push_str(&format!("{:?}: {}\n", entry.category, entry.detail));
337 }
338 if self.changes.is_empty() {
339 output.push_str("no import/export changes classified\n");
340 }
341 output
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct LiveCaptureError {
348 pub message: String,
349}
350
351impl std::fmt::Display for LiveCaptureError {
352 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 formatter.write_str(&self.message)
354 }
355}
356
357impl std::error::Error for LiveCaptureError {}
358
359fn invalid(message: impl Into<String>) -> LiveCaptureError {
360 LiveCaptureError {
361 message: message.into(),
362 }
363}
364
365fn validate_name(field: &str, value: &str) -> Result<(), LiveCaptureError> {
366 if value.trim().is_empty() || value.chars().any(char::is_control) {
367 Err(invalid(format!("{field} must be non-empty and printable")))
368 } else {
369 Ok(())
370 }
371}
372
373fn validate_timestamp(value: &str) -> Result<(), LiveCaptureError> {
374 validate_name("capturedAt", value)?;
375 let Some(time) = value.split_once('T').map(|(_, time)| time) else {
379 return Err(invalid(
380 "capturedAt must use an ISO-8601 timestamp with a timezone",
381 ));
382 };
383 let has_offset = time.ends_with('Z') || time.ends_with('z') || time.contains(['+', '-']);
384 if !has_offset {
385 return Err(invalid(
386 "capturedAt must use an ISO-8601 timestamp with a timezone",
387 ));
388 }
389 Ok(())
390}
391
392fn validate_catalog(catalog: &CatalogIdentity) -> Result<(), LiveCaptureError> {
393 validate_name(
394 "catalog.implementationVersion",
395 &catalog.implementation_version,
396 )?;
397 validate_name("catalog.catalogVersion", &catalog.catalog_version)?;
398 let digest = catalog
399 .catalog_digest
400 .as_deref()
401 .ok_or_else(|| invalid("catalog.catalogDigest is required to identify the catalog"))?;
402 validate_sha256("catalog.catalogDigest", digest)?;
403 validate_name("catalog.target.game", &catalog.target.game)?;
404 validate_name("catalog.target.format", &catalog.target.format)?;
405 validate_name("catalog.target.surface", &catalog.target.surface)?;
406 if catalog.locale_coverage.is_empty() {
407 return Err(invalid("catalog.localeCoverage must not be empty"));
408 }
409 Ok(())
410}
411
412fn validate_census(census: &CensusIdentity) -> Result<(), LiveCaptureError> {
413 if census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
414 return Err(invalid(format!(
415 "unsupported census identity schema version {}; expected {}",
416 census.schema_version, CENSUS_IDENTITY_SCHEMA_VERSION
417 )));
418 }
419 validate_sha256("census.digest", &census.digest)?;
420 if census.shards.is_empty() || census.shards.windows(2).any(|pair| pair[0] >= pair[1]) {
421 return Err(invalid(
422 "census.shards must be non-empty and strictly sorted",
423 ));
424 }
425 Ok(())
426}
427
428fn validate_artifact(
429 field: &str,
430 artifact: &TestArtifact,
431 require_pin: bool,
432) -> Result<(), LiveCaptureError> {
433 validate_name(&format!("{field}.name"), &artifact.name)?;
434 if require_pin {
435 validate_name(
436 &format!("{field}.revision"),
437 artifact.revision.as_deref().unwrap_or_default(),
438 )?;
439 validate_name(
440 &format!("{field}.path"),
441 artifact.path.as_deref().unwrap_or_default(),
442 )?;
443 validate_sha256(
444 &format!("{field}.sha256"),
445 artifact.sha256.as_deref().unwrap_or_default(),
446 )?;
447 }
448 Ok(())
449}
450
451fn validate_sha256(field: &str, digest: &str) -> Result<(), LiveCaptureError> {
452 if !is_sha256_digest(digest) {
453 return Err(invalid(format!(
454 "{field} must be a 64-character hexadecimal SHA-256 digest"
455 )));
456 }
457 Ok(())
458}
459
460fn normalize_game(value: &str) -> String {
461 value
462 .chars()
463 .filter(char::is_ascii_alphanumeric)
464 .flat_map(char::to_lowercase)
465 .collect()
466}
467
468fn same_features(left: &ConformanceResult, right: &ConformanceResult) -> bool {
469 left.features.len() == right.features.len()
470 && left
471 .features
472 .iter()
473 .all(|feature| right.features.contains(feature))
474}
475
476fn is_runtime_uncertain(result: &ConformanceResult) -> bool {
477 !result.status.is_match() || result.comparison.mode == Equivalence::NotComparable
478}
479
480#[cfg(test)]
481mod tests {
482 use super::super::conformance::{
483 CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, FeatureKind, FeatureNamespace,
484 ReasonCode,
485 };
486 use super::*;
487 use workshop_rs::catalog::Catalog;
488
489 const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
490 const OTHER_DIGEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
491
492 fn catalog() -> CatalogIdentity {
493 Catalog::builtin()
494 .expect("built-in catalog for constructed unit data")
495 .identity()
496 }
497
498 fn raw(digest: &str) -> TestArtifact {
499 TestArtifact {
500 name: "constructed-unit-test/raw.ws".to_string(),
501 revision: Some("unit-test-revision".to_string()),
502 path: Some("constructed-unit-test/raw.ws".to_string()),
503 sha256: Some(digest.to_string()),
504 license: Some("MIT".to_string()),
505 }
506 }
507
508 fn feature(name: &str) -> FeatureId {
509 FeatureId::owned(FeatureNamespace::Wir, FeatureKind::Structural, name)
510 .expect("constructed feature identity")
511 }
512
513 fn result(
514 identity: &CatalogIdentity,
515 locale: &Locale,
516 raw: &TestArtifact,
517 case_id: &str,
518 status: ConformanceStatus,
519 feature_name: &str,
520 ) -> ConformanceResult {
521 let matched = status == ConformanceStatus::Matched;
522 ConformanceResult {
523 schema_version: CONFORMANCE_SCHEMA_VERSION,
524 case_id: case_id.to_string(),
525 features: vec![feature(feature_name)],
526 status,
527 comparison: Comparison {
528 mode: if matched {
529 Equivalence::Normalized
530 } else {
531 Equivalence::NotComparable
532 },
533 expected: matched.then(|| TestArtifact::new("constructed-unit-test/oracle")),
534 observed: matched.then(|| TestArtifact {
535 name: "constructed-unit-test/observed.ws".to_string(),
536 revision: Some("unit-test-revision".to_string()),
537 path: Some("constructed-unit-test/raw.ws".to_string()),
538 sha256: Some(raw.sha256.clone().unwrap()),
539 license: Some("MIT".to_string()),
540 }),
541 normalizer: matched.then(|| "constructed-unit-test-normalizer".to_string()),
542 },
543 source: raw.clone(),
544 catalog: identity.clone(),
545 locale: Some(locale.clone()),
546 reason: (!matched).then(|| ConformanceReason {
547 code: ReasonCode::Inconclusive,
548 detail: "constructed unit uncertainty".to_string(),
549 }),
550 }
551 }
552
553 fn make_capture(
554 capture_id: &str,
555 locale: &str,
556 digest: &str,
557 result_status: ConformanceStatus,
558 feature_name: &str,
559 ) -> LiveCapture {
560 let identity = catalog();
561 let locale = Locale::new(locale);
562 let raw = raw(digest);
563 LiveCapture {
564 schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
565 capture_id: capture_id.to_string(),
566 game: "overwatch-2".to_string(),
567 client: "constructed-unit-test-client".to_string(),
568 season: "constructed-unit-test-season".to_string(),
569 captured_at: "2026-08-18T00:00:00Z".to_string(),
570 environment: "constructed schema/diff unit test; not a client capture".to_string(),
571 locale: locale.clone(),
572 catalog: identity.clone(),
573 census: CensusIdentity {
574 schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
575 digest: DIGEST.to_string(),
576 shards: vec!["constructed-unit-test-shard".to_string()],
577 },
578 raw_artifact: raw.clone(),
579 results: vec![result(
580 &identity,
581 &locale,
582 &raw,
583 "constructed-unit-test/case",
584 result_status,
585 feature_name,
586 )],
587 }
588 }
589
590 #[test]
591 fn constructed_capture_schema_round_trips_without_runtime_claim() {
592 let capture = make_capture(
593 "capture-a",
594 "en-US",
595 DIGEST,
596 ConformanceStatus::Matched,
597 "one",
598 );
599 let json = capture.to_json().expect("constructed schema serializes");
600 let decoded = LiveCapture::from_json(&json).expect("constructed schema validates");
601 assert_eq!(decoded, capture);
602 }
603
604 #[test]
605 fn schema_rejects_missing_raw_pin_and_mismatched_capture_metadata() {
606 let mut capture = make_capture(
607 "capture-a",
608 "en-US",
609 DIGEST,
610 ConformanceStatus::Matched,
611 "one",
612 );
613 capture.raw_artifact.sha256 = None;
614 assert!(capture.validate().is_err());
615
616 let mut capture = make_capture(
617 "capture-a",
618 "en-US",
619 DIGEST,
620 ConformanceStatus::Matched,
621 "one",
622 );
623 capture.game = "not-overwatch".to_string();
624 assert!(capture.validate().is_err());
625
626 let mut capture = make_capture(
627 "capture-a",
628 "en-US",
629 DIGEST,
630 ConformanceStatus::Matched,
631 "one",
632 );
633 capture.results[0].source.name = "different source".to_string();
634 assert!(capture.validate().is_err());
635 }
636
637 #[test]
638 fn diff_reports_requested_categories_and_separates_runtime_uncertainty() {
639 let prior = make_capture(
640 "capture-a",
641 "en-US",
642 DIGEST,
643 ConformanceStatus::Matched,
644 "one",
645 );
646 let mut newer = make_capture(
647 "capture-b",
648 "zh-CN",
649 OTHER_DIGEST,
650 ConformanceStatus::Inconclusive,
651 "two",
652 );
653 newer.catalog.catalog_digest = Some(OTHER_DIGEST.to_string());
654 newer.results[0].catalog = newer.catalog.clone();
655 let diff = prior.diff(&newer).expect("constructed diff validates");
656 let categories: HashSet<_> = diff.changes.iter().map(|entry| entry.category).collect();
657 assert!(categories.contains(&DiffCategory::Locale));
658 assert!(categories.contains(&DiffCategory::Catalog));
659 assert!(categories.contains(&DiffCategory::Content));
660 assert!(categories.contains(&DiffCategory::SemanticSchema));
661 assert!(
662 diff.changes
663 .iter()
664 .any(|entry| entry.detail.contains("conformance status changed")),
665 "status drift from a comparable baseline must be a change: {:?}",
666 diff.changes
667 );
668 assert!(
669 diff.runtime_uncertainty
670 .iter()
671 .all(|entry| !entry.detail.contains("comparable semantic match")),
672 "a certain baseline reports drift as a change, not uncertainty"
673 );
674 let json = diff.to_json().expect("structured diff serializes");
675 let document: serde_json::Value = serde_json::from_str(&json).expect("valid diff JSON");
676 assert!(document["changes"].is_array());
677 assert!(document["runtimeUncertainty"].is_array());
678 }
679
680 #[test]
683 fn diff_reports_uncertainty_when_baseline_not_comparable() {
684 let prior = make_capture(
685 "capture-a",
686 "en-US",
687 DIGEST,
688 ConformanceStatus::Inconclusive,
689 "one",
690 );
691 let newer = make_capture(
692 "capture-b",
693 "en-US",
694 DIGEST,
695 ConformanceStatus::Matched,
696 "one",
697 );
698 let diff = prior.diff(&newer).expect("constructed diff validates");
699 assert!(
700 diff.runtime_uncertainty
701 .iter()
702 .any(|entry| entry.category == DiffCategory::RuntimeUncertainty),
703 "an uncertain baseline cannot attribute drift to a real change"
704 );
705 assert!(
706 !diff
707 .changes
708 .iter()
709 .any(|entry| entry.detail.contains("conformance status changed")),
710 "status drift on an uncertain baseline is not a change: {:?}",
711 diff.changes
712 );
713 }
714
715 #[test]
718 fn timestamp_accepts_rfc3339_offsets() {
719 for valid in [
720 "2026-08-18T00:00:00Z",
721 "2026-08-18T00:00:00z",
722 "2026-08-18T00:00:00-08:00",
723 "2026-08-18T00:00:00+08:00",
724 ] {
725 validate_timestamp(valid).unwrap_or_else(|_| panic!("{valid} must validate"));
726 }
727 for invalid in ["2026-08-18", "2026-08-18T00:00:00", "not+a+timestamp"] {
728 assert!(validate_timestamp(invalid).is_err(), "{invalid} must fail");
729 }
730 }
731
732 #[test]
733 fn diff_refuses_capture_with_invalid_result_metadata() {
734 let prior = make_capture(
735 "capture-a",
736 "en-US",
737 DIGEST,
738 ConformanceStatus::Matched,
739 "one",
740 );
741 let mut newer = make_capture(
742 "capture-b",
743 "en-US",
744 DIGEST,
745 ConformanceStatus::Matched,
746 "one",
747 );
748 newer.results[0].catalog.catalog_digest = Some("different".to_string());
749 assert!(prior.diff(&newer).is_err());
750 }
751}