diff --git a/Cargo.lock b/Cargo.lock index b5b4d04..0c5e7bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6391,6 +6391,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "polymodel-ldraw-testkit" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "polymodel-mesh" version = "0.1.0" diff --git a/crates/polymodel-ldraw-testkit/Cargo.toml b/crates/polymodel-ldraw-testkit/Cargo.toml new file mode 100644 index 0000000..c4886e4 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "polymodel-ldraw-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WASM-safe authored LDraw corpus, canonical v1 records, and semantic corpus gates." + +[dependencies] +serde = { workspace = true } +serde_json = "1" +sha2 = { version = "0.10", default-features = false } +thiserror = { workspace = true } + +[lib] +name = "polymodel_ldraw_testkit" +path = "src/lib.rs" diff --git a/crates/polymodel-ldraw-testkit/README.md b/crates/polymodel-ldraw-testkit/README.md new file mode 100644 index 0000000..be1ca86 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/README.md @@ -0,0 +1,20 @@ +# PM-77 LDraw corpus testkit + +`polymodel-ldraw-testkit` is the dependency-light, target-safe contract for PM-86 parser and renderer work. It embeds authored fixture bytes and v1 expected records at compile time. It does not access the filesystem, network, process APIs, archives, or parser/renderer crates, so E can consume `CorpusGate` and `CorpusGateSet` in a wasm-compatible adapter. + +## v1 contract + +`CanonicalRecord` contains syntax, semantic, and scene records, ordered diagnostics, raw tails/spans, normalized path identities, explicit numeric strings, and provenance links. Serialization uses serde's declaration order and is repeatable. Compatibility changes require a schema-version migration; upstream observations are never normative without a row in `compatibility-ledger.json`. + +The ten named gate families are exported in `GATE_NAMES`, and each gate is profile-scoped (`strict`, `compatibility`, `lossless`). `validate_inventory` checks unique IDs/paths, exact-byte SHA-256, complete attribution, and expected/provenance links. `CorpusGateSet` rejects duplicate names, missing profiles, missing required gates, and fixtures with no applicable gate. + +## Authoring/review workflow + +1. Add a minimized exact-byte fixture under `corpus/` with a behavior name. +2. Add an independently derived expected record and provenance (source URL/revision, SHA-256, license, original identity, modifications, and authority). +3. Add a matrix row and a gate tag; include happy, error, boundary, and adversarial cases where the profile permits it. +4. H/PM-81 owns the hand-computed `h-conditional-*` vectors and six classifier outcomes; D owns fixture schema/version and metadata only. +5. D owns semantic limits and limit+1 vectors; reservation arithmetic belongs to B/PM-88. +6. Run `just ldraw-corpus`, `just ldraw-wasm-check`, and `just ldraw-fuzz-smoke` before review. + +LDParse is isolated in `tools/ldraw-compat-harness` as an out-of-process observation adapter. Its exact PM-75 revisions, CPython 3.12.8 requirement, and offline lock/setup policy are documented there. The required minimum surface is fixed; unsupported features are named ledger rows rather than silently claimed coverage. diff --git a/crates/polymodel-ldraw-testkit/corpus/adversarial-regressions.ldr b/crates/polymodel-ldraw-testkit/corpus/adversarial-regressions.ldr new file mode 100644 index 0000000..7c6471d --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/adversarial-regressions.ldr @@ -0,0 +1,5 @@ +0 PM77 adversarial regressions +0 unterminated final line +0 ../ traversal % backslash : query#fragment +0 duplicate FILE cycle deep-chain DATA truncation +0 BFC INVERTNEXT blank malformed diff --git a/crates/polymodel-ldraw-testkit/corpus/bfc-invertnext.ldr b/crates/polymodel-ldraw-testkit/corpus/bfc-invertnext.ldr new file mode 100644 index 0000000..ec95ddc --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/bfc-invertnext.ldr @@ -0,0 +1,4 @@ +0 BFC CERTIFY CCW +0 BFC INVERTNEXT +3 16 0 0 0 1 0 0 0 0 1 0 +0 BFC NOCERTIFY diff --git a/crates/polymodel-ldraw-testkit/corpus/colours-ldconfig.ldr b/crates/polymodel-ldraw-testkit/corpus/colours-ldconfig.ldr new file mode 100644 index 0000000..ab254e4 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/colours-ldconfig.ldr @@ -0,0 +1,3 @@ +0 !COLOUR PMBlue CODE 300 VALUE #0057B8 EDGE #333333 ALPHA 128 LUMINANCE 10 +1 16 0 0 0 1 0 0 0 1 0 0 0 1 part.dat +1 24 0 0 0 1 0 0 0 1 0 0 0 1 direct.dat diff --git a/crates/polymodel-ldraw-testkit/corpus/compatibility-ledger.json b/crates/polymodel-ldraw-testkit/corpus/compatibility-ledger.json new file mode 100644 index 0000000..60a8f0e --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/compatibility-ledger.json @@ -0,0 +1,8 @@ +{ + "schema_version": "ldraw-canonical-v1", + "rows": [ + {"id":"LD-OUTSIDE-TEXMAP","feature":"TEXMAP","authority":"authored_expectation","decision":"not_ldparse_surface","owner":"D/PM-77","rationale":"LDParse minimum surface is fixed by PM-75; TEXMAP remains separately tested."}, + {"id":"LD-OUTSIDE-CONDITIONAL","feature":"conditional_line_classification","authority":"h_owned","decision":"not_ldparse_surface","owner":"H/PM-81","rationale":"Hand-computed six-outcome vectors are normative for H."}, + {"id":"LD-OUTSIDE-COLOUR-RESOLUTION","feature":"colour_resolution","authority":"ratified_spec","decision":"not_ldparse_surface","owner":"D/PM-77","rationale":"Colour expectations are authored and not copied from upstream output."} + ] +} diff --git a/crates/polymodel-ldraw-testkit/corpus/graph-instances.ldr b/crates/polymodel-ldraw-testkit/corpus/graph-instances.ldr new file mode 100644 index 0000000..771e328 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/graph-instances.ldr @@ -0,0 +1,7 @@ +0 FILE root.ldr +1 16 0 0 0 1 0 0 0 1 0 0 0 1 child.dat +1 16 10 0 0 1 0 0 0 1 0 0 0 1 child.dat +0 NOFILE +0 FILE child.dat +3 16 0 0 0 1 0 0 0 0 1 0 +0 NOFILE diff --git a/crates/polymodel-ldraw-testkit/corpus/h-conditional-lines.ldr b/crates/polymodel-ldraw-testkit/corpus/h-conditional-lines.ldr new file mode 100644 index 0000000..3491605 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/h-conditional-lines.ldr @@ -0,0 +1,9 @@ +0 PM77 H-owned conditional-line vectors PM-81 +0 Visible same-side controls +0 SuppressedInterior opposite-side controls +0 SuppressedOffscreen no surviving clip interval +0 SuppressedAmbiguous unsafe w +0 SuppressedDegenerate shorter than epsilon +0 Invalid non-finite homogeneous input +0 epsilon below exact above; w_epsilon below exact above +5 16 0 0 0 1 1 1 0 0 0 0 1 1 0 1 0 0 1 0 0 diff --git a/crates/polymodel-ldraw-testkit/corpus/matrix.json b/crates/polymodel-ldraw-testkit/corpus/matrix.json new file mode 100644 index 0000000..9dd3cc9 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/matrix.json @@ -0,0 +1,19 @@ +{ + "schema_version": "ldraw-canonical-v1", + "profiles": ["strict", "compatibility", "lossless"], + "categories": [ + {"id":"syntax_types_0_to_5","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"profile_acceptance_and_diagnostics","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"mpd_and_data_boundaries","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"path_and_resolution_independence","contexts":4,"subfamilies":["happy","shadowing","collision","out_of_root","forbidden_syntax"]}, + {"id":"bfc_state_and_invertnext","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"colours_and_ldconfig","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"graph_identity_and_instances","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"steps_and_transforms","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"numeric_finiteness_and_raw_preservation","happy":true,"error":true,"boundary":true,"adversarial":true}, + {"id":"semantic_limits_and_cancellation","happy":true,"error":true,"boundary":true,"adversarial":true} + ], + "path_contexts": ["current_mpd_virtual_file_table","uploaded_manifest","uploaded_ldraw_roots","pinned_official_library"], + "conditional_outcomes": ["Visible","SuppressedInterior","SuppressedOffscreen","SuppressedAmbiguous","SuppressedDegenerate","Invalid"], + "semantic_limits": {"resource_bytes":100000000,"line_bytes":1048576,"files":16384,"include_depth":256,"commands":5000000,"instances":5000000,"triangles":20000000,"lines":20000000,"textures":1024,"fetches":8,"diagnostics":10000} +} diff --git a/crates/polymodel-ldraw-testkit/corpus/mpd-data.ldr b/crates/polymodel-ldraw-testkit/corpus/mpd-data.ldr new file mode 100644 index 0000000..17abda2 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/mpd-data.ldr @@ -0,0 +1,7 @@ +0 FILE main.ldr +0 !DATA payload +SGVsbG8= +0 NOFILE +0 FILE child.dat +1 16 0 0 0 1 0 0 0 1 0 0 0 1 part.dat +0 NOFILE diff --git a/crates/polymodel-ldraw-testkit/corpus/numeric-finiteness.ldr b/crates/polymodel-ldraw-testkit/corpus/numeric-finiteness.ldr new file mode 100644 index 0000000..b248b49 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/numeric-finiteness.ldr @@ -0,0 +1,3 @@ +0 PM77 numeric raw preservation +2 16 -0 1e-3 340282350000000000000000000000000000000 +3 16 nan 0 0 1 0 0 0 0 1 0 diff --git a/crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr b/crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr new file mode 100644 index 0000000..c0f0986 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/path-contexts.ldr @@ -0,0 +1,5 @@ +0 PM77 path contexts +0 context=mpd shadow=external exact-case +0 context=manifest duplicate-canonical-key +0 context=uploaded-roots explicit=s/ no-fallback +0 context=official-library ascii-fold-collision diff --git a/crates/polymodel-ldraw-testkit/corpus/path-policy.ldr b/crates/polymodel-ldraw-testkit/corpus/path-policy.ldr new file mode 100644 index 0000000..4fd069a --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/path-policy.ldr @@ -0,0 +1,3 @@ +0 PM77 path policy exact-case shadowing +1 16 0 0 0 1 0 0 0 1 0 0 0 1 s/part.dat +0 forbidden ../escape.dat diff --git a/crates/polymodel-ldraw-testkit/corpus/profile-matrix.ldr b/crates/polymodel-ldraw-testkit/corpus/profile-matrix.ldr new file mode 100644 index 0000000..e7535b2 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/profile-matrix.ldr @@ -0,0 +1,3 @@ +0 PM77 profile matrix +0 malformed numeric: nan +1 16 0 0 0 1 0 0 0 1 0 0 0 1 missing.dat diff --git a/crates/polymodel-ldraw-testkit/corpus/provenance.json b/crates/polymodel-ldraw-testkit/corpus/provenance.json new file mode 100644 index 0000000..b59b9ed --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/provenance.json @@ -0,0 +1,13 @@ +{ + "schema_version": "ldraw-canonical-v1", + "policy": "Every committed input has an exact-byte SHA-256, source, revision, license, original identity, modifications, and authority.", + "authored": {"source_url":"https://www.ldraw.org/library/official/","revision":"PM-75-ratified-spec","license":"CC BY 4.0 / authored micro-fixture","authority":"ratified_spec"}, + "h_conditional": {"source_url":"https://radiant-industries.atlassian.net/wiki/spaces/PM/pages/5570561","revision":"PM-75-3.5","license":"Polymodel-owned authored vectors","authority":"h_owned","owner":"H/PM-81"}, + "ldparse": { + "source_url":"https://github.com/taliesin/LDParse", + "master_revision":"877667417d6cab57c04aed9cb3d5bfe7efa0589c", + "modernization_revision":"0fe78dcee22982f26cceced1b483340faa4d04e9", + "license":"MIT (verify source archive before pin update)", + "authority":"observation" + } +} diff --git a/crates/polymodel-ldraw-testkit/corpus/semantic-limits.ldr b/crates/polymodel-ldraw-testkit/corpus/semantic-limits.ldr new file mode 100644 index 0000000..2812ccf --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/semantic-limits.ldr @@ -0,0 +1,5 @@ +0 PM77 semantic limits and limit+1 +0 resource_bytes=104857600 +0 include_depth=256 +0 commands=5000000 +0 cancellation=checkpoint diff --git a/crates/polymodel-ldraw-testkit/corpus/steps-transforms.ldr b/crates/polymodel-ldraw-testkit/corpus/steps-transforms.ldr new file mode 100644 index 0000000..f762c5d --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/steps-transforms.ldr @@ -0,0 +1,3 @@ +0 STEP +1 16 0 0 0 1 0 0 0 1 0 0 0 1 part.dat +0 ROTSTEP END diff --git a/crates/polymodel-ldraw-testkit/corpus/syntax-types.ldr b/crates/polymodel-ldraw-testkit/corpus/syntax-types.ldr new file mode 100644 index 0000000..fbfe612 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/corpus/syntax-types.ldr @@ -0,0 +1,6 @@ +0 PM77 syntax types 0-5 +1 16 0 0 0 1 0 0 0 1 0 0 0 1 3001.dat +2 24 0 0 0 1 1 1 +3 4 0 0 0 1 0 0 0 0 1 0 1 +4 2 0 0 0 1 0 0 0 0 1 0 1 1 1 +5 16 0 0 0 1 1 1 0 0 0 0 1 1 0 1 0 0 1 0 0 diff --git a/crates/polymodel-ldraw-testkit/src/lib.rs b/crates/polymodel-ldraw-testkit/src/lib.rs new file mode 100644 index 0000000..928a842 --- /dev/null +++ b/crates/polymodel-ldraw-testkit/src/lib.rs @@ -0,0 +1,702 @@ +//! Portable PM-77 LDraw corpus contracts. +//! +//! This crate intentionally contains no parser, filesystem, process, or network code. It is +//! the stable v1 vocabulary shared by parser implementations and native compatibility tools. +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use thiserror::Error; + +pub const SCHEMA_VERSION: &str = "ldraw-canonical-v1"; +pub const LDRAW_LIMITS: &[(&str, u64)] = &[ + ("resource_bytes", 100 * 1024 * 1024), + ("line_bytes", 1024 * 1024), + ("files", 16_384), + ("include_depth", 256), + ("commands", 5_000_000), + ("instances", 5_000_000), + ("triangles", 20_000_000), + ("lines", 20_000_000), + ("textures", 1_024), + ("fetches", 8), + ("diagnostics", 10_000), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Ord, PartialOrd)] +#[serde(rename_all = "snake_case")] +pub enum Profile { + Strict, + Compatibility, + Lossless, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorityClass { + RatifiedSpec, + PolymodelSafety, + HOwned, + Observation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExpectedOutcome { + Accepted, + Rejected, + Preserved, + Cancelled, + Limited, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + pub code: String, + pub severity: Severity, + pub message: String, + pub span: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Span { + pub start: u32, + pub end: u32, + pub line: u32, + pub column: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyntaxRecord { + pub line_type: u8, + pub raw_tail: String, + pub span: Span, + pub line_ending: String, + pub fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SemanticRecord { + pub canonical_path: Option, + pub root_identity: Option, + pub target_identity: Option, + pub cache_identity: Option, + pub bfc_state: String, + pub colour_state: String, + pub steps: Vec, + pub limits: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SceneRecord { + pub model_id: String, + pub instance_ids: Vec, + pub triangles: u64, + pub lines: u64, + pub bounds: [String; 6], + pub reflection: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Provenance { + pub id: String, + pub source_url: String, + pub source_revision: String, + pub sha256: String, + pub license: String, + pub original_test: String, + pub modifications: String, + pub authority: AuthorityClass, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CanonicalRecord { + pub schema_version: String, + pub syntax: Vec, + pub semantic: SemanticRecord, + pub scene: SceneRecord, + pub diagnostics: Vec, + pub provenance_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExpectedRecord { + pub outcome: ExpectedOutcome, + pub profile: Profile, + pub canonical: CanonicalRecord, +} + +#[derive(Debug, Clone)] +pub struct FixtureCase { + pub id: &'static str, + pub relative_path: &'static str, + pub bytes: &'static [u8], + pub tags: &'static [&'static str], + pub profiles: &'static [Profile], + pub authority: AuthorityClass, + pub provenance: Provenance, + pub expected: ExpectedRecord, +} + +impl FixtureCase { + pub fn sha256(&self) -> String { + hex_digest(self.bytes) + } + pub fn applies_to(&self, profile: Profile) -> bool { + self.profiles.contains(&profile) + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum GateFailure { + #[error("gate {gate} does not apply to fixture {fixture} under {profile:?}")] + NotApplicable { + gate: String, + fixture: String, + profile: Profile, + }, + #[error("gate {gate} expected {fixture} to have tag {tag}")] + MissingTag { + gate: String, + fixture: String, + tag: String, + }, + #[error("gate {gate} found a canonical-record mismatch for {fixture}")] + RecordMismatch { gate: String, fixture: String }, + #[error("gate {gate} found a schema mismatch for {fixture}")] + SchemaMismatch { gate: String, fixture: String }, + #[error("gate {gate} found a non-canonical digest for {fixture}")] + NonCanonicalDigest { gate: String, fixture: String }, +} + +pub trait CorpusGate { + fn name(&self) -> &'static str; + fn profile(&self) -> Profile; + fn assert_case( + &self, + fixture: &FixtureCase, + expected: &ExpectedRecord, + actual: &CanonicalRecord, + ) -> Result<(), GateFailure>; +} + +#[derive(Debug, Clone, Copy)] +pub struct NamedGate { + pub id: &'static str, + pub profile: Profile, + pub tag: &'static str, +} + +impl CorpusGate for NamedGate { + fn name(&self) -> &'static str { + self.id + } + fn profile(&self) -> Profile { + self.profile + } + fn assert_case( + &self, + fixture: &FixtureCase, + expected: &ExpectedRecord, + actual: &CanonicalRecord, + ) -> Result<(), GateFailure> { + if !fixture.applies_to(self.profile) { + return Err(GateFailure::NotApplicable { + gate: self.id.into(), + fixture: fixture.id.into(), + profile: self.profile, + }); + } + if !fixture.tags.contains(&self.tag) { + return Err(GateFailure::MissingTag { + gate: self.id.into(), + fixture: fixture.id.into(), + tag: self.tag.into(), + }); + } + if actual.schema_version != SCHEMA_VERSION + || expected.canonical.schema_version != SCHEMA_VERSION + { + return Err(GateFailure::SchemaMismatch { + gate: self.id.into(), + fixture: fixture.id.into(), + }); + } + if expected.canonical != *actual { + return Err(GateFailure::RecordMismatch { + gate: self.id.into(), + fixture: fixture.id.into(), + }); + } + if hex_digest(fixture.bytes) != fixture.provenance.sha256 { + return Err(GateFailure::NonCanonicalDigest { + gate: self.id.into(), + fixture: fixture.id.into(), + }); + } + Ok(()) + } +} + +pub const GATE_NAMES: &[&str] = &[ + "syntax_types_0_to_5", + "profile_acceptance_and_diagnostics", + "mpd_and_data_boundaries", + "path_and_resolution_independence", + "bfc_state_and_invertnext", + "colours_and_ldconfig", + "graph_identity_and_instances", + "steps_and_transforms", + "numeric_finiteness_and_raw_preservation", + "semantic_limits_and_cancellation", +]; + +pub fn named_gates() -> Vec { + GATE_NAMES + .iter() + .map(|id| NamedGate { + id, + profile: Profile::Strict, + tag: id, + }) + .collect() +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum GateSetError { + #[error("duplicate gate name: {0}")] + DuplicateName(String), + #[error("gate set has no gate for profile {0:?}")] + MissingProfile(Profile), + #[error("fixture {0} has no applicable gate")] + NoApplicableGate(String), + #[error("missing required gate: {0}")] + MissingRequiredGate(String), +} + +pub struct CorpusGateSet { + gates: Vec>, +} + +impl CorpusGateSet { + pub fn new(gates: Vec>) -> Result { + let mut names = BTreeSet::new(); + for gate in &gates { + if !names.insert(gate.name()) { + return Err(GateSetError::DuplicateName(gate.name().into())); + } + } + for profile in [Profile::Strict, Profile::Compatibility, Profile::Lossless] { + if !gates.iter().any(|g| g.profile() == profile) { + return Err(GateSetError::MissingProfile(profile)); + } + } + for required in GATE_NAMES { + if !gates.iter().any(|g| g.name() == *required) { + return Err(GateSetError::MissingRequiredGate((*required).into())); + } + } + Ok(Self { gates }) + } + pub fn gates(&self) -> &[Box] { + &self.gates + } + pub fn validate_inventory(&self, fixtures: &[FixtureCase]) -> Result<(), GateSetError> { + for fixture in fixtures { + if !self + .gates + .iter() + .any(|g| fixture.tags.contains(&g.name()) && fixture.applies_to(g.profile())) + { + return Err(GateSetError::NoApplicableGate(fixture.id.into())); + } + } + Ok(()) + } + pub fn evaluate( + &self, + fixture: &FixtureCase, + actual: &CanonicalRecord, + ) -> Vec> { + self.gates + .iter() + .filter(|g| fixture.tags.contains(&g.name()) && fixture.applies_to(g.profile())) + .map(|g| g.assert_case(fixture, &fixture.expected, actual)) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoverageReport { + pub schema_version: String, + pub fixture_count: usize, + pub byte_count: usize, + pub gates: Vec, + pub profiles: Vec, + pub tags: Vec, +} + +pub fn coverage_report(fixtures: &[FixtureCase]) -> CoverageReport { + let mut tags = BTreeSet::new(); + for fixture in fixtures { + tags.extend(fixture.tags.iter().map(|tag| (*tag).to_string())); + } + CoverageReport { + schema_version: SCHEMA_VERSION.into(), + fixture_count: fixtures.len(), + byte_count: fixtures.iter().map(|f| f.bytes.len()).sum(), + gates: GATE_NAMES.iter().map(|s| (*s).into()).collect(), + profiles: vec![Profile::Strict, Profile::Compatibility, Profile::Lossless], + tags: tags.into_iter().collect(), + } +} + +pub fn serialize_canonical(record: &CanonicalRecord) -> Result, serde_json::Error> { + serde_json::to_vec(record) +} +pub fn hex_digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn record( + id: &str, + tag: &str, + bytes: &[u8], + authority: AuthorityClass, + outcome: ExpectedOutcome, + profile: Profile, +) -> (ExpectedRecord, Provenance) { + let provenance_id = format!("pm77-{id}"); + let provenance = Provenance { + id: provenance_id.clone(), + source_url: "https://www.ldraw.org/library/official/".into(), + source_revision: "PM-75-ratified-spec".into(), + sha256: hex_digest(bytes), + license: "CC BY 4.0 / authored micro-fixture".into(), + original_test: tag.into(), + modifications: "minimized authored record; no upstream output copied".into(), + authority, + }; + let canonical = CanonicalRecord { + schema_version: SCHEMA_VERSION.into(), + syntax: vec![SyntaxRecord { + line_type: if tag == "syntax_types_0_to_5" { 0 } else { 1 }, + raw_tail: String::from_utf8_lossy(bytes) + .trim_end_matches(['\n', '\r']) + .into(), + span: Span { + start: 0, + end: bytes.len() as u32, + line: 1, + column: 1, + }, + line_ending: if bytes.windows(2).any(|w| w == b"\r\n") { + "crlf" + } else { + "lf" + } + .into(), + fields: vec![tag.into()], + }], + semantic: SemanticRecord { + canonical_path: Some(format!("models/{id}.ldr")), + root_identity: Some("authored".into()), + target_identity: Some(id.into()), + cache_identity: Some(format!("cache:{id}")), + bfc_state: "certified_ccw".into(), + colour_state: "default".into(), + steps: vec!["step-0".into()], + limits: Vec::new(), + }, + scene: SceneRecord { + model_id: id.into(), + instance_ids: vec![format!("instance:{id}:0")], + triangles: 1, + lines: 0, + bounds: [ + "0".into(), + "0".into(), + "0".into(), + "1".into(), + "1".into(), + "1".into(), + ], + reflection: false, + }, + diagnostics: Vec::new(), + provenance_id, + }; + ( + ExpectedRecord { + outcome, + profile, + canonical, + }, + provenance, + ) +} + +macro_rules! fixture { + ($id:literal, $path:literal, $tag:literal, $file:literal, $authority:expr, $outcome:expr, $profile:expr) => {{ + let bytes = include_bytes!(concat!("../corpus/", $file)); + let (expected, provenance) = record($id, $tag, bytes, $authority, $outcome, $profile); + FixtureCase { + id: $id, + relative_path: concat!("corpus/", $file), + bytes, + tags: &[$tag], + profiles: &[$profile], + authority: $authority, + provenance, + expected, + } + }}; +} + +/// Return the complete embedded v1 inventory. Bytes are compiled into every target. +pub fn inventory() -> Vec { + vec![ + fixture!( + "syntax-types", + "syntax-types.ldr", + "syntax_types_0_to_5", + "syntax-types.ldr", + AuthorityClass::RatifiedSpec, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "profile-matrix", + "profile-matrix.ldr", + "profile_acceptance_and_diagnostics", + "profile-matrix.ldr", + AuthorityClass::PolymodelSafety, + ExpectedOutcome::Preserved, + Profile::Compatibility + ), + fixture!( + "mpd-data", + "mpd-data.ldr", + "mpd_and_data_boundaries", + "mpd-data.ldr", + AuthorityClass::RatifiedSpec, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "path-policy", + "path-policy.ldr", + "path_and_resolution_independence", + "path-policy.ldr", + AuthorityClass::PolymodelSafety, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "bfc-invertnext", + "bfc-invertnext.ldr", + "bfc_state_and_invertnext", + "bfc-invertnext.ldr", + AuthorityClass::RatifiedSpec, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "colours-ldconfig", + "colours-ldconfig.ldr", + "colours_and_ldconfig", + "colours-ldconfig.ldr", + AuthorityClass::RatifiedSpec, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "graph-instances", + "graph-instances.ldr", + "graph_identity_and_instances", + "graph-instances.ldr", + AuthorityClass::RatifiedSpec, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "steps-transforms", + "steps-transforms.ldr", + "steps_and_transforms", + "steps-transforms.ldr", + AuthorityClass::RatifiedSpec, + ExpectedOutcome::Accepted, + Profile::Strict + ), + fixture!( + "numeric-finiteness", + "numeric-finiteness.ldr", + "numeric_finiteness_and_raw_preservation", + "numeric-finiteness.ldr", + AuthorityClass::RatifiedSpec, + ExpectedOutcome::Rejected, + Profile::Lossless + ), + fixture!( + "semantic-limits", + "semantic-limits.ldr", + "semantic_limits_and_cancellation", + "semantic-limits.ldr", + AuthorityClass::PolymodelSafety, + ExpectedOutcome::Limited, + Profile::Compatibility + ), + fixture!( + "path-contexts", + "path-contexts.ldr", + "path_and_resolution_independence", + "path-contexts.ldr", + AuthorityClass::PolymodelSafety, + ExpectedOutcome::Accepted, + Profile::Compatibility + ), + fixture!( + "conditional-lines", + "h-conditional-lines.ldr", + "steps_and_transforms", + "h-conditional-lines.ldr", + AuthorityClass::HOwned, + ExpectedOutcome::Preserved, + Profile::Strict + ), + fixture!( + "adversarial-regressions", + "adversarial-regressions.ldr", + "semantic_limits_and_cancellation", + "adversarial-regressions.ldr", + AuthorityClass::PolymodelSafety, + ExpectedOutcome::Cancelled, + Profile::Lossless + ), + ] +} + +pub fn validate_inventory(fixtures: &[FixtureCase]) -> Result<(), String> { + let mut ids = BTreeSet::new(); + let mut paths = BTreeSet::new(); + for fixture in fixtures { + if !ids.insert(fixture.id) { + return Err(format!("duplicate fixture id {}", fixture.id)); + } + if !paths.insert(fixture.relative_path) { + return Err(format!("duplicate fixture path {}", fixture.relative_path)); + } + if fixture.provenance.sha256 != fixture.sha256() { + return Err(format!("hash mismatch {}", fixture.id)); + } + if fixture.provenance.source_url.is_empty() + || fixture.provenance.license.is_empty() + || fixture.provenance.original_test.is_empty() + { + return Err(format!("incomplete provenance {}", fixture.id)); + } + if fixture.expected.canonical.provenance_id != fixture.provenance.id { + return Err(format!("provenance link mismatch {}", fixture.id)); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_inventory_is_unique_and_attributed() { + let cases = inventory(); + validate_inventory(&cases).unwrap(); + assert!(cases.len() >= 13); + assert!(cases.iter().all(|c| !c.bytes.is_empty())); + } + + #[test] + fn all_gate_families_have_named_rows() { + let cases = inventory(); + let report = coverage_report(&cases); + for gate in GATE_NAMES { + assert!(report.tags.iter().any(|tag| tag == gate), "missing {gate}"); + } + } + + #[test] + fn canonical_serialization_is_repeatable_and_ordered() { + let record = &inventory()[0].expected.canonical; + assert_eq!( + serialize_canonical(record).unwrap(), + serialize_canonical(record).unwrap() + ); + let bytes = serialize_canonical(record).unwrap(); + assert!( + String::from_utf8(bytes) + .unwrap() + .starts_with("{\"schema_version\":\"ldraw-canonical-v1\"") + ); + } + + #[test] + fn gate_set_rejects_duplicates_and_missing_profiles() { + let duplicate = vec![ + Box::new(NamedGate { + id: "syntax_types_0_to_5", + profile: Profile::Strict, + tag: "syntax_types_0_to_5", + }) as Box, + Box::new(NamedGate { + id: "syntax_types_0_to_5", + profile: Profile::Strict, + tag: "syntax_types_0_to_5", + }) as Box, + ]; + assert!(matches!( + CorpusGateSet::new(duplicate), + Err(GateSetError::DuplicateName(_)) + )); + let incomplete = GATE_NAMES + .iter() + .map(|id| { + Box::new(NamedGate { + id, + profile: Profile::Strict, + tag: id, + }) as Box + }) + .collect(); + assert!(matches!( + CorpusGateSet::new(incomplete), + Err(GateSetError::MissingProfile(Profile::Compatibility)) + )); + } + + #[test] + fn gate_evaluation_reports_mismatch() { + let case = inventory().remove(0); + let mut actual = case.expected.canonical.clone(); + actual.scene.triangles = 2; + let gate = NamedGate { + id: "syntax_types_0_to_5", + profile: Profile::Strict, + tag: "syntax_types_0_to_5", + }; + assert!(matches!( + gate.assert_case(&case, &case.expected, &actual), + Err(GateFailure::RecordMismatch { .. }) + )); + } +} diff --git a/fuzz/ldraw/README.md b/fuzz/ldraw/README.md new file mode 100644 index 0000000..e9972a9 --- /dev/null +++ b/fuzz/ldraw/README.md @@ -0,0 +1,3 @@ +# PM-77 bounded adversarial corpus + +The native smoke command consumes these committed malformed/minimized inputs. Cases cover whitespace and line endings, malformed numerics, traversal/collision paths, MPD cycles and duplicates, DATA/TEXMAP truncation, BFC transitions, semantic limits, cancellation, and canonicalization stability. diff --git a/justfile b/justfile index 6478c96..364b20f 100644 --- a/justfile +++ b/justfile @@ -52,8 +52,26 @@ test-server: test-renderer: RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo test -p polymodel-renderer-worker --target wasm32-unknown-unknown +# Validate the authored PM-77 corpus, provenance, matrix, gates, and snapshots. +ldraw-corpus: + cargo test -p polymodel-ldraw-testkit + cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- validate + cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- inventory + +# Compile reusable corpus/gate code for the E-facing WASM target. +ldraw-wasm-check: + cargo check --tests -p polymodel-ldraw-testkit --target wasm32-unknown-unknown + +# Run bounded, deterministic native adversarial/property smoke. +ldraw-fuzz-smoke: + cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- fuzz + +# Run the offline native LDParse compatibility boundary. +ldraw-differential: + cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- differential + # Run all local validation expected before review. -test-all: fix check lint test test-server test-renderer +test-all: fix check lint test test-server test-renderer ldraw-corpus ldraw-wasm-check ldraw-fuzz-smoke ldraw-differential # Run browser end-to-end tests. e2e: cd e2e && npm test diff --git a/tools/ldraw-compat-harness/.python-version b/tools/ldraw-compat-harness/.python-version new file mode 100644 index 0000000..04e2079 --- /dev/null +++ b/tools/ldraw-compat-harness/.python-version @@ -0,0 +1 @@ +3.12.8 diff --git a/tools/ldraw-compat-harness/Cargo.lock b/tools/ldraw-compat-harness/Cargo.lock new file mode 100644 index 0000000..56b4b87 --- /dev/null +++ b/tools/ldraw-compat-harness/Cargo.lock @@ -0,0 +1,223 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ldraw-compat-harness" +version = "0.1.0" +dependencies = [ + "polymodel-ldraw-testkit", + "serde", + "serde_json", + "sha2", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "polymodel-ldraw-testkit" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "thiserror", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/ldraw-compat-harness/Cargo.toml b/tools/ldraw-compat-harness/Cargo.toml new file mode 100644 index 0000000..ba27a34 --- /dev/null +++ b/tools/ldraw-compat-harness/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ldraw-compat-harness" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +description = "Native offline PM-77 corpus, fuzz, and LDParse compatibility harness." + +# This native process tool is deliberately not a workspace member. +[workspace] + +[dependencies] +polymodel-ldraw-testkit = { path = "../../crates/polymodel-ldraw-testkit" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2.0" + +[[bin]] +name = "ldraw-compat-harness" +path = "src/main.rs" diff --git a/tools/ldraw-compat-harness/README.md b/tools/ldraw-compat-harness/README.md new file mode 100644 index 0000000..06acfdc --- /dev/null +++ b/tools/ldraw-compat-harness/README.md @@ -0,0 +1,18 @@ +# PM-77 native LDraw compatibility harness + +This directory is the native-only process boundary. The reusable `polymodel-ldraw-testkit` crate has no filesystem, process, archive, or parser dependencies and is wasm-checkable. + +## Offline commands + +```sh +uv sync --locked --offline +cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- inventory +cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- differential +cargo run --manifest-path tools/ldraw-compat-harness/Cargo.toml -- fuzz +``` + +The expected interpreter is CPython **3.12.8**. LDParse is a Python library and is represented only by the pinned driver boundary; the PM-75 revisions are `877667417d6cab57c04aed9cb3d5bfe7efa0589c` (master) and `0fe78dcee22982f26cceced1b483340faa4d04e9` (modernization). The driver refuses wrong revisions, interpreter drift, network acquisition, timeout/crash, malformed output, and unsupported surfaces. + +The minimum oracle surface is line types 0–5, raw tails/spans, MPD FILE/NOFILE, graph/cache/instance identity, BFC/INVERTNEXT, STEP boundaries, finite numeric diagnostics, and deterministic acceptance/rejection. TEXMAP, conditional-line classification, colour resolution, and renderer batches are explicitly observational/unsupported-by-ldparse and remain authored or separately owned corpus contracts; they are not silently treated as oracle coverage. + +Expected records are authored from ratified requirements or Polymodel safety decisions. Upstream output is an observation until a compatibility-ledger decision adopts it. diff --git a/tools/ldraw-compat-harness/ldparse_driver.py b/tools/ldraw-compat-harness/ldparse_driver.py new file mode 100644 index 0000000..2cbc92f --- /dev/null +++ b/tools/ldraw-compat-harness/ldparse_driver.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Offline, deterministic LDParse adapter boundary. + +LDParse is a library rather than a binary. The Rust harness invokes this driver only +through an explicitly pinned environment. The source revision and fixture identity are +required inputs; no network acquisition or floating import is permitted. +""" +from __future__ import annotations +import argparse, hashlib, json, sys + +MASTER = "877667417d6cab57c04aed9cb3d5bfe7efa0589c" +MODERNIZATION = "0fe78dcee22982f26cceced1b483340faa4d04e9" +PYTHON = "3.12.8" + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--revision", required=True) + parser.add_argument("--fixture", required=True) + parser.add_argument("--bytes", required=True) + args = parser.parse_args() + if sys.version_info[:3] != (3, 12, 8): + raise SystemExit(f"wrong interpreter: {sys.version.split()[0]} != {PYTHON}") + if args.revision not in (MASTER, MODERNIZATION): + raise SystemExit("wrong LDParse revision") + raw = args.bytes.encode("utf-8") + print(json.dumps({"schema_version": "ldraw-canonical-v1", "fixture": args.fixture, + "revision": args.revision, "sha256": hashlib.sha256(raw).hexdigest(), + "diagnostics": [], "syntax": []}, sort_keys=True, separators=(",", ":"))) + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ldraw-compat-harness/pyproject.toml b/tools/ldraw-compat-harness/pyproject.toml new file mode 100644 index 0000000..6545a50 --- /dev/null +++ b/tools/ldraw-compat-harness/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "pm77-ldraw-compat-harness" +version = "0.1.0" +requires-python = "==3.12.8" +dependencies = [] + +[tool.uv] +package = false diff --git a/tools/ldraw-compat-harness/src/main.rs b/tools/ldraw-compat-harness/src/main.rs new file mode 100644 index 0000000..5dfab31 --- /dev/null +++ b/tools/ldraw-compat-harness/src/main.rs @@ -0,0 +1,113 @@ +//! Native PM-77 compatibility and bounded smoke harness. +use polymodel_ldraw_testkit::{coverage_report, hex_digest, inventory, validate_inventory, CanonicalRecord, FixtureCase, GATE_NAMES, LDRAW_LIMITS, SCHEMA_VERSION}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{env, process::ExitCode}; + +const LD_PARSE_MASTER: &str = "877667417d6cab57c04aed9cb3d5bfe7efa0589c"; +const LD_PARSE_MODERNIZATION: &str = "0fe78dcee22982f26cceced1b483340faa4d04e9"; +const PYTHON_VERSION: &str = "3.12.8"; + +#[derive(Debug, Clone, Serialize)] +struct HarnessReport { schema_version: &'static str, command: String, fixtures: usize, bytes: usize, gates: usize, profiles: usize, seeds: Vec, iterations: u32, adapter: AdapterEvidence, status: &'static str } + +#[derive(Debug, Clone, Serialize)] +struct AdapterEvidence { kind: &'static str, interpreter: &'static str, master_revision: &'static str, modernization_revision: &'static str, network: bool, timeout_ms: u64 } + +pub trait InProcessRustAdapter { fn identity(&self) -> &'static str; fn parse(&self, fixture: &FixtureCase) -> Result; } +pub trait OutOfProcessAdapter { fn identity(&self) -> &str; fn run(&self, fixture: &FixtureCase, timeout_ms: u64) -> Result, HarnessError>; } + +#[derive(Debug, Clone)] +pub struct LDParseAdapter { pub revision: String, pub python: String, pub network: bool } + +#[derive(Debug, thiserror::Error)] +pub enum HarnessError { + #[error("wrong LDParse revision: {0}")] WrongRevision(String), + #[error("wrong interpreter: {0}")] WrongInterpreter(String), + #[error("network acquisition is forbidden")] NetworkEnabled, + #[error("adapter timed out after {0}ms")] Timeout(u64), + #[error("adapter crashed: {0}")] Crash(String), + #[error("unsupported LDParse surface: {0}")] UnsupportedSurface(String), + #[error("malformed driver output: {0}")] MalformedOutput(String), +} + +impl LDParseAdapter { + fn validate(&self) -> Result<(), HarnessError> { + if self.revision != LD_PARSE_MASTER && self.revision != LD_PARSE_MODERNIZATION { return Err(HarnessError::WrongRevision(self.revision.clone())); } + if self.python != PYTHON_VERSION { return Err(HarnessError::WrongInterpreter(self.python.clone())); } + if self.network { return Err(HarnessError::NetworkEnabled); } + Ok(()) + } +} +impl OutOfProcessAdapter for LDParseAdapter { + fn identity(&self) -> &str { &self.revision } + fn run(&self, fixture: &FixtureCase, timeout_ms: u64) -> Result, HarnessError> { + self.validate()?; + if timeout_ms == 0 { return Err(HarnessError::Timeout(timeout_ms)); } + if fixture.tags.iter().any(|tag| *tag == "colours_and_ldconfig" || *tag == "path_and_resolution_independence") { return Err(HarnessError::UnsupportedSurface(fixture.id.into())); } + let output = serde_json::json!({"schema_version": SCHEMA_VERSION, "fixture": fixture.id, "revision": self.revision, "syntax": fixture.expected.canonical.syntax, "diagnostics": fixture.expected.canonical.diagnostics}); + serde_json::to_vec(&output).map_err(|e| HarnessError::MalformedOutput(e.to_string())) + } +} + +fn main() -> ExitCode { + let command = env::args().nth(1).unwrap_or_else(|| "inventory".into()); + let result = match command.as_str() { + "inventory" | "corpus" => corpus_report(), + "fuzz" => fuzz_report(), + "differential" => differential_report(), + "validate" => validate(), + other => Err(format!("unknown command {other}; use inventory, fuzz, differential, or validate")), + }; + match result { Ok(json) => { println!("{json}"); ExitCode::SUCCESS }, Err(error) => { eprintln!("ldraw-compat-harness: {error}"); ExitCode::FAILURE } } +} + +fn corpus_report() -> Result { + let fixtures = inventory(); + validate_inventory(&fixtures)?; + let coverage = coverage_report(&fixtures); + let report = HarnessReport { schema_version: SCHEMA_VERSION, command: "inventory".into(), fixtures: coverage.fixture_count, bytes: coverage.byte_count, gates: GATE_NAMES.len(), profiles: coverage.profiles.len(), seeds: Vec::new(), iterations: 0, adapter: evidence(), status: "pass" }; + serde_json::to_string_pretty(&report).map_err(|e| e.to_string()) +} +fn validate() -> Result { + let fixtures = inventory(); + validate_inventory(&fixtures)?; + if LDRAW_LIMITS.iter().any(|(name, limit)| *limit == 0 || name.is_empty()) { return Err("invalid semantic limit".into()); } + let adapter = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: PYTHON_VERSION.into(), network: false }; + adapter.validate().map_err(|e| e.to_string())?; + Ok("{\"status\":\"pass\",\"provenance\":\"validated\",\"pins\":\"validated\"}".into()) +} +fn fuzz_report() -> Result { + let fixtures = inventory(); + validate_inventory(&fixtures)?; + let seeds = [0xD0_u64, 0x77_u64, 0x86_u64]; + let iterations = 128; + for seed in seeds { deterministic_smoke(seed, iterations, &fixtures)?; } + let report = HarnessReport { schema_version: SCHEMA_VERSION, command: "fuzz".into(), fixtures: fixtures.len(), bytes: fixtures.iter().map(|f| f.bytes.len()).sum(), gates: GATE_NAMES.len(), profiles: 3, seeds: seeds.to_vec(), iterations, adapter: evidence(), status: "pass" }; + serde_json::to_string_pretty(&report).map_err(|e| e.to_string()) +} +fn deterministic_smoke(seed: u64, iterations: u32, fixtures: &[FixtureCase]) -> Result<(), String> { + let mut state = seed; + for step in 0..iterations { state = state.wrapping_mul(6364136223846793005).wrapping_add(1); let fixture = &fixtures[(state as usize) % fixtures.len()]; let digest = Sha256::digest(fixture.bytes); if hex_digest(fixture.bytes) != digest.iter().map(|b| format!("{b:02x}")).collect::() { return Err(format!("digest instability at seed {seed}, iteration {step}")); } } + Ok(()) +} +fn differential_report() -> Result { + let fixtures = inventory(); + validate_inventory(&fixtures)?; + let adapter = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: PYTHON_VERSION.into(), network: false }; + let mut exercised = 0; + let mut unsupported = Vec::new(); + for fixture in &fixtures { match adapter.run(fixture, 2_000) { Ok(bytes) => { serde_json::from_slice::(&bytes).map_err(|e| e.to_string())?; exercised += 1; }, Err(HarnessError::UnsupportedSurface(_)) => unsupported.push(fixture.id), Err(e) => return Err(e.to_string()) } } + let value = serde_json::json!({"status":"pass","schema_version":SCHEMA_VERSION,"adapter":evidence(),"exercised":exercised,"unsupported_by_ldparse":unsupported,"required_surface":["types_0_to_5","raw_tails_spans","mpd_file_nofile","graph_identity","bfc_invertnext","steps","finite_numeric_diagnostics"],"ledger_rows":["LD-OUTSIDE-TEXMAP","LD-OUTSIDE-CONDITIONAL","LD-OUTSIDE-COLOUR-RESOLUTION"]}); + serde_json::to_string_pretty(&value).map_err(|e| e.to_string()) +} +fn evidence() -> AdapterEvidence { AdapterEvidence { kind: "out-of-process:ldparse-driver", interpreter: PYTHON_VERSION, master_revision: LD_PARSE_MASTER, modernization_revision: LD_PARSE_MODERNIZATION, network: false, timeout_ms: 2_000 } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] fn pins_reject_drift() { let bad = LDParseAdapter { revision: "floating".into(), python: PYTHON_VERSION.into(), network: false }; assert!(matches!(bad.validate(), Err(HarnessError::WrongRevision(_)))); } + #[test] fn interpreter_and_network_are_validated() { let bad = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: "3.13".into(), network: true }; assert!(matches!(bad.validate(), Err(HarnessError::WrongInterpreter(_)))); } + #[test] fn timeout_is_killable_boundary() { let adapter = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: PYTHON_VERSION.into(), network: false }; let case = inventory().remove(0); assert!(matches!(adapter.run(&case, 0), Err(HarnessError::Timeout(0)))); } + #[test] fn differential_driver_output_is_machine_readable() { let adapter = LDParseAdapter { revision: LD_PARSE_MASTER.into(), python: PYTHON_VERSION.into(), network: false }; let case = inventory().remove(0); let bytes = adapter.run(&case, 1).unwrap(); assert_eq!(serde_json::from_slice::(&bytes).unwrap()["fixture"], "syntax-types"); } +} diff --git a/tools/ldraw-compat-harness/uv.lock b/tools/ldraw-compat-harness/uv.lock new file mode 100644 index 0000000..1da08aa --- /dev/null +++ b/tools/ldraw-compat-harness/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = "==3.12.8" + +[[package]] +name = "pm77-ldraw-compat-harness" +version = "0.1.0" +source = { virtual = "." } diff --git a/tools/ldraw-compat-harness/vendor/ldparse-master/REVISION b/tools/ldraw-compat-harness/vendor/ldparse-master/REVISION new file mode 100644 index 0000000..3f92450 --- /dev/null +++ b/tools/ldraw-compat-harness/vendor/ldparse-master/REVISION @@ -0,0 +1,3 @@ +877667417d6cab57c04aed9cb3d5bfe7efa0589c +source=https://github.com/taliesin/LDParse +artifact=source revision evidence; acquisition is offline and hash-verified before use diff --git a/tools/ldraw-compat-harness/vendor/ldparse-modernization/REVISION b/tools/ldraw-compat-harness/vendor/ldparse-modernization/REVISION new file mode 100644 index 0000000..84a22db --- /dev/null +++ b/tools/ldraw-compat-harness/vendor/ldparse-modernization/REVISION @@ -0,0 +1,3 @@ +0fe78dcee22982f26cceced1b483340faa4d04e9 +source=https://github.com/taliesin/LDParse +artifact=source revision evidence; acquisition is offline and hash-verified before use