Skip to main content

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