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    rule_source(
763        "Enum",
764        &format!("Set Global Variable(probe, {domain_spelling}({member_spelling}));"),
765    )
766}
767
768fn settings_probe(definition: &SettingDefinition, catalog: &Catalog) -> String {
769    let mut lines = vec!["settings {".to_string()];
770    let mut depth = 1;
771    let parts: Vec<_> = definition.path().split('.').collect();
772    for (index, part) in parts.iter().enumerate() {
773        let name = match *part {
774            "gamemodes" => "modes".to_string(),
775            "heroes" => "heroes".to_string(),
776            "<team>" => "General".to_string(),
777            "<hero>" => {
778                catalog_enum_spelling(catalog, "Hero", "mei").unwrap_or_else(|| "Mei".to_string())
779            }
780            "general" => "General".to_string(),
781            value if index == 1 && parts.first() == Some(&"gamemodes") => {
782                mode_spelling(catalog, value)
783            }
784            value => value.to_string(),
785        };
786        lines.push(format!("{}{} {{", "    ".repeat(depth), name));
787        depth += 1;
788    }
789    let indent = "    ".repeat(depth);
790    let name = definition.presentation().english_name;
791    match definition.domain() {
792        SettingValueDomain::PresenceOnly => lines.push(format!("{indent}{name}")),
793        SettingValueDomain::String => lines.push(format!("{indent}{name}: \"census\"")),
794        SettingValueDomain::Boolean => {
795            let value = definition
796                .enum_members()
797                .next()
798                .map(|member| member.english_name().to_string())
799                .unwrap_or_else(|| "On".to_string());
800            lines.push(format!("{indent}{name}: {value}"));
801        }
802        SettingValueDomain::Number(_) => lines.push(format!("{indent}{name}: 1")),
803        SettingValueDomain::Percent(_) => lines.push(format!("{indent}{name}: 100%")),
804        SettingValueDomain::Enum { .. } => {
805            let value = definition
806                .enum_members()
807                .next()
808                .map(|member| member.english_name().to_string())
809                .unwrap_or_else(|| "Off".to_string());
810            lines.push(format!("{indent}{name}: {value}"));
811        }
812        SettingValueDomain::HeroList | SettingValueDomain::MapList => {
813            lines.push(format!("{indent}{name} {{"));
814            lines.push(format!("{indent}}}"));
815        }
816        other => unreachable!("census has no probe for settings value domain {other:?}"),
817    }
818    while depth > 1 {
819        depth -= 1;
820        lines.push(format!("{}{}", "    ".repeat(depth), "}"));
821    }
822    lines.push("}".to_string());
823    lines.join("\n")
824}
825
826fn mode_spelling(catalog: &Catalog, key: &str) -> String {
827    catalog_enum_spelling(catalog, "Gamemode", key).unwrap_or_else(|| key.to_string())
828}
829
830fn catalog_enum_spelling(catalog: &Catalog, domain: &str, key: &str) -> Option<String> {
831    let normalize = |value: &str| {
832        value
833            .chars()
834            .filter(|character| character.is_ascii_alphanumeric())
835            .flat_map(char::to_lowercase)
836            .collect::<String>()
837    };
838    let normalized = normalize(key);
839    let member = catalog
840        .enum_domains()
841        .find(|candidate| candidate.domain == domain)?
842        .members
843        .iter()
844        .find(|candidate| normalize(&candidate.member) == normalized)?;
845    catalog
846        .enum_spelling(domain, &Locale::new(EN_US), &member.member)
847        .map(str::to_string)
848}
849
850fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult {
851    let source = artifact(
852        format!("census/{shard_id}/{}.ws", case.case_id),
853        &case.source,
854    );
855    let base = |status, comparison, reason, locale| ConformanceResult {
856        schema_version: CONFORMANCE_SCHEMA_VERSION,
857        case_id: case.case_id.clone(),
858        features: case.features.clone(),
859        status,
860        comparison,
861        source: source.clone(),
862        catalog: catalog.identity(),
863        locale,
864        reason,
865    };
866    match &case.support {
867        CensusSupport::Unsupported { detail } => base(
868            ConformanceStatus::Unsupported,
869            not_comparable(),
870            Some(reason(ReasonCode::Unsupported, detail)),
871            None,
872        ),
873        CensusSupport::KnownGap { detail } => base(
874            ConformanceStatus::KnownGap,
875            not_comparable(),
876            Some(reason(ReasonCode::KnownGap, detail)),
877            None,
878        ),
879        CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)),
880        CensusSupport::Exercise => execute_case(case, base, catalog, None),
881    }
882}
883
884/// A pipeline failure carrying the stage's reason code and locale.
885struct Fail {
886    code: ReasonCode,
887    detail: String,
888    locale: Locale,
889}
890
891impl Fail {
892    fn workshop(error: &WorkshopError, locale: &Locale) -> Self {
893        let code = match error {
894            WorkshopError::Unsupported { .. } => ReasonCode::Unsupported,
895            WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap,
896            _ => ReasonCode::UnexpectedRegression,
897        };
898        Self {
899            code,
900            detail: error.to_string(),
901            locale: locale.clone(),
902        }
903    }
904
905    fn regression(detail: impl Into<String>, locale: &Locale) -> Self {
906        Self {
907            code: ReasonCode::UnexpectedRegression,
908            detail: detail.into(),
909            locale: locale.clone(),
910        }
911    }
912}
913
914fn execute_case(
915    case: &CensusCase,
916    base: impl Fn(
917        ConformanceStatus,
918        Comparison,
919        Option<ConformanceReason>,
920        Option<Locale>,
921    ) -> ConformanceResult,
922    catalog: &Catalog,
923    inconclusive_detail: Option<&str>,
924) -> ConformanceResult {
925    match run_pipeline(case, catalog, inconclusive_detail) {
926        Ok((comparison, locale)) => {
927            base(ConformanceStatus::Matched, comparison, None, Some(locale))
928        }
929        Err(fail) => failed_text(base, fail.code, fail.detail, Some(fail.locale)),
930    }
931}
932
933/// Run the census probe pipeline: en-US parse/validate/emit/reparse checks,
934/// the target-locale round trip, and the independent-reference comparison.
935/// Returns the matched comparison record and source locale on success.
936fn run_pipeline(
937    case: &CensusCase,
938    catalog: &Catalog,
939    inconclusive_detail: Option<&str>,
940) -> Result<(Comparison, Locale), Fail> {
941    let source_locale = Locale::new(&case.source_locale);
942    let target_locale = if source_locale.as_str() == ZH_CN {
943        Locale::new(EN_US)
944    } else {
945        Locale::new(ZH_CN)
946    };
947
948    let parse = |source: &str, locale: &Locale| {
949        parser::parse_with_context(source, catalog, locale, catalog)
950            .map_err(|error| Fail::workshop(&error, locale))
951    };
952    let validate = |program: &workshop_rs::Program, locale: &Locale| {
953        program
954            .validate()
955            .map_err(|error| Fail::regression(error.to_string(), locale))
956    };
957    let emit = |program: &workshop_rs::Program, locale: &Locale| {
958        emitter::emit(program, catalog, locale).map_err(|error| Fail::workshop(&error, locale))
959    };
960    let convert_or =
961        |source: &str, from: &Locale, to: &Locale| -> Result<convert::Conversion, Fail> {
962            convert::convert(source, catalog, from, to, &Default::default())
963                .map_err(|error| Fail::workshop(&error, to))
964        };
965
966    let program = parse(&case.source, &source_locale)?;
967    validate(&program, &source_locale)?;
968    let emitted = emit(&program, &source_locale)?;
969    let reparsed = parse(&emitted, &source_locale)?;
970    validate(&reparsed, &source_locale)?;
971    let reemitted = emit(&reparsed, &source_locale)?;
972    if !roundtrip::equivalent(&program, &reparsed)
973        || normalize_workshop(&emitted) != normalize_workshop(&reemitted)
974    {
975        return Err(Fail::regression(
976            "en-US semantic or normalized gate diverged",
977            &source_locale,
978        ));
979    }
980
981    let converted = convert_or(&case.source, &source_locale, &target_locale)?;
982    let target_program = parse(&converted.text, &target_locale)?;
983    validate(&target_program, &target_locale)?;
984    if !roundtrip::equivalent(&program, &target_program) {
985        return Err(Fail::regression(
986            "zh-CN conversion changed canonical WIR semantics",
987            &target_locale,
988        ));
989    }
990    let back = convert_or(&converted.text, &target_locale, &source_locale)?;
991    let back_program = parse(&back.text, &source_locale)?;
992    if !roundtrip::equivalent(&program, &back_program)
993        || normalize_workshop(&back.text) != normalize_workshop(&case.source)
994    {
995        return Err(Fail::regression(
996            "cross-locale semantic or normalized gate diverged",
997            &source_locale,
998        ));
999    }
1000
1001    let Some(reference_source) = case.reference_source.as_ref() else {
1002        return Err(Fail {
1003            code: ReasonCode::Inconclusive,
1004            detail: inconclusive_detail
1005                .unwrap_or(
1006                    "offline semantic and locale gates passed, but no independent expectation artifact is recorded",
1007                )
1008                .to_string(),
1009            locale: source_locale,
1010        });
1011    };
1012    let expected_program = parse(reference_source, &target_locale)?;
1013    if !roundtrip::equivalent(&program, &expected_program)
1014        || normalize_workshop(&converted.text) != normalize_workshop(reference_source)
1015    {
1016        return Err(Fail::regression(
1017            "conversion differed from the independent reference source",
1018            &target_locale,
1019        ));
1020    }
1021    Ok((
1022        Comparison {
1023            mode: Equivalence::Semantic,
1024            expected: Some(reference_artifact(case, reference_source)),
1025            observed: Some(artifact(
1026                format!("census/{}/converted-output.ws", case.case_id),
1027                &converted.text,
1028            )),
1029            normalizer: Some("canonical-wir;normalized-workshop-text".to_string()),
1030        },
1031        source_locale,
1032    ))
1033}
1034
1035fn failed_text(
1036    base: impl Fn(
1037        ConformanceStatus,
1038        Comparison,
1039        Option<ConformanceReason>,
1040        Option<Locale>,
1041    ) -> ConformanceResult,
1042    code: ReasonCode,
1043    detail: String,
1044    locale: Option<Locale>,
1045) -> ConformanceResult {
1046    let status = match code {
1047        ReasonCode::Unsupported => ConformanceStatus::Unsupported,
1048        ReasonCode::KnownGap => ConformanceStatus::KnownGap,
1049        ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression,
1050        ReasonCode::Inconclusive => ConformanceStatus::Inconclusive,
1051    };
1052    let comparison = if code == ReasonCode::UnexpectedRegression {
1053        Comparison {
1054            mode: Equivalence::Normalized,
1055            expected: None,
1056            observed: None,
1057            normalizer: Some("census-stage".to_string()),
1058        }
1059    } else {
1060        not_comparable()
1061    };
1062    base(status, comparison, Some(reason(code, &detail)), locale)
1063}
1064
1065fn reason(code: ReasonCode, detail: &str) -> ConformanceReason {
1066    ConformanceReason {
1067        code,
1068        detail: detail.to_string(),
1069    }
1070}
1071
1072fn not_comparable() -> Comparison {
1073    Comparison {
1074        mode: Equivalence::NotComparable,
1075        expected: None,
1076        observed: None,
1077        normalizer: None,
1078    }
1079}
1080
1081fn artifact(name: impl Into<String>, content: &str) -> TestArtifact {
1082    TestArtifact {
1083        name: name.into(),
1084        revision: None,
1085        path: None,
1086        sha256: Some(sha256(content)),
1087        license: Some("MIT".to_string()),
1088    }
1089}
1090
1091fn reference_artifact(case: &CensusCase, content: &str) -> TestArtifact {
1092    TestArtifact {
1093        name: format!("census reference for {}", case.case_id),
1094        revision: Some("census-v1".to_string()),
1095        path: Some("tests/fixtures/census/reference.ws".to_string()),
1096        sha256: Some(sha256(content)),
1097        license: Some("MIT".to_string()),
1098    }
1099}
1100
1101fn sha256(content: &str) -> String {
1102    let mut hasher = Sha256::new();
1103    hasher.update(content.as_bytes());
1104    format!("{:x}", hasher.finalize())
1105}
1106
1107fn normalize_workshop(text: &str) -> String {
1108    text.split_whitespace().collect::<Vec<_>>().join(" ")
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114
1115    #[test]
1116    fn builtin_census_is_derived_and_deterministic() {
1117        let catalog = Catalog::builtin().expect("builtin catalog");
1118        let first = Census::builtin(&catalog).expect("census");
1119        let second = Census::builtin(&catalog).expect("census");
1120        assert_eq!(first, second);
1121        assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions");
1122        assert!(
1123            first
1124                .cases()
1125                .any(|case| case.features.iter().any(|feature| feature.name == "wait"))
1126        );
1127        assert!(first.cases().any(|case| {
1128            case.features
1129                .iter()
1130                .any(|feature| feature.kind == FeatureKind::Setting)
1131        }));
1132        assert!(first.cases().any(|case| {
1133            case.features
1134                .iter()
1135                .any(|feature| feature.kind == FeatureKind::ControlFlow)
1136        }));
1137        assert_eq!(first.export_json().unwrap(), second.export_json().unwrap());
1138        assert_eq!(first.identity(), second.identity());
1139        assert_eq!(first.identity().digest.len(), 64);
1140    }
1141
1142    #[test]
1143    fn settings_probes_use_catalog_gamemode_spellings() {
1144        let catalog = Catalog::builtin().expect("builtin catalog");
1145
1146        for (mode, expected) in [
1147            ("ffa", "Deathmatch"),
1148            ("tdm", "Team Deathmatch"),
1149            ("ctf", "Capture The Flag"),
1150        ] {
1151            let prefix = format!("gamemodes.{mode}.");
1152            let definition = settings_schema::definitions()
1153                .find(|definition| definition.path().starts_with(&prefix))
1154                .unwrap_or_else(|| panic!("missing settings definition for {mode}"));
1155            let probe = settings_probe(&definition, &catalog);
1156            assert!(
1157                probe.contains(&format!("{expected} {{")),
1158                "{mode} probe used the wrong mode spelling:\n{probe}"
1159            );
1160        }
1161    }
1162
1163    #[test]
1164    fn explicit_non_matching_states_remain_machine_readable() {
1165        let feature_case = |id: &str, support| {
1166            CensusCase::probe(
1167                format!("state-tests/{id}"),
1168                vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)],
1169                format!("rule (\"{id}\") {{}}"),
1170            )
1171            .with_support(support)
1172        };
1173        let shard = CensusShard::new(
1174            "state-tests",
1175            vec![
1176                feature_case(
1177                    "unsupported",
1178                    CensusSupport::Unsupported {
1179                        detail: "not declared".to_string(),
1180                    },
1181                ),
1182                feature_case(
1183                    "known-gap",
1184                    CensusSupport::KnownGap {
1185                        detail: "missing mapping".to_string(),
1186                    },
1187                ),
1188                feature_case(
1189                    "inconclusive",
1190                    CensusSupport::Inconclusive {
1191                        detail: "no oracle".to_string(),
1192                    },
1193                ),
1194            ],
1195        )
1196        .unwrap();
1197        let report = Census::assemble(vec![shard])
1198            .unwrap()
1199            .run(&Catalog::builtin().unwrap());
1200        report
1201            .validate()
1202            .expect("states use the current conformance adapter");
1203        let json = report.to_json().unwrap();
1204        assert!(json.contains("unsupported"));
1205        assert!(json.contains("known-gap"));
1206        assert!(json.contains("inconclusive"));
1207    }
1208
1209    #[test]
1210    fn builtin_census_report_validates_against_the_catalog() {
1211        let catalog = Catalog::builtin().expect("builtin catalog");
1212        let census = Census::builtin(&catalog).expect("census");
1213        let report = census.run(&catalog);
1214        report
1215            .validate_against(&catalog)
1216            .expect("census results use canonical catalog identities");
1217        assert_eq!(report.census, census.identity());
1218        assert_eq!(
1219            report
1220                .results
1221                .iter()
1222                .filter(|result| result.case_id.starts_with("localization/"))
1223                .count(),
1224            2
1225        );
1226    }
1227
1228    #[test]
1229    fn census_report_rejects_a_malformed_identity_digest() {
1230        let catalog = Catalog::builtin().expect("builtin catalog");
1231        let census = Census::builtin(&catalog).expect("census");
1232        let mut report = census.run(&catalog);
1233        report.census.digest = "not-a-digest".to_string();
1234
1235        let error = report
1236            .validate_against(&catalog)
1237            .expect_err("report identity must carry a SHA-256 digest");
1238        assert!(error.to_string().contains("SHA-256"));
1239    }
1240}