From e15dee483240f7b965ee09aeb22eca5a03a6e2a0 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 14:30:53 +0800 Subject: [PATCH 1/5] feat(census): add canonical sharded feature census Derive deterministic feature shards from the canonical catalog, settings table, and Workshop IR capabilities; run independent semantic/locale gates where evidence exists, preserve inconclusive and unsupported states, and expose deterministic JSON export through the CLI. Fixes #19 --- crates/workshop-rs-cli/src/lib.rs | 46 ++ crates/workshop-rs/src/census.rs | 999 ++++++++++++++++++++++++++++++ crates/workshop-rs/src/lib.rs | 1 + docs/adr/0003-sharded-census.md | 39 ++ 4 files changed, 1085 insertions(+) create mode 100644 crates/workshop-rs/src/census.rs create mode 100644 docs/adr/0003-sharded-census.md 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/src/census.rs b/crates/workshop-rs/src/census.rs new file mode 100644 index 0000000..2ad6383 --- /dev/null +++ b/crates/workshop-rs/src/census.rs @@ -0,0 +1,999 @@ +//! 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}; + +pub const CENSUS_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. Source text is canonical en-US Workshop text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CensusCase { + pub case_id: String, + pub features: Vec, + pub source: String, + 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 + ))); + } + 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, +} + +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(), + shards: self + .shards + .iter() + .map(|shard| shard.shard_id.clone()) + .collect(), + results, + } + } + + /// 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 shards: Vec, + 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")); + } + for result in &self.results { + result + .validate_against(catalog) + .map_err(|error| CensusError::new(error.to_string()))?; + } + 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 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, + 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: enum_probe(catalog, domain, &member.member), + 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: enum_probe(catalog, domain, &member.member), + 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: settings_probe(entry), + support: generated_probe_support(), + } + }) + .collect(); + CensusShard::new("settings", cases) +} + +fn wir_shard() -> Result { + CensusShard::new( + "wir", + vec![ + wir_case( + "variables-global", + FeatureKind::Variable, + "global", + variables_source(), + ), + CensusCase { + case_id: "wir/variables-player".to_string(), + features: vec![feature( + FeatureNamespace::Wir, + FeatureKind::Variable, + "player", + )], + source: player_variable_source(), + support: generated_probe_support(), + }, + wir_case( + "subroutine", + FeatureKind::Subroutine, + "declaration-and-call", + subroutine_source(), + ), + control_flow_case("if", "If(True);\n Wait(0);\nEnd;"), + control_flow_case( + "else-if", + "If(True);\n Wait(0);\nElse If(False);\n Wait(0);\nEnd;", + ), + control_flow_case("else", "If(True);\n Wait(0);\nElse;\n Wait(0);\nEnd;"), + control_flow_case("while", "While(True);\n Wait(0);\nEnd;"), + control_flow_case( + "for-global-variable", + "For Global Variable(probe, 0, 1, 1);\n Wait(0);\nEnd;", + ), + CensusCase { + case_id: "wir/string/custom-string".to_string(), + features: vec![feature( + FeatureNamespace::Wir, + FeatureKind::String, + "custom-string", + )], + source: rule_source( + "String", + "Set Global Variable(probe, Custom String(\"census\"));", + ), + support: CensusSupport::Exercise, + }, + ], + ) +} + +fn localization_shard() -> Result { + let source = rule_source( + "Localization", + "Set Global Variable(probe, Custom String(\"census\"));", + ); + 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: source.clone(), + support: CensusSupport::Exercise, + }, + CensusCase { + case_id: "localization/zh-cn-to-en-us".to_string(), + features: vec![feature( + FeatureNamespace::Localization, + FeatureKind::Localization, + "zh-cn-to-en-us", + )], + source, + support: CensusSupport::Exercise, + }, + ], + ) +} + +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, + 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: rule_source(name, actions), + 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 evidence = |locale: Option| Evidence { + class: EvidenceClass::Synthetic, + fixture: fixture.clone(), + expectation: 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, + }, + 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 } => base( + ConformanceStatus::Inconclusive, + not_comparable(), + Some(reason(ReasonCode::Inconclusive, detail, None)), + None, + ), + CensusSupport::Exercise => execute_case(case, base, catalog), + } +} + +fn execute_case( + case: &CensusCase, + base: impl Fn( + ConformanceStatus, + Comparison, + Option, + Option, + ) -> ConformanceResult, + catalog: &Catalog, +) -> ConformanceResult { + let en = Locale::new(EN_US); + let zh = Locale::new(ZH_CN); + let program = match parser::parse_with_context(&case.source, catalog, &en, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &en), + }; + if let Err(error) = program.validate() { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + error.to_string(), + Some(en), + ); + } + let emitted_en = match emitter::emit(&program, catalog, &en) { + Ok(output) => output, + Err(error) => return failed(base, &error, &en), + }; + let reparsed_en = match parser::parse_with_context(&emitted_en, catalog, &en, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &en), + }; + if let Err(error) = reparsed_en.validate() { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + error.to_string(), + Some(en), + ); + } + let emitted_en_again = match emitter::emit(&reparsed_en, catalog, &en) { + Ok(output) => output, + Err(error) => return failed(base, &error, &en), + }; + if !roundtrip::equivalent(&program, &reparsed_en) + || normalize_workshop(&emitted_en) != normalize_workshop(&emitted_en_again) + { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + "en-US semantic or normalized gate diverged".to_string(), + Some(en), + ); + } + let converted_zh = match convert::convert(&case.source, catalog, &en, &zh, &Default::default()) + { + Ok(output) => output, + Err(error) => return failed(base, &error, &zh), + }; + let program_zh = match parser::parse_with_context(&converted_zh.text, catalog, &zh, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &zh), + }; + if let Err(error) = program_zh.validate() { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + error.to_string(), + Some(zh), + ); + } + if !roundtrip::equivalent(&program, &program_zh) { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + "zh-CN conversion changed canonical WIR semantics".to_string(), + Some(zh), + ); + } + let back_to_en = + match convert::convert(&converted_zh.text, catalog, &zh, &en, &Default::default()) { + Ok(output) => output, + Err(error) => return failed(base, &error, &en), + }; + let reparsed_back = match parser::parse_with_context(&back_to_en.text, catalog, &en, catalog) { + Ok(program) => program, + Err(error) => return failed(base, &error, &en), + }; + if !roundtrip::equivalent(&program, &reparsed_back) + || normalize_workshop(&back_to_en.text) != normalize_workshop(&case.source) + { + return failed_text( + base, + ReasonCode::UnexpectedRegression, + "cross-locale semantic or normalized gate diverged".to_string(), + Some(en), + ); + } + base( + ConformanceStatus::Matched, + Comparison { + mode: Equivalence::Semantic, + expected: Some(artifact("canonical-wir", &program.dump())), + observed: Some(artifact("zh-cn-wir", &program_zh.dump())), + normalizer: Some("canonical-wir;normalized-workshop-text".to_string()), + }, + None, + Some(en), + ) +} + +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 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()); + } + + #[test] + fn explicit_non_matching_states_remain_machine_readable() { + let feature_case = |id: &str, support| CensusCase { + case_id: id.to_string(), + features: vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)], + source: format!("rule (\"{id}\") {{}}"), + 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"); + } +} 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/docs/adr/0003-sharded-census.md b/docs/adr/0003-sharded-census.md new file mode 100644 index 0000000..01b25a7 --- /dev/null +++ b/docs/adr/0003-sharded-census.md @@ -0,0 +1,39 @@ +# 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 report retains every `ConformanceResult`, including +unsupported, known-gap, unexpected-regression, and inconclusive states. +`CensusReport::validate_against` binds catalog feature IDs to the actual +catalog. `Census::export_json` exports shard definitions without making the +export itself an oracle. + +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. From 4b37b001f11ef7b0a9ad03c15aa8c1004e35c54e Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 14:46:32 +0800 Subject: [PATCH 2/5] fix(census): restore independent evidence boundary Refs #19 --- crates/workshop-rs-cli/tests/cli.rs | 27 ++ crates/workshop-rs/src/census.rs | 298 +++++++++++++----- crates/workshop-rs/src/wir/mod.rs | 56 ++++ .../fixtures/census/localization-en-us.ws | 8 + .../fixtures/census/localization-zh-cn.ws | 8 + docs/adr/0003-sharded-census.md | 14 +- 6 files changed, 327 insertions(+), 84 deletions(-) create mode 100644 crates/workshop-rs/tests/fixtures/census/localization-en-us.ws create mode 100644 crates/workshop-rs/tests/fixtures/census/localization-zh-cn.ws diff --git a/crates/workshop-rs-cli/tests/cli.rs b/crates/workshop-rs-cli/tests/cli.rs index 33c0ead..1d94f3a 100644 --- a/crates/workshop-rs-cli/tests/cli.rs +++ b/crates/workshop-rs-cli/tests/cli.rs @@ -169,3 +169,30 @@ 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!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid report"); + assert!( + report["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"] == "matched") + && results + .iter() + .any(|result| result["status"] == "inconclusive") + })); + + 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 index 2ad6383..970d2e6 100644 --- a/crates/workshop-rs/src/census.rs +++ b/crates/workshop-rs/src/census.rs @@ -22,6 +22,7 @@ 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; const EN_US: &str = "en-US"; @@ -45,12 +46,20 @@ pub enum CensusSupport { }, } -/// One deterministic case. Source text is canonical en-US Workshop text. +/// 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, } @@ -99,6 +108,17 @@ impl CensusCase { 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 @@ -228,10 +248,36 @@ impl CensusReport { 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.shards.is_empty() || self.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 + .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(()) } @@ -274,6 +320,10 @@ fn validate_name(field: &str, value: &str) -> Result<(), CensusError> { } } +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") } @@ -302,7 +352,9 @@ fn catalog_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(), } }) @@ -322,7 +374,9 @@ fn enum_shard(catalog: &Catalog) -> Result { 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(), }); } @@ -343,7 +397,9 @@ fn content_id_shard(catalog: &Catalog) -> Result { 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(), }); } @@ -363,7 +419,9 @@ fn settings_shard() -> Result { FeatureKind::Setting, path, )], + source_locale: EN_US.to_string(), source: settings_probe(entry), + reference_source: None, support: generated_probe_support(), } }) @@ -372,64 +430,69 @@ fn settings_shard() -> Result { } fn wir_shard() -> Result { - CensusShard::new( - "wir", - vec![ - wir_case( + let cases = CENSUS_CAPABILITIES + .iter() + .map(|capability| match capability.kind { + CensusCapabilityKind::Variable => wir_case( "variables-global", FeatureKind::Variable, - "global", + capability.name, variables_source(), ), - CensusCase { + CensusCapabilityKind::PlayerVariable => CensusCase { case_id: "wir/variables-player".to_string(), features: vec![feature( FeatureNamespace::Wir, FeatureKind::Variable, - "player", + capability.name, )], + source_locale: EN_US.to_string(), source: player_variable_source(), + reference_source: None, support: generated_probe_support(), }, - wir_case( + CensusCapabilityKind::Subroutine => wir_case( "subroutine", FeatureKind::Subroutine, - "declaration-and-call", + capability.name, subroutine_source(), ), - control_flow_case("if", "If(True);\n Wait(0);\nEnd;"), - control_flow_case( - "else-if", - "If(True);\n Wait(0);\nElse If(False);\n Wait(0);\nEnd;", - ), - control_flow_case("else", "If(True);\n Wait(0);\nElse;\n Wait(0);\nEnd;"), - control_flow_case("while", "While(True);\n Wait(0);\nEnd;"), - control_flow_case( - "for-global-variable", - "For Global Variable(probe, 0, 1, 1);\n Wait(0);\nEnd;", - ), - CensusCase { + 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, - "custom-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 source = rule_source( - "Localization", - "Set Global Variable(probe, Custom String(\"census\"));", - ); + 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![ @@ -440,7 +503,9 @@ fn localization_shard() -> Result { FeatureKind::Localization, "en-us-to-zh-cn", )], - source: source.clone(), + source_locale: EN_US.to_string(), + source: en_source.clone(), + reference_source: Some(zh_source.clone()), support: CensusSupport::Exercise, }, CensusCase { @@ -450,7 +515,9 @@ fn localization_shard() -> Result { FeatureKind::Localization, "zh-cn-to-en-us", )], - source, + source_locale: ZH_CN.to_string(), + source: zh_source, + reference_source: Some(en_source), support: CensusSupport::Exercise, }, ], @@ -461,7 +528,9 @@ fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> Cen 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, } } @@ -474,7 +543,9 @@ fn control_flow_case(name: &str, actions: &str) -> CensusCase { FeatureKind::ControlFlow, name, )], + source_locale: EN_US.to_string(), source: rule_source(name, actions), + reference_source: None, support: generated_probe_support(), } } @@ -648,10 +719,15 @@ fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> Conformance format!("census/{shard_id}/{}.ws", case.case_id), &case.source, ); - let evidence = |locale: Option| Evidence { - class: EvidenceClass::Synthetic, - fixture: fixture.clone(), - expectation: ExpectationSource { + 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(), @@ -661,7 +737,11 @@ fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> Conformance 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, @@ -721,104 +801,147 @@ fn execute_case( ) -> ConformanceResult, catalog: &Catalog, ) -> ConformanceResult { - let en = Locale::new(EN_US); - let zh = Locale::new(ZH_CN); - let program = match parser::parse_with_context(&case.source, catalog, &en, catalog) { + 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, &en), + Err(error) => return failed(base, &error, &source_locale), }; if let Err(error) = program.validate() { return failed_text( base, ReasonCode::UnexpectedRegression, error.to_string(), - Some(en), + Some(source_locale.clone()), ); } - let emitted_en = match emitter::emit(&program, catalog, &en) { + let emitted_source = match emitter::emit(&program, catalog, &source_locale) { Ok(output) => output, - Err(error) => return failed(base, &error, &en), - }; - let reparsed_en = match parser::parse_with_context(&emitted_en, catalog, &en, catalog) { - Ok(program) => program, - Err(error) => return failed(base, &error, &en), + Err(error) => return failed(base, &error, &source_locale), }; - if let Err(error) = reparsed_en.validate() { + 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(en), + Some(source_locale.clone()), ); } - let emitted_en_again = match emitter::emit(&reparsed_en, catalog, &en) { + let emitted_source_again = match emitter::emit(&reparsed_source, catalog, &source_locale) { Ok(output) => output, - Err(error) => return failed(base, &error, &en), + Err(error) => return failed(base, &error, &source_locale), }; - if !roundtrip::equivalent(&program, &reparsed_en) - || normalize_workshop(&emitted_en) != normalize_workshop(&emitted_en_again) + 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(en), + Some(source_locale.clone()), ); } - let converted_zh = match convert::convert(&case.source, catalog, &en, &zh, &Default::default()) - { + let converted = match convert::convert( + &case.source, + catalog, + &source_locale, + &target_locale, + &Default::default(), + ) { Ok(output) => output, - Err(error) => return failed(base, &error, &zh), + Err(error) => return failed(base, &error, &target_locale), }; - let program_zh = match parser::parse_with_context(&converted_zh.text, catalog, &zh, catalog) { - Ok(program) => program, - Err(error) => return failed(base, &error, &zh), - }; - if let Err(error) = program_zh.validate() { + 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(zh), + Some(target_locale.clone()), ); } - if !roundtrip::equivalent(&program, &program_zh) { + if !roundtrip::equivalent(&program, &program_target) { return failed_text( base, ReasonCode::UnexpectedRegression, "zh-CN conversion changed canonical WIR semantics".to_string(), - Some(zh), + Some(target_locale.clone()), ); } - let back_to_en = - match convert::convert(&converted_zh.text, catalog, &zh, &en, &Default::default()) { - Ok(output) => output, - Err(error) => return failed(base, &error, &en), - }; - let reparsed_back = match parser::parse_with_context(&back_to_en.text, catalog, &en, catalog) { - Ok(program) => program, - Err(error) => return failed(base, &error, &en), + 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_en.text) != normalize_workshop(&case.source) + || 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(en), + Some(source_locale.clone()), + ); + } + let Some(reference_source) = case.reference_source.as_ref() else { + return failed_text( + base, + ReasonCode::Inconclusive, + "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(artifact("canonical-wir", &program.dump())), - observed: Some(artifact("zh-cn-wir", &program_zh.dump())), + 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(en), + Some(source_locale), ) } @@ -903,6 +1026,21 @@ fn artifact(name: impl Into, content: &str) -> EvidenceArtifact { } } +fn reference_artifact(case: &CensusCase, content: &str) -> EvidenceArtifact { + let path = match case.case_id.as_str() { + "localization/en-us-to-zh-cn" => "tests/fixtures/census/localization-zh-cn.ws", + "localization/zh-cn-to-en-us" => "tests/fixtures/census/localization-en-us.ws", + _ => "tests/fixtures/census/reference.ws", + }; + EvidenceArtifact { + name: format!("census reference for {}", case.case_id), + revision: Some("census-v1".to_string()), + path: Some(path.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()); @@ -945,9 +1083,11 @@ mod tests { #[test] fn explicit_non_matching_states_remain_machine_readable() { let feature_case = |id: &str, support| CensusCase { - case_id: id.to_string(), + 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( 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 index 01b25a7..550afff 100644 --- a/docs/adr/0003-sharded-census.md +++ b/docs/adr/0003-sharded-census.md @@ -20,11 +20,15 @@ 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 report retains every `ConformanceResult`, including -unsupported, known-gap, unexpected-regression, and inconclusive states. -`CensusReport::validate_against` binds catalog feature IDs to the actual -catalog. `Census::export_json` exports shard definitions without making the -export itself an oracle. +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. +`CensusReport::validate_against` 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 report's comparison fields describe the independent semantic/normalized gate; they do not turn the implementation's own output into an expected From 098a623e4077208e67c70fddcc92401ebb4a0779 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 15:40:31 +0800 Subject: [PATCH 3/5] feat(census): publish canonical census identity Fixes #19 --- crates/workshop-rs/src/census.rs | 87 +++++++++++++++++++++++++++----- docs/adr/0003-sharded-census.md | 14 +++-- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/crates/workshop-rs/src/census.rs b/crates/workshop-rs/src/census.rs index 970d2e6..12ae934 100644 --- a/crates/workshop-rs/src/census.rs +++ b/crates/workshop-rs/src/census.rs @@ -25,6 +25,7 @@ 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"; @@ -140,6 +141,15 @@ 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 { @@ -207,12 +217,24 @@ impl Census { 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(), - results, } } @@ -230,7 +252,7 @@ pub struct CensusReport { pub schema_version: u32, pub conformance_schema_version: u32, pub catalog: crate::catalog::CatalogIdentity, - pub shards: Vec, + pub census: CensusIdentity, pub results: Vec, } @@ -253,7 +275,25 @@ impl CensusReport { "report catalog identity does not match the loaded catalog", )); } - if self.shards.is_empty() || self.shards.windows(2).any(|pair| pair[0] >= pair[1]) { + 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", )); @@ -263,6 +303,7 @@ impl CensusReport { .validate_against(catalog) .map_err(|error| CensusError::new(error.to_string()))?; let matching_shards = self + .census .shards .iter() .filter(|shard| { @@ -504,9 +545,9 @@ fn localization_shard() -> Result { "en-us-to-zh-cn", )], source_locale: EN_US.to_string(), - source: en_source.clone(), - reference_source: Some(zh_source.clone()), - support: CensusSupport::Exercise, + source: en_source, + reference_source: None, + support: generated_probe_support(), }, CensusCase { case_id: "localization/zh-cn-to-en-us".to_string(), @@ -517,8 +558,8 @@ fn localization_shard() -> Result { )], source_locale: ZH_CN.to_string(), source: zh_source, - reference_source: Some(en_source), - support: CensusSupport::Exercise, + reference_source: None, + support: generated_probe_support(), }, ], ) @@ -1027,15 +1068,10 @@ fn artifact(name: impl Into, content: &str) -> EvidenceArtifact { } fn reference_artifact(case: &CensusCase, content: &str) -> EvidenceArtifact { - let path = match case.case_id.as_str() { - "localization/en-us-to-zh-cn" => "tests/fixtures/census/localization-zh-cn.ws", - "localization/zh-cn-to-en-us" => "tests/fixtures/census/localization-en-us.ws", - _ => "tests/fixtures/census/reference.ws", - }; EvidenceArtifact { name: format!("census reference for {}", case.case_id), revision: Some("census-v1".to_string()), - path: Some(path.to_string()), + path: Some("tests/fixtures/census/reference.ws".to_string()), sha256: Some(sha256(content)), license: Some("MIT".to_string()), } @@ -1078,6 +1114,8 @@ mod tests { .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] @@ -1135,5 +1173,26 @@ mod tests { 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/docs/adr/0003-sharded-census.md b/docs/adr/0003-sharded-census.md index 550afff..0ed94d7 100644 --- a/docs/adr/0003-sharded-census.md +++ b/docs/adr/0003-sharded-census.md @@ -26,9 +26,17 @@ 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. -`CensusReport::validate_against` 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. +`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 From 3469897da1491d803095afb061375cf92213f4f2 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 15:46:27 +0800 Subject: [PATCH 4/5] test(census): assert authoritative report identity Fixes #19 --- crates/workshop-rs-cli/tests/cli.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/workshop-rs-cli/tests/cli.rs b/crates/workshop-rs-cli/tests/cli.rs index 1d94f3a..47f7e57 100644 --- a/crates/workshop-rs-cli/tests/cli.rs +++ b/crates/workshop-rs-cli/tests/cli.rs @@ -180,15 +180,14 @@ fn census_runs_with_machine_readable_results() { ); let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid report"); assert!( - report["shards"] + 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"] == "matched") - && results - .iter() - .any(|result| result["status"] == "inconclusive") + results + .iter() + .any(|result| result["status"] == "inconclusive") })); let output = run(&["census", "--json", "extra"]); From f9a520b9735b57ceb4766edd76432f8551eb5d9a Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 15:54:09 +0800 Subject: [PATCH 5/5] fix(census): execute probes before inconclusive classification Fixes #19 --- crates/workshop-rs-cli/tests/cli.rs | 9 ++++----- crates/workshop-rs/src/census.rs | 15 +++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/crates/workshop-rs-cli/tests/cli.rs b/crates/workshop-rs-cli/tests/cli.rs index 47f7e57..e78ebd3 100644 --- a/crates/workshop-rs-cli/tests/cli.rs +++ b/crates/workshop-rs-cli/tests/cli.rs @@ -173,11 +173,7 @@ fn usage_errors_exit_2() { #[test] fn census_runs_with_machine_readable_results() { let output = run(&["census", "--json"]); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); + 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"] @@ -188,6 +184,9 @@ fn census_runs_with_machine_readable_results() { results .iter() .any(|result| result["status"] == "inconclusive") + && results + .iter() + .any(|result| result["status"] == "unexpected-regression") })); let output = run(&["census", "--json", "extra"]); diff --git a/crates/workshop-rs/src/census.rs b/crates/workshop-rs/src/census.rs index 12ae934..1ca6be1 100644 --- a/crates/workshop-rs/src/census.rs +++ b/crates/workshop-rs/src/census.rs @@ -822,13 +822,8 @@ fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> Conformance )), None, ), - CensusSupport::Inconclusive { detail } => base( - ConformanceStatus::Inconclusive, - not_comparable(), - Some(reason(ReasonCode::Inconclusive, detail, None)), - None, - ), - CensusSupport::Exercise => execute_case(case, base, catalog), + CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)), + CensusSupport::Exercise => execute_case(case, base, catalog, None), } } @@ -841,6 +836,7 @@ fn execute_case( 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() { @@ -951,7 +947,10 @@ fn execute_case( return failed_text( base, ReasonCode::Inconclusive, - "offline semantic and locale gates passed, but no independent expectation artifact is recorded".to_string(), + inconclusive_detail.unwrap_or( + "offline semantic and locale gates passed, but no independent expectation artifact is recorded", + ) + .to_string(), Some(source_locale), ); };