diff --git a/crates/workshop-rs-cli/src/lib.rs b/crates/workshop-rs-cli/src/lib.rs index 42b2866..6402462 100644 --- a/crates/workshop-rs-cli/src/lib.rs +++ b/crates/workshop-rs-cli/src/lib.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::census; use workshop_rs::convert::{self, ConvertOptions}; use workshop_rs::detect; use workshop_rs::emitter::{self, EmitOptions}; @@ -37,6 +38,9 @@ commands: Print the machine-readable catalog identity: implementation version, catalog version and content digest, locale coverage, target evidence, and provenance. + census [--json] + Run the deterministic offline Workshop feature census. Unexpected + regressions exit with status 1; known gaps remain visible. "; pub fn run(args: Vec) -> i32 { @@ -52,6 +56,7 @@ pub fn run(args: Vec) -> i32 { "convert" => convert_command(rest), "locales" => locales_command(rest), "version" => version_command(rest), + "census" => census_command(rest), "help" | "--help" | "-h" => { print!("{USAGE}"); 0 @@ -359,6 +364,47 @@ fn version_command(args: Vec) -> i32 { 0 } +fn census_command(args: Vec) -> i32 { + let json = match args.as_slice() { + [] => false, + [flag] if flag == "--json" => true, + _ => return usage_error("census accepts only the optional --json flag"), + }; + let catalog = match Catalog::builtin() { + Ok(catalog) => catalog, + Err(error) => return usage_error(&format!("cannot load catalog: {error}")), + }; + let census = match census::Census::builtin(&catalog) { + Ok(census) => census, + Err(error) => return usage_error(&format!("cannot build census: {error}")), + }; + let report = census.run(&catalog); + if let Err(error) = report.validate_against(&catalog) { + return usage_error(&format!("invalid census report: {error}")); + } + if json { + match report.to_json() { + Ok(text) => println!("{text}"), + Err(error) => return usage_error(&format!("cannot serialize census: {error}")), + } + } else { + println!( + "census schema {} / conformance schema {}", + report.schema_version, report.conformance_schema_version + ); + for result in &report.results { + println!("{}: {:?}", result.case_id, result.status); + } + } + if report.results.iter().any(|result| { + result.status == workshop_rs::conformance::ConformanceStatus::UnexpectedRegression + }) { + 1 + } else { + 0 + } +} + fn usage_error(message: &str) -> i32 { eprintln!("workshop-rs-cli: {message}"); eprintln!("{USAGE}"); diff --git a/crates/workshop-rs-cli/tests/cli.rs b/crates/workshop-rs-cli/tests/cli.rs index 33c0ead..e78ebd3 100644 --- a/crates/workshop-rs-cli/tests/cli.rs +++ b/crates/workshop-rs-cli/tests/cli.rs @@ -169,3 +169,28 @@ fn usage_errors_exit_2() { let output = run(&["no-such-command"]); assert_eq!(output.status.code(), Some(2)); } + +#[test] +fn census_runs_with_machine_readable_results() { + let output = run(&["census", "--json"]); + assert_eq!(output.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid report"); + assert!( + report["census"]["shards"] + .as_array() + .is_some_and(|shards| !shards.is_empty()) + ); + assert!(report["results"].as_array().is_some_and(|results| { + results + .iter() + .any(|result| result["status"] == "inconclusive") + && results + .iter() + .any(|result| result["status"] == "unexpected-regression") + })); + + let output = run(&["census", "--json", "extra"]); + assert_eq!(output.status.code(), Some(2)); + let output = run(&["census", "--bogus"]); + assert_eq!(output.status.code(), Some(2)); +} diff --git a/crates/workshop-rs/src/census.rs b/crates/workshop-rs/src/census.rs new file mode 100644 index 0000000..1ca6be1 --- /dev/null +++ b/crates/workshop-rs/src/census.rs @@ -0,0 +1,1197 @@ +//! Deterministic, sharded census of the canonical Workshop surface. +//! +//! The census is derived from this crate's catalog, settings table, and WIR +//! capabilities. It is a runner and evidence assembler, not a source-language +//! inventory or a live-client oracle. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::catalog::{Catalog, CatalogEntry, EnumDomain, Kind, Locale}; +use crate::conformance::{ + CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, ConformanceResult, + ConformanceStatus, Equivalence, Evidence, EvidenceArtifact, EvidenceBasis, EvidenceClass, + ExpectationSource, FeatureId, FeatureKind, FeatureNamespace, ImplementationIdentity, + ReasonCode, +}; +use crate::convert; +use crate::emitter; +use crate::error::WorkshopError; +use crate::parser; +use crate::roundtrip; +use crate::settings::table::{self, KeyKind, PathPart, TableEntry}; +use crate::wir::{CENSUS_CAPABILITIES, CensusCapabilityKind}; + +pub const CENSUS_SCHEMA_VERSION: u32 = 1; +pub const CENSUS_IDENTITY_SCHEMA_VERSION: u32 = 1; +const EN_US: &str = "en-US"; +const ZH_CN: &str = "zh-CN"; +const CENSUS_TRACKING_REF: &str = "#19"; + +/// An explicit support classification for a census case. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum CensusSupport { + Exercise, + Unsupported { + detail: String, + }, + KnownGap { + detail: String, + tracking_ref: String, + }, + Inconclusive { + detail: String, + }, +} + +/// One deterministic case with explicit source-locale provenance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CensusCase { + pub case_id: String, + pub features: Vec, + /// Locale of `source`; a zh-CN case is a real input, not an + /// implementation-generated conversion. + #[serde(default = "default_source_locale")] + pub source_locale: String, + pub source: String, + /// Independently recorded source text, when this case has an offline + /// expectation. None means the case remains inconclusive offline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference_source: Option, + pub support: CensusSupport, +} + +/// A named collection of independently attributable cases. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CensusShard { + pub shard_id: String, + pub cases: Vec, +} + +impl CensusShard { + pub fn new( + shard_id: impl Into, + mut cases: Vec, + ) -> Result { + let shard_id = shard_id.into(); + validate_name("shard_id", &shard_id)?; + cases.sort_by(|left, right| left.case_id.cmp(&right.case_id)); + for case in &cases { + case.validate()?; + } + if cases + .windows(2) + .any(|pair| pair[0].case_id == pair[1].case_id) + { + return Err(CensusError::new(format!( + "shard '{shard_id}' contains duplicate case IDs" + ))); + } + Ok(Self { shard_id, cases }) + } +} + +impl CensusCase { + fn validate(&self) -> Result<(), CensusError> { + validate_name("case_id", &self.case_id)?; + if self.features.is_empty() { + return Err(CensusError::new(format!( + "case '{}' has no feature IDs", + self.case_id + ))); + } + if self.source.trim().is_empty() { + return Err(CensusError::new(format!( + "case '{}' has no source", + self.case_id + ))); + } + validate_name("source_locale", &self.source_locale)?; + if self + .reference_source + .as_deref() + .is_some_and(|source| source.trim().is_empty()) + { + return Err(CensusError::new(format!( + "case '{}' has an empty reference source", + self.case_id + ))); + } + let mut features = HashSet::new(); + if self + .features + .iter() + .any(|feature| !features.insert(feature)) + { + return Err(CensusError::new(format!( + "case '{}' contains duplicate feature IDs", + self.case_id + ))); + } + Ok(()) + } +} + +/// The complete census assembled from deterministic shards. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Census { + shards: Vec, +} + +/// The stable identity of a reviewed census definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CensusIdentity { + pub schema_version: u32, + pub digest: String, + pub shards: Vec, +} + +impl Census { + /// Assemble shards in stable shard and case order. + pub fn assemble(mut shards: Vec) -> Result { + shards.sort_by(|left, right| left.shard_id.cmp(&right.shard_id)); + let mut shard_ids = HashSet::new(); + let mut case_ids = HashSet::new(); + for shard in &shards { + if !shard_ids.insert(shard.shard_id.clone()) { + return Err(CensusError::new(format!( + "duplicate census shard '{}'", + shard.shard_id + ))); + } + for case in &shard.cases { + if !case_ids.insert(case.case_id.clone()) { + return Err(CensusError::new(format!( + "duplicate census case '{}'", + case.case_id + ))); + } + } + } + Ok(Self { shards }) + } + + /// Derive the current surface from the canonical catalog, settings table, + /// and WIR capability names owned by this crate. + pub fn builtin(catalog: &Catalog) -> Result { + Self::assemble(vec![ + catalog_shard(catalog, Kind::Event, "catalog-events")?, + catalog_shard(catalog, Kind::Action, "catalog-actions")?, + catalog_shard(catalog, Kind::Value, "catalog-values")?, + catalog_shard(catalog, Kind::Operator, "catalog-operators")?, + catalog_shard(catalog, Kind::Structural, "catalog-structural")?, + enum_shard(catalog)?, + settings_shard()?, + wir_shard()?, + localization_shard()?, + content_id_shard(catalog)?, + ]) + } + + pub fn shards(&self) -> &[CensusShard] { + &self.shards + } + + pub fn cases(&self) -> impl Iterator { + self.shards.iter().flat_map(|shard| shard.cases.iter()) + } + + /// Execute all cases. No result state is dropped or converted to success. + pub fn run(&self, catalog: &Catalog) -> CensusReport { + let mut results: Vec<_> = self + .shards + .iter() + .flat_map(|shard| { + shard + .cases + .iter() + .map(move |case| run_case(case, &shard.shard_id, catalog)) + }) + .collect(); + results.sort_by(|left, right| left.case_id.cmp(&right.case_id)); + CensusReport { + schema_version: CENSUS_SCHEMA_VERSION, + conformance_schema_version: CONFORMANCE_SCHEMA_VERSION, + catalog: catalog.identity(), + census: self.identity(), + results, + } + } + + /// Return the deterministic identity of this census definition. + pub fn identity(&self) -> CensusIdentity { + let definition = self + .export_json() + .expect("census definitions must remain serializable"); + CensusIdentity { + schema_version: CENSUS_IDENTITY_SCHEMA_VERSION, + digest: sha256(&definition), + shards: self + .shards + .iter() + .map(|shard| shard.shard_id.clone()) + .collect(), + } + } + + /// Export shard definitions without executing them. + pub fn export_json(&self) -> Result { + serde_json::to_string_pretty(&self.shards) + .map_err(|error| CensusError::new(format!("cannot serialize census shards: {error}"))) + } +} + +/// Machine-readable output from a census run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CensusReport { + pub schema_version: u32, + pub conformance_schema_version: u32, + pub catalog: crate::catalog::CatalogIdentity, + pub census: CensusIdentity, + pub results: Vec, +} + +impl CensusReport { + pub fn validate(&self) -> Result<(), CensusError> { + let catalog = + Catalog::builtin().map_err(|error| CensusError::new(format!("catalog: {error}")))?; + self.validate_against(&catalog) + } + + pub fn validate_against(&self, catalog: &Catalog) -> Result<(), CensusError> { + if self.schema_version != CENSUS_SCHEMA_VERSION { + return Err(CensusError::new("unsupported census schema version")); + } + if self.conformance_schema_version != CONFORMANCE_SCHEMA_VERSION { + return Err(CensusError::new("unsupported conformance schema version")); + } + if self.catalog != catalog.identity() { + return Err(CensusError::new( + "report catalog identity does not match the loaded catalog", + )); + } + if self.census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION { + return Err(CensusError::new( + "unsupported census identity schema version", + )); + } + if self.census.digest.len() != 64 + || !self + .census + .digest + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(CensusError::new( + "census identity digest must be a SHA-256 hex digest", + )); + } + if self.census.shards.is_empty() + || self.census.shards.windows(2).any(|pair| pair[0] >= pair[1]) + { + return Err(CensusError::new( + "report shards must be non-empty and strictly sorted", + )); + } + for result in &self.results { + result + .validate_against(catalog) + .map_err(|error| CensusError::new(error.to_string()))?; + let matching_shards = self + .census + .shards + .iter() + .filter(|shard| { + result + .case_id + .strip_prefix(shard.as_str()) + .is_some_and(|rest| rest.starts_with('/')) + }) + .count(); + if matching_shards != 1 { + return Err(CensusError::new(format!( + "result '{}' does not map to exactly one census shard", + result.case_id + ))); + } + } + Ok(()) + } + + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string_pretty(self) + .map_err(|error| CensusError::new(format!("cannot serialize census report: {error}"))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CensusError { + pub message: String, +} + +impl CensusError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for CensusError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for CensusError {} + +fn validate_name(field: &str, value: &str) -> Result<(), CensusError> { + if value.trim().is_empty() || value.chars().any(char::is_control) { + Err(CensusError::new(format!( + "{field} must be non-empty and printable" + ))) + } else { + Ok(()) + } +} + +fn default_source_locale() -> String { + EN_US.to_string() +} + +fn feature(namespace: FeatureNamespace, kind: FeatureKind, name: impl Into) -> FeatureId { + FeatureId::owned(namespace, kind, name).expect("canonical census feature ID") +} + +fn catalog_feature(kind: Kind, id: &str) -> FeatureId { + FeatureId::from_catalog(kind, id).expect("catalog IDs are validated by Catalog::load") +} + +fn catalog_shard( + catalog: &Catalog, + kind: Kind, + shard_id: &str, +) -> Result { + let cases = catalog + .entries_of(kind) + .map(|entry| { + let source = match kind { + Kind::Event => event_probe(catalog, entry), + Kind::Action => action_probe(catalog, entry), + Kind::Value => value_probe(catalog, entry), + Kind::Operator => operator_probe(catalog, entry), + Kind::Structural => structural_probe(catalog, entry), + Kind::Setting => unreachable!("settings use the settings table"), + Kind::Enum => unreachable!("enum domains use the enum shard"), + }; + CensusCase { + case_id: format!("{shard_id}/{}", entry.id), + features: vec![catalog_feature(kind, &entry.id)], + source_locale: EN_US.to_string(), + source, + reference_source: None, + support: generated_probe_support(), + } + }) + .collect(); + CensusShard::new(shard_id, cases) +} + +fn enum_shard(catalog: &Catalog) -> Result { + let mut cases = Vec::new(); + for domain in catalog.enum_domains() { + for member in &domain.members { + let features = vec![ + catalog_feature(Kind::Enum, &domain.domain), + FeatureId::from_enum_member(&domain.domain, &member.member) + .expect("canonical enum member ID"), + ]; + cases.push(CensusCase { + case_id: format!("catalog-enums/{}/{}", domain.domain, member.member), + features, + source_locale: EN_US.to_string(), + source: enum_probe(catalog, domain, &member.member), + reference_source: None, + support: generated_probe_support(), + }); + } + } + CensusShard::new("catalog-enums", cases) +} + +fn content_id_shard(catalog: &Catalog) -> Result { + let mut cases = Vec::new(); + for domain in catalog.enum_domains() { + if !matches!(domain.domain.as_str(), "Hero" | "Map") { + continue; + } + for member in &domain.members { + cases.push(CensusCase { + case_id: format!("content-ids/{}/{}", domain.domain, member.member), + features: vec![ + FeatureId::from_enum_member(&domain.domain, &member.member) + .expect("canonical content enum-member ID"), + ], + source_locale: EN_US.to_string(), + source: enum_probe(catalog, domain, &member.member), + reference_source: None, + support: generated_probe_support(), + }); + } + } + CensusShard::new("content-ids", cases) +} + +fn settings_shard() -> Result { + let cases = table::ENTRIES + .iter() + .map(|entry| { + let path = table::path_string(entry.path); + CensusCase { + case_id: format!("settings/{path}"), + features: vec![feature( + FeatureNamespace::Settings, + FeatureKind::Setting, + path, + )], + source_locale: EN_US.to_string(), + source: settings_probe(entry), + reference_source: None, + support: generated_probe_support(), + } + }) + .collect(); + CensusShard::new("settings", cases) +} + +fn wir_shard() -> Result { + let cases = CENSUS_CAPABILITIES + .iter() + .map(|capability| match capability.kind { + CensusCapabilityKind::Variable => wir_case( + "variables-global", + FeatureKind::Variable, + capability.name, + variables_source(), + ), + CensusCapabilityKind::PlayerVariable => CensusCase { + case_id: "wir/variables-player".to_string(), + features: vec![feature( + FeatureNamespace::Wir, + FeatureKind::Variable, + capability.name, + )], + source_locale: EN_US.to_string(), + source: player_variable_source(), + reference_source: None, + support: generated_probe_support(), + }, + CensusCapabilityKind::Subroutine => wir_case( + "subroutine", + FeatureKind::Subroutine, + capability.name, + subroutine_source(), + ), + CensusCapabilityKind::ControlFlow => { + let actions = match capability.name { + "if" => "If(True);\n Wait(0);\nEnd;", + "else-if" => "If(True);\n Wait(0);\nElse If(False);\n Wait(0);\nEnd;", + "else" => "If(True);\n Wait(0);\nElse;\n Wait(0);\nEnd;", + "while" => "While(True);\n Wait(0);\nEnd;", + "for-global-variable" => { + "For Global Variable(probe, 0, 1, 1);\n Wait(0);\nEnd;" + } + _ => unreachable!("unknown WIR control-flow census capability"), + }; + control_flow_case(capability.name, actions) + } + CensusCapabilityKind::String => CensusCase { + case_id: "wir/string/custom-string".to_string(), + features: vec![feature( + FeatureNamespace::Wir, + FeatureKind::String, + capability.name, + )], + source_locale: EN_US.to_string(), + source: rule_source( + "String", + "Set Global Variable(probe, Custom String(\"census\"));", + ), + reference_source: None, + support: CensusSupport::Exercise, + }, + }) + .collect(); + CensusShard::new("wir", cases) +} + +fn localization_shard() -> Result { + let en_source = include_str!("../tests/fixtures/census/localization-en-us.ws").to_string(); + let zh_source = include_str!("../tests/fixtures/census/localization-zh-cn.ws").to_string(); + CensusShard::new( + "localization", + vec![ + CensusCase { + case_id: "localization/en-us-to-zh-cn".to_string(), + features: vec![feature( + FeatureNamespace::Localization, + FeatureKind::Localization, + "en-us-to-zh-cn", + )], + source_locale: EN_US.to_string(), + source: en_source, + reference_source: None, + support: generated_probe_support(), + }, + CensusCase { + case_id: "localization/zh-cn-to-en-us".to_string(), + features: vec![feature( + FeatureNamespace::Localization, + FeatureKind::Localization, + "zh-cn-to-en-us", + )], + source_locale: ZH_CN.to_string(), + source: zh_source, + reference_source: None, + support: generated_probe_support(), + }, + ], + ) +} + +fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> CensusCase { + CensusCase { + case_id: format!("wir/{case_id}"), + features: vec![feature(FeatureNamespace::Wir, kind, name)], + source_locale: EN_US.to_string(), + source, + reference_source: None, + support: CensusSupport::Exercise, + } +} + +fn control_flow_case(name: &str, actions: &str) -> CensusCase { + CensusCase { + case_id: format!("wir/control-flow/{name}"), + features: vec![feature( + FeatureNamespace::Wir, + FeatureKind::ControlFlow, + name, + )], + source_locale: EN_US.to_string(), + source: rule_source(name, actions), + reference_source: None, + support: generated_probe_support(), + } +} + +fn generated_probe_support() -> CensusSupport { + CensusSupport::Inconclusive { + detail: "generated probe is exportable for independent Workshop/client evidence but has no independently recorded expected result".to_string(), + } +} + +fn rule_source(name: &str, actions: &str) -> String { + format!( + "variables {{\n global:\n 0: probe\n}}\n\nrule (\"{name}\") {{\n event {{\n Ongoing - Global;\n }}\n actions {{\n {actions}\n }}\n}}\n" + ) +} + +fn variables_source() -> String { + "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" + .to_string() +} + +fn player_variable_source() -> String { + "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" + .to_string() +} + +fn subroutine_source() -> String { + "subroutines {\n 0: probe\n}\n\nrule (\"Subroutine\") {\n event {\n Subroutine;\n probe;\n }\n actions {\n Call Subroutine(probe);\n }\n}\n" + .to_string() +} + +fn event_probe(catalog: &Catalog, entry: &CatalogEntry) -> String { + let spelling = catalog + .spelling(Kind::Event, &Locale::new(EN_US), &entry.id) + .unwrap_or(&entry.id); + let filters = if matches!(entry.id.as_str(), "global" | "subroutine") { + String::new() + } else { + " All;\n All;\n".to_string() + }; + let subroutine = if entry.id == "subroutine" { + " probe;\n" + } else { + "" + }; + format!( + "subroutines {{\n 0: probe\n}}\n\nrule (\"Event\") {{\n event {{\n {spelling};\n{filters}{subroutine} }}\n actions {{\n Wait;\n }}\n}}\n" + ) +} + +fn action_probe(catalog: &Catalog, entry: &CatalogEntry) -> String { + let spelling = catalog + .spelling(Kind::Action, &Locale::new(EN_US), &entry.id) + .unwrap_or(&entry.id); + let call = if matches!( + entry.id.as_str(), + "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime" + ) { + format!("{spelling}(Event Player, probe, 0, 1, 0);") + } else { + format!("{spelling};") + }; + rule_source("Action", &call) +} + +fn value_probe(catalog: &Catalog, entry: &CatalogEntry) -> String { + let spelling = catalog + .spelling(Kind::Value, &Locale::new(EN_US), &entry.id) + .unwrap_or(&entry.id); + rule_source("Value", &format!("Set Global Variable(probe, {spelling});")) +} + +fn operator_probe(catalog: &Catalog, entry: &CatalogEntry) -> String { + let spelling = catalog + .spelling(Kind::Operator, &Locale::new(EN_US), &entry.id) + .unwrap_or(&entry.id); + rule_source( + "Operator", + &format!("If(1 {spelling} 1);\n Wait(0);\nEnd;"), + ) +} + +fn structural_probe(catalog: &Catalog, entry: &CatalogEntry) -> String { + let spelling = catalog + .spelling(Kind::Structural, &Locale::new(EN_US), &entry.id) + .unwrap_or(&entry.id); + let actions = match entry.id.as_str() { + "if" => format!("{spelling}(True);\n Wait(0);\nEnd;"), + "elseIf" => format!("If(True);\n Wait(0);\n{spelling}(False);\n Wait(0);\nEnd;"), + "else" => format!("If(True);\n Wait(0);\n{spelling};\n Wait(0);\nEnd;"), + "end" => format!("If(True);\n Wait(0);\n{spelling};"), + "while" => format!("{spelling}(True);\n Wait(0);\nEnd;"), + "forGlobalVariable" => format!("{spelling}(probe, 0, 1, 1);\n Wait(0);\nEnd;"), + "setGlobalVariable" => format!("{spelling}(probe, 1);"), + "modifyGlobalVariable" => format!("{spelling}(probe, Add, 1);"), + "setPlayerVariable" => format!("{spelling}(Event Player, probe, 1);"), + "modifyPlayerVariable" => format!("{spelling}(Event Player, probe, Add, 1);"), + "callSubroutine" => format!("{spelling}(probe);"), + _ => format!("{spelling};"), + }; + let prefix = match entry.id.as_str() { + "setPlayerVariable" | "modifyPlayerVariable" => { + "variables {\n player:\n 0: probe\n}\n\n" + } + "callSubroutine" => "subroutines {\n 0: probe\n}\n\n", + _ => "", + }; + format!("{prefix}{}", rule_source("Structural", &actions)) +} + +fn enum_probe(catalog: &Catalog, domain: &EnumDomain, member: &str) -> String { + let locale = Locale::new(EN_US); + let domain_spelling = catalog + .spelling(Kind::Value, &locale, &domain.domain) + .unwrap_or(&domain.domain); + let member_spelling = catalog + .enum_spelling(&domain.domain, &locale, member) + .unwrap_or(member); + rule_source( + "Enum", + &format!("Set Global Variable(probe, {domain_spelling}({member_spelling}));"), + ) +} + +fn settings_probe(entry: &TableEntry) -> String { + let mut lines = vec!["settings {".to_string()]; + let mut depth = 1; + for part in entry.path { + let name = match part { + PathPart::Part("gamemodes") => "modes", + PathPart::Part("heroes") => "heroes", + PathPart::Part("main") => "main", + PathPart::Part("lobby") => "lobby", + PathPart::Part(value) => table::mode_name(value).unwrap_or(value), + PathPart::Team => "General", + PathPart::Hero => "Mei", + }; + lines.push(format!("{}{} {{", " ".repeat(depth), name)); + depth += 1; + } + let indent = " ".repeat(depth); + match entry.kind { + KeyKind::String => lines.push(format!("{indent}{}: \"census\"", entry.workshop_name)), + KeyKind::Bool => lines.push(format!("{indent}{}: On", entry.workshop_name)), + KeyKind::Number => lines.push(format!("{indent}{}: 1", entry.workshop_name)), + KeyKind::Percent => lines.push(format!("{indent}{}: 100%", entry.workshop_name)), + KeyKind::Enum(domain) => { + let member = if domain == "roleLimit" { + "2OfEachRolePerTeam" + } else { + "off" + }; + let value = table::enum_name(domain, member).unwrap_or("Off"); + lines.push(format!("{indent}{}: {value}", entry.workshop_name)); + } + KeyKind::ListMap | KeyKind::ListHero => { + lines.push(format!("{indent}{} {{", entry.workshop_name)); + lines.push(format!("{indent}}}")); + } + } + while depth > 1 { + depth -= 1; + lines.push(format!("{}{}", " ".repeat(depth), "}")); + } + lines.push("}".to_string()); + lines.join("\n") +} + +fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult { + let fixture = artifact( + format!("census/{shard_id}/{}.ws", case.case_id), + &case.source, + ); + let expectation = case + .reference_source + .as_ref() + .map(|source| ExpectationSource { + basis: EvidenceBasis::PreservedRegression, + artifact: reference_artifact(case, source), + tracking_ref: Some(CENSUS_TRACKING_REF.to_string()), + }) + .unwrap_or_else(|| ExpectationSource { + basis: EvidenceBasis::SemanticContract, + artifact: EvidenceArtifact { + name: "docs/adr/0002-conformance-contract.md".to_string(), + revision: Some("ADR-0002".to_string()), + path: Some("docs/adr/0002-conformance-contract.md".to_string()), + sha256: None, + license: Some("MIT".to_string()), + }, + tracking_ref: None, + }); + let evidence = |locale: Option| Evidence { + class: EvidenceClass::Synthetic, + fixture: fixture.clone(), + expectation: expectation.clone(), + catalog: catalog.identity(), + locale, + client: None, + implementation: Some(ImplementationIdentity { + name: "workshop-rs".to_string(), + version: Catalog::implementation_version().to_string(), + revision: None, + artifact: None, + }), + }; + let base = |status, comparison, reason, locale| ConformanceResult { + schema_version: CONFORMANCE_SCHEMA_VERSION, + case_id: case.case_id.clone(), + features: case.features.clone(), + status, + comparison, + evidence: evidence(locale), + reason, + }; + match &case.support { + CensusSupport::Unsupported { detail } => base( + ConformanceStatus::Unsupported, + not_comparable(), + Some(reason(ReasonCode::Unsupported, detail, None)), + None, + ), + CensusSupport::KnownGap { + detail, + tracking_ref, + } => base( + ConformanceStatus::KnownGap, + not_comparable(), + Some(reason( + ReasonCode::KnownGap, + detail, + Some(tracking_ref.clone()), + )), + None, + ), + CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)), + CensusSupport::Exercise => execute_case(case, base, catalog, None), + } +} + +fn execute_case( + case: &CensusCase, + base: impl Fn( + ConformanceStatus, + Comparison, + Option, + Option, + ) -> ConformanceResult, + catalog: &Catalog, + inconclusive_detail: Option<&str>, +) -> ConformanceResult { + let source_locale = Locale::new(&case.source_locale); + let target_locale = if source_locale.as_str() == Locale::new(ZH_CN).as_str() { + Locale::new(EN_US) + } else { + Locale::new(ZH_CN) + }; + let program = match parser::parse_with_context(&case.source, catalog, &source_locale, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &source_locale), + }; + if let Err(error) = program.validate() { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + error.to_string(), + Some(source_locale.clone()), + ); + } + let emitted_source = match emitter::emit(&program, catalog, &source_locale) { + Ok(output) => output, + Err(error) => return failed(base, &error, &source_locale), + }; + let reparsed_source = + match parser::parse_with_context(&emitted_source, catalog, &source_locale, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &source_locale), + }; + if let Err(error) = reparsed_source.validate() { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + error.to_string(), + Some(source_locale.clone()), + ); + } + let emitted_source_again = match emitter::emit(&reparsed_source, catalog, &source_locale) { + Ok(output) => output, + Err(error) => return failed(base, &error, &source_locale), + }; + if !roundtrip::equivalent(&program, &reparsed_source) + || normalize_workshop(&emitted_source) != normalize_workshop(&emitted_source_again) + { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + "en-US semantic or normalized gate diverged".to_string(), + Some(source_locale.clone()), + ); + } + let converted = match convert::convert( + &case.source, + catalog, + &source_locale, + &target_locale, + &Default::default(), + ) { + Ok(output) => output, + Err(error) => return failed(base, &error, &target_locale), + }; + let program_target = + match parser::parse_with_context(&converted.text, catalog, &target_locale, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &target_locale), + }; + if let Err(error) = program_target.validate() { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + error.to_string(), + Some(target_locale.clone()), + ); + } + if !roundtrip::equivalent(&program, &program_target) { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + "zh-CN conversion changed canonical WIR semantics".to_string(), + Some(target_locale.clone()), + ); + } + let back_to_source = match convert::convert( + &converted.text, + catalog, + &target_locale, + &source_locale, + &Default::default(), + ) { + Ok(output) => output, + Err(error) => return failed(base, &error, &source_locale), + }; + let reparsed_back = + match parser::parse_with_context(&back_to_source.text, catalog, &source_locale, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &source_locale), + }; + if !roundtrip::equivalent(&program, &reparsed_back) + || normalize_workshop(&back_to_source.text) != normalize_workshop(&case.source) + { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + "cross-locale semantic or normalized gate diverged".to_string(), + Some(source_locale.clone()), + ); + } + let Some(reference_source) = case.reference_source.as_ref() else { + return failed_text( + base, + ReasonCode::Inconclusive, + inconclusive_detail.unwrap_or( + "offline semantic and locale gates passed, but no independent expectation artifact is recorded", + ) + .to_string(), + Some(source_locale), + ); + }; + let expected_program = + match parser::parse_with_context(reference_source, catalog, &target_locale, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &target_locale), + }; + if !roundtrip::equivalent(&program, &expected_program) + || normalize_workshop(&converted.text) != normalize_workshop(reference_source) + { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + "conversion differed from the independent reference source".to_string(), + Some(target_locale), + ); + } + base( + ConformanceStatus::Matched, + Comparison { + mode: Equivalence::Semantic, + expected: Some(reference_artifact(case, reference_source)), + observed: Some(artifact( + format!("census/{}/converted-output.ws", case.case_id), + &converted.text, + )), + normalizer: Some("canonical-wir;normalized-workshop-text".to_string()), + }, + None, + Some(source_locale), + ) +} + +fn failed( + base: impl Fn( + ConformanceStatus, + Comparison, + Option, + Option, + ) -> ConformanceResult, + error: &WorkshopError, + locale: &Locale, +) -> ConformanceResult { + let code = match error { + WorkshopError::Unsupported { .. } => ReasonCode::Unsupported, + WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap, + _ => ReasonCode::UnexpectedRegression, + }; + failed_text(base, code, error.to_string(), Some(locale.clone())) +} + +fn failed_text( + base: impl Fn( + ConformanceStatus, + Comparison, + Option, + Option, + ) -> ConformanceResult, + code: ReasonCode, + detail: String, + locale: Option, +) -> ConformanceResult { + let status = match code { + ReasonCode::Unsupported => ConformanceStatus::Unsupported, + ReasonCode::KnownGap => ConformanceStatus::KnownGap, + ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression, + ReasonCode::Inconclusive => ConformanceStatus::Inconclusive, + }; + let comparison = if code == ReasonCode::UnexpectedRegression { + Comparison { + mode: Equivalence::Normalized, + expected: None, + observed: None, + normalizer: Some("census-stage".to_string()), + } + } else { + not_comparable() + }; + let tracking = (code == ReasonCode::KnownGap).then(|| CENSUS_TRACKING_REF.to_string()); + base( + status, + comparison, + Some(reason(code, &detail, tracking)), + locale, + ) +} + +fn reason(code: ReasonCode, detail: &str, tracking_ref: Option) -> ConformanceReason { + ConformanceReason { + code, + detail: detail.to_string(), + tracking_ref, + } +} + +fn not_comparable() -> Comparison { + Comparison { + mode: Equivalence::NotComparable, + expected: None, + observed: None, + normalizer: None, + } +} + +fn artifact(name: impl Into, content: &str) -> EvidenceArtifact { + EvidenceArtifact { + name: name.into(), + revision: None, + path: None, + sha256: Some(sha256(content)), + license: Some("MIT".to_string()), + } +} + +fn reference_artifact(case: &CensusCase, content: &str) -> EvidenceArtifact { + EvidenceArtifact { + name: format!("census reference for {}", case.case_id), + revision: Some("census-v1".to_string()), + path: Some("tests/fixtures/census/reference.ws".to_string()), + sha256: Some(sha256(content)), + license: Some("MIT".to_string()), + } +} + +fn sha256(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn normalize_workshop(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_census_is_derived_and_deterministic() { + let catalog = Catalog::builtin().expect("builtin catalog"); + let first = Census::builtin(&catalog).expect("census"); + let second = Census::builtin(&catalog).expect("census"); + assert_eq!(first, second); + assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions"); + assert!( + first + .cases() + .any(|case| case.features.iter().any(|feature| feature.name == "wait")) + ); + assert!(first.cases().any(|case| { + case.features + .iter() + .any(|feature| feature.kind == FeatureKind::Setting) + })); + assert!(first.cases().any(|case| { + case.features + .iter() + .any(|feature| feature.kind == FeatureKind::ControlFlow) + })); + assert_eq!(first.export_json().unwrap(), second.export_json().unwrap()); + assert_eq!(first.identity(), second.identity()); + assert_eq!(first.identity().digest.len(), 64); + } + + #[test] + fn explicit_non_matching_states_remain_machine_readable() { + let feature_case = |id: &str, support| CensusCase { + case_id: format!("state-tests/{id}"), + features: vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)], + source_locale: EN_US.to_string(), + source: format!("rule (\"{id}\") {{}}"), + reference_source: None, + support, + }; + let shard = CensusShard::new( + "state-tests", + vec![ + feature_case( + "unsupported", + CensusSupport::Unsupported { + detail: "not declared".to_string(), + }, + ), + feature_case( + "known-gap", + CensusSupport::KnownGap { + detail: "missing mapping".to_string(), + tracking_ref: "#19".to_string(), + }, + ), + feature_case( + "inconclusive", + CensusSupport::Inconclusive { + detail: "no oracle".to_string(), + }, + ), + ], + ) + .unwrap(); + let report = Census::assemble(vec![shard]) + .unwrap() + .run(&Catalog::builtin().unwrap()); + report + .validate() + .expect("states use the current #18 adapter"); + let json = report.to_json().unwrap(); + assert!(json.contains("unsupported")); + assert!(json.contains("known-gap")); + assert!(json.contains("inconclusive")); + } + + #[test] + fn builtin_census_report_validates_against_the_catalog() { + let catalog = Catalog::builtin().expect("builtin catalog"); + let census = Census::builtin(&catalog).expect("census"); + let report = census.run(&catalog); + report + .validate_against(&catalog) + .expect("census results use canonical catalog identities"); + assert_eq!(report.census, census.identity()); + assert!( + report + .results + .iter() + .filter(|result| result.case_id.starts_with("localization/")) + .all(|result| result.status == ConformanceStatus::Inconclusive) + ); + } + + #[test] + fn census_report_rejects_a_malformed_identity_digest() { + let catalog = Catalog::builtin().expect("builtin catalog"); + let census = Census::builtin(&catalog).expect("census"); + let mut report = census.run(&catalog); + report.census.digest = "not-a-digest".to_string(); + + let error = report + .validate_against(&catalog) + .expect_err("report identity must carry a SHA-256 digest"); + assert!(error.to_string().contains("SHA-256")); + } +} diff --git a/crates/workshop-rs/src/lib.rs b/crates/workshop-rs/src/lib.rs index e2248eb..ec8acd0 100644 --- a/crates/workshop-rs/src/lib.rs +++ b/crates/workshop-rs/src/lib.rs @@ -29,6 +29,7 @@ pub mod arena; pub mod catalog; +pub mod census; pub mod conformance; pub mod convert; pub mod detect; diff --git a/crates/workshop-rs/src/wir/mod.rs b/crates/workshop-rs/src/wir/mod.rs index 73e9d9d..8d5e423 100644 --- a/crates/workshop-rs/src/wir/mod.rs +++ b/crates/workshop-rs/src/wir/mod.rs @@ -20,6 +20,62 @@ mod validate; pub mod error; +/// The WIR-owned capability surface used by the canonical census. Providers +/// do not contribute source-language inventories to this registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CensusCapabilityKind { + Variable, + PlayerVariable, + Subroutine, + ControlFlow, + String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CensusCapability { + pub kind: CensusCapabilityKind, + pub name: &'static str, +} + +pub const CENSUS_CAPABILITIES: &[CensusCapability] = &[ + CensusCapability { + kind: CensusCapabilityKind::Variable, + name: "global", + }, + CensusCapability { + kind: CensusCapabilityKind::PlayerVariable, + name: "player", + }, + CensusCapability { + kind: CensusCapabilityKind::Subroutine, + name: "declaration-and-call", + }, + CensusCapability { + kind: CensusCapabilityKind::ControlFlow, + name: "if", + }, + CensusCapability { + kind: CensusCapabilityKind::ControlFlow, + name: "else-if", + }, + CensusCapability { + kind: CensusCapabilityKind::ControlFlow, + name: "else", + }, + CensusCapability { + kind: CensusCapabilityKind::ControlFlow, + name: "while", + }, + CensusCapability { + kind: CensusCapabilityKind::ControlFlow, + name: "for-global-variable", + }, + CensusCapability { + kind: CensusCapabilityKind::String, + name: "custom-string", + }, +]; + use crate::arena::Arena; use crate::ids::Id; use crate::source::{SourceFile, Span}; diff --git a/crates/workshop-rs/tests/fixtures/census/localization-en-us.ws b/crates/workshop-rs/tests/fixtures/census/localization-en-us.ws new file mode 100644 index 0000000..a2e4463 --- /dev/null +++ b/crates/workshop-rs/tests/fixtures/census/localization-en-us.ws @@ -0,0 +1,8 @@ +rule ("Localization") { + event { + Ongoing - Global; + } + actions { + Disable Inspector Recording; + } +} diff --git a/crates/workshop-rs/tests/fixtures/census/localization-zh-cn.ws b/crates/workshop-rs/tests/fixtures/census/localization-zh-cn.ws new file mode 100644 index 0000000..6194903 --- /dev/null +++ b/crates/workshop-rs/tests/fixtures/census/localization-zh-cn.ws @@ -0,0 +1,8 @@ +rule ("Localization") { + event { + 持续 - 全局; + } + actions { + 禁用查看器录制; + } +} diff --git a/docs/adr/0003-sharded-census.md b/docs/adr/0003-sharded-census.md new file mode 100644 index 0000000..0ed94d7 --- /dev/null +++ b/docs/adr/0003-sharded-census.md @@ -0,0 +1,51 @@ +# ADR-0003: Canonical sharded Workshop feature census + +## Status + +Accepted as the offline census layer for the #10 evidence corpus. + +## Decision + +`workshop_rs::census::Census::builtin` derives deterministic shards from the +canonical catalog, the reviewed settings table, and Workshop IR capabilities +owned by `workshop-rs`. It does not import OPY/DEL inventories or claim live +client/runtime behavior. + +Each `CensusCase` has a stable case ID, one or more #18 `FeatureId` values, +canonical en-US source text, and an explicit support classification. Catalog +entries and enum members are emitted from the loaded canonical catalog; +content IDs use their domain-qualified canonical enum-member identities. +Settings, variables, subroutines, control flow, strings, and localization +cases use `workshop-rs`-owned namespaces. + +`Census::run` executes each case independently through parse, WIR validation, +canonical identity validation, emission, semantic round-trip, and en-US/zh-CN +conversion checks. The WIR shard is derived from the WIR-owned +`CENSUS_CAPABILITIES` registry. A case is matched only when its conversion is +also equal to an independently recorded reference source; generated probes +without such an artifact remain inconclusive even when all offline gates pass. +The report retains every `ConformanceResult`, including unsupported, +known-gap, unexpected-regression, and inconclusive states. +`Census::identity` exposes a deterministic SHA-256 digest of the reviewed, +serialized shard definition together with the sorted shard IDs. Every +`CensusReport` carries that authoritative `CensusIdentity`; report validation +checks its schema, digest shape, and shard list. `CensusReport::validate_against` +also binds catalog feature IDs and result case IDs to the actual catalog and +declared shards. `Census::export_json` exports shard definitions without +making the export itself an oracle. + +The committed localization inputs are newly authored probes, not independent +locale evidence. They therefore remain `Inconclusive` until reviewed external +or historical captures are recorded through the #18 contract. + +The report's comparison fields describe the independent semantic/normalized +gate; they do not turn the implementation's own output into an expected +oracle. Focused parser/catalog/emitter tests remain complementary and are not +replaced by census totals. + +## Consequences + +Future live-client workflows can assemble the same shards into probes while +retaining feature attribution. A census result is offline evidence only until +an independent client or other expectation source is recorded through the #18 +contract.