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