Skip to main content

workshop_rs_cli/
live_capture.rs

1//! Offline schema and comparison support for recorded client captures.
2
3use 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
13/// The current machine-readable live-capture schema version.
14pub const LIVE_CAPTURE_SCHEMA_VERSION: u32 = 1;
15
16/// One machine-readable capture from a manually operated Workshop client.
17#[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    /// The client version/build string as observed by the maintainer.
24    pub client: String,
25    pub season: String,
26    pub captured_at: String,
27    /// Environment notes, including platform and any conditions relevant to
28    /// interpreting import/export behavior.
29    pub environment: String,
30    pub locale: Locale,
31    pub catalog: CatalogIdentity,
32    pub census: CensusIdentity,
33    /// The exact exported Workshop text or archive, pinned by revision/path
34    /// and SHA-256. The bytes are intentionally not embedded in this schema.
35    pub raw_artifact: TestArtifact,
36    /// Feature-attributed results for the captured probes.
37    pub results: Vec<ConformanceResult>,
38}
39
40impl LiveCapture {
41    /// Validate a capture's structural and provenance contract. Historical
42    /// captures may refer to an older catalog identity, so this does not
43    /// silently substitute the current bundled catalog.
44    pub fn validate(&self) -> Result<(), LiveCaptureError> {
45        self.validate_structural(None)
46    }
47
48    /// Validate a current capture against the loaded canonical catalog.
49    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    /// Deserialize and validate a JSON capture in one operation.
130    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    /// Serialize a validated capture as stable, human-reviewable JSON.
138    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    /// Compare two validated captures without contacting a provider or client.
145    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                    let uncertain = is_runtime_uncertain(previous) || is_runtime_uncertain(current);
216                    if uncertain {
217                        runtime_uncertainty.push(DiffEntry::result(
218                            DiffCategory::RuntimeUncertainty,
219                            current,
220                            "result is not a comparable semantic match",
221                        ));
222                    }
223                    if previous.status != current.status {
224                        if !uncertain {
225                            changes.push(DiffEntry::result(
226                                DiffCategory::SemanticSchema,
227                                current,
228                                format!(
229                                    "conformance status changed from {:?} to {:?}",
230                                    previous.status, current.status
231                                ),
232                            ));
233                        }
234                    } else if previous.comparison.mode != current.comparison.mode {
235                        changes.push(DiffEntry::result(
236                            DiffCategory::SemanticSchema,
237                            current,
238                            "comparison mode changed",
239                        ));
240                    } else if previous.comparison != current.comparison {
241                        changes.push(DiffEntry::result(
242                            DiffCategory::Content,
243                            current,
244                            "expected or observed feature artifact changed",
245                        ));
246                    }
247                }
248                (None, None) => unreachable!("case ID came from one of the result maps"),
249            }
250        }
251
252        Ok(LiveCaptureDiff {
253            schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
254            prior_capture_id: self.capture_id.clone(),
255            new_capture_id: newer.capture_id.clone(),
256            changes,
257            runtime_uncertainty,
258        })
259    }
260}
261
262/// The classification used by the offline capture comparison.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
264#[serde(rename_all = "kebab-case")]
265pub enum DiffCategory {
266    Locale,
267    Catalog,
268    Content,
269    SemanticSchema,
270    RuntimeUncertainty,
271}
272
273/// One feature-attributed or capture-level diff observation.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct DiffEntry {
277    pub category: DiffCategory,
278    pub case_id: Option<String>,
279    pub features: Vec<FeatureId>,
280    pub detail: String,
281}
282
283impl DiffEntry {
284    fn metadata(category: DiffCategory, detail: impl Into<String>) -> Self {
285        Self {
286            category,
287            case_id: None,
288            features: Vec::new(),
289            detail: detail.into(),
290        }
291    }
292
293    fn result(
294        category: DiffCategory,
295        result: &ConformanceResult,
296        detail: impl Into<String>,
297    ) -> Self {
298        Self {
299            category,
300            case_id: Some(result.case_id.clone()),
301            features: result.features.clone(),
302            detail: detail.into(),
303        }
304    }
305}
306
307/// Machine-readable output of [`LiveCapture::diff`].
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct LiveCaptureDiff {
311    pub schema_version: u32,
312    pub prior_capture_id: String,
313    pub new_capture_id: String,
314    pub changes: Vec<DiffEntry>,
315    /// Runtime/gameplay uncertainty is intentionally separate from
316    /// import/export changes and is always present for this workflow.
317    pub runtime_uncertainty: Vec<DiffEntry>,
318}
319
320impl LiveCaptureDiff {
321    pub fn to_json(&self) -> Result<String, LiveCaptureError> {
322        serde_json::to_string_pretty(self)
323            .map_err(|error| invalid(format!("cannot serialize live capture diff: {error}")))
324    }
325
326    pub fn human_summary(&self) -> String {
327        let mut output = format!(
328            "live capture diff schema {}\n{} -> {}\n",
329            self.schema_version, self.prior_capture_id, self.new_capture_id
330        );
331        for entry in self.changes.iter().chain(self.runtime_uncertainty.iter()) {
332            output.push_str(&format!("{:?}: {}\n", entry.category, entry.detail));
333        }
334        if self.changes.is_empty() {
335            output.push_str("no import/export changes classified\n");
336        }
337        output
338    }
339}
340
341/// A validation or serialization failure in the offline capture workflow.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct LiveCaptureError {
344    pub message: String,
345}
346
347impl std::fmt::Display for LiveCaptureError {
348    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        formatter.write_str(&self.message)
350    }
351}
352
353impl std::error::Error for LiveCaptureError {}
354
355fn invalid(message: impl Into<String>) -> LiveCaptureError {
356    LiveCaptureError {
357        message: message.into(),
358    }
359}
360
361fn validate_name(field: &str, value: &str) -> Result<(), LiveCaptureError> {
362    if value.trim().is_empty() || value.chars().any(char::is_control) {
363        Err(invalid(format!("{field} must be non-empty and printable")))
364    } else {
365        Ok(())
366    }
367}
368
369fn validate_timestamp(value: &str) -> Result<(), LiveCaptureError> {
370    validate_name("capturedAt", value)?;
371    if !value.contains('T') || !(value.ends_with('Z') || value.contains('+')) {
372        return Err(invalid(
373            "capturedAt must use an ISO-8601 timestamp with a timezone",
374        ));
375    }
376    Ok(())
377}
378
379fn validate_catalog(catalog: &CatalogIdentity) -> Result<(), LiveCaptureError> {
380    validate_name(
381        "catalog.implementationVersion",
382        &catalog.implementation_version,
383    )?;
384    validate_name("catalog.catalogVersion", &catalog.catalog_version)?;
385    let digest = catalog
386        .catalog_digest
387        .as_deref()
388        .ok_or_else(|| invalid("catalog.catalogDigest is required to identify the catalog"))?;
389    validate_sha256("catalog.catalogDigest", digest)?;
390    validate_name("catalog.target.game", &catalog.target.game)?;
391    validate_name("catalog.target.format", &catalog.target.format)?;
392    validate_name("catalog.target.surface", &catalog.target.surface)?;
393    if catalog.locale_coverage.is_empty() {
394        return Err(invalid("catalog.localeCoverage must not be empty"));
395    }
396    Ok(())
397}
398
399fn validate_census(census: &CensusIdentity) -> Result<(), LiveCaptureError> {
400    if census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
401        return Err(invalid(format!(
402            "unsupported census identity schema version {}; expected {}",
403            census.schema_version, CENSUS_IDENTITY_SCHEMA_VERSION
404        )));
405    }
406    validate_sha256("census.digest", &census.digest)?;
407    if census.shards.is_empty() || census.shards.windows(2).any(|pair| pair[0] >= pair[1]) {
408        return Err(invalid(
409            "census.shards must be non-empty and strictly sorted",
410        ));
411    }
412    Ok(())
413}
414
415fn validate_artifact(
416    field: &str,
417    artifact: &TestArtifact,
418    require_pin: bool,
419) -> Result<(), LiveCaptureError> {
420    validate_name(&format!("{field}.name"), &artifact.name)?;
421    if require_pin {
422        validate_name(
423            &format!("{field}.revision"),
424            artifact.revision.as_deref().unwrap_or_default(),
425        )?;
426        validate_name(
427            &format!("{field}.path"),
428            artifact.path.as_deref().unwrap_or_default(),
429        )?;
430        validate_sha256(
431            &format!("{field}.sha256"),
432            artifact.sha256.as_deref().unwrap_or_default(),
433        )?;
434    }
435    Ok(())
436}
437
438fn validate_sha256(field: &str, digest: &str) -> Result<(), LiveCaptureError> {
439    if !is_sha256_digest(digest) {
440        return Err(invalid(format!(
441            "{field} must be a 64-character hexadecimal SHA-256 digest"
442        )));
443    }
444    Ok(())
445}
446
447fn normalize_game(value: &str) -> String {
448    value
449        .chars()
450        .filter(char::is_ascii_alphanumeric)
451        .flat_map(char::to_lowercase)
452        .collect()
453}
454
455fn same_features(left: &ConformanceResult, right: &ConformanceResult) -> bool {
456    left.features.len() == right.features.len()
457        && left
458            .features
459            .iter()
460            .all(|feature| right.features.contains(feature))
461}
462
463fn is_runtime_uncertain(result: &ConformanceResult) -> bool {
464    !result.status.is_match() || result.comparison.mode == Equivalence::NotComparable
465}
466
467#[cfg(test)]
468mod tests {
469    use super::super::conformance::{
470        CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, FeatureKind, FeatureNamespace,
471        ReasonCode,
472    };
473    use super::*;
474    use workshop_rs::catalog::Catalog;
475
476    const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
477    const OTHER_DIGEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
478
479    fn catalog() -> CatalogIdentity {
480        Catalog::builtin()
481            .expect("built-in catalog for constructed unit data")
482            .identity()
483    }
484
485    fn raw(digest: &str) -> TestArtifact {
486        TestArtifact {
487            name: "constructed-unit-test/raw.ws".to_string(),
488            revision: Some("unit-test-revision".to_string()),
489            path: Some("constructed-unit-test/raw.ws".to_string()),
490            sha256: Some(digest.to_string()),
491            license: Some("MIT".to_string()),
492        }
493    }
494
495    fn feature(name: &str) -> FeatureId {
496        FeatureId::owned(FeatureNamespace::Wir, FeatureKind::Structural, name)
497            .expect("constructed feature identity")
498    }
499
500    fn result(
501        identity: &CatalogIdentity,
502        locale: &Locale,
503        raw: &TestArtifact,
504        case_id: &str,
505        status: ConformanceStatus,
506        feature_name: &str,
507    ) -> ConformanceResult {
508        let matched = status == ConformanceStatus::Matched;
509        ConformanceResult {
510            schema_version: CONFORMANCE_SCHEMA_VERSION,
511            case_id: case_id.to_string(),
512            features: vec![feature(feature_name)],
513            status,
514            comparison: Comparison {
515                mode: if matched {
516                    Equivalence::Normalized
517                } else {
518                    Equivalence::NotComparable
519                },
520                expected: matched.then(|| TestArtifact::new("constructed-unit-test/oracle")),
521                observed: matched.then(|| TestArtifact {
522                    name: "constructed-unit-test/observed.ws".to_string(),
523                    revision: Some("unit-test-revision".to_string()),
524                    path: Some("constructed-unit-test/raw.ws".to_string()),
525                    sha256: Some(raw.sha256.clone().unwrap()),
526                    license: Some("MIT".to_string()),
527                }),
528                normalizer: matched.then(|| "constructed-unit-test-normalizer".to_string()),
529            },
530            source: raw.clone(),
531            catalog: identity.clone(),
532            locale: Some(locale.clone()),
533            reason: (!matched).then(|| ConformanceReason {
534                code: ReasonCode::Inconclusive,
535                detail: "constructed unit uncertainty".to_string(),
536            }),
537        }
538    }
539
540    fn make_capture(
541        capture_id: &str,
542        locale: &str,
543        digest: &str,
544        result_status: ConformanceStatus,
545        feature_name: &str,
546    ) -> LiveCapture {
547        let identity = catalog();
548        let locale = Locale::new(locale);
549        let raw = raw(digest);
550        LiveCapture {
551            schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
552            capture_id: capture_id.to_string(),
553            game: "overwatch-2".to_string(),
554            client: "constructed-unit-test-client".to_string(),
555            season: "constructed-unit-test-season".to_string(),
556            captured_at: "2026-08-18T00:00:00Z".to_string(),
557            environment: "constructed schema/diff unit test; not a client capture".to_string(),
558            locale: locale.clone(),
559            catalog: identity.clone(),
560            census: CensusIdentity {
561                schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
562                digest: DIGEST.to_string(),
563                shards: vec!["constructed-unit-test-shard".to_string()],
564            },
565            raw_artifact: raw.clone(),
566            results: vec![result(
567                &identity,
568                &locale,
569                &raw,
570                "constructed-unit-test/case",
571                result_status,
572                feature_name,
573            )],
574        }
575    }
576
577    #[test]
578    fn constructed_capture_schema_round_trips_without_runtime_claim() {
579        let capture = make_capture(
580            "capture-a",
581            "en-US",
582            DIGEST,
583            ConformanceStatus::Matched,
584            "one",
585        );
586        let json = capture.to_json().expect("constructed schema serializes");
587        let decoded = LiveCapture::from_json(&json).expect("constructed schema validates");
588        assert_eq!(decoded, capture);
589    }
590
591    #[test]
592    fn schema_rejects_missing_raw_pin_and_mismatched_capture_metadata() {
593        let mut capture = make_capture(
594            "capture-a",
595            "en-US",
596            DIGEST,
597            ConformanceStatus::Matched,
598            "one",
599        );
600        capture.raw_artifact.sha256 = None;
601        assert!(capture.validate().is_err());
602
603        let mut capture = make_capture(
604            "capture-a",
605            "en-US",
606            DIGEST,
607            ConformanceStatus::Matched,
608            "one",
609        );
610        capture.game = "not-overwatch".to_string();
611        assert!(capture.validate().is_err());
612
613        let mut capture = make_capture(
614            "capture-a",
615            "en-US",
616            DIGEST,
617            ConformanceStatus::Matched,
618            "one",
619        );
620        capture.results[0].source.name = "different source".to_string();
621        assert!(capture.validate().is_err());
622    }
623
624    #[test]
625    fn diff_reports_requested_categories_and_separates_runtime_uncertainty() {
626        let prior = make_capture(
627            "capture-a",
628            "en-US",
629            DIGEST,
630            ConformanceStatus::Matched,
631            "one",
632        );
633        let mut newer = make_capture(
634            "capture-b",
635            "zh-CN",
636            OTHER_DIGEST,
637            ConformanceStatus::Inconclusive,
638            "two",
639        );
640        newer.catalog.catalog_digest = Some(OTHER_DIGEST.to_string());
641        newer.results[0].catalog = newer.catalog.clone();
642        let diff = prior.diff(&newer).expect("constructed diff validates");
643        let categories: HashSet<_> = diff.changes.iter().map(|entry| entry.category).collect();
644        assert!(categories.contains(&DiffCategory::Locale));
645        assert!(categories.contains(&DiffCategory::Catalog));
646        assert!(categories.contains(&DiffCategory::Content));
647        assert!(categories.contains(&DiffCategory::SemanticSchema));
648        assert!(!diff.runtime_uncertainty.is_empty());
649        assert!(
650            diff.runtime_uncertainty
651                .iter()
652                .all(|entry| entry.category == DiffCategory::RuntimeUncertainty)
653        );
654        let json = diff.to_json().expect("structured diff serializes");
655        let document: serde_json::Value = serde_json::from_str(&json).expect("valid diff JSON");
656        assert!(document["changes"].is_array());
657        assert!(document["runtimeUncertainty"].is_array());
658    }
659
660    #[test]
661    fn diff_refuses_capture_with_invalid_result_metadata() {
662        let prior = make_capture(
663            "capture-a",
664            "en-US",
665            DIGEST,
666            ConformanceStatus::Matched,
667            "one",
668        );
669        let mut newer = make_capture(
670            "capture-b",
671            "en-US",
672            DIGEST,
673            ConformanceStatus::Matched,
674            "one",
675        );
676        newer.results[0].catalog.catalog_digest = Some("different".to_string());
677        assert!(prior.diff(&newer).is_err());
678    }
679}