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