Skip to main content

workshop_rs_cli/
census.rs

1//! Deterministic, sharded census of the canonical Workshop surface.
2//!
3//! The census is derived from this crate's catalog, settings table, and WIR
4//! capabilities. It is tooling that runs contract/regression probes and
5//! produces structured results, not a source-language inventory or a
6//! live-client oracle.
7
8use std::collections::HashSet;
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::conformance::{
14    CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, ConformanceResult,
15    ConformanceStatus, Equivalence, Evidence, EvidenceArtifact, EvidenceBasis, EvidenceClass,
16    ExpectationSource, FeatureId, FeatureKind, FeatureNamespace, ImplementationIdentity,
17    ReasonCode,
18};
19use workshop_rs::catalog::{Catalog, CatalogEntry, EnumDomain, Kind, Locale};
20use workshop_rs::settings::table::{self, KeyKind, PathPart, TableEntry};
21use workshop_rs::wir::{CENSUS_CAPABILITIES, CensusCapabilityKind};
22use workshop_rs::{WorkshopError, convert, emitter, parser, roundtrip};
23
24pub const CENSUS_SCHEMA_VERSION: u32 = 1;
25pub const CENSUS_IDENTITY_SCHEMA_VERSION: u32 = 1;
26const EN_US: &str = "en-US";
27const ZH_CN: &str = "zh-CN";
28const CENSUS_TRACKING_REF: &str = "#19";
29const LOCALIZATION_EN_US_SOURCE: &str = r#"rule ("Localization") {
30    event {
31        Ongoing - Global;
32    }
33    actions {
34        Disable Inspector Recording;
35    }
36}
37"#;
38const LOCALIZATION_ZH_CN_SOURCE: &str = r#"rule ("Localization") {
39    event {
40        持续 - 全局;
41    }
42    actions {
43        禁用查看器录制;
44    }
45}
46"#;
47
48/// An explicit support classification for a census case.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "kind", rename_all = "kebab-case")]
51pub enum CensusSupport {
52    Exercise,
53    Unsupported {
54        detail: String,
55    },
56    KnownGap {
57        detail: String,
58        tracking_ref: String,
59    },
60    Inconclusive {
61        detail: String,
62    },
63}
64
65/// One deterministic case with explicit source-locale provenance.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct CensusCase {
68    pub case_id: String,
69    pub features: Vec<FeatureId>,
70    /// Locale of `source`; a zh-CN case is a real input, not an
71    /// implementation-generated conversion.
72    #[serde(default = "default_source_locale")]
73    pub source_locale: String,
74    pub source: String,
75    /// Independently recorded source text, when this case has an offline
76    /// expectation. None means the case remains inconclusive offline.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub reference_source: Option<String>,
79    pub support: CensusSupport,
80}
81
82/// A named collection of independently attributable cases.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct CensusShard {
85    pub shard_id: String,
86    pub cases: Vec<CensusCase>,
87}
88
89impl CensusShard {
90    pub fn new(
91        shard_id: impl Into<String>,
92        mut cases: Vec<CensusCase>,
93    ) -> Result<Self, CensusError> {
94        let shard_id = shard_id.into();
95        validate_name("shard_id", &shard_id)?;
96        cases.sort_by(|left, right| left.case_id.cmp(&right.case_id));
97        for case in &cases {
98            case.validate()?;
99        }
100        if cases
101            .windows(2)
102            .any(|pair| pair[0].case_id == pair[1].case_id)
103        {
104            return Err(CensusError::new(format!(
105                "shard '{shard_id}' contains duplicate case IDs"
106            )));
107        }
108        Ok(Self { shard_id, cases })
109    }
110}
111
112impl CensusCase {
113    fn validate(&self) -> Result<(), CensusError> {
114        validate_name("case_id", &self.case_id)?;
115        if self.features.is_empty() {
116            return Err(CensusError::new(format!(
117                "case '{}' has no feature IDs",
118                self.case_id
119            )));
120        }
121        if self.source.trim().is_empty() {
122            return Err(CensusError::new(format!(
123                "case '{}' has no source",
124                self.case_id
125            )));
126        }
127        validate_name("source_locale", &self.source_locale)?;
128        if self
129            .reference_source
130            .as_deref()
131            .is_some_and(|source| source.trim().is_empty())
132        {
133            return Err(CensusError::new(format!(
134                "case '{}' has an empty reference source",
135                self.case_id
136            )));
137        }
138        let mut features = HashSet::new();
139        if self
140            .features
141            .iter()
142            .any(|feature| !features.insert(feature))
143        {
144            return Err(CensusError::new(format!(
145                "case '{}' contains duplicate feature IDs",
146                self.case_id
147            )));
148        }
149        Ok(())
150    }
151}
152
153/// The complete census assembled from deterministic shards.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct Census {
156    shards: Vec<CensusShard>,
157}
158
159/// The stable identity of a reviewed census definition.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct CensusIdentity {
163    pub schema_version: u32,
164    pub digest: String,
165    pub shards: Vec<String>,
166}
167
168impl Census {
169    /// Assemble shards in stable shard and case order.
170    pub fn assemble(mut shards: Vec<CensusShard>) -> Result<Self, CensusError> {
171        shards.sort_by(|left, right| left.shard_id.cmp(&right.shard_id));
172        let mut shard_ids = HashSet::new();
173        let mut case_ids = HashSet::new();
174        for shard in &shards {
175            if !shard_ids.insert(shard.shard_id.clone()) {
176                return Err(CensusError::new(format!(
177                    "duplicate census shard '{}'",
178                    shard.shard_id
179                )));
180            }
181            for case in &shard.cases {
182                if !case_ids.insert(case.case_id.clone()) {
183                    return Err(CensusError::new(format!(
184                        "duplicate census case '{}'",
185                        case.case_id
186                    )));
187                }
188            }
189        }
190        Ok(Self { shards })
191    }
192
193    /// Derive the current surface from the canonical catalog, settings table,
194    /// and WIR capability names owned by this crate.
195    pub fn builtin(catalog: &Catalog) -> Result<Self, CensusError> {
196        Self::assemble(vec![
197            catalog_shard(catalog, Kind::Event, "catalog-events")?,
198            catalog_shard(catalog, Kind::Action, "catalog-actions")?,
199            catalog_shard(catalog, Kind::Value, "catalog-values")?,
200            catalog_shard(catalog, Kind::Operator, "catalog-operators")?,
201            catalog_shard(catalog, Kind::Structural, "catalog-structural")?,
202            enum_shard(catalog)?,
203            settings_shard()?,
204            wir_shard()?,
205            localization_shard()?,
206            content_id_shard(catalog)?,
207        ])
208    }
209
210    pub fn shards(&self) -> &[CensusShard] {
211        &self.shards
212    }
213
214    pub fn cases(&self) -> impl Iterator<Item = &CensusCase> {
215        self.shards.iter().flat_map(|shard| shard.cases.iter())
216    }
217
218    /// Execute all cases. No result state is dropped or converted to success.
219    pub fn run(&self, catalog: &Catalog) -> CensusReport {
220        let mut results: Vec<_> = self
221            .shards
222            .iter()
223            .flat_map(|shard| {
224                shard
225                    .cases
226                    .iter()
227                    .map(move |case| run_case(case, &shard.shard_id, catalog))
228            })
229            .collect();
230        results.sort_by(|left, right| left.case_id.cmp(&right.case_id));
231        CensusReport {
232            schema_version: CENSUS_SCHEMA_VERSION,
233            conformance_schema_version: CONFORMANCE_SCHEMA_VERSION,
234            catalog: catalog.identity(),
235            census: self.identity(),
236            results,
237        }
238    }
239
240    /// Return the deterministic identity of this census definition.
241    pub fn identity(&self) -> CensusIdentity {
242        let definition = self
243            .export_json()
244            .expect("census definitions must remain serializable");
245        CensusIdentity {
246            schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
247            digest: sha256(&definition),
248            shards: self
249                .shards
250                .iter()
251                .map(|shard| shard.shard_id.clone())
252                .collect(),
253        }
254    }
255
256    /// Export shard definitions without executing them.
257    pub fn export_json(&self) -> Result<String, CensusError> {
258        serde_json::to_string_pretty(&self.shards)
259            .map_err(|error| CensusError::new(format!("cannot serialize census shards: {error}")))
260    }
261}
262
263/// Machine-readable output from a census run.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct CensusReport {
267    pub schema_version: u32,
268    pub conformance_schema_version: u32,
269    pub catalog: workshop_rs::catalog::CatalogIdentity,
270    pub census: CensusIdentity,
271    pub results: Vec<ConformanceResult>,
272}
273
274impl CensusReport {
275    pub fn validate(&self) -> Result<(), CensusError> {
276        let catalog =
277            Catalog::builtin().map_err(|error| CensusError::new(format!("catalog: {error}")))?;
278        self.validate_against(&catalog)
279    }
280
281    pub fn validate_against(&self, catalog: &Catalog) -> Result<(), CensusError> {
282        if self.schema_version != CENSUS_SCHEMA_VERSION {
283            return Err(CensusError::new("unsupported census schema version"));
284        }
285        if self.conformance_schema_version != CONFORMANCE_SCHEMA_VERSION {
286            return Err(CensusError::new("unsupported conformance schema version"));
287        }
288        if self.catalog != catalog.identity() {
289            return Err(CensusError::new(
290                "report catalog identity does not match the loaded catalog",
291            ));
292        }
293        if self.census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
294            return Err(CensusError::new(
295                "unsupported census identity schema version",
296            ));
297        }
298        if self.census.digest.len() != 64
299            || !self
300                .census
301                .digest
302                .chars()
303                .all(|character| character.is_ascii_hexdigit())
304        {
305            return Err(CensusError::new(
306                "census identity digest must be a SHA-256 hex digest",
307            ));
308        }
309        if self.census.shards.is_empty()
310            || self.census.shards.windows(2).any(|pair| pair[0] >= pair[1])
311        {
312            return Err(CensusError::new(
313                "report shards must be non-empty and strictly sorted",
314            ));
315        }
316        for result in &self.results {
317            result
318                .validate_against(catalog)
319                .map_err(|error| CensusError::new(error.to_string()))?;
320            let matching_shards = self
321                .census
322                .shards
323                .iter()
324                .filter(|shard| {
325                    result
326                        .case_id
327                        .strip_prefix(shard.as_str())
328                        .is_some_and(|rest| rest.starts_with('/'))
329                })
330                .count();
331            if matching_shards != 1 {
332                return Err(CensusError::new(format!(
333                    "result '{}' does not map to exactly one census shard",
334                    result.case_id
335                )));
336            }
337        }
338        Ok(())
339    }
340
341    pub fn to_json(&self) -> Result<String, CensusError> {
342        self.validate()?;
343        serde_json::to_string_pretty(self)
344            .map_err(|error| CensusError::new(format!("cannot serialize census report: {error}")))
345    }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct CensusError {
350    pub message: String,
351}
352
353impl CensusError {
354    fn new(message: impl Into<String>) -> Self {
355        Self {
356            message: message.into(),
357        }
358    }
359}
360
361impl std::fmt::Display for CensusError {
362    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        formatter.write_str(&self.message)
364    }
365}
366
367impl std::error::Error for CensusError {}
368
369fn validate_name(field: &str, value: &str) -> Result<(), CensusError> {
370    if value.trim().is_empty() || value.chars().any(char::is_control) {
371        Err(CensusError::new(format!(
372            "{field} must be non-empty and printable"
373        )))
374    } else {
375        Ok(())
376    }
377}
378
379fn default_source_locale() -> String {
380    EN_US.to_string()
381}
382
383fn feature(namespace: FeatureNamespace, kind: FeatureKind, name: impl Into<String>) -> FeatureId {
384    FeatureId::owned(namespace, kind, name).expect("canonical census feature ID")
385}
386
387fn catalog_feature(kind: Kind, id: &str) -> FeatureId {
388    FeatureId::from_catalog(kind, id).expect("catalog IDs are validated by Catalog::load")
389}
390
391fn catalog_shard(
392    catalog: &Catalog,
393    kind: Kind,
394    shard_id: &str,
395) -> Result<CensusShard, CensusError> {
396    let cases = catalog
397        .entries_of(kind)
398        .map(|entry| {
399            let source = match kind {
400                Kind::Event => event_probe(catalog, entry),
401                Kind::Action => action_probe(catalog, entry),
402                Kind::Value => value_probe(catalog, entry),
403                Kind::Operator => operator_probe(catalog, entry),
404                Kind::Structural => structural_probe(catalog, entry),
405                Kind::Setting => unreachable!("settings use the settings table"),
406                Kind::Enum => unreachable!("enum domains use the enum shard"),
407            };
408            CensusCase {
409                case_id: format!("{shard_id}/{}", entry.id),
410                features: vec![catalog_feature(kind, &entry.id)],
411                source_locale: EN_US.to_string(),
412                source,
413                reference_source: None,
414                support: generated_probe_support(),
415            }
416        })
417        .collect();
418    CensusShard::new(shard_id, cases)
419}
420
421fn enum_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
422    let mut cases = Vec::new();
423    for domain in catalog.enum_domains() {
424        for member in &domain.members {
425            let features = vec![
426                catalog_feature(Kind::Enum, &domain.domain),
427                FeatureId::from_enum_member(&domain.domain, &member.member)
428                    .expect("canonical enum member ID"),
429            ];
430            cases.push(CensusCase {
431                case_id: format!("catalog-enums/{}/{}", domain.domain, member.member),
432                features,
433                source_locale: EN_US.to_string(),
434                source: enum_probe(catalog, domain, &member.member),
435                reference_source: None,
436                support: generated_probe_support(),
437            });
438        }
439    }
440    CensusShard::new("catalog-enums", cases)
441}
442
443fn content_id_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
444    let mut cases = Vec::new();
445    for domain in catalog.enum_domains() {
446        if !matches!(domain.domain.as_str(), "Hero" | "Map") {
447            continue;
448        }
449        for member in &domain.members {
450            cases.push(CensusCase {
451                case_id: format!("content-ids/{}/{}", domain.domain, member.member),
452                features: vec![
453                    FeatureId::from_enum_member(&domain.domain, &member.member)
454                        .expect("canonical content enum-member ID"),
455                ],
456                source_locale: EN_US.to_string(),
457                source: enum_probe(catalog, domain, &member.member),
458                reference_source: None,
459                support: generated_probe_support(),
460            });
461        }
462    }
463    CensusShard::new("content-ids", cases)
464}
465
466fn settings_shard() -> Result<CensusShard, CensusError> {
467    let cases = table::ENTRIES
468        .iter()
469        .map(|entry| {
470            let path = table::path_string(entry.path);
471            CensusCase {
472                case_id: format!("settings/{path}"),
473                features: vec![feature(
474                    FeatureNamespace::Settings,
475                    FeatureKind::Setting,
476                    path,
477                )],
478                source_locale: EN_US.to_string(),
479                source: settings_probe(entry),
480                reference_source: None,
481                support: generated_probe_support(),
482            }
483        })
484        .collect();
485    CensusShard::new("settings", cases)
486}
487
488fn wir_shard() -> Result<CensusShard, CensusError> {
489    let cases = CENSUS_CAPABILITIES
490        .iter()
491        .map(|capability| match capability.kind {
492            CensusCapabilityKind::Variable => wir_case(
493                "variables-global",
494                FeatureKind::Variable,
495                capability.name,
496                variables_source(),
497            ),
498            CensusCapabilityKind::PlayerVariable => CensusCase {
499                case_id: "wir/variables-player".to_string(),
500                features: vec![feature(
501                    FeatureNamespace::Wir,
502                    FeatureKind::Variable,
503                    capability.name,
504                )],
505                source_locale: EN_US.to_string(),
506                source: player_variable_source(),
507                reference_source: None,
508                support: generated_probe_support(),
509            },
510            CensusCapabilityKind::Subroutine => wir_case(
511                "subroutine",
512                FeatureKind::Subroutine,
513                capability.name,
514                subroutine_source(),
515            ),
516            CensusCapabilityKind::ControlFlow => {
517                let actions = match capability.name {
518                    "if" => "If(True);\n    Wait(0);\nEnd;",
519                    "else-if" => "If(True);\n    Wait(0);\nElse If(False);\n    Wait(0);\nEnd;",
520                    "else" => "If(True);\n    Wait(0);\nElse;\n    Wait(0);\nEnd;",
521                    "while" => "While(True);\n    Wait(0);\nEnd;",
522                    "for-global-variable" => {
523                        "For Global Variable(probe, 0, 1, 1);\n    Wait(0);\nEnd;"
524                    }
525                    _ => unreachable!("unknown WIR control-flow census capability"),
526                };
527                control_flow_case(capability.name, actions)
528            }
529            CensusCapabilityKind::String => CensusCase {
530                case_id: "wir/string/custom-string".to_string(),
531                features: vec![feature(
532                    FeatureNamespace::Wir,
533                    FeatureKind::String,
534                    capability.name,
535                )],
536                source_locale: EN_US.to_string(),
537                source: rule_source(
538                    "String",
539                    "Set Global Variable(probe, Custom String(\"census\"));",
540                ),
541                reference_source: None,
542                support: CensusSupport::Exercise,
543            },
544        })
545        .collect();
546    CensusShard::new("wir", cases)
547}
548
549fn localization_shard() -> Result<CensusShard, CensusError> {
550    CensusShard::new(
551        "localization",
552        vec![
553            CensusCase {
554                case_id: "localization/en-us-to-zh-cn".to_string(),
555                features: vec![feature(
556                    FeatureNamespace::Localization,
557                    FeatureKind::Localization,
558                    "en-us-to-zh-cn",
559                )],
560                source_locale: EN_US.to_string(),
561                source: LOCALIZATION_EN_US_SOURCE.to_string(),
562                reference_source: None,
563                support: generated_probe_support(),
564            },
565            CensusCase {
566                case_id: "localization/zh-cn-to-en-us".to_string(),
567                features: vec![feature(
568                    FeatureNamespace::Localization,
569                    FeatureKind::Localization,
570                    "zh-cn-to-en-us",
571                )],
572                source_locale: ZH_CN.to_string(),
573                source: LOCALIZATION_ZH_CN_SOURCE.to_string(),
574                reference_source: None,
575                support: generated_probe_support(),
576            },
577        ],
578    )
579}
580
581fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> CensusCase {
582    CensusCase {
583        case_id: format!("wir/{case_id}"),
584        features: vec![feature(FeatureNamespace::Wir, kind, name)],
585        source_locale: EN_US.to_string(),
586        source,
587        reference_source: None,
588        support: CensusSupport::Exercise,
589    }
590}
591
592fn control_flow_case(name: &str, actions: &str) -> CensusCase {
593    CensusCase {
594        case_id: format!("wir/control-flow/{name}"),
595        features: vec![feature(
596            FeatureNamespace::Wir,
597            FeatureKind::ControlFlow,
598            name,
599        )],
600        source_locale: EN_US.to_string(),
601        source: rule_source(name, actions),
602        reference_source: None,
603        support: generated_probe_support(),
604    }
605}
606
607fn generated_probe_support() -> CensusSupport {
608    CensusSupport::Inconclusive {
609        detail: "generated probe is exportable for independent Workshop/client evidence but has no independently recorded expected result".to_string(),
610    }
611}
612
613fn rule_source(name: &str, actions: &str) -> String {
614    format!(
615        "variables {{\n    global:\n        0: probe\n}}\n\nrule (\"{name}\") {{\n    event {{\n        Ongoing - Global;\n    }}\n    actions {{\n        {actions}\n    }}\n}}\n"
616    )
617}
618
619fn variables_source() -> String {
620    "variables {\n    global:\n        0: probe\n}\n\nrule (\"Global variable\") {\n    event {\n        Ongoing - Global;\n    }\n    actions {\n        Set Global Variable(probe, 1);\n    }\n}\n"
621        .to_string()
622}
623
624fn player_variable_source() -> String {
625    "variables {\n    player:\n        0: probe\n}\n\nrule (\"Player variable\") {\n    event {\n        Ongoing - Each Player;\n        All;\n        All;\n    }\n    actions {\n        Set Player Variable(Event Player, probe, 1);\n    }\n}\n"
626        .to_string()
627}
628
629fn subroutine_source() -> String {
630    "subroutines {\n    0: probe\n}\n\nrule (\"Subroutine\") {\n    event {\n        Subroutine;\n        probe;\n    }\n    actions {\n        Call Subroutine(probe);\n    }\n}\n"
631        .to_string()
632}
633
634fn event_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
635    let spelling = catalog
636        .spelling(Kind::Event, &Locale::new(EN_US), &entry.id)
637        .unwrap_or(&entry.id);
638    let filters = if matches!(entry.id.as_str(), "global" | "subroutine") {
639        String::new()
640    } else {
641        "        All;\n        All;\n".to_string()
642    };
643    let subroutine = if entry.id == "subroutine" {
644        "        probe;\n"
645    } else {
646        ""
647    };
648    format!(
649        "subroutines {{\n    0: probe\n}}\n\nrule (\"Event\") {{\n    event {{\n        {spelling};\n{filters}{subroutine}    }}\n    actions {{\n        Wait;\n    }}\n}}\n"
650    )
651}
652
653fn action_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
654    let spelling = catalog
655        .spelling(Kind::Action, &Locale::new(EN_US), &entry.id)
656        .unwrap_or(&entry.id);
657    let call = if matches!(
658        entry.id.as_str(),
659        "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime"
660    ) {
661        format!("{spelling}(Event Player, probe, 0, 1, 0);")
662    } else {
663        format!("{spelling};")
664    };
665    rule_source("Action", &call)
666}
667
668fn value_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
669    let spelling = catalog
670        .spelling(Kind::Value, &Locale::new(EN_US), &entry.id)
671        .unwrap_or(&entry.id);
672    rule_source("Value", &format!("Set Global Variable(probe, {spelling});"))
673}
674
675fn operator_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
676    let spelling = catalog
677        .spelling(Kind::Operator, &Locale::new(EN_US), &entry.id)
678        .unwrap_or(&entry.id);
679    rule_source(
680        "Operator",
681        &format!("If(1 {spelling} 1);\n    Wait(0);\nEnd;"),
682    )
683}
684
685fn structural_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
686    let spelling = catalog
687        .spelling(Kind::Structural, &Locale::new(EN_US), &entry.id)
688        .unwrap_or(&entry.id);
689    let actions = match entry.id.as_str() {
690        "if" => format!("{spelling}(True);\n    Wait(0);\nEnd;"),
691        "elseIf" => format!("If(True);\n    Wait(0);\n{spelling}(False);\n    Wait(0);\nEnd;"),
692        "else" => format!("If(True);\n    Wait(0);\n{spelling};\n    Wait(0);\nEnd;"),
693        "end" => format!("If(True);\n    Wait(0);\n{spelling};"),
694        "while" => format!("{spelling}(True);\n    Wait(0);\nEnd;"),
695        "forGlobalVariable" => format!("{spelling}(probe, 0, 1, 1);\n    Wait(0);\nEnd;"),
696        "setGlobalVariable" => format!("{spelling}(probe, 1);"),
697        "modifyGlobalVariable" => format!("{spelling}(probe, Add, 1);"),
698        "setPlayerVariable" => format!("{spelling}(Event Player, probe, 1);"),
699        "modifyPlayerVariable" => format!("{spelling}(Event Player, probe, Add, 1);"),
700        "callSubroutine" => format!("{spelling}(probe);"),
701        _ => format!("{spelling};"),
702    };
703    let prefix = match entry.id.as_str() {
704        "setPlayerVariable" | "modifyPlayerVariable" => {
705            "variables {\n    player:\n        0: probe\n}\n\n"
706        }
707        "callSubroutine" => "subroutines {\n    0: probe\n}\n\n",
708        _ => "",
709    };
710    format!("{prefix}{}", rule_source("Structural", &actions))
711}
712
713fn enum_probe(catalog: &Catalog, domain: &EnumDomain, member: &str) -> String {
714    let locale = Locale::new(EN_US);
715    let domain_spelling = catalog
716        .spelling(Kind::Value, &locale, &domain.domain)
717        .unwrap_or(&domain.domain);
718    let member_spelling = catalog
719        .enum_spelling(&domain.domain, &locale, member)
720        .unwrap_or(member);
721    rule_source(
722        "Enum",
723        &format!("Set Global Variable(probe, {domain_spelling}({member_spelling}));"),
724    )
725}
726
727fn settings_probe(entry: &TableEntry) -> String {
728    let mut lines = vec!["settings {".to_string()];
729    let mut depth = 1;
730    for part in entry.path {
731        let name = match part {
732            PathPart::Part("gamemodes") => "modes",
733            PathPart::Part("heroes") => "heroes",
734            PathPart::Part("main") => "main",
735            PathPart::Part("lobby") => "lobby",
736            PathPart::Part(value) => table::mode_name(value).unwrap_or(value),
737            PathPart::Team => "General",
738            PathPart::Hero => "Mei",
739        };
740        lines.push(format!("{}{} {{", "    ".repeat(depth), name));
741        depth += 1;
742    }
743    let indent = "    ".repeat(depth);
744    match entry.kind {
745        KeyKind::Flag => lines.push(format!("{indent}{}", entry.workshop_name)),
746        KeyKind::String => lines.push(format!("{indent}{}: \"census\"", entry.workshop_name)),
747        KeyKind::Bool => lines.push(format!("{indent}{}: On", entry.workshop_name)),
748        KeyKind::BoolEnum(domain) => {
749            let value = table::enum_name(domain, "enabled").unwrap_or("Enabled");
750            lines.push(format!("{indent}{}: {value}", entry.workshop_name));
751        }
752        KeyKind::Number => lines.push(format!("{indent}{}: 1", entry.workshop_name)),
753        KeyKind::Percent => lines.push(format!("{indent}{}: 100%", entry.workshop_name)),
754        KeyKind::Enum(domain) => {
755            let member = if domain == "roleLimit" {
756                "2OfEachRolePerTeam"
757            } else {
758                "off"
759            };
760            let value = table::enum_name(domain, member).unwrap_or("Off");
761            lines.push(format!("{indent}{}: {value}", entry.workshop_name));
762        }
763        KeyKind::ListMap | KeyKind::ListHero => {
764            lines.push(format!("{indent}{} {{", entry.workshop_name));
765            lines.push(format!("{indent}}}"));
766        }
767    }
768    while depth > 1 {
769        depth -= 1;
770        lines.push(format!("{}{}", "    ".repeat(depth), "}"));
771    }
772    lines.push("}".to_string());
773    lines.join("\n")
774}
775
776fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult {
777    let fixture = artifact(
778        format!("census/{shard_id}/{}.ws", case.case_id),
779        &case.source,
780    );
781    let expectation = case
782        .reference_source
783        .as_ref()
784        .map(|source| ExpectationSource {
785            basis: EvidenceBasis::PreservedRegression,
786            artifact: reference_artifact(case, source),
787            tracking_ref: Some(CENSUS_TRACKING_REF.to_string()),
788        })
789        .unwrap_or_else(|| ExpectationSource {
790            basis: EvidenceBasis::SemanticContract,
791            artifact: EvidenceArtifact {
792                name: "docs/adr/0002-conformance-contract.md".to_string(),
793                revision: Some("ADR-0002".to_string()),
794                path: Some("docs/adr/0002-conformance-contract.md".to_string()),
795                sha256: None,
796                license: Some("MIT".to_string()),
797            },
798            tracking_ref: None,
799        });
800    let evidence = |locale: Option<Locale>| Evidence {
801        class: EvidenceClass::Synthetic,
802        fixture: fixture.clone(),
803        expectation: expectation.clone(),
804        catalog: catalog.identity(),
805        locale,
806        client: None,
807        implementation: Some(ImplementationIdentity {
808            name: "workshop-rs".to_string(),
809            version: Catalog::implementation_version().to_string(),
810            revision: None,
811            artifact: None,
812        }),
813    };
814    let base = |status, comparison, reason, locale| ConformanceResult {
815        schema_version: CONFORMANCE_SCHEMA_VERSION,
816        case_id: case.case_id.clone(),
817        features: case.features.clone(),
818        status,
819        comparison,
820        evidence: evidence(locale),
821        reason,
822    };
823    match &case.support {
824        CensusSupport::Unsupported { detail } => base(
825            ConformanceStatus::Unsupported,
826            not_comparable(),
827            Some(reason(ReasonCode::Unsupported, detail, None)),
828            None,
829        ),
830        CensusSupport::KnownGap {
831            detail,
832            tracking_ref,
833        } => base(
834            ConformanceStatus::KnownGap,
835            not_comparable(),
836            Some(reason(
837                ReasonCode::KnownGap,
838                detail,
839                Some(tracking_ref.clone()),
840            )),
841            None,
842        ),
843        CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)),
844        CensusSupport::Exercise => execute_case(case, base, catalog, None),
845    }
846}
847
848fn execute_case(
849    case: &CensusCase,
850    base: impl Fn(
851        ConformanceStatus,
852        Comparison,
853        Option<ConformanceReason>,
854        Option<Locale>,
855    ) -> ConformanceResult,
856    catalog: &Catalog,
857    inconclusive_detail: Option<&str>,
858) -> ConformanceResult {
859    let source_locale = Locale::new(&case.source_locale);
860    let target_locale = if source_locale.as_str() == Locale::new(ZH_CN).as_str() {
861        Locale::new(EN_US)
862    } else {
863        Locale::new(ZH_CN)
864    };
865    let program = match parser::parse_with_context(&case.source, catalog, &source_locale, catalog) {
866        Ok(program) => program,
867        Err(error) => return failed(base, &error, &source_locale),
868    };
869    if let Err(error) = program.validate() {
870        return failed_text(
871            base,
872            ReasonCode::UnexpectedRegression,
873            error.to_string(),
874            Some(source_locale.clone()),
875        );
876    }
877    let emitted_source = match emitter::emit(&program, catalog, &source_locale) {
878        Ok(output) => output,
879        Err(error) => return failed(base, &error, &source_locale),
880    };
881    let reparsed_source =
882        match parser::parse_with_context(&emitted_source, catalog, &source_locale, catalog) {
883            Ok(program) => program,
884            Err(error) => return failed(base, &error, &source_locale),
885        };
886    if let Err(error) = reparsed_source.validate() {
887        return failed_text(
888            base,
889            ReasonCode::UnexpectedRegression,
890            error.to_string(),
891            Some(source_locale.clone()),
892        );
893    }
894    let emitted_source_again = match emitter::emit(&reparsed_source, catalog, &source_locale) {
895        Ok(output) => output,
896        Err(error) => return failed(base, &error, &source_locale),
897    };
898    if !roundtrip::equivalent(&program, &reparsed_source)
899        || normalize_workshop(&emitted_source) != normalize_workshop(&emitted_source_again)
900    {
901        return failed_text(
902            base,
903            ReasonCode::UnexpectedRegression,
904            "en-US semantic or normalized gate diverged".to_string(),
905            Some(source_locale.clone()),
906        );
907    }
908    let converted = match convert::convert(
909        &case.source,
910        catalog,
911        &source_locale,
912        &target_locale,
913        &Default::default(),
914    ) {
915        Ok(output) => output,
916        Err(error) => return failed(base, &error, &target_locale),
917    };
918    let program_target =
919        match parser::parse_with_context(&converted.text, catalog, &target_locale, catalog) {
920            Ok(program) => program,
921            Err(error) => return failed(base, &error, &target_locale),
922        };
923    if let Err(error) = program_target.validate() {
924        return failed_text(
925            base,
926            ReasonCode::UnexpectedRegression,
927            error.to_string(),
928            Some(target_locale.clone()),
929        );
930    }
931    if !roundtrip::equivalent(&program, &program_target) {
932        return failed_text(
933            base,
934            ReasonCode::UnexpectedRegression,
935            "zh-CN conversion changed canonical WIR semantics".to_string(),
936            Some(target_locale.clone()),
937        );
938    }
939    let back_to_source = match convert::convert(
940        &converted.text,
941        catalog,
942        &target_locale,
943        &source_locale,
944        &Default::default(),
945    ) {
946        Ok(output) => output,
947        Err(error) => return failed(base, &error, &source_locale),
948    };
949    let reparsed_back =
950        match parser::parse_with_context(&back_to_source.text, catalog, &source_locale, catalog) {
951            Ok(program) => program,
952            Err(error) => return failed(base, &error, &source_locale),
953        };
954    if !roundtrip::equivalent(&program, &reparsed_back)
955        || normalize_workshop(&back_to_source.text) != normalize_workshop(&case.source)
956    {
957        return failed_text(
958            base,
959            ReasonCode::UnexpectedRegression,
960            "cross-locale semantic or normalized gate diverged".to_string(),
961            Some(source_locale.clone()),
962        );
963    }
964    let Some(reference_source) = case.reference_source.as_ref() else {
965        return failed_text(
966            base,
967            ReasonCode::Inconclusive,
968            inconclusive_detail.unwrap_or(
969                "offline semantic and locale gates passed, but no independent expectation artifact is recorded",
970            )
971            .to_string(),
972            Some(source_locale),
973        );
974    };
975    let expected_program =
976        match parser::parse_with_context(reference_source, catalog, &target_locale, catalog) {
977            Ok(program) => program,
978            Err(error) => return failed(base, &error, &target_locale),
979        };
980    if !roundtrip::equivalent(&program, &expected_program)
981        || normalize_workshop(&converted.text) != normalize_workshop(reference_source)
982    {
983        return failed_text(
984            base,
985            ReasonCode::UnexpectedRegression,
986            "conversion differed from the independent reference source".to_string(),
987            Some(target_locale),
988        );
989    }
990    base(
991        ConformanceStatus::Matched,
992        Comparison {
993            mode: Equivalence::Semantic,
994            expected: Some(reference_artifact(case, reference_source)),
995            observed: Some(artifact(
996                format!("census/{}/converted-output.ws", case.case_id),
997                &converted.text,
998            )),
999            normalizer: Some("canonical-wir;normalized-workshop-text".to_string()),
1000        },
1001        None,
1002        Some(source_locale),
1003    )
1004}
1005
1006fn failed(
1007    base: impl Fn(
1008        ConformanceStatus,
1009        Comparison,
1010        Option<ConformanceReason>,
1011        Option<Locale>,
1012    ) -> ConformanceResult,
1013    error: &WorkshopError,
1014    locale: &Locale,
1015) -> ConformanceResult {
1016    let code = match error {
1017        WorkshopError::Unsupported { .. } => ReasonCode::Unsupported,
1018        WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap,
1019        _ => ReasonCode::UnexpectedRegression,
1020    };
1021    failed_text(base, code, error.to_string(), Some(locale.clone()))
1022}
1023
1024fn failed_text(
1025    base: impl Fn(
1026        ConformanceStatus,
1027        Comparison,
1028        Option<ConformanceReason>,
1029        Option<Locale>,
1030    ) -> ConformanceResult,
1031    code: ReasonCode,
1032    detail: String,
1033    locale: Option<Locale>,
1034) -> ConformanceResult {
1035    let status = match code {
1036        ReasonCode::Unsupported => ConformanceStatus::Unsupported,
1037        ReasonCode::KnownGap => ConformanceStatus::KnownGap,
1038        ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression,
1039        ReasonCode::Inconclusive => ConformanceStatus::Inconclusive,
1040    };
1041    let comparison = if code == ReasonCode::UnexpectedRegression {
1042        Comparison {
1043            mode: Equivalence::Normalized,
1044            expected: None,
1045            observed: None,
1046            normalizer: Some("census-stage".to_string()),
1047        }
1048    } else {
1049        not_comparable()
1050    };
1051    let tracking = (code == ReasonCode::KnownGap).then(|| CENSUS_TRACKING_REF.to_string());
1052    base(
1053        status,
1054        comparison,
1055        Some(reason(code, &detail, tracking)),
1056        locale,
1057    )
1058}
1059
1060fn reason(code: ReasonCode, detail: &str, tracking_ref: Option<String>) -> ConformanceReason {
1061    ConformanceReason {
1062        code,
1063        detail: detail.to_string(),
1064        tracking_ref,
1065    }
1066}
1067
1068fn not_comparable() -> Comparison {
1069    Comparison {
1070        mode: Equivalence::NotComparable,
1071        expected: None,
1072        observed: None,
1073        normalizer: None,
1074    }
1075}
1076
1077fn artifact(name: impl Into<String>, content: &str) -> EvidenceArtifact {
1078    EvidenceArtifact {
1079        name: name.into(),
1080        revision: None,
1081        path: None,
1082        sha256: Some(sha256(content)),
1083        license: Some("MIT".to_string()),
1084    }
1085}
1086
1087fn reference_artifact(case: &CensusCase, content: &str) -> EvidenceArtifact {
1088    EvidenceArtifact {
1089        name: format!("census reference for {}", case.case_id),
1090        revision: Some("census-v1".to_string()),
1091        path: Some("tests/fixtures/census/reference.ws".to_string()),
1092        sha256: Some(sha256(content)),
1093        license: Some("MIT".to_string()),
1094    }
1095}
1096
1097fn sha256(content: &str) -> String {
1098    let mut hasher = Sha256::new();
1099    hasher.update(content.as_bytes());
1100    format!("{:x}", hasher.finalize())
1101}
1102
1103fn normalize_workshop(text: &str) -> String {
1104    text.split_whitespace().collect::<Vec<_>>().join(" ")
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109    use super::*;
1110
1111    #[test]
1112    fn builtin_census_is_derived_and_deterministic() {
1113        let catalog = Catalog::builtin().expect("builtin catalog");
1114        let first = Census::builtin(&catalog).expect("census");
1115        let second = Census::builtin(&catalog).expect("census");
1116        assert_eq!(first, second);
1117        assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions");
1118        assert!(
1119            first
1120                .cases()
1121                .any(|case| case.features.iter().any(|feature| feature.name == "wait"))
1122        );
1123        assert!(first.cases().any(|case| {
1124            case.features
1125                .iter()
1126                .any(|feature| feature.kind == FeatureKind::Setting)
1127        }));
1128        assert!(first.cases().any(|case| {
1129            case.features
1130                .iter()
1131                .any(|feature| feature.kind == FeatureKind::ControlFlow)
1132        }));
1133        assert_eq!(first.export_json().unwrap(), second.export_json().unwrap());
1134        assert_eq!(first.identity(), second.identity());
1135        assert_eq!(first.identity().digest.len(), 64);
1136    }
1137
1138    #[test]
1139    fn explicit_non_matching_states_remain_machine_readable() {
1140        let feature_case = |id: &str, support| CensusCase {
1141            case_id: format!("state-tests/{id}"),
1142            features: vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)],
1143            source_locale: EN_US.to_string(),
1144            source: format!("rule (\"{id}\") {{}}"),
1145            reference_source: None,
1146            support,
1147        };
1148        let shard = CensusShard::new(
1149            "state-tests",
1150            vec![
1151                feature_case(
1152                    "unsupported",
1153                    CensusSupport::Unsupported {
1154                        detail: "not declared".to_string(),
1155                    },
1156                ),
1157                feature_case(
1158                    "known-gap",
1159                    CensusSupport::KnownGap {
1160                        detail: "missing mapping".to_string(),
1161                        tracking_ref: "#19".to_string(),
1162                    },
1163                ),
1164                feature_case(
1165                    "inconclusive",
1166                    CensusSupport::Inconclusive {
1167                        detail: "no oracle".to_string(),
1168                    },
1169                ),
1170            ],
1171        )
1172        .unwrap();
1173        let report = Census::assemble(vec![shard])
1174            .unwrap()
1175            .run(&Catalog::builtin().unwrap());
1176        report
1177            .validate()
1178            .expect("states use the current #18 adapter");
1179        let json = report.to_json().unwrap();
1180        assert!(json.contains("unsupported"));
1181        assert!(json.contains("known-gap"));
1182        assert!(json.contains("inconclusive"));
1183    }
1184
1185    #[test]
1186    fn builtin_census_report_validates_against_the_catalog() {
1187        let catalog = Catalog::builtin().expect("builtin catalog");
1188        let census = Census::builtin(&catalog).expect("census");
1189        let report = census.run(&catalog);
1190        report
1191            .validate_against(&catalog)
1192            .expect("census results use canonical catalog identities");
1193        assert_eq!(report.census, census.identity());
1194        assert_eq!(
1195            report
1196                .results
1197                .iter()
1198                .filter(|result| result.case_id.starts_with("localization/"))
1199                .count(),
1200            2
1201        );
1202    }
1203
1204    #[test]
1205    fn census_report_rejects_a_malformed_identity_digest() {
1206        let catalog = Catalog::builtin().expect("builtin catalog");
1207        let census = Census::builtin(&catalog).expect("census");
1208        let mut report = census.run(&catalog);
1209        report.census.digest = "not-a-digest".to_string();
1210
1211        let error = report
1212            .validate_against(&catalog)
1213            .expect_err("report identity must carry a SHA-256 digest");
1214        assert!(error.to_string().contains("SHA-256"));
1215    }
1216}