diff --git a/.github/workflows/owen-cli-release.yml b/.github/workflows/owen-cli-release.yml index 76b4f494..4638b987 100644 --- a/.github/workflows/owen-cli-release.yml +++ b/.github/workflows/owen-cli-release.yml @@ -60,6 +60,12 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + # This job runs the whole Python suite below, which includes + # tests/test_checkpoint_status.py: it verifies that a recorded + # mutation campaign names a commit that exists and is an ancestor of + # HEAD, and a depth-1 checkout cannot answer that. ci.yml's tests job + # takes the history for the same reason. + fetch-depth: 0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" diff --git a/docs/evidence/p022-shadow-cp1.json b/docs/evidence/p022-shadow-cp1.json new file mode 100644 index 00000000..efaaf167 --- /dev/null +++ b/docs/evidence/p022-shadow-cp1.json @@ -0,0 +1,406 @@ +{ + "schema": 1, + "campaign": "p022-shadow-cp1", + "description": "The mutation campaign for P-022 step 7a checkpoint 1 (shadow-mode infrastructure layer 0: the same-input capture and the reproduction artifact). Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test, because P-022 discipline 2 makes a test evidence only once its mutation fails through the surface it claims to protect. Both layers run for every mutation (discipline 3: no fail-fast), so the recorded result names every catching layer, not the first. M00 is the harness-honesty control.", + "layers": [ + { + "id": "python", + "cwd": ".", + "command": [ + "python3", + "tests/test_repro_fixtures.py" + ], + "parser": "python-fail" + }, + { + "id": "rust", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-unit", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--lib", + "--no-fail-fast" + ], + "parser": "cargo" + } + ], + "control": { + "id": "M00", + "description": "harness-honesty control: no mutation at all, which must report zero failing layers" + }, + "mutations": [ + { + "id": "M01", + "description": "the reference's canonical form stops sorting keys", + "rule": "canonical: keys sorted by code point", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ value,\\ sort_keys=True,\\ separators=\\(\",\",\\ \":\"\\),\\ ensure_ascii=False", + "replacement": " value, sort_keys=False, separators=(\",\", \":\"), ensure_ascii=False", + "expected_catchers": [ + "python::artifact-golden", + "python::digest-ledger" + ] + }, + { + "id": "M02", + "description": "the reference's canonical form escapes non-ASCII instead of emitting it raw", + "rule": "canonical: every code point outside the C0 rule is raw", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ value,\\ sort_keys=True,\\ separators=\\(\",\",\\ \":\"\\),\\ ensure_ascii=False", + "replacement": " value, sort_keys=True, separators=(\",\", \":\"), ensure_ascii=True", + "expected_catchers": [ + "python::artifact-golden", + "python::digest-ledger" + ] + }, + { + "id": "M03", + "description": "the reference's canonical form re-introduces insignificant whitespace", + "rule": "canonical: no insignificant whitespace", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ value,\\ sort_keys=True,\\ separators=\\(\",\",\\ \":\"\\),\\ ensure_ascii=False", + "replacement": " value, sort_keys=True, separators=(\", \", \": \"), ensure_ascii=False", + "expected_catchers": [ + "python::artifact-golden", + "python::digest-ledger" + ] + }, + { + "id": "M04", + "description": "the reference stops refusing the literal -0", + "rule": "canonical domain: -0 is where the two parsers disagree about what parsing means", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ if\\ value\\ ==\\ 0\\ and\\ literal\\.lstrip\\(\\)\\.startswith\\(\"\\-\"\\):", + "replacement": " if False and value == 0 and literal.lstrip().startswith(\"-\"):", + "expected_catchers": [ + "python::domain-refusal" + ] + }, + { + "id": "M05", + "description": "the reference stops bounding integer literals to signed 64 bits", + "rule": "canonical domain: integers lie in [-2**63, 2**63-1]", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ value\\ =\\ int\\(literal\\)\\\n\\ \\ \\ \\ if\\ not\\ \\(_I64_MIN\\ <=\\ value\\ <=\\ _I64_MAX\\):", + "replacement": " value = int(literal)\n if False:", + "expected_catchers": [ + "python::domain-refusal-reason" + ] + }, + { + "id": "M06", + "description": "the reference accepts a float literal instead of refusing it", + "rule": "canonical domain: the OwnIR vocabulary has no float", + "target": "ownlang/repro.py", + "pattern": "def\\ _parse_float_literal\\(literal:\\ str\\)\\ \\->\\ float:\\\n\\ \\ \\ \\ raise\\ ReproError\\(", + "replacement": "def _parse_float_literal(literal: str) -> float:\n return float(literal)\n raise ReproError(", + "expected_catchers": [ + "python::domain-refusal-reason" + ] + }, + { + "id": "M07", + "description": "the reference accepts NaN/Infinity, which serde_json rejects as invalid JSON", + "rule": "canonical domain: both engines must agree the document parses at all", + "target": "ownlang/repro.py", + "pattern": "def\\ _parse_constant\\(literal:\\ str\\)\\ \\->\\ float:\\\n\\ \\ \\ \\ raise\\ ReproError\\(", + "replacement": "def _parse_constant(literal: str) -> float:\n return float(\"nan\")\n raise ReproError(", + "expected_catchers": [ + "python::domain-refusal-reason" + ] + }, + { + "id": "M08", + "description": "the reference's VALUE-level domain backstop stops refusing a float", + "rule": "canonical domain: enforced at the value level too, for an already-parsed document", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ if\\ isinstance\\(value,\\ float\\):\\\n\\ \\ \\ \\ \\ \\ \\ \\ raise\\ ReproError\\(", + "replacement": " if isinstance(value, float):\n return\n raise ReproError(", + "expected_catchers": [ + "python::domain-backstop" + ] + }, + { + "id": "M09", + "description": "the reference's verification stops comparing the recomputed hash", + "rule": "artifact: the digest is a gate, recomputed from the embedded document", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ if\\ claimed\\ !=\\ actual:", + "replacement": " if False and claimed != actual:", + "expected_catchers": [ + "python::tamper-refusal" + ] + }, + { + "id": "M10", + "description": "the reference stops lifting a layer refusal into the envelope's status", + "rule": "artifact: one layer envelope, status produced|refused", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ error\\ =\\ doc\\.get\\(\"error\"\\)\\\n\\ \\ \\ \\ if\\ error\\ is\\ not\\ None:", + "replacement": " error = doc.get(\"error\")\n if False and error is not None:", + "expected_catchers": [ + "python::artifact-golden" + ] + }, + { + "id": "M11", + "description": "the reference stops lifting surface_version into the layer envelope", + "rule": "artifact: a refused layer still names the surface it refused on", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \"surface_version\":\\ surface_version,", + "replacement": " \"surface_version\": None,", + "expected_catchers": [ + "python::artifact-golden" + ] + }, + { + "id": "M12", + "description": "the reference stops carrying the document's own declared schema version", + "rule": "artifact: input.ownir_version is the document's declared version, verbatim", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \"ownir_version\":\\ facts\\.get\\(\"ownir_version\"\\),", + "replacement": " \"ownir_version\": None,", + "expected_catchers": [ + "python::artifact-golden" + ] + }, + { + "id": "M13", + "description": "the reference reorders the frozen layer vocabulary", + "rule": "artifact: layers are an ORDERED array in pipeline order", + "target": "ownlang/repro.py", + "pattern": "LAYER_ORDER:\\ tuple\\[str,\\ \\.\\.\\.\\]\\ =\\ \\(\"lowered\",\\ \"summaries\",\\ \"verdicts\"\\)", + "replacement": "LAYER_ORDER: tuple[str, ...] = (\"summaries\", \"lowered\", \"verdicts\")", + "expected_catchers": [ + "python::artifact-verify", + "python::capture-verify" + ] + }, + { + "id": "M14", + "description": "the reference's verification stops checking the frozen engine order", + "rule": "artifact: engines are an ORDERED array over the frozen vocabulary", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ elif\\ seen\\ and\\ ENGINE_ORDER\\.index\\(eid\\)\\ <\\ ENGINE_ORDER\\.index\\(seen\\[\\-1\\]\\):", + "replacement": " elif False and seen and ENGINE_ORDER.index(eid) < ENGINE_ORDER.index(seen[-1]):", + "expected_catchers": [ + "python::structural-control" + ] + }, + { + "id": "M15", + "description": "the reference renders the artifact with a different indent", + "rule": "artifact rendering: 2-space indent, byte-identical on re-run", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ return\\ json\\.dumps\\(project_repro\\(facts,\\ foreign\\),\\ indent=2,", + "replacement": " return json.dumps(project_repro(facts, foreign), indent=4,", + "expected_catchers": [ + "python::artifact-golden" + ] + }, + { + "id": "M16", + "description": "the port's canonical form stops sorting object keys", + "rule": "canonical: keys sorted by code point", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ sorted\\.sort_by\\(\\|a,\\ b\\|\\ a\\.0\\.cmp\\(\\&b\\.0\\)\\);", + "replacement": " sorted.sort_by(|a, b| b.0.cmp(&a.0));", + "expected_catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ] + }, + { + "id": "M17", + "description": "the port escapes U+007F, which the reference emits raw", + "rule": "canonical escape rule: U+007F is NOT escaped", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ c\\ if\\ u32::from\\(c\\)\\ <\\ 0x20\\ =>\\ \\{", + "replacement": " c if u32::from(c) < 0x20 || u32::from(c) == 0x7f => {", + "expected_catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest" + ] + }, + { + "id": "M18", + "description": "the port escapes control code points with uppercase hex", + "rule": "canonical escape rule: \\u00xx with LOWERCASE hex", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ let\\ _\\ =\\ write!\\(out,\\ \"\\\\\\\\u\\{:04x\\}\",\\ u32::from\\(c\\)\\);", + "replacement": " let _ = write!(out, \"\\\\\\\\u{:04X}\", u32::from(c));", + "expected_catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest" + ] + }, + { + "id": "M19", + "description": "the port renders the artifact with a different indent width", + "rule": "artifact rendering: 2-space indent, byte-for-byte with the reference", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ for\\ _\\ in\\ 0\\.\\.level\\.saturating_mul\\(2\\)\\ \\{", + "replacement": " for _ in 0..level.saturating_mul(4) {", + "expected_catchers": [ + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting", + "rust/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte" + ] + }, + { + "id": "M20", + "description": "the port renders an artifact's objects in sorted rather than document order", + "rule": "artifact rendering keeps DOCUMENT order while the hash sorts — two serializations, two jobs", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ out\\.push_str\\(\"\\{\\\\n\"\\);\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ for\\ \\(i,\\ \\(key,\\ value\\)\\)\\ in\\ entries\\.iter\\(\\)\\.enumerate\\(\\)\\ \\{", + "replacement": " out.push_str(\"{\\\\n\");\n let mut entries = entries.clone();\n entries.sort_by(|a, b| a.0.cmp(&b.0));\n for (i, (key, value)) in entries.iter().enumerate() {", + "expected_catchers": [ + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting", + "rust/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte" + ] + }, + { + "id": "M21", + "description": "the port resolves a duplicate key first-wins instead of last-wins", + "rule": "canonical: the parsed document is what CPython's dict would hold", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ if\\ let\\ Some\\(slot\\)\\ =\\ entries\\.iter_mut\\(\\)\\.find\\(\\|\\(k,\\ _\\)\\|\\ \\*k\\ ==\\ key\\)\\ \\{\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ slot\\.1\\ =\\ value;\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\}\\ else\\ \\{", + "replacement": " if entries.iter().any(|(k, _)| *k == key) {\n } else {", + "expected_catchers": [ + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting" + ] + }, + { + "id": "M22", + "description": "the port wraps an out-of-i64 integer instead of refusing it", + "rule": "canonical domain: integers lie in [-2**63, 2**63-1]", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ i64::try_from\\(v\\)\\.map\\(Json::Int\\)\\.map_err\\(\\|_\\|\\ \\{", + "replacement": " #[allow(clippy::cast_possible_wrap)]\n return Ok(Json::Int(v as i64));\n #[allow(unreachable_code)]\n i64::try_from(v).map(Json::Int).map_err(|_| {", + "expected_catchers": [ + "rust/tests/repro.rs::every_declared_unnameable_document_is_refused", + "rust/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse" + ] + }, + { + "id": "M23", + "description": "the port accepts a float as an integer instead of refusing it", + "rule": "canonical domain: the OwnIR vocabulary has no float", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ fn\\ visit_f64\\(self,\\ v:\\ f64\\)\\ \\->\\ Result\\ \\{", + "replacement": " #[allow(clippy::cast_possible_truncation)]\n fn visit_f64(self, v: f64) -> Result {\n return Ok(Json::Int(v as i64));\n #[allow(unreachable_code)]", + "expected_catchers": [ + "rust/tests/repro.rs::every_declared_unnameable_document_is_refused", + "rust/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse" + ] + }, + { + "id": "M24", + "description": "the port's verification stops comparing the recomputed digest", + "rule": "artifact: the digest is a gate, recomputed from the embedded document", + "target": "rust/crates/own-shadow/src/artifact.rs", + "pattern": "\\ \\ \\ \\ let\\ matches\\ =\\ algorithm\\ ==\\ Some\\(CANONICAL_ALGORITHM\\)", + "replacement": " let matches = true || algorithm == Some(CANONICAL_ALGORITHM)", + "expected_catchers": [ + "rust/tests/repro.rs::a_changed_byte_in_the_embedded_document_is_refused" + ] + }, + { + "id": "M25", + "description": "the port's verification stops checking the frozen layer order", + "rule": "artifact: every engine reports exactly the frozen layers, in that order", + "target": "rust/crates/own-shadow/src/artifact.rs", + "pattern": "\\ \\ \\ \\ if\\ names\\ !=\\ expected\\ \\{", + "replacement": " if false && names != expected {", + "expected_catchers": [ + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ] + }, + { + "id": "M26", + "description": "the port's verification stops checking the frozen engine order", + "rule": "artifact: engines are an ORDERED array over the frozen vocabulary", + "target": "rust/crates/own-shadow/src/artifact.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ if\\ rank\\(id\\)\\ <\\ rank\\(previous\\)\\ \\{", + "replacement": " if false && rank(id) < rank(previous) {", + "expected_catchers": [ + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ] + }, + { + "id": "M27", + "description": "the port renders the digest with uppercase hex", + "rule": "canonical hash: SHA-256, LOWERCASE hex", + "target": "rust/crates/own-shadow/src/canonical.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ let\\ _\\ =\\ write!\\(digest,\\ \"\\{byte:02x\\}\"\\);", + "replacement": " let _ = write!(digest, \"{byte:02X}\");", + "expected_catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ] + }, + { + "id": "M28", + "description": "the port hashes the RENDERING form instead of the canonical form", + "rule": "canonical: the hash is taken over the canonical bytes, not over the artifact's rendering", + "target": "rust/crates/own-shadow/src/canonical.rs", + "pattern": "\\ \\ \\ \\ value\\.to_canonical\\(\\)\\.into_bytes\\(\\)", + "replacement": " value.to_pretty().into_bytes()", + "expected_catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest", + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ] + }, + { + "id": "M29", + "description": "the port reorders the frozen layer vocabulary", + "rule": "artifact: layers are an ORDERED array in pipeline order", + "target": "rust/crates/own-shadow/src/artifact.rs", + "pattern": "pub\\ const\\ LAYER_ORDER:\\ \\[\\&str;\\ 3\\]\\ =\\ \\[\"lowered\",\\ \"summaries\",\\ \"verdicts\"\\];", + "replacement": "pub const LAYER_ORDER: [&str; 3] = [\"summaries\", \"lowered\", \"verdicts\"];", + "expected_catchers": [ + "rust/tests/engine.rs::both_engines_report_the_same_layers_in_the_same_order", + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ] + }, + { + "id": "M30", + "description": "the port's `has` stops distinguishing a present member from an absent one", + "rule": "artifact: document present exactly when produced, error exactly when refused", + "target": "rust/crates/own-shadow/src/json.rs", + "pattern": "\\ \\ \\ \\ pub\\ fn\\ has\\(\\&self,\\ key:\\ \\&str\\)\\ \\->\\ bool\\ \\{\\\n\\ \\ \\ \\ \\ \\ \\ \\ self\\.get\\(key\\)\\.is_some\\(\\)\\\n\\ \\ \\ \\ \\}", + "replacement": " pub fn has(&self, key: &str) -> bool {\n let _ = key;\n true\n }", + "expected_catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ] + } + ] +} diff --git a/docs/evidence/p022-shadow-cp1.result.json b/docs/evidence/p022-shadow-cp1.result.json new file mode 100644 index 00000000..521f51e3 --- /dev/null +++ b/docs/evidence/p022-shadow-cp1.result.json @@ -0,0 +1,298 @@ +{ + "schema": 1, + "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", + "campaign": "p022-shadow-cp1", + "definition": "docs/evidence/p022-shadow-cp1.json", + "definition_sha256": "a5df997a1d84effc16dbbb3a37f0b8cb73b5ee810faa9eae042e969b591b79b0", + "source_commit": "0cdbd0f4410bb5ee4a418337f567e515f4146b3b", + "dirty": false, + "recorded_at": "2026-09-06T13:21:42Z", + "layers": [ + "python", + "rust", + "rust-unit" + ], + "command": "every layer the definition declares, for every mutation", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 0.5 + }, + "mutations": [ + { + "id": "M01", + "outcome": "caught", + "catchers": [ + "python::artifact-golden", + "python::digest-ledger" + ], + "elapsed_seconds": 0.5 + }, + { + "id": "M02", + "outcome": "caught", + "catchers": [ + "python::artifact-golden", + "python::digest-ledger" + ], + "elapsed_seconds": 9.8 + }, + { + "id": "M03", + "outcome": "caught", + "catchers": [ + "python::artifact-golden", + "python::digest-ledger" + ], + "elapsed_seconds": 1.2 + }, + { + "id": "M04", + "outcome": "caught", + "catchers": [ + "python::domain-refusal" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M05", + "outcome": "caught", + "catchers": [ + "python::domain-refusal-reason" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M06", + "outcome": "caught", + "catchers": [ + "python::domain-refusal-reason" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M07", + "outcome": "caught", + "catchers": [ + "python::domain-refusal-reason" + ], + "elapsed_seconds": 1.2 + }, + { + "id": "M08", + "outcome": "caught", + "catchers": [ + "python::domain-backstop" + ], + "elapsed_seconds": 1.0 + }, + { + "id": "M09", + "outcome": "caught", + "catchers": [ + "python::tamper-refusal" + ], + "elapsed_seconds": 1.2 + }, + { + "id": "M10", + "outcome": "caught", + "catchers": [ + "python::artifact-golden" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M11", + "outcome": "caught", + "catchers": [ + "python::artifact-golden" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M12", + "outcome": "caught", + "catchers": [ + "python::artifact-golden" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M13", + "outcome": "caught", + "catchers": [ + "python::artifact-verify", + "python::capture-verify", + "python::reduction-golden" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M14", + "outcome": "caught", + "catchers": [ + "python::structural-control" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M15", + "outcome": "caught", + "catchers": [ + "python::artifact-golden" + ], + "elapsed_seconds": 1.1 + }, + { + "id": "M16", + "outcome": "caught", + "catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.5 + }, + { + "id": "M17", + "outcome": "caught", + "catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest" + ], + "elapsed_seconds": 1.4 + }, + { + "id": "M18", + "outcome": "caught", + "catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest" + ], + "elapsed_seconds": 1.4 + }, + { + "id": "M19", + "outcome": "caught", + "catchers": [ + "rust/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting", + "rust/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte" + ], + "elapsed_seconds": 1.4 + }, + { + "id": "M20", + "outcome": "caught", + "catchers": [ + "rust/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting", + "rust/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte" + ], + "elapsed_seconds": 1.6 + }, + { + "id": "M21", + "outcome": "caught", + "catchers": [ + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting" + ], + "elapsed_seconds": 1.4 + }, + { + "id": "M22", + "outcome": "caught", + "catchers": [ + "rust/tests/repro.rs::every_declared_unnameable_document_is_refused", + "rust/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse" + ], + "elapsed_seconds": 1.3 + }, + { + "id": "M23", + "outcome": "caught", + "catchers": [ + "rust/tests/repro.rs::every_declared_unnameable_document_is_refused", + "rust/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse" + ], + "elapsed_seconds": 1.3 + }, + { + "id": "M24", + "outcome": "caught", + "catchers": [ + "rust/tests/repro.rs::a_changed_byte_in_the_embedded_document_is_refused" + ], + "elapsed_seconds": 1.5 + }, + { + "id": "M25", + "outcome": "caught", + "catchers": [ + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.6 + }, + { + "id": "M26", + "outcome": "caught", + "catchers": [ + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.6 + }, + { + "id": "M27", + "outcome": "caught", + "catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.4 + }, + { + "id": "M28", + "outcome": "caught", + "catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest", + "rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.4 + }, + { + "id": "M29", + "outcome": "caught", + "catchers": [ + "rust/tests/engine.rs::both_engines_report_the_same_layers_in_the_same_order", + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.6 + }, + { + "id": "M30", + "outcome": "caught", + "catchers": [ + "rust/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust/tests/reduce.rs::the_reducer_is_silent_on_unchanged_data_and_names_a_synthetic_divergence", + "rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies", + "rust/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.5 + } + ] +} diff --git a/docs/evidence/p022-shadow-cp2.json b/docs/evidence/p022-shadow-cp2.json new file mode 100644 index 00000000..37847a90 --- /dev/null +++ b/docs/evidence/p022-shadow-cp2.json @@ -0,0 +1,186 @@ +{ + "schema": 1, + "campaign": "p022-shadow-cp2", + "description": "The mutation campaign for P-022 step 7a checkpoint 2 (the engine protocol: how each engine reports its per-layer outputs, and what it declares it could produce). Separate from the cp1 campaign on purpose — each checkpoint's evidence stays frozen at what it measured, so a later checkpoint cannot quietly restate an earlier one's numbers. Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test. Three layers run for every mutation (discipline 3: no fail-fast), including the port's own engine suite. M00 is the harness-honesty control.", + "layers": [ + { + "id": "python", + "cwd": ".", + "command": [ + "python3", + "tests/test_repro_fixtures.py" + ], + "parser": "python-fail" + }, + { + "id": "rust-repro", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "repro", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-engine", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "engine", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-unit", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--lib", + "--no-fail-fast" + ], + "parser": "cargo" + } + ], + "control": { + "id": "M00", + "description": "harness-honesty control: no mutation at all, which must report zero failing layers" + }, + "mutations": [ + { + "id": "M31", + "description": "the reference stops declaring a projection on its layers", + "rule": "engine protocol: every layer declares what its engine could produce", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \"projection\":\\ dict\\(projection\\)\\ if\\ projection\\ else\\ dict\\(FULL\\),", + "replacement": " \"projection\": dict(projection) if projection else {\"kind\": \"partial\"},", + "expected_catchers": [ + "python::artifact-golden", + "python::capture-verify" + ] + }, + { + "id": "M32", + "description": "the reference silently drops the foreign engine entries when regenerating", + "rule": "engine protocol: an engine writes only its own entry, and carries foreign ones through", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ if\\ isinstance\\(entry,\\ dict\\)\\ and\\ entry\\.get\\(\"id\"\\)\\ !=\\ ENGINE_PYTHON:", + "replacement": " if False and isinstance(entry, dict) and entry.get(\"id\") != ENGINE_PYTHON:", + "expected_catchers": [ + "python::artifact-golden" + ] + }, + { + "id": "M33", + "description": "the reference's verification accepts a partial projection that names no members", + "rule": "engine protocol: a partial projection must NAME the members it carries", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ elif\\ kind\\ ==\\ PROJECTION_PARTIAL:\\\n\\ \\ \\ \\ \\ \\ \\ \\ members\\ =\\ projection\\.get\\(\"members\"\\)", + "replacement": " elif kind == PROJECTION_PARTIAL:\n return problems\n members = projection.get(\"members\")", + "expected_catchers": [ + "python::structural-control" + ] + }, + { + "id": "M34", + "description": "the reference's verification accepts a 'full' projection that also names members", + "rule": "engine protocol: a full projection emits the whole surface and claims nothing else", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ if\\ kind\\ ==\\ PROJECTION_FULL:\\\n\\ \\ \\ \\ \\ \\ \\ \\ for\\ name\\ in\\ \\(\"members\",\\ \"reason\"\\):", + "replacement": " if kind == PROJECTION_FULL:\n return problems\n for name in (\"members\", \"reason\"):", + "expected_catchers": [ + "python::structural-control" + ] + }, + { + "id": "M35", + "description": "the port declares its verdict layer FULL while it is at the checkpoint-4 projection", + "rule": "engine protocol: a projection that over-claims is exactly what the field exists to prevent", + "target": "rust/crates/own-shadow/src/engine.rs", + "pattern": "\\ \\ \\ \\ let\\ projection\\ =\\ partial_projection\\(\\&VERDICT_MEMBERS,\\ VERDICT_PROJECTION_REASON\\);", + "replacement": " let projection = full_projection();", + "expected_catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ] + }, + { + "id": "M36", + "description": "the port drops `column` from its verdict records while still claiming it", + "rule": "engine protocol: a projection names exactly the members its documents carry", + "target": "rust/crates/own-shadow/src/engine.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\(\"column\",\\ f\\.column\\.map_or\\(Json::Null,\\ Json::Int\\)\\),", + "replacement": "", + "expected_catchers": [ + "rust-engine/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries", + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ] + }, + { + "id": "M37", + "description": "the port stamps a refused layer with the 'produced' status", + "rule": "artifact: status is produced or refused, and a refusal carries no document", + "target": "rust/crates/own-shadow/src/engine.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\(\"status\",\\ Json::Str\\(STATUS_REFUSED\\.to_owned\\(\\)\\)\\),", + "replacement": " (\"status\", Json::Str(STATUS_PRODUCED.to_owned())),", + "expected_catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ] + }, + { + "id": "M38", + "description": "the port claims a surface version for the MOS dump, which has none", + "rule": "artifact: surface_version is null when the surface has none — absence is data", + "target": "rust/crates/own-shadow/src/engine.rs", + "pattern": "\\ \\ \\ \\ Ok\\(produced\\(\"summaries\",\\ Json::Null,\\ full_projection\\(\\),\\ value\\)\\)", + "replacement": " Ok(produced(\"summaries\", Json::Int(1), full_projection(), value))", + "expected_catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ] + }, + { + "id": "M39", + "description": "the port reports a typed-door refusal on one layer instead of all three", + "rule": "artifact: every engine reports exactly the frozen layers, in that order", + "target": "rust/crates/own-shadow/src/engine.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ LAYER_ORDER\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\.iter\\(\\)\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\.map\\(\\|layer\\|\\ refused\\(layer,\\ Json::Null,\\ full_projection\\(\\),\\ \\&text\\)\\)\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\.collect\\(\\)", + "replacement": " LAYER_ORDER\n .iter()\n .take(1)\n .map(|layer| refused(layer, Json::Null, full_projection(), &text))\n .collect()", + "expected_catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ] + }, + { + "id": "M40", + "description": "the port's verification stops rejecting a 'full' projection that names members", + "rule": "engine protocol: a full projection emits the whole surface and claims nothing else", + "target": "rust/crates/own-shadow/src/artifact.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ if\\ projection\\.has\\(name\\)\\ \\{", + "replacement": " if false && projection.has(name) {", + "expected_catchers": [ + "rust-repro/tests/repro.rs::verify_refuses_each_structural_violation" + ] + }, + { + "id": "M41", + "description": "the port's verification accepts an EMPTY reason on a partial projection", + "rule": "engine protocol: a partial projection must say WHY the remaining members are absent", + "target": "rust/crates/own-shadow/src/artifact.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\.is_some_and\\(\\|r\\|\\ !r\\.is_empty\\(\\)\\)", + "replacement": " .is_some_and(|r| r.is_empty() || !r.is_empty())", + "expected_catchers": [ + "rust-repro/tests/repro.rs::verify_refuses_each_structural_violation" + ] + } + ] +} diff --git a/docs/evidence/p022-shadow-cp2.result.json b/docs/evidence/p022-shadow-cp2.result.json new file mode 100644 index 00000000..06ddcaf2 --- /dev/null +++ b/docs/evidence/p022-shadow-cp2.result.json @@ -0,0 +1,115 @@ +{ + "schema": 1, + "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", + "campaign": "p022-shadow-cp2", + "definition": "docs/evidence/p022-shadow-cp2.json", + "definition_sha256": "fdeb494e11d5169f07bafdda29fc1338ab99b78243505043cc82607e0e7f5a60", + "source_commit": "0cdbd0f4410bb5ee4a418337f567e515f4146b3b", + "dirty": false, + "recorded_at": "2026-09-06T13:22:33Z", + "layers": [ + "python", + "rust-repro", + "rust-engine", + "rust-unit" + ], + "command": "every layer the definition declares, for every mutation", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 1.7 + }, + "mutations": [ + { + "id": "M31", + "outcome": "caught", + "catchers": [ + "python::artifact-golden", + "python::capture-verify" + ], + "elapsed_seconds": 0.5 + }, + { + "id": "M32", + "outcome": "caught", + "catchers": [ + "python::artifact-golden" + ], + "elapsed_seconds": 1.5 + }, + { + "id": "M33", + "outcome": "caught", + "catchers": [ + "python::structural-control" + ], + "elapsed_seconds": 1.5 + }, + { + "id": "M34", + "outcome": "caught", + "catchers": [ + "python::structural-control" + ], + "elapsed_seconds": 1.5 + }, + { + "id": "M35", + "outcome": "caught", + "catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 1.9 + }, + { + "id": "M36", + "outcome": "caught", + "catchers": [ + "rust-engine/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries", + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M37", + "outcome": "caught", + "catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 1.7 + }, + { + "id": "M38", + "outcome": "caught", + "catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 1.6 + }, + { + "id": "M39", + "outcome": "caught", + "catchers": [ + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M40", + "outcome": "caught", + "catchers": [ + "rust-repro/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 2.0 + }, + { + "id": "M41", + "outcome": "caught", + "catchers": [ + "rust-repro/tests/repro.rs::verify_refuses_each_structural_violation" + ], + "elapsed_seconds": 1.8 + } + ] +} diff --git a/docs/evidence/p022-shadow-cp3.json b/docs/evidence/p022-shadow-cp3.json new file mode 100644 index 00000000..5744058a --- /dev/null +++ b/docs/evidence/p022-shadow-cp3.json @@ -0,0 +1,203 @@ +{ + "schema": 1, + "campaign": "p022-shadow-cp3", + "description": "The mutation campaign for P-022 step 7a checkpoint 3 (the AnalysisTrace, #269: stable-ID normalization and declared per-layer ordering semantics). Separate from the cp1/cp2 campaigns on purpose — each checkpoint's evidence stays frozen at what it measured. Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test. Four layers run for every mutation (discipline 3: no fail-fast). M00 is the harness-honesty control.", + "layers": [ + { + "id": "python", + "cwd": ".", + "command": [ + "python3", + "tests/test_repro_fixtures.py" + ], + "parser": "python-fail" + }, + { + "id": "rust-repro", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "repro", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-engine", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "engine", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-trace", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "trace", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-unit", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--lib", + "--no-fail-fast" + ], + "parser": "cargo" + } + ], + "control": { + "id": "M00", + "description": "harness-honesty control: no mutation at all, which must report zero failing layers" + }, + "mutations": [ + { + "id": "M42", + "description": "the reference derives a handle's stable id from the counter instead of the record's identity", + "rule": "trace: internal identifiers are normalized away by IDENTITY, never by position", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ return\\ \"\\|\"\\.join\\(str\\(record\\.get\\(k,\\ \"\"\\)\\)\\ for\\ k\\ in\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\(\"component\",\\ \"file\",\\ \"line\",\\ \"event\",\\ \"handler\"\\)\\)", + "replacement": " return str(record.get(\"handle\", \"\"))", + "expected_catchers": [ + "python::" + ] + }, + { + "id": "M43", + "description": "the reference drops `line` from a handle's identity, fusing two facts on one line-distinct site", + "rule": "trace: the rename is a BIJECTION — it may not fuse two facts into one address", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\(\"component\",\\ \"file\",\\ \"line\",\\ \"event\",\\ \"handler\"\\)\\)", + "replacement": " (\"component\", \"file\", \"event\", \"handler\"))", + "expected_catchers": [ + "python::trace-golden" + ] + }, + { + "id": "M44", + "description": "the reference stops disambiguating a repeated address", + "rule": "trace: a duplicate address takes a ~ suffix, inside the bracket", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ return\\ address\\ if\\ n\\ ==\\ 0\\ else\\ f\"\\{address\\}\\~\\{n\\}\"", + "replacement": " return address", + "expected_catchers": [ + "python::trace-golden", + "python::trace-shape" + ] + }, + { + "id": "M45", + "description": "the reference declares the lowered layer's order canonical, licensing a sort", + "rule": "trace: order is DECLARED, never normalized away — lowered order is semantic (BR-D4/BR-L5)", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \"lowered\":\\ ORDER_SIGNIFICANT,\\ \\ \\ \\ \\#\\ BR\\-D4\\ /\\ BR\\-L5:\\ document\\ \\+\\ lowering\\ order", + "replacement": " \"lowered\": ORDER_CANONICAL, # BR-D4 / BR-L5: document + lowering order", + "expected_catchers": [ + "python::trace-golden" + ] + }, + { + "id": "M46", + "description": "the reference declares the verdict layer's order canonical, hiding a tie-order defect", + "rule": "trace: BR-V8 leaves ties in construction order, so position carries information", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \"verdicts\":\\ ORDER_SIGNIFICANT,\\ \\ \\ \\#\\ BR\\-V8:\\ ties\\ stay\\ in\\ construction\\ order", + "replacement": " \"verdicts\": ORDER_CANONICAL, # BR-V8: ties stay in construction order", + "expected_catchers": [ + "python::trace-golden" + ] + }, + { + "id": "M47", + "description": "the reference narrows the minted-handle pattern so `loc_` names leak through unrewritten", + "rule": "trace: the rename is TOTAL — no counter-shaped name survives", + "target": "ownlang/repro.py", + "pattern": "\\(sub\\|cap\\|parg\\|loc\\)_", + "replacement": "(sub|cap|parg)_", + "expected_catchers": [ + "python::trace-golden", + "python::trace-normalization" + ] + }, + { + "id": "M48", + "description": "the reference gives a refused layer an empty step list AND drops its error", + "rule": "trace: a refused layer carries its error and no steps", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ out\\[\"error\"\\]\\ =\\ layer\\.get\\(\"error\"\\)\\\n\\ \\ \\ \\ \\ \\ \\ \\ out\\[\"steps\"\\]\\ =\\ \\[\\]", + "replacement": " out[\"error\"] = None\n out[\"steps\"] = []", + "expected_catchers": [ + "python::trace-golden" + ] + }, + { + "id": "M49", + "description": "the port derives a handle's stable id from the counter instead of the record's identity", + "rule": "trace: internal identifiers are normalized away by IDENTITY, never by position", + "target": "rust/crates/own-shadow/src/trace.rs", + "pattern": "\\ \\ \\ \\ \\[\"component\",\\ \"file\",\\ \"line\",\\ \"event\",\\ \"handler\"\\]", + "replacement": " [\"handle\"]", + "expected_catchers": [ + "rust-trace/tests/trace.rs::a_mint_order_shift_moves_the_order_but_not_the_stable_ids", + "rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte", + "rust-unit/src/lib.rs::trace::tests::a_fully_listed_document_normalizes" + ] + }, + { + "id": "M50", + "description": "the port declares the lowered layer's order canonical, licensing a sort", + "rule": "trace: order is DECLARED, never normalized away", + "target": "rust/crates/own-shadow/src/trace.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \"summaries\"\\ =>\\ ORDER_CANONICAL,\\\n\\ \\ \\ \\ \\ \\ \\ \\ _\\ =>\\ ORDER_SIGNIFICANT,", + "replacement": " \"summaries\" | \"lowered\" => ORDER_CANONICAL,\n _ => ORDER_SIGNIFICANT,", + "expected_catchers": [ + "rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte" + ] + }, + { + "id": "M51", + "description": "the port stops asserting that the handle rewrite is total", + "rule": "trace: a claim nothing can falsify is not a contract", + "target": "rust/crates/own-shadow/src/trace.rs", + "pattern": "\\ \\ \\ \\ if\\ leftovers\\.is_empty\\(\\)\\ \\{", + "replacement": " if true || leftovers.is_empty() {", + "expected_catchers": [ + "rust-unit/src/lib.rs::trace::tests::a_handle_reference_the_rename_cannot_reach_is_refused" + ] + }, + { + "id": "M52", + "description": "the port drops the mint kind from the handle record", + "rule": "trace: the mint kind is preserved as a comparable VALUE so a routing difference stays one step", + "target": "rust/crates/own-shadow/src/trace.rs", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ fields\\.push\\(\\(\"mint\"\\.to_owned\\(\\),\\ Json::Str\\(mint\\)\\)\\);", + "replacement": " let _ = mint;", + "expected_catchers": [ + "rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte", + "rust-unit/src/lib.rs::trace::tests::a_fully_listed_document_normalizes" + ] + } + ] +} diff --git a/docs/evidence/p022-shadow-cp3.result.json b/docs/evidence/p022-shadow-cp3.result.json new file mode 100644 index 00000000..3bbed467 --- /dev/null +++ b/docs/evidence/p022-shadow-cp3.result.json @@ -0,0 +1,119 @@ +{ + "schema": 1, + "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", + "campaign": "p022-shadow-cp3", + "definition": "docs/evidence/p022-shadow-cp3.json", + "definition_sha256": "99e99aacfe3f4ac4e5dff92bc7a5cf07bd3826c843657c28b6f0d490e2534ffa", + "source_commit": "001f6fd3be4e73f4d3878dc430dd4507a90ca273", + "dirty": false, + "recorded_at": "2026-09-06T13:24:40Z", + "layers": [ + "python", + "rust-repro", + "rust-engine", + "rust-trace", + "rust-unit" + ], + "command": "every layer the definition declares, for every mutation", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 2.0 + }, + "mutations": [ + { + "id": "M42", + "outcome": "caught", + "catchers": [ + "python::" + ], + "elapsed_seconds": 0.5 + }, + { + "id": "M43", + "outcome": "caught", + "catchers": [ + "python::trace-golden" + ], + "elapsed_seconds": 1.7 + }, + { + "id": "M44", + "outcome": "caught", + "catchers": [ + "python::trace-golden", + "python::trace-shape" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M45", + "outcome": "caught", + "catchers": [ + "python::trace-golden" + ], + "elapsed_seconds": 1.7 + }, + { + "id": "M46", + "outcome": "caught", + "catchers": [ + "python::trace-golden" + ], + "elapsed_seconds": 1.7 + }, + { + "id": "M47", + "outcome": "caught", + "catchers": [ + "python::trace-golden", + "python::trace-normalization" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M48", + "outcome": "caught", + "catchers": [ + "python::trace-golden" + ], + "elapsed_seconds": 1.7 + }, + { + "id": "M49", + "outcome": "caught", + "catchers": [ + "rust-trace/tests/trace.rs::a_mint_order_shift_moves_the_order_but_not_the_stable_ids", + "rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte", + "rust-unit/src/lib.rs::trace::tests::a_fully_listed_document_normalizes" + ], + "elapsed_seconds": 2.0 + }, + { + "id": "M50", + "outcome": "caught", + "catchers": [ + "rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte" + ], + "elapsed_seconds": 1.9 + }, + { + "id": "M51", + "outcome": "caught", + "catchers": [ + "rust-unit/src/lib.rs::trace::tests::a_handle_reference_the_rename_cannot_reach_is_refused" + ], + "elapsed_seconds": 2.6 + }, + { + "id": "M52", + "outcome": "caught", + "catchers": [ + "rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte", + "rust-unit/src/lib.rs::trace::tests::a_fully_listed_document_normalizes" + ], + "elapsed_seconds": 2.6 + } + ] +} diff --git a/docs/evidence/p022-shadow-cp4.json b/docs/evidence/p022-shadow-cp4.json new file mode 100644 index 00000000..acdd3c77 --- /dev/null +++ b/docs/evidence/p022-shadow-cp4.json @@ -0,0 +1,202 @@ +{ + "schema": 1, + "campaign": "p022-shadow-cp4", + "description": "The mutation campaign for P-022 step 7a checkpoint 4 (first-divergence reduction over the lowered and MOS layers). Separate from cp1/cp2/cp3 on purpose — each checkpoint's evidence stays frozen at what it measured. Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test. Five layers run for every mutation (discipline 3: no fail-fast), including the crate's unit tests. M00 is the harness-honesty control.", + "layers": [ + { + "id": "python", + "cwd": ".", + "command": [ + "python3", + "tests/test_repro_fixtures.py" + ], + "parser": "python-fail" + }, + { + "id": "rust-repro", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "repro", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-trace", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "trace", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-reduce", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--test", + "reduce", + "--no-fail-fast" + ], + "parser": "cargo" + }, + { + "id": "rust-unit", + "cwd": "rust", + "command": [ + "cargo", + "test", + "-p", + "own-shadow", + "--lib", + "--no-fail-fast" + ], + "parser": "cargo" + } + ], + "control": { + "id": "M00", + "description": "harness-honesty control: no mutation at all, which must report zero failing layers" + }, + "mutations": [ + { + "id": "M53", + "description": "the reference widens the reduction scope to the verdict layer", + "rule": "reduction: comparing final diagnostics is #260's ACCEPTANCE, blocked by #259 — the scope is a contract, not a parameter", + "target": "ownlang/repro.py", + "pattern": "REDUCTION_SCOPE:\\ tuple\\[str,\\ \\.\\.\\.\\]\\ =\\ \\(\"lowered\",\\ \"summaries\"\\)", + "replacement": "REDUCTION_SCOPE: tuple[str, ...] = (\"lowered\", \"summaries\", \"verdicts\")", + "expected_catchers": [ + "python::reduction-control", + "python::reduction-golden" + ] + }, + { + "id": "M54", + "description": "the reference reports the whole step instead of the minimal difference inside it", + "rule": "reduction: the difference must be MINIMAL — the field, not the step body", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ if\\ key\\ in\\ left\\ and\\ key\\ in\\ right:\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ return\\ _minimal_difference\\(left\\[key\\],\\ right\\[key\\],\\ f\"\\{path\\}\\.\\{key\\}\"\\)", + "replacement": " if key in left and key in right:\n return path, left, right", + "expected_catchers": [ + "python::reduction-control" + ] + }, + { + "id": "M55", + "description": "the reference stops noticing a step only one engine addresses", + "rule": "reduction: left-only / right-only are two of the four content classes", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ if\\ sid\\ not\\ in\\ right_steps:", + "replacement": " if False and sid not in right_steps:", + "expected_catchers": [ + "python::" + ] + }, + { + "id": "M56", + "description": "the reference stops noticing an ordering-only difference", + "rule": "reduction: on a layer whose order is declared significant, the sequence IS the difference", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ if\\ left_order\\ !=\\ right_order:", + "replacement": " if False and left_order != right_order:", + "expected_catchers": [ + "python::reduction-control" + ] + }, + { + "id": "M57", + "description": "the reference compares two engines' refusal TEXTS, manufacturing a divergence out of message vocabulary", + "rule": "reduction: when both engines refused, compare THAT they refused, never how they phrased it", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ if\\ left\\.get\\(\"status\"\\)\\ ==\\ STATUS_REFUSED:", + "replacement": " if False and left.get(\"status\") == STATUS_REFUSED:", + "expected_catchers": [ + "python::reduction-control" + ] + }, + { + "id": "M58", + "description": "the reference reports the LAST divergence instead of the first", + "rule": "reduction: the reducer names the FIRST divergence in pipeline order", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \"first\":\\ observations\\[0\\]\\ if\\ observations\\ else\\ None,", + "replacement": " \"first\": observations[-1] if observations else None,", + "expected_catchers": [ + "python::reduction-golden" + ] + }, + { + "id": "M59", + "description": "the reference stops distinguishing a key-ORDER difference from agreement", + "rule": "reduction: object key order is significant — the surfaces fix field order byte-exactly", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ return\\ \\(list\\(left\\)\\ ==\\ list\\(right\\)\\\n\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ and\\ all\\(_same\\(left\\[k\\],\\ right\\[k\\]\\)\\ for\\ k\\ in\\ left\\)\\)", + "replacement": " return (set(left) == set(right)\n and all(_same(left[k], right[k]) for k in left))", + "expected_catchers": [ + "python::reduction-control" + ] + }, + { + "id": "M60", + "description": "the reference carries the MOS document in `dump_summaries`' insertion order rather than its surface's", + "rule": "capture: each layer document is carried in the key order its OWN surface fixes", + "target": "ownlang/repro.py", + "pattern": "\\ \\ \\ \\ summaries\\ =\\ json\\.loads\\(json\\.dumps\\(dump_summaries\\(facts\\),\\ sort_keys=True\\)\\)", + "replacement": " summaries = dump_summaries(facts)", + "expected_catchers": [ + "python::artifact-golden" + ] + }, + { + "id": "M61", + "description": "the port widens the reduction scope to the verdict layer", + "rule": "reduction: the scope is a contract, not a parameter", + "target": "rust/crates/own-shadow/src/reduce.rs", + "pattern": "pub\\ const\\ REDUCTION_SCOPE:\\ \\[\\&str;\\ 2\\]\\ =\\ \\[\"lowered\",\\ \"summaries\"\\];", + "replacement": "pub const REDUCTION_SCOPE: [&str; 3] = [\"lowered\", \"summaries\", \"verdicts\"];", + "expected_catchers": [ + "rust-reduce/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte", + "rust-reduce/tests/reduce.rs::the_reducer_is_silent_on_unchanged_data_and_names_a_synthetic_divergence", + "rust-reduce/tests/reduce.rs::the_verdict_layer_is_refused_not_silently_skipped", + "rust-reduce/tests/reduce.rs::two_engines_that_both_refused_a_layer_agree" + ] + }, + { + "id": "M62", + "description": "the port compares two engines' refusal texts instead of the fact that both refused", + "rule": "reduction: a refusal's text is each engine's own", + "target": "rust/crates/own-shadow/src/reduce.rs", + "pattern": "\\ \\ \\ \\ if\\ ls\\ ==\\ Some\\(STATUS_REFUSED\\)\\ \\{\\\n\\ \\ \\ \\ \\ \\ \\ \\ return\\ Vec::new\\(\\);\\\n\\ \\ \\ \\ \\}", + "replacement": " if false && ls == Some(STATUS_REFUSED) {\n return Vec::new();\n }", + "expected_catchers": [ + "rust-reduce/tests/reduce.rs::two_engines_that_both_refused_a_layer_agree" + ] + }, + { + "id": "M63", + "description": "the port reports the last divergence instead of the first", + "rule": "reduction: the reducer names the FIRST divergence in pipeline order", + "target": "rust/crates/own-shadow/src/reduce.rs", + "pattern": "\\ \\ \\ \\ let\\ first\\ =\\ observations\\.first\\(\\)\\.cloned\\(\\)\\.unwrap_or\\(Json::Null\\);", + "replacement": " let first = observations.last().cloned().unwrap_or(Json::Null);", + "expected_catchers": [ + "rust-reduce/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte" + ] + } + ] +} diff --git a/docs/evidence/p022-shadow-cp4.result.json b/docs/evidence/p022-shadow-cp4.result.json new file mode 100644 index 00000000..29578f0e --- /dev/null +++ b/docs/evidence/p022-shadow-cp4.result.json @@ -0,0 +1,118 @@ +{ + "schema": 1, + "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", + "campaign": "p022-shadow-cp4", + "definition": "docs/evidence/p022-shadow-cp4.json", + "definition_sha256": "ad87275b58dc706535d6ed25a31b8b735725a1f0ce217975563100379d5fb5d6", + "source_commit": "001f6fd3be4e73f4d3878dc430dd4507a90ca273", + "dirty": false, + "recorded_at": "2026-09-06T13:25:01Z", + "layers": [ + "python", + "rust-repro", + "rust-trace", + "rust-reduce", + "rust-unit" + ], + "command": "every layer the definition declares, for every mutation", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 1.9 + }, + "mutations": [ + { + "id": "M53", + "outcome": "caught", + "catchers": [ + "python::reduction-control", + "python::reduction-golden" + ], + "elapsed_seconds": 0.6 + }, + { + "id": "M54", + "outcome": "caught", + "catchers": [ + "python::reduction-control" + ], + "elapsed_seconds": 1.7 + }, + { + "id": "M55", + "outcome": "caught", + "catchers": [ + "python::" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M56", + "outcome": "caught", + "catchers": [ + "python::reduction-control" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M57", + "outcome": "caught", + "catchers": [ + "python::reduction-control" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M58", + "outcome": "caught", + "catchers": [ + "python::reduction-golden" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M59", + "outcome": "caught", + "catchers": [ + "python::reduction-control" + ], + "elapsed_seconds": 1.8 + }, + { + "id": "M60", + "outcome": "caught", + "catchers": [ + "python::artifact-golden" + ], + "elapsed_seconds": 1.7 + }, + { + "id": "M61", + "outcome": "caught", + "catchers": [ + "rust-reduce/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte", + "rust-reduce/tests/reduce.rs::the_reducer_is_silent_on_unchanged_data_and_names_a_synthetic_divergence", + "rust-reduce/tests/reduce.rs::the_verdict_layer_is_refused_not_silently_skipped", + "rust-reduce/tests/reduce.rs::two_engines_that_both_refused_a_layer_agree" + ], + "elapsed_seconds": 1.9 + }, + { + "id": "M62", + "outcome": "caught", + "catchers": [ + "rust-reduce/tests/reduce.rs::two_engines_that_both_refused_a_layer_agree" + ], + "elapsed_seconds": 1.9 + }, + { + "id": "M63", + "outcome": "caught", + "catchers": [ + "rust-reduce/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte" + ], + "elapsed_seconds": 2.1 + } + ] +} diff --git a/docs/generated/p022-shadow-census.md b/docs/generated/p022-shadow-census.md new file mode 100644 index 00000000..79ed3f4c --- /dev/null +++ b/docs/generated/p022-shadow-census.md @@ -0,0 +1,180 @@ + + +# P-022 step 7a — shadow-mode infrastructure: census + +**Infrastructure for shadow mode, not shadow mode.** Nothing measured here +compares two engines' end diagnostics — or any of their layer *contents*. That +comparison is #260's acceptance and is blocked on #259 (cp5 and 4b). Nothing +here is a parity claim either. + +This document is the **live view** of the slice as it stands; the recorded +mutation campaigns are their own fragment +([`p022-shadow-mutations.md`](p022-shadow-mutations.md)), each frozen at what it +measured. Where the slice departed from the brief it was given — the checkpoint +grouping, the `-0` domain decision, the `sha2` dependency — the departures are +decisions on the record in +[the owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md), +which also states the byte-level boundary repeated in the unmeasured set below. + +## The measured set — same-input capture (checkpoint 1) + +| corpus | documents | +|---|---| +| `tests/fixtures/lowered` | 27 | +| `tests/fixtures/ownir` | 22 | +| `tests/fixtures/repro` | 3 | +| `tests/fixtures/summaries` | 9 | +| `tests/fixtures/verdicts` | 19 | +| **total** | **80** | + +Every one of those documents is canonicalized and hashed by the reference +(`ownlang/repro.py`) and re-hashed from the same file by the port +(`own-shadow`), which is what makes "both engines saw the same input" a +checked fact rather than an assumption — **at the level of canonical document +identity**. That is a weaker statement than #260's acceptance invariant, and +the difference is named in the unmeasured set below. + +| surface | count | +|---|---| +| documents captured and digest-pinned | 80 | +| tamper controls (one changed character per document, refusal required) | 80 | +| documents both engines must REFUSE to name (`domain_refusals`) | 6 | +| reproduction artifacts committed and replayed byte-for-byte | 9 | +| structural negative controls on `verify` (each side) | 18 | +| value-level domain backstop controls | 5 | + +## The engine protocol (checkpoint 2) + +Each engine authors only its own `engines[]` entry, and declares per layer what +it could **produce**. Over the committed artifacts: + +| engine | layers produced | layers refused | projection `full` | projection `partial` | +|---|---|---|---|---| +| `python-ownlang` | 24 | 3 | 27 | 0 | +| `rust-own-bridge` | 20 | 7 | 19 | 8 | + +The port's `partial` layers are its verdict surface: `own_bridge::check_facts` +is at the #259 checkpoint-4 projection, which carries every `Finding` member +except `message`, `related` and `flow`. It says so in the artifact rather than +emitting a short document a later comparison would score as agreement, and a +test asserts the claim matches the records byte for byte. + +**Layer envelopes where the two engines' status differs** — structural +accounting, not a content comparison, and every one of them a boundary the port +declares rather than a disagreement it stumbled into: + +| case | layer | statuses | +|---|---|---| +| `protocol_isloaded_violation` | `verdicts` | python-ownlang: produced, rust-own-bridge: refused | +| `verdict_door_effect_deps_not_strings` | `lowered` | python-ownlang: produced, rust-own-bridge: refused | +| `verdict_door_effect_deps_not_strings` | `summaries` | python-ownlang: produced, rust-own-bridge: refused | +| `verdict_door_effect_deps_not_strings` | `verdicts` | python-ownlang: produced, rust-own-bridge: refused | + +## The AnalysisTrace (checkpoint 3) + +Each capture is normalized into a walkable shape: internal identifiers are +replaced by addresses derived from what they identify, and each layer's +ordering semantics are **declared** rather than normalized away. + +| surface | count | +|---|---| +| trace layers projected (both engines, every artifact) | 54 | +| addressed steps | 254 | +| of those, handle addresses standing in for a mint counter | 12 | + +The normalization is proven on the property it exists for, over the whole +captured corpus: permuting a document's components reshuffles the global mint +counters (BR-L2) so the raw handle names change wholesale, and the **stable +ids must not move** — while the lowered layer's step **order** must still +change, because that difference is real. Both halves are asserted; a trace that +hid the second would delete the defect the layer exists to expose. + +## First-divergence reduction (checkpoint 4), and the classification + +The reducer walks the pair in pipeline order over **['lowered', 'summaries']** and names the +first place they part company: the layer, the step address and the *minimal* +difference inside it. The `verdicts` layer is **refused, not skipped** — +comparing final diagnostics is #260's acceptance, blocked by #259 — and the +refusal is carried in every reduction, so "not compared" can never be read as +"compared and agreed". + +Over the 9 committed reductions, 8 are +`identical`. The counters below are **computed** by the reducer, not implied by +a green build: + +| class | count | +|---|---| +| Python-only (`left-only`) | **0** | +| Rust-only (`right-only`) | **0** | +| Changed | **0** | +| Ordering-only | **0** | +| Unexplained | **0** | +| *status* (a layer-level disagreement, each a declared boundary) | 2 | +| *projection* (surfaces not comparable member-for-member) | 0 | + +`status` and `projection` are counted apart from the four content classes on +purpose: neither is a difference in what an engine *computed*. Every `status` +row in the table above is a boundary the port declares in its own error text — +the unported obligation-protocol analysis, and the typed door. + +The same-input layer carries its own counters, and those remain gate-enforced +rather than computed: the port asserts per-document equality of the canonical +identity and byte-exact equality of every committed artifact and trace, so a +non-zero counter there is not representable as a passing build. The gates: + +- `own-shadow/tests/repro.rs::a_changed_byte_in_the_embedded_document_is_refused` +- `own-shadow/tests/repro.rs::every_committed_artifact_round_trips_and_verifies` +- `own-shadow/tests/repro.rs::every_declared_unnameable_document_is_refused` +- `own-shadow/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest` +- `own-shadow/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting` +- `own-shadow/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse` +- `own-shadow/tests/repro.rs::verify_refuses_each_structural_violation` +- `own-shadow/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries` +- `own-shadow/tests/engine.rs::both_engines_report_the_same_layers_in_the_same_order` +- `own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` +- `own-shadow/tests/trace.rs::a_mint_order_shift_moves_the_order_but_not_the_stable_ids` +- `own-shadow/tests/trace.rs::a_refused_layer_carries_no_steps` +- `own-shadow/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte` +- `own-shadow/tests/trace.rs::no_counter_shaped_handle_survives_anywhere_in_a_trace` +- `own-shadow/tests/trace.rs::the_declared_order_semantics_are_the_frozen_ones` +- `own-shadow/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte` +- `own-shadow/tests/reduce.rs::the_reducer_is_silent_on_unchanged_data_and_names_a_synthetic_divergence` +- `own-shadow/tests/reduce.rs::the_same_fields_in_a_different_key_order_are_a_difference` +- `own-shadow/tests/reduce.rs::the_verdict_layer_is_refused_not_silently_skipped` +- `own-shadow/tests/reduce.rs::two_engines_that_both_refused_a_layer_agree` + +## The unmeasured set, named + +- **#260's raw-byte same-input invariant.** #260 asks that the `OwnIR` + document be produced or loaded exactly once, that the **raw bytes** be + hashed, and that *those exact bytes* reach both engines. What this slice + proves is shared **canonical document identity**: each engine parses the + file and agrees on the canonical form's digest. Canonical-equivalent input + is not byte-identical input — two files differing in whitespace, in object + key order, or in duplicate-key resolution share one canonical identity, + because ignoring exactly those differences is the canonical form's job. + Acceptance must therefore prove the byte-level invariant separately; until + it does, "same input" here means canonical identity and nothing stronger + ([owner decision B-1](../notes/p022-shadow-infra-owner-decisions.md)). +- **End diagnostics compared as an acceptance surface** — #260's acceptance, + blocked by #259 (cp5 and 4b). Not attempted, not approximated. +- **The verdict layer.** Refused by the reducer, and recorded as refused in + every reduction. This is the same blocker as the row above, stated where a + tool could otherwise have quietly crossed it. +- **Nested statement bodies as individual steps.** A `then`/`else`/`while` body + is part of its enclosing statement's step, so a difference inside a branch is + reported on that statement rather than on the branch's own address. +- **Rendered-byte parity of the three layer surfaces.** The artifact carries + layer outputs as JSON *values*, so a rendering difference (indent, + `ensure_ascii`) is invisible here. That contract stays with each layer's own + fixture family (`tests/test_lowered_fixtures.py`, + `tests/test_summaries_fixtures.py`, `tests/test_verdict_fixtures.py`). +- **The strict door.** Every layer in an artifact is projected through the + **tolerant** door, so that the three entries describe one capture. Strict-door + behaviour is Layer 1's own family (`own-ir`'s validation controls). +- **Engine build identity.** The artifact names *which* engine, never which + build of it — a version stamp would make an artifact non-reproducible from + the same inputs. +- **Nesting-depth agreement.** CPython's recursion limit and `serde_json`'s + 128-level cap differ; `spec/OwnIR.md` §4.2 bounds a conforming document + well inside both, so no conforming document reaches the difference. diff --git a/docs/generated/p022-shadow-mutations.md b/docs/generated/p022-shadow-mutations.md new file mode 100644 index 00000000..4692f51c --- /dev/null +++ b/docs/generated/p022-shadow-mutations.md @@ -0,0 +1,156 @@ + + +# P-022 step 7a — shadow-mode infrastructure: mutation campaigns + +Every mutation edits a **production** surface (P-022 discipline 2) and every declared layer runs for every mutation (discipline 3: no fail-fast). Each campaign stays frozen at what it measured; the counts below are derived from the recorded runs by `scripts/mutate_campaign.summarize()`, never typed. + +## checkpoint 1 — same-input capture and the reproduction artifact + +Campaign `p022-shadow-cp1` — The mutation campaign for P-022 step 7a checkpoint 1 (shadow-mode infrastructure layer 0: the same-input capture and the reproduction artifact). Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test, because P-022 discipline 2 makes a test evidence only once its mutation fails through the surface it claims to protect. Both layers run for every mutation (discipline 3: no fail-fast), so the recorded result names every catching layer, not the first. M00 is the harness-honesty control. + +Definition: `docs/evidence/p022-shadow-cp1.json` (sha256 `a5df997a1d84effc…`, 30 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-shadow-cp1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|--------------------------------------------------|---| +| recorded at commit | `0cdbd0f4410bb5ee4a418337f567e515f4146b3b` | +| layers run (every one, for every mutation) | `python`, `rust`, `rust-unit` | +| mutations | 30 | +| caught | 30 | +| survived | 0 | +| compile-error (no evidence either way) | 0 | +| invalid-mutation | 0 | +| runner-error | 0 | +| caught without every expected catcher | none | +| honesty control `M00` (unmutated tree must pass) | survived — as required | + +| id | rule | mutation | outcome | caught by | +|---|---|---|---|---| +| M01 | canonical: keys sorted by code point | the reference's canonical form stops sorting keys | caught | `python::artifact-golden`
`python::digest-ledger` | +| M02 | canonical: every code point outside the C0 rule is raw | the reference's canonical form escapes non-ASCII instead of emitting it raw | caught | `python::artifact-golden`
`python::digest-ledger` | +| M03 | canonical: no insignificant whitespace | the reference's canonical form re-introduces insignificant whitespace | caught | `python::artifact-golden`
`python::digest-ledger` | +| M04 | canonical domain: -0 is where the two parsers disagree about what parsing means | the reference stops refusing the literal -0 | caught | `python::domain-refusal` | +| M05 | canonical domain: integers lie in [-2**63, 2**63-1] | the reference stops bounding integer literals to signed 64 bits | caught | `python::domain-refusal-reason` | +| M06 | canonical domain: the OwnIR vocabulary has no float | the reference accepts a float literal instead of refusing it | caught | `python::domain-refusal-reason` | +| M07 | canonical domain: both engines must agree the document parses at all | the reference accepts NaN/Infinity, which serde_json rejects as invalid JSON | caught | `python::domain-refusal-reason` | +| M08 | canonical domain: enforced at the value level too, for an already-parsed document | the reference's VALUE-level domain backstop stops refusing a float | caught | `python::domain-backstop` | +| M09 | artifact: the digest is a gate, recomputed from the embedded document | the reference's verification stops comparing the recomputed hash | caught | `python::tamper-refusal` | +| M10 | artifact: one layer envelope, status produced|refused | the reference stops lifting a layer refusal into the envelope's status | caught | `python::artifact-golden` | +| M11 | artifact: a refused layer still names the surface it refused on | the reference stops lifting surface_version into the layer envelope | caught | `python::artifact-golden` | +| M12 | artifact: input.ownir_version is the document's declared version, verbatim | the reference stops carrying the document's own declared schema version | caught | `python::artifact-golden` | +| M13 | artifact: layers are an ORDERED array in pipeline order | the reference reorders the frozen layer vocabulary | caught | `python::artifact-verify`
`python::capture-verify`
`python::reduction-golden` | +| M14 | artifact: engines are an ORDERED array over the frozen vocabulary | the reference's verification stops checking the frozen engine order | caught | `python::structural-control` | +| M15 | artifact rendering: 2-space indent, byte-identical on re-run | the reference renders the artifact with a different indent | caught | `python::artifact-golden` | +| M16 | canonical: keys sorted by code point | the port's canonical form stops sorting object keys | caught | `rust/tests/engine.rs::this_engine_reproduces_its_committed_capture`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest`
`rust/tests/repro.rs::verify_refuses_each_structural_violation` | +| M17 | canonical escape rule: U+007F is NOT escaped | the port escapes U+007F, which the reference emits raw | caught | `rust/tests/engine.rs::this_engine_reproduces_its_committed_capture`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest` | +| M18 | canonical escape rule: \u00xx with LOWERCASE hex | the port escapes control code points with uppercase hex | caught | `rust/tests/engine.rs::this_engine_reproduces_its_committed_capture`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest` | +| M19 | artifact rendering: 2-space indent, byte-for-byte with the reference | the port renders the artifact with a different indent width | caught | `rust/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting`
`rust/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte` | +| M20 | artifact rendering keeps DOCUMENT order while the hash sorts — two serializations, two jobs | the port renders an artifact's objects in sorted rather than document order | caught | `rust/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting`
`rust/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte` | +| M21 | canonical: the parsed document is what CPython's dict would hold | the port resolves a duplicate key first-wins instead of last-wins | caught | `rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting` | +| M22 | canonical domain: integers lie in [-2**63, 2**63-1] | the port wraps an out-of-i64 integer instead of refusing it | caught | `rust/tests/repro.rs::every_declared_unnameable_document_is_refused`
`rust/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse` | +| M23 | canonical domain: the OwnIR vocabulary has no float | the port accepts a float as an integer instead of refusing it | caught | `rust/tests/repro.rs::every_declared_unnameable_document_is_refused`
`rust/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse` | +| M24 | artifact: the digest is a gate, recomputed from the embedded document | the port's verification stops comparing the recomputed digest | caught | `rust/tests/repro.rs::a_changed_byte_in_the_embedded_document_is_refused` | +| M25 | artifact: every engine reports exactly the frozen layers, in that order | the port's verification stops checking the frozen layer order | caught | `rust/tests/repro.rs::verify_refuses_each_structural_violation` | +| M26 | artifact: engines are an ORDERED array over the frozen vocabulary | the port's verification stops checking the frozen engine order | caught | `rust/tests/repro.rs::verify_refuses_each_structural_violation` | +| M27 | canonical hash: SHA-256, LOWERCASE hex | the port renders the digest with uppercase hex | caught | `rust/tests/engine.rs::this_engine_reproduces_its_committed_capture`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest`
`rust/tests/repro.rs::verify_refuses_each_structural_violation` | +| M28 | canonical: the hash is taken over the canonical bytes, not over the artifact's rendering | the port hashes the RENDERING form instead of the canonical form | caught | `rust/tests/engine.rs::this_engine_reproduces_its_committed_capture`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::every_shared_document_hashes_to_the_recorded_digest`
`rust/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting`
`rust/tests/repro.rs::verify_refuses_each_structural_violation` | +| M29 | artifact: layers are an ORDERED array in pipeline order | the port reorders the frozen layer vocabulary | caught | `rust/tests/engine.rs::both_engines_report_the_same_layers_in_the_same_order`
`rust/tests/engine.rs::this_engine_reproduces_its_committed_capture`
`rust/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::verify_refuses_each_structural_violation` | +| M30 | artifact: document present exactly when produced, error exactly when refused | the port's `has` stops distinguishing a present member from an absent one | caught | `rust/tests/engine.rs::this_engine_reproduces_its_committed_capture`
`rust/tests/reduce.rs::the_reducer_is_silent_on_unchanged_data_and_names_a_synthetic_divergence`
`rust/tests/repro.rs::every_committed_artifact_round_trips_and_verifies`
`rust/tests/repro.rs::verify_refuses_each_structural_violation` | + +## checkpoint 2 — the engine protocol + +Campaign `p022-shadow-cp2` — The mutation campaign for P-022 step 7a checkpoint 2 (the engine protocol: how each engine reports its per-layer outputs, and what it declares it could produce). Separate from the cp1 campaign on purpose — each checkpoint's evidence stays frozen at what it measured, so a later checkpoint cannot quietly restate an earlier one's numbers. Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test. Three layers run for every mutation (discipline 3: no fail-fast), including the port's own engine suite. M00 is the harness-honesty control. + +Definition: `docs/evidence/p022-shadow-cp2.json` (sha256 `fdeb494e11d5169f…`, 11 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-shadow-cp2.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|--------------------------------------------------|---| +| recorded at commit | `0cdbd0f4410bb5ee4a418337f567e515f4146b3b` | +| layers run (every one, for every mutation) | `python`, `rust-repro`, `rust-engine`, `rust-unit` | +| mutations | 11 | +| caught | 11 | +| survived | 0 | +| compile-error (no evidence either way) | 0 | +| invalid-mutation | 0 | +| runner-error | 0 | +| caught without every expected catcher | none | +| honesty control `M00` (unmutated tree must pass) | survived — as required | + +| id | rule | mutation | outcome | caught by | +|---|---|---|---|---| +| M31 | engine protocol: every layer declares what its engine could produce | the reference stops declaring a projection on its layers | caught | `python::artifact-golden`
`python::capture-verify` | +| M32 | engine protocol: an engine writes only its own entry, and carries foreign ones through | the reference silently drops the foreign engine entries when regenerating | caught | `python::artifact-golden` | +| M33 | engine protocol: a partial projection must NAME the members it carries | the reference's verification accepts a partial projection that names no members | caught | `python::structural-control` | +| M34 | engine protocol: a full projection emits the whole surface and claims nothing else | the reference's verification accepts a 'full' projection that also names members | caught | `python::structural-control` | +| M35 | engine protocol: a projection that over-claims is exactly what the field exists to prevent | the port declares its verdict layer FULL while it is at the checkpoint-4 projection | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M36 | engine protocol: a projection names exactly the members its documents carry | the port drops `column` from its verdict records while still claiming it | caught | `rust-engine/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries`
`rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M37 | artifact: status is produced or refused, and a refusal carries no document | the port stamps a refused layer with the 'produced' status | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M38 | artifact: surface_version is null when the surface has none — absence is data | the port claims a surface version for the MOS dump, which has none | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M39 | artifact: every engine reports exactly the frozen layers, in that order | the port reports a typed-door refusal on one layer instead of all three | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M40 | engine protocol: a full projection emits the whole surface and claims nothing else | the port's verification stops rejecting a 'full' projection that names members | caught | `rust-repro/tests/repro.rs::verify_refuses_each_structural_violation` | +| M41 | engine protocol: a partial projection must say WHY the remaining members are absent | the port's verification accepts an EMPTY reason on a partial projection | caught | `rust-repro/tests/repro.rs::verify_refuses_each_structural_violation` | + +## checkpoint 3 — the AnalysisTrace and stable-ID normalization + +Campaign `p022-shadow-cp3` — The mutation campaign for P-022 step 7a checkpoint 3 (the AnalysisTrace, #269: stable-ID normalization and declared per-layer ordering semantics). Separate from the cp1/cp2 campaigns on purpose — each checkpoint's evidence stays frozen at what it measured. Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test. Four layers run for every mutation (discipline 3: no fail-fast). M00 is the harness-honesty control. + +Definition: `docs/evidence/p022-shadow-cp3.json` (sha256 `99e99aacfe3f4ac4…`, 11 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-shadow-cp3.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|--------------------------------------------------|---| +| recorded at commit | `001f6fd3be4e73f4d3878dc430dd4507a90ca273` | +| layers run (every one, for every mutation) | `python`, `rust-repro`, `rust-engine`, `rust-trace`, `rust-unit` | +| mutations | 11 | +| caught | 11 | +| survived | 0 | +| compile-error (no evidence either way) | 0 | +| invalid-mutation | 0 | +| runner-error | 0 | +| caught without every expected catcher | none | +| honesty control `M00` (unmutated tree must pass) | survived — as required | + +| id | rule | mutation | outcome | caught by | +|---|---|---|---|---| +| M42 | trace: internal identifiers are normalized away by IDENTITY, never by position | the reference derives a handle's stable id from the counter instead of the record's identity | caught | `python::` | +| M43 | trace: the rename is a BIJECTION — it may not fuse two facts into one address | the reference drops `line` from a handle's identity, fusing two facts on one line-distinct site | caught | `python::trace-golden` | +| M44 | trace: a duplicate address takes a ~ suffix, inside the bracket | the reference stops disambiguating a repeated address | caught | `python::trace-golden`
`python::trace-shape` | +| M45 | trace: order is DECLARED, never normalized away — lowered order is semantic (BR-D4/BR-L5) | the reference declares the lowered layer's order canonical, licensing a sort | caught | `python::trace-golden` | +| M46 | trace: BR-V8 leaves ties in construction order, so position carries information | the reference declares the verdict layer's order canonical, hiding a tie-order defect | caught | `python::trace-golden` | +| M47 | trace: the rename is TOTAL — no counter-shaped name survives | the reference narrows the minted-handle pattern so `loc_` names leak through unrewritten | caught | `python::trace-golden`
`python::trace-normalization` | +| M48 | trace: a refused layer carries its error and no steps | the reference gives a refused layer an empty step list AND drops its error | caught | `python::trace-golden` | +| M49 | trace: internal identifiers are normalized away by IDENTITY, never by position | the port derives a handle's stable id from the counter instead of the record's identity | caught | `rust-trace/tests/trace.rs::a_mint_order_shift_moves_the_order_but_not_the_stable_ids`
`rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte`
`rust-unit/src/lib.rs::trace::tests::a_fully_listed_document_normalizes` | +| M50 | trace: order is DECLARED, never normalized away | the port declares the lowered layer's order canonical, licensing a sort | caught | `rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte` | +| M51 | trace: a claim nothing can falsify is not a contract | the port stops asserting that the handle rewrite is total | caught | `rust-unit/src/lib.rs::trace::tests::a_handle_reference_the_rename_cannot_reach_is_refused` | +| M52 | trace: the mint kind is preserved as a comparable VALUE so a routing difference stays one step | the port drops the mint kind from the handle record | caught | `rust-trace/tests/trace.rs::every_trace_golden_is_reproduced_byte_for_byte`
`rust-unit/src/lib.rs::trace::tests::a_fully_listed_document_normalizes` | + +## checkpoint 4 — first-divergence reduction + +Campaign `p022-shadow-cp4` — The mutation campaign for P-022 step 7a checkpoint 4 (first-divergence reduction over the lowered and MOS layers). Separate from cp1/cp2/cp3 on purpose — each checkpoint's evidence stays frozen at what it measured. Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test. Five layers run for every mutation (discipline 3: no fail-fast), including the crate's unit tests. M00 is the harness-honesty control. + +Definition: `docs/evidence/p022-shadow-cp4.json` (sha256 `ad87275b58dc7065…`, 11 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-shadow-cp4.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|--------------------------------------------------|---| +| recorded at commit | `001f6fd3be4e73f4d3878dc430dd4507a90ca273` | +| layers run (every one, for every mutation) | `python`, `rust-repro`, `rust-trace`, `rust-reduce`, `rust-unit` | +| mutations | 11 | +| caught | 11 | +| survived | 0 | +| compile-error (no evidence either way) | 0 | +| invalid-mutation | 0 | +| runner-error | 0 | +| caught without every expected catcher | none | +| honesty control `M00` (unmutated tree must pass) | survived — as required | + +| id | rule | mutation | outcome | caught by | +|---|---|---|---|---| +| M53 | reduction: comparing final diagnostics is #260's ACCEPTANCE, blocked by #259 — the scope is a contract, not a parameter | the reference widens the reduction scope to the verdict layer | caught | `python::reduction-control`
`python::reduction-golden` | +| M54 | reduction: the difference must be MINIMAL — the field, not the step body | the reference reports the whole step instead of the minimal difference inside it | caught | `python::reduction-control` | +| M55 | reduction: left-only / right-only are two of the four content classes | the reference stops noticing a step only one engine addresses | caught | `python::` | +| M56 | reduction: on a layer whose order is declared significant, the sequence IS the difference | the reference stops noticing an ordering-only difference | caught | `python::reduction-control` | +| M57 | reduction: when both engines refused, compare THAT they refused, never how they phrased it | the reference compares two engines' refusal TEXTS, manufacturing a divergence out of message vocabulary | caught | `python::reduction-control` | +| M58 | reduction: the reducer names the FIRST divergence in pipeline order | the reference reports the LAST divergence instead of the first | caught | `python::reduction-golden` | +| M59 | reduction: object key order is significant — the surfaces fix field order byte-exactly | the reference stops distinguishing a key-ORDER difference from agreement | caught | `python::reduction-control` | +| M60 | capture: each layer document is carried in the key order its OWN surface fixes | the reference carries the MOS document in `dump_summaries`' insertion order rather than its surface's | caught | `python::artifact-golden` | +| M61 | reduction: the scope is a contract, not a parameter | the port widens the reduction scope to the verdict layer | caught | `rust-reduce/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte`
`rust-reduce/tests/reduce.rs::the_reducer_is_silent_on_unchanged_data_and_names_a_synthetic_divergence`
`rust-reduce/tests/reduce.rs::the_verdict_layer_is_refused_not_silently_skipped`
`rust-reduce/tests/reduce.rs::two_engines_that_both_refused_a_layer_agree` | +| M62 | reduction: a refusal's text is each engine's own | the port compares two engines' refusal texts instead of the fact that both refused | caught | `rust-reduce/tests/reduce.rs::two_engines_that_both_refused_a_layer_agree` | +| M63 | reduction: the reducer names the FIRST divergence in pipeline order | the port reports the last divergence instead of the first | caught | `rust-reduce/tests/reduce.rs::every_reduction_golden_is_reproduced_byte_for_byte` | diff --git a/docs/notes/p022-shadow-infra-checkpoint1.md b/docs/notes/p022-shadow-infra-checkpoint1.md new file mode 100644 index 00000000..341a3b8a --- /dev/null +++ b/docs/notes/p022-shadow-infra-checkpoint1.md @@ -0,0 +1,261 @@ +# P-022 step 7a (#260/#269) — checkpoint 1: same-input capture and the reproduction artifact + +> Status: **infrastructure for shadow mode**, checkpoint 1 of the row-7a +> slice. This is not shadow mode and does not claim parity. Comparing two +> engines' end diagnostics as an acceptance surface is #260's *acceptance* and +> is blocked by #259 (cp5 and 4b); nothing here attempts it, approximates it, +> or should be quoted as it. Written from the tree at the commit that landed +> it; every number in it is generated — see +> [`docs/generated/p022-shadow-census.md`](../generated/p022-shadow-census.md). +> +> **Two owner decisions were taken about this checkpoint after it landed**, and +> the note is left as written rather than edited to match them: it carries +> *both* of the brief's first two items where the brief asked for a checkpoint +> each (D-1), and the `-0` contract decision below was the checkpoint's to +> *report*, not to take (D-2). Both are ratified, with reasons, in +> [the owner-decision ledger](p022-shadow-infra-owner-decisions.md) — which +> also states the boundary this note words too strongly: what is proved here is +> **canonical document identity**, not #260's raw-byte same-input invariant +> (B-1). + +Two questions have to be settled **before** two engines can be compared at +all, and neither of them is a comparison: + +1. **Did both engines see the same input?** Without a canonical identity for + an `OwnIR` document, "same input" is an assumption about which file was + passed where — and a differential harness built on an assumption reports + agreement it never measured. +2. **What does a reproduction look like?** A divergence is only actionable if + it can be re-run from one self-contained thing. + +Checkpoint 1 answers both, and nothing else. + +## What landed + +- **`ownlang/repro.py`** — the authoritative emitter. Strictly an OBSERVER, + like `ownlang/lowered.py` and `ownlang/verdicts.py`: it never mutates facts, + never changes a verdict, and is imported by nothing in the production path. + It **composes** the three frozen layer surfaces and never re-encodes them. + Its docstring freezes the canonical form and the artifact format, in the + house style (the emitter's docstring is the contract, not a second spec that + drifts from it). +- **`rust/crates/own-shadow`** — the replaying half, with **zero Python**: the + canonical form and digest, the artifact's verification, and the artifact's + rendering. `verify` is deliberately an *independent reading* of the same + frozen rule rather than a port of the reference's code, so a divergence + between the two is itself a finding. +- **`tests/fixtures/repro/digests.json`** — the canonical hash of **every** + facts document in the shared corpora plus this family's own canonical-form + controls. This is the same-input capture surface, and the Rust side + recomputes all of it. +- **`tests/fixtures/repro/.repro.json`** — reproduction artifacts for a + curated set, replayed byte-for-byte by the port. +- **`tests/test_repro_fixtures.py`** — verify / `--write`, and the controls. +- **`scripts/mutate_campaign.py`** + **`scripts/render_checkpoint_status.py`** + + **`tests/test_checkpoint_status.py`** — the campaign as data and the census as + a generated document, both gated. See "Method" below. +- DAG: `own-shadow` added to the allowed edge set with an **empty** dependency + set, and a named test asserts no core crate — nor `own-bridge` — depends on + it. An oracle a core crate can reach is an oracle the core can shape. + (Checkpoint 2 widened that set to `own-ir`/`own-lowered`/`own-bridge` — the + engine protocol drives the port's layer surfaces — as a deliberate, reviewed + edit to the same map. The constraint that runs the other way did not move.) + +## Census + +Generated, not typed: +[`docs/generated/p022-shadow-census.md`](../generated/p022-shadow-census.md) — +the **live** view of the slice, which moves as later checkpoints land. What +stays frozen at what *this* checkpoint measured is its recorded campaign, +`docs/evidence/p022-shadow-cp1.result.json`. The figures when this +checkpoint landed: **80 documents** captured and +digest-pinned across five corpora, **80** tamper controls, **6** documents both +engines must refuse to name, **8** artifacts round-tripped and verified +(carrying 21 produced and 3 refused layer envelopes), **12** structural and +**5** domain-backstop negative controls on each side, and a mutation campaign +of **30 mutations, 30 caught, 0 survivors**. + +**Python-only 0 / Rust-only 0 / Changed 0 / Ordering-only n/a / Unexplained +0**, over the 80-document measured set. *Ordering-only* is named as +inapplicable rather than reported as a zero: the canonical form sorts by +construction, so there is no ordered output being compared at this layer yet. +The census names the gates that enforce the rest, and names the unmeasured set +beside them. + +## The finding: `-0` + +**The two engines' JSON parsers disagree about what "parsed" means.** CPython's +`json` reads the literal `-0` as the **integer** `0`; `serde_json` reads it as +the **float** `-0.0`. Reproduction: + +```console +$ python3 -c "import json; v=json.loads('-0'); print(repr(v), type(v).__name__)" +0 int +$ # serde_json: `-0` arrives at `visit_f64(-0.0)`, never at `visit_i64` +``` + +Found by the canonical-form torture fixture on its first run — it carried a +`negative_zero` member, and the Rust side refused a document the reference had +already hashed. + +**Recorded as a finding, resolved by defining the contract — not by bending +either engine.** The reference is not changed; the port is not taught to +"agree"; no golden is regenerated. The canonical **domain** is narrowed to +exclude `-0`, because a canonical form that hashed it would assert *"both +engines saw the same document"* while the two engines held different values — +which is the exact lie this surface exists to prevent. Reconciling instead of +refusing would have meant picking one parser's reading and calling the other +wrong, which is a contract decision this checkpoint has no standing to take. + +> Nor, as the owner's review pointed out, had it standing to take the +> *narrowing* — an engine divergence was the brief's stop-and-report trigger, +> and this paragraph declines the decision in the sentence before taking one. +> The outcome stands, now as an owner decision: +> [D-2](p022-shadow-infra-owner-decisions.md). + +It costs the contract nothing: `spec/OwnIR.md` §4.2 already bounds every +validated coordinate to signed 64 bits, and no `OwnIR` producer emits `-0` +(measured: no document in any corpus contains one, a float, or an exponent). + +The consequence is architectural rather than cosmetic. The disagreement is +**invisible after parsing** on the reference side — by then `-0` is already +`0` — so the domain has to be enforced where each engine can still see the +*literal*: `load_document` through `json`'s `parse_int`/`parse_float`/ +`parse_constant` hooks on one side, the typed value's `Deserialize` on the +other. Both are backed by an executable `domain_refusals` ledger: six +documents that **both** engines must refuse, each with the reason and the +substring its refusal must carry. The day either engine starts accepting one, +its suite goes red demanding a decision. + +`NaN`/`Infinity`/`-Infinity` fall under the same rule and are in the ledger: +CPython accepts them as an extension, `serde_json` rejects them as invalid +JSON, so the two engines do not agree that such a document parses at all. + +### A declared boundary beside it + +Nesting depth. The two parsers cap recursion differently (CPython's +interpreter limit; `serde_json`'s 128). The canonical form does not attempt to +unify them. `spec/OwnIR.md` §4.2 bounds a conforming document at 32 nested +bodies and 128 raw levels, which sits inside both caps, so no conforming +document reaches the difference — recorded rather than claimed away. + +## The decisions this checkpoint took, and why + +- **The canonical form is over the PARSED document, not the file's bytes.** + Whitespace, key order and a parser-resolved duplicate key are insignificant + text formatting; a change to any parsed value is not. Two files that parse to + the same document are the same input, and the hash says so. Duplicate keys + follow the reference exactly — last value wins, first position kept — and + both sides implement that, because the canonical form is only meaningful if + the two parsers agree about what parsing means. +- **The domain is closed and refuses rather than rounds.** Object, array, + string, `i64` integer, bool, null. On the Rust side this is enforced *by the + type* (there is no float variant), which makes `canonical_bytes` total; on + the Python side it is a run-time check, because `json` has no such type. One + contract, two enforcement points — and the two say *which one fired*, which + turned out to be load-bearing (see Method). +- **The artifact embeds its input.** It reproduces without the corpus it came + from, and the recomputed hash is what makes the embedded copy trustworthy. +- **`engines` and `layers` are ordered ARRAYS over frozen vocabularies.** Key + order is not a sound carrier of semantic order for a byte-exact + cross-language contract — the same decision the Layer 2 handle array took, + for the same reason. The layer order is the *pipeline* order, which is what a + first-divergence reduction will walk. +- **One layer envelope for all three layers.** `{layer, surface_version, + status, document | error}`. A surface that encodes its own refusal as + `{"error": …}` has it **lifted** into the envelope's status; a produced + document is carried **verbatim**, duplicate `*_version` included, because + lifting is what lets a *refused* layer still name the surface it refused on. + `summaries` has no surface version of its own, so its `surface_version` is + `null` — absence is data. +- **One door for all three layers: the tolerant one.** A reproduction artifact + must describe what the layers did with *one and the same* input; mixing the + strict and tolerant doors across layers would mean the three entries no + longer describe one capture. Strict-door behaviour is Layer 1's own family. +- **No engine build identity.** The artifact names *which* engine, never which + build of it: a git SHA would make an artifact non-reproducible from the same + inputs, and every surface it carries is already versioned. A boundary, not + an oversight. +- **Goldens for a curated set, properties over the whole corpus.** Every + artifact embeds its input plus three layer documents that already live in the + tree, so committing 80 of them would triple the corpus to prove nothing the + curated set does not. Determinism, byte-exact round-trip, self-verification + and tamper refusal run over **all 80**; the goldens pin the *format* on 8. +- **A new dependency, deliberately.** `sha2` (RustCrypto) enters a + deliberately lean workspace, used by `own-shadow` alone. The reference side + is `hashlib.sha256`; hand-rolling a digest in a crate that denies + `arithmetic_side_effects` and `indexing_slicing` would have traded an audited + implementation for a page of justified suppressions. No core crate depends + on it. + +## Method: the campaign is data, and the census is generated + +Two pieces of tooling landed with this checkpoint because the discipline +already demanded them and prose was standing in: + +- **`scripts/mutate_campaign.py`.** A campaign is a definition file (each + mutation an exact text edit to a **production** file) plus a recorded result + (per mutation, which layers failed). Both layers run for every mutation — + rule 3, no fail-fast. The definition is checkable **without running + anything**: every edit's anchor must still occur exactly once in its target, + so a campaign whose code has moved is a red build rather than a quiet + fiction. The recorded result's internal consistency is gated too, because a + file written by a script is still a file somebody can edit. +- **`scripts/render_checkpoint_status.py`.** The census is rendered from + committed evidence, and `tests/test_checkpoint_status.py` makes a stale copy a + red build. The generator deliberately does **not** invent the divergence + counters: it states that they are enforced by a gate, names the gates (read + out of the Rust test source, so a renamed test makes the census stale), and + names the unmeasured set. A counter the generator could not have computed + would be the same hand-typed claim in a new place. + +### Mutation campaign — three rounds + +Definition: `docs/evidence/p022-shadow-cp1.json`. Result: +`.../campaign.json`. `M00` is the harness-honesty control. + +**Round 1 — 30 mutations, 27 caught, 3 survivors.** M05, M06 and M07 each +deleted the *literal-level* domain check (the i64 bound, the float refusal, the +non-finite refusal) and the suite stayed green: the **value-level backstop** +refused the same documents, and the controls only asked *that* something +refused. That is P-022 discipline 2's failure mode exactly — "a test can +exercise a private copy of the logic and pass while the public path rots". + +Fixed by making the two enforcement points **distinguishable** and pinning +each control to the one it claims to protect: a literal-level refusal now +names "the integer/float/non-finite literal", a value-level one names the path +it walked to, and the ledger's needles were tightened accordingly. + +**Round 2 — a harness defect, not a result.** Every row from M15 on reported +`python::artifact-golden` as a catcher, including fifteen **Rust-only** +mutations, which is impossible. Cause: M15 changes `indent=2` to `indent=4` — +the same file **size**. CPython validates a `.pyc` against the source's integer +mtime and size, so restoring the original left the *mutated* bytecode in place +and every later row measured the leftover. The runner now invalidates cached +bytecode on every write and runs the layers with `PYTHONDONTWRITEBYTECODE=1`. + +This is the third time a campaign in this project has had to fix its own +harness before its numbers meant anything (cp1's `git checkout` restore, cp4's +split streams, this). The lesson is recorded in the script rather than in a +note, so the next campaign inherits it. + +**Round 3 — 30 mutations, 30 caught, 0 survivors**, control clean, and no +Rust-only mutation attributed to a Python catcher. **15** mutations have +exactly one catching layer; a rule with a single catcher is a rule with a +single control, and the census lists them by id. + +## What checkpoint 2 needs + +The remaining row-7a checkpoints, in order, each with its own evidence, commit +and status row: + +| # | deliverable | what it adds | +|---|---|---| +| 2 | the **engine protocol** | the port fills its own `engines[]` entry through a shared protocol, so an artifact carries two captures instead of one. The format already has the slot and the vocabulary | +| 3 | the **`AnalysisTrace` schema** (#269) + **stable-ID normalization** | so a comparison does not break on insignificant order or on internal identifiers | +| 4 | **first-divergence reduction** over the *lowered* and *MOS* layers | naming the layer, the case and the minimal difference; proven against a synthetic divergence introduced into a copy of a Layer 2 golden, and silent on unchanged data | + +And the wording discipline that comes with all of them: what this checkpoint +proved is "80 documents share one canonical identity across two engines, and +8 reproduction artifacts round-trip byte-for-byte" — never "shadow mode", and +never "parity". diff --git a/docs/notes/p022-shadow-infra-checkpoint2.md b/docs/notes/p022-shadow-infra-checkpoint2.md new file mode 100644 index 00000000..f3b3caf0 --- /dev/null +++ b/docs/notes/p022-shadow-infra-checkpoint2.md @@ -0,0 +1,152 @@ +# P-022 step 7a (#260/#269) — checkpoint 2: the engine protocol + +> Status: **infrastructure for shadow mode**, checkpoint 2 of the row-7a +> slice. Still not shadow mode, still no parity claim, and — this checkpoint's +> own sharpest line — **still not a comparison**. An artifact now carries two +> engines' captures side by side, and nothing reads one against the other. +> Comparing them is #260's acceptance, blocked by #259 (cp5 and 4b); the +> reduction that will consume the pairing is a later checkpoint in this same +> slice. Numbers are generated: +> [`docs/generated/p022-shadow-census.md`](../generated/p022-shadow-census.md). + +Checkpoint 1 gave an input a name and a reproduction a format, with one engine +filling it. Checkpoint 2 answers the next question, which is also not a +comparison: **how does each engine report its per-layer outputs, in one +format?** + +## What landed + +- **`own_shadow::capture`** — the port's half of the protocol. It drives + `own-bridge`'s three layer surfaces (`lower`, `dump_summaries`, + `check_facts`) and reports each in the shared envelope. The reference's half + is `ownlang/repro.py::project_layers`, and the two stay independent readings + of one frozen format rather than one being a translation of the other. +- **`projection` on the layer envelope** (format version 2) — each layer + declares what its engine could *produce*: `{"kind": "full"}`, or + `{"kind": "partial", "members": [...], "reason": "..."}`. +- **An engine writes only its own entry.** The reference authors + `python-ownlang` and carries any foreign entry through untouched; the port + authors `rust-own-bridge` under `OWN_SHADOW_WRITE=1` and touches nothing + else. Neither half can quietly become a comparison of one implementation + against itself, and each is produced with **zero** of the other's runtime. +- **`own-shadow` gains `own-ir`/`own-lowered`/`own-bridge`** in the allowed DAG + edge set — a deliberate, reviewed edit. Only entry-point crates may depend on + `own-bridge`, and the harness is one. The constraint that runs the other way + did not move: no core crate, nor `own-bridge` itself, may depend on the + harness. + +## Why the format needs a projection + +Because the port is mid-migration, and the alternatives are both dishonest. + +Two of its three layers emit the whole frozen surface — the Layer 2 lowered +document and the MOS summaries dump are byte-exact against the reference's own +goldens (#259 cp2 and cp3). The third does not: `own_bridge::check_facts` is at +the **#259 checkpoint-4 projection**, carrying every `Finding` member except +`message`, `related` and `flow`, because message synthesis (BR-V4) and the +evidence slices are cp5 and are not ported. + +Without a projection field a port in that state has exactly two options: + +1. **emit a short document** — and a later comparison scores the three absent + members as agreement, which is the failure the whole differential apparatus + exists to prevent; or +2. **refuse the layer** — and throw away the eleven members it *can* produce, + which is a worse answer than the truth. + +So the envelope carries the truth, and a test holds it to it: a partial +projection must name exactly the members its records actually have. A +projection that over-claims is the one way this field can lie, and without +that test it would be prose. + +This is the cp4 discipline generalized — *a replay declares what it compares, +and the golden always carries everything* — moved from a test's docstring into +the data, where a later checkpoint can act on it. + +## What the artifacts now show + +Both engines' captures, side by side, with every place they part company +**declared** rather than stumbled into. Four layer envelopes across the +committed set have differing statuses, and all four are boundaries the port +states in its own error text: + +| case | layer | why | +|---|---|---| +| `protocol_isloaded_violation` | `verdicts` | the obligation-protocol analysis (OBL001–005) has no `own-analysis` port; the bridge refuses rather than return a list with a family missing (#259 row 4b) | +| `verdict_door_effect_deps_not_strings` | all three | the port's **typed door** refuses the document (#294 OD-1) — and the door is upstream of every layer, so all three report the door's text | + +The typed-door case is a shape a first-divergence reduction must not mistake +for a layer-level disagreement, which is why it is a committed artifact and not +a footnote: the reduction checkpoint inherits a worked example of the +distinction. + +## The decisions this checkpoint took, and why + +- **A door refusal is three refused layers, not one envelope-level error.** + The format's rule is that every engine reports exactly the frozen layers; an + engine-level error would break it, and would make "the door refused this + document" indistinguishable from "this layer is not implemented". Their + projections stay `full`: a refusal is *complete* information about what the + engine did, not a partial answer. +- **The projection describes the engine's output, not the surface's version.** + The port's verdict layer still carries `surface_version: 1` — it replays the + reference's surface; what differs is how much of it. Conflating the two would + have invented a second version number for one surface. +- **`surface_version` is read back out of the produced document** where the + surface stamps one, so the envelope cannot claim a version the document does + not carry. +- **`OWN_SHADOW_WRITE` is opt-in, and the reading tests stand down under it.** + A suite that rewrites its own expectations on every run proves nothing, and + "implementation disagreed with the golden → regenerate → agreement" is the + move this family exists to make impossible. The stand-down exists because + cargo runs a target's tests in parallel: without it a regeneration pass races + its own readers over half-written files, and a self-inflicted flaky red is + worse than no signal. + +## Mutation campaign + +Definition and recorded result: +`docs/evidence/p022-shadow-cp2.json` and its `.result.json`. Separate from checkpoint 1's on purpose +— each checkpoint's evidence stays frozen at what it measured, so a later one +cannot quietly restate an earlier one's numbers. Three layers run for every +mutation (the reference harness, and the port's two suites), no fail-fast. + +**Round 1 — 11 mutations, 8 caught, 1 compile error, 2 survivors.** + +- **M39 found untested code.** The port reports a typed-door refusal on all + three layers; no committed artifact had a document the typed door refuses, so + the path had **no control at all**. Fixed by promoting + `verdict_door_effect_deps_not_strings` to a committed artifact — which is + also the worked example the reduction checkpoint will want. +- **M41 found a control that stopped one step short.** `verify` requires a + *non-empty* reason on a partial projection; only the *missing* case was + controlled, so an empty one would have passed. Both sides gained the control. +- **M37 did not compile**, and is recorded as a compile error rather than as + "caught": a mutation that does not build proves nothing about the tests. It + was re-written into one that does. + +**Round 2 — 11 mutations, 11 caught, 0 survivors, 0 compile errors**, control +clean. + +### The gate earned its keep between the rounds + +Checkpoint 2 reshaped the layer envelope, and two of checkpoint 1's mutations +(M11, M15) lost their anchors. `mutate_campaign.py --check` — which runs in +`tests/run_tests.py` and executes nothing — caught it before the recorded +result could go on describing a tree that no longer exists. They were +re-anchored and checkpoint 1's campaign re-run: **30/30**, unchanged. + +That is the whole argument for a campaign being data rather than prose. A +hand-written table would have kept asserting the old numbers, and nothing would +have said otherwise. + +## What checkpoint 3 needs + +| # | deliverable | what it adds | +|---|---|---| +| 3 | the **`AnalysisTrace` schema** (#269) + **stable-ID normalization** | so a comparison does not break on insignificant order or on internal identifiers that differ by construction between two implementations | +| 4 | **first-divergence reduction** over the *lowered* and *MOS* layers | naming the layer, the case and the minimal difference; proven against a synthetic divergence introduced into a copy of a Layer 2 golden, and silent on unchanged data | + +And the wording, unchanged: what this checkpoint proved is "two engines report +their layer outputs in one format, and each declares what it could produce" — +never "shadow mode", and never "parity". diff --git a/docs/notes/p022-shadow-infra-checkpoint3.md b/docs/notes/p022-shadow-infra-checkpoint3.md new file mode 100644 index 00000000..cc7d2a4f --- /dev/null +++ b/docs/notes/p022-shadow-infra-checkpoint3.md @@ -0,0 +1,146 @@ +# P-022 step 7a (#260/#269) — checkpoint 3: the `AnalysisTrace` and stable-ID normalization + +> Status: **infrastructure for shadow mode**, checkpoint 3 of the row-7a +> slice. Not shadow mode, no parity claim, and **still not a comparison**: the +> trace is the *shape* a comparison would need, and producing it is not +> performing one. Numbers are generated: +> [`docs/generated/p022-shadow-census.md`](../generated/p022-shadow-census.md). + +Checkpoint 2 left two engines' captures sitting side by side in one artifact. +Two things stand between that pairing and a comparison, and only one of them +should be removed. + +## The problem, precisely + +**Internal identifiers.** The Layer 2 handles — `sub_0`, `cap_1`, `parg_0`, +`loc_3` — are minted from **global counters in document order** (BR-L2). They +are positions wearing the costume of names. Measured on +`handles_global_counters`, reversing the component list gives: + +```text +raw handles, as written : cap_0 sub_1 sub_2 cap_3 +raw handles, permuted : sub_0 cap_1 cap_2 sub_3 +``` + +Four facts, unchanged; eight names, none shared. A comparison over raw +documents would report every handle as a difference, and the one real +difference — the *order* — would be buried under them. + +**Order.** Which is the thing that must **not** be normalized away. Document +and lowering order is semantic (BR-D4, BR-L5); BR-V8 sorts verdicts by +`(file, line, column, code)` and leaves ties in construction order, so position +carries information there too. Sorting a layer to make a comparison pass would +delete the defect the layer exists to expose. + +So the trace **normalizes the identifiers and declares the order**. + +## What landed + +- **`ownlang/repro.py`** gains the trace projection (still the one observer + module this slice adds to `ownlang/`, still importing nothing into the + production path). Its docstring freezes the schema. +- **`own_shadow::project_traces`** — the port's independent reading of that + schema. +- **`tests/fixtures/repro/.trace.json`** — both engines' traces per + artifact. Both sides project **both** engines: projecting a capture is not + authoring it, and doing it twice is what cross-checks the *normalization + itself*. +- **Stable ids** are `component | file | line | event | handler`, rebuilt from + the record the bridge attached to the handle. Every occurrence of the minted + name anywhere in the document is rewritten; the rename is a **bijection** and + **total**, and both are asserted rather than assumed. +- **The mint kind is not discarded** — it moves onto the handle record as + `mint`. A routing difference (R5 minting `cap_` where R6 would mint `sub_`) + therefore stays a comparable **value on one step**, instead of splitting into + a pair of "only in one engine" addresses that a reduction would have to + re-join. + +Measured on the same permutation: + +```text +stable ids, as written : A|A.cs|3|SystemEvents.A|HA A|A.cs|4|bus.A|HB … +stable ids, permuted : A|A.cs|3|SystemEvents.A|HA A|A.cs|4|bus.A|HB … (identical) +lowered step order : still different — and that difference is real +``` + +Both halves are asserted, on both sides, over the whole captured corpus. + +## The decisions this checkpoint took, and why + +- **Addresses come from identity, `~` only where identity repeats.** Two + records sharing component, file, line, event and handler are the same fact + seen twice, and nothing but their order distinguishes them. That suffix is + the one place position leaks back into an address, and it is recorded rather + than hidden — a duplicate *finding* address is exactly the tie whose order + `verdicts` declares significant. +- **A refused layer carries its error and no steps.** An empty step list that + compared equal to another engine's empty one would score a refusal as + agreement. +- **Nested statement bodies stay inside their statement's value.** Flattening + deeper needs a path grammar, and the enclosing statement is already the + smallest unit that names a lowering site. +- **The trace carries the input hash**, so it cannot be read against a document + it did not come from. + +## The finding: two readings of one schema + +The two implementations disagreed, and the disagreement was in **my own +schema**, not in either engine. + +`mosdump_degraded_duplicate_key` declares two functions named `Take`. The +reference addressed the second as `functions[Take~1]`; the port addressed it as +`functions[Take]~1`. Both are faithful readings of "a duplicate address takes a +`~` suffix" — and the reference was, on top of that, **inconsistent with +itself**: it suffixed inside the bracket for functions and outside for every +other addressed list. + +Resolved by making the rule explicit and uniform — **inside the bracket**, +everywhere. It disambiguates *which of the repeated items*, a property of the +item rather than of the path, and it is what lets a nested prefix compose: +`functions[Take~1].body[0]` addresses the second `Take`'s first statement. + +This is the argument for implementing the projection twice. A single +implementation would have shipped the inconsistency, and the first comparison +built on it would have inherited it. + +A second defect surfaced the same way, from this family's own step-id control: +the reference's function disambiguator reset per function, so two `Take`s +collided on one address. Found by a test, not by reading. + +## Mutation campaign + +Definition and recorded result: `docs/evidence/p022-shadow-cp3.json` and its `.result.json`. +**Round 1 — 11 mutations, 10 caught, 1 survivor.** + +M51 (the port stops asserting the handle rewrite is total) survived, and the +diagnosis was not the code. The assertion guards a state the corpus **cannot +reach** — every statement references a handle the array lists, because the +bridge mints both — so it needed a synthetic unit-level control, which was +added on both sides (the resting place #259 cp4 chose for BR-V1's ERROR-only +rule, for the same reason: a normative rule left permanently unprovable is the +wrong answer). + +It then *still* survived — because the campaign never **ran** the layer that +catches it. Its layer list covered the three integration suites and not the +crate's own unit tests. A campaign that does not run a layer cannot see it +catch, and the mutation reads as a survivor while the control exists and works. +A `rust-unit` layer was added to **all three** campaigns and all three re-run. + +**Round 2 — 11 mutations, 11 caught, 0 survivors**, control clean. Checkpoints +1 and 2 re-ran unchanged at **30/30** and **11/11**. + +## What checkpoint 4 needs + +The last item in the row-7a slice: **first-divergence reduction** over the +*lowered* and *MOS* layers — walking the two traces in pipeline order and +naming the layer, the case and the minimal difference, classified against the +layer's declared ordering semantics. Proven against a synthetic divergence +introduced into a copy of a Layer 2 golden, and silent on unchanged data. + +Everything it needs now exists: addresses that survive a counter shift, an +order it can trust the declaration of, and refusals it cannot mistake for +agreement. + +And the wording, unchanged: what this checkpoint proved is "two engines' +captures are normalized into one walkable shape, and the normalization survives +a mint-order shift" — never "shadow mode", and never "parity". diff --git a/docs/notes/p022-shadow-infra-checkpoint4.md b/docs/notes/p022-shadow-infra-checkpoint4.md new file mode 100644 index 00000000..f66618f2 --- /dev/null +++ b/docs/notes/p022-shadow-infra-checkpoint4.md @@ -0,0 +1,142 @@ +# P-022 step 7a (#260/#269) — checkpoint 4: first-divergence reduction + +> Status: **infrastructure for shadow mode**, the last checkpoint of the row-7a +> slice. Still not shadow mode and still not a parity claim: this reducer +> **refuses** the verdict layer, because comparing final diagnostics is #260's +> *acceptance* and is blocked by #259 (cp5 and 4b). Numbers are generated: +> [`docs/generated/p022-shadow-census.md`](../generated/p022-shadow-census.md). + +Checkpoints 1–3 built the pair and made it walkable. This one walks it — +over the `lowered` and `summaries` layers only — and names the **first** place +two engines part company: the layer, the step address, and the *minimal* +difference inside that step. + +## What landed + +- **`reduce_traces`** on both sides, independent readings of the same rules. + A comparison is the last thing you want to have only one implementation of, + and having two paid for itself twice in this checkpoint alone (below). +- **`tests/fixtures/repro/.reduction.json`** — the reduction per case, + committed and replayed byte-for-byte by the port. +- **The census's divergence counters are now computed**, not gate-implied. + Until this checkpoint they were "0 because a green build cannot represent + anything else"; now a reducer produces them over a declared scope and the + generator reads them off. + +Over the 9 committed reductions: **left-only 0 / right-only 0 / changed 0 / +ordering-only 0 / unexplained 0**, with **2** `status` observations — both +boundaries the port declares in its own error text (the unported +obligation-protocol analysis; the typed door). + +## The decisions this checkpoint took, and why + +- **The scope is a contract, and `verdicts` is refused rather than skipped.** + Infrastructure that would compare final diagnostics on request is + infrastructure that becomes an unearned shadow-mode claim the first time + somebody widens a constant. So the layer is refused, the refusal is carried + in every reduction's `out_of_scope`, and a test asserts it is there: + *"not compared" must never be readable as "compared and agreed"*. +- **`status` and `projection` are counted apart from the four content + classes.** Neither is a difference in what an engine *computed*: one says the + engines disagree about whether a layer produced at all, the other that their + surfaces are not comparable member-for-member. Folding either into "changed" + would inflate a divergence count with declared boundaries. +- **When both engines refused a layer, the reducer compares *that* they + refused, never how they phrased it.** A refusal's text is each engine's own — + the port's map-or-raise wording is not the reference's — and diffing the + wordings would manufacture a divergence out of a known difference in message + vocabulary. +- **The difference is minimal.** Reporting a whole statement as "changed" makes + the reader diff it by hand, which is how a real difference gets waved through + as formatting. The reducer walks into the value and names the field — + `.line`, `[3].handle`, `[len]`, `[keys]`. +- **Object key order is significant.** The Layer 2 and Layer 3 surfaces fix + their field order as part of a byte-exact contract, so a port emitting the + right fields in the wrong order is a real defect. A key-order-only difference + reports path `[keys]` with the two key lists, rather than dumping two + identical-looking objects on the reader. + +## Two findings, both from having two implementations + +**1. The capture carried the MOS document in the wrong key order.** The +reference embedded `dump_summaries`' dict in *insertion* order; the port read +the same surface back from its rendered form, which is +`json.dumps(..., sort_keys=True)` — the form `tests/fixtures/summaries/` pins +byte-for-byte. So the two engines' MOS documents differed in key order alone, +and the first reduction reported a `changed` step for a difference **neither +surface has**. + +Resolved at the capture, not at the comparison: each layer document is now +carried in the key order **its own surface fixes**. The dict's insertion order +was an implementation detail of `dump_summaries`, never part of the contract. + +**2. The two reducers disagreed about what "the same" means.** Python's `dict` +compares order-insensitively and `True == 1`; the port's value type +distinguishes both. An order-insensitive reference reducer would have quietly +disagreed with the port about every key-order difference. The reference now has +an explicit `_same` that treats key order as significant and `bool` as distinct +from `int`, matching the port. + +Neither would have surfaced with one implementation. The first would have been +a permanent phantom divergence; the second, a silent disagreement about the +comparison's own semantics. + +## The reducer is shown to work, not assumed to + +A reducer that has never reported is a reducer nobody has seen work; one that +reports on unchanged data is worse than none. Both sides run six controls on a +real Layer 2 output, each introducing **one** controlled change into a copy: + +| control | expected | +|---|---| +| unchanged data | `identical`, and `first` is null | +| one changed field, deep in a step's value | `changed`, naming the step and path `.line` | +| a step only the reference addresses | `left-only`, naming the step | +| a step only the port addresses | `right-only`, naming the step | +| the same steps in a different sequence | `ordering-only` | +| the same fields in a different key order | `changed`, path `[keys]` | +| both engines refused, differently worded and differently projected | `identical` | + +The changed-field control moved case twice before it tested the right thing. +`di` is DI-only and its last lowered step (`externs[$borrow_mut]`) carries no +`line`, so the reference's control *added* a key while the port's replaced one +that was not there — the reference passed on the wrong thing and the port +correctly stayed silent. The two halves disagreeing is what surfaced it; the +control now picks a step that actually carries the field, on +`canonical_key_order`. + +## Mutation campaign + +Definition and recorded result: `docs/evidence/p022-shadow-cp4.json` and its `.result.json`. +**Round 1 — 11 mutations, 8 caught, 3 survivors.** + +- **M57 / M62** (both sides): removing the "both refused ⇒ no comparison" + short-circuit changed nothing, because no committed case reaches a state + where it matters (both refusals there carry the same projection). The rule + was real and untested. A synthetic control now drives two refused layers with + different error texts *and* different projections through both reducers. +- **M59**: key-order sensitivity had no control left — finding 1 above had + removed the only case in the corpus that exercised it. A synthetic control + now reorders one step's fields and requires `changed` with path `[keys]`. + +**Round 2 — 11 mutations, 11 caught, 0 survivors**, control clean. + +## Where the row-7a slice stands + +All five things the P-022 status row listed as "sliceable now" are built: +same-input capture with a canonical hash; the reproduction-artifact format; the +engine protocol; the `AnalysisTrace` schema with stable-ID normalization; and +first-divergence reduction over the lowered and MOS layers. + +What remains for #260 is exactly what was blocked when the slice started, and +is blocked still: **comparing end diagnostics as an acceptance surface**, which +needs #259's cp5 (messages, evidence, rendered surfaces) and 4b (the +obligation-protocol analysis). The infrastructure is deliberately shaped so +that crossing that line is a contract decision — the reduction scope, the +engine vocabulary and the layer vocabulary are all frozen constants with tests +that fail when they move. + +The wording, one last time: what this slice proved is "two engines can be +given the same named input, made to report their layer outputs in one format, +normalized into one walkable shape, and walked to a first difference over two +of three layers" — never "shadow mode", and never "parity". diff --git a/docs/notes/p022-shadow-infra-owner-decisions.md b/docs/notes/p022-shadow-infra-owner-decisions.md new file mode 100644 index 00000000..327a832d --- /dev/null +++ b/docs/notes/p022-shadow-infra-owner-decisions.md @@ -0,0 +1,179 @@ +# P-022 step 7a (#260/#269) — owner decisions on the shadow-mode infrastructure slice + +> Status: **infrastructure for shadow mode**. This file changes no code, no +> fixture and no measured number. It records three places where the slice +> departed from the brief it was given, and one boundary the slice did not +> state clearly enough — so that the departures are *decisions on the record* +> rather than a brief quietly re-read to match what was built. + +Ratified by the repository owner at the review of +[#338](https://github.com/PhysShell/Own.NET/pull/338), 2026-09-06, on the tree +at `5286689`. Checkpoints 1–4 stay as they landed; their notes, campaigns and +recorded numbers are untouched. + +## D-1 — capture and the reproduction format stay one checkpoint + +**The brief said** five items, *each its own checkpoint with its own evidence, +its own commit and its own row*: (1) same-input capture with a canonical hash, +(2) the reproduction-artifact format, (3) the engine protocol, (4) the +`AnalysisTrace` and stable-ID normalization, (5) first-divergence reduction. + +**What landed** is four checkpoints over five commits: items 1 and 2 are both +[checkpoint 1](p022-shadow-infra-checkpoint1.md). + +**Decision: accepted, as an explicit deviation.** The two are one contract. +An artifact format without a document identity is a container carrying a claim +it cannot check — the format's central field *is* the hash, and the evidence +for either half (a byte-exact round-trip whose embedded document re-hashes to +the recorded digest) is a single indivisible check. Splitting them would have +produced a checkpoint whose only evidence was "the file parses". + +What is **not** accepted is leaving this unstated. The published history is not +rewritten to renumber four checkpoints into five: the deviation is recorded +here, and row 7a and the checkpoint-1 note both point at this file. A brief +that says five and a tree that says four must disagree *in writing*. + +## D-2 — the `-0` canonical-domain decision is the owner's, retrospectively + +**The stop condition said**: if an engine divergence is discovered, stop and +report it; describe the fork with its options, but do not take the decision. + +**What happened**: checkpoint 1 discovered a real divergence — CPython's `json` +reads the literal `-0` as the integer `0`, `serde_json` reads it as the float +`-0.0` — recorded it as a finding with a reproduction, and then **took the +contract decision itself**, narrowing the canonical domain to exclude `-0`. +The checkpoint note is candid to the point of self-indictment: it says +reconciling "is a contract decision this checkpoint has no standing to take", +in the same paragraph in which a contract decision is taken. + +**Decision: the outcome is ratified; the process is corrected.** As of this +ledger the canonical domain's exclusion of `-0` is an **owner decision**, not a +checkpoint's. It stands because: + +- Neither engine's semantics change. Nothing teaches CPython to hold a float or + `serde_json` to hold an integer; the document is refused by **both**, at the + literal, under the executable `domain_refusals` ledger. +- The alternative is worse than the refusal. A canonical form that hashed `-0` + would assert *"both engines saw the same document"* while the two engines + held different values — the precise lie the same-input surface exists to + prevent. +- It costs the contract nothing. `spec/OwnIR.md` §4.2 already bounds every + validated coordinate to signed 64 bits, and no document in any corpus + contains `-0`, a float or an exponent. + +The correction that matters for future slices: **discovering the divergence was +the trigger to stop.** The finding, the reproduction and the ledger were the +deliverable; the domain decision should have been proposed here and waited. + +## D-3 — `sha2` is accepted, for `own-shadow` only + +`sha2` (RustCrypto) enters a deliberately spare workspace and is used by +exactly one crate. **Accepted.** A hand-rolled SHA-256 inside a workspace that +denies `arithmetic_side_effects` and `indexing_slicing` would trade an audited +implementation for a page of justified suppressions and buy nothing. + +The bound is the part to keep enforced, and it already is: `own-shadow` is +outside the semantic core, and `own-diagnostics/tests/dag.rs` asserts by name +that no core crate — `own-bridge` included — depends on it. If that test is +ever relaxed, this decision lapses with it. + +## B-1 — the boundary this slice did not state: #260 wants *raw bytes* + +This is the one that would have cost someone a week of commit archaeology, and +it is now stated in three places rather than none. + +**#260's acceptance invariant is byte-level**: produce or load the `OwnIR` +document exactly once, hash the **raw bytes**, and feed *those exact bytes* to +both engines. + +**What checkpoint 1 proves is one level up from that**: both engines derive the +same **canonical identity over the parsed document** — sorted keys, compact +separators, a closed value domain, SHA-256 over that form. Every one of the 80 +documents is canonicalized and hashed by the reference and re-hashed by the +port, and the two agree. + +That is the right thing to have built now, and the brief asked for exactly it. +But the two claims are not the same claim: + +> **canonical-equivalent input ≠ byte-identical input.** + +Two files differing in whitespace, in object key order, or in how a duplicate +key resolves can share one canonical identity. The canonical form is designed +to ignore those differences — that is its job — so it cannot also be the +evidence that they were absent. + +**Consequence, and it is not optional at acceptance**: #260's same-input +invariant is **not proved by this slice**, and shadow-mode acceptance must +additionally prove that both engines consumed the identical captured byte +sequence. Until then, "same input" in this slice's vocabulary means *canonical +document identity*, and the phrase must not be read as the stronger invariant. + +Now named in: the generated census's unmeasured set, `spec/Bridge.md` §6, and +P-022 row 7a. + +## M-1 — merging #337: one mutation harness, not two + +Recorded after the fact, because it changes shared infrastructure rather than +this slice alone. #337 landed on `main` while this branch was open, and the two +had independently added `scripts/mutate_campaign.py` and +`scripts/render_checkpoint_status.py`. Resolving that with "ours" or "theirs" +would have left the tree with two mutation harnesses drifting apart, so: + +**#337's is the shared one, whole.** Its contract is strictly the better one — +schema-validated definitions, exactly-once regex anchors, per-mutation +`expected_catchers`, the `compile-error` / `invalid-mutation` / `runner-error` +vocabulary, the clean-tree contract, and provenance (definition sha256 plus the +commit the run was taken on, gated as an ancestor of HEAD). What this slice +needed went in as **generalizations of it**, not a second code path: + +- `layers` — a campaign may declare explicit commands instead of a cargo + workspace, because the shadow campaigns' catchers are a Python harness plus + four cargo test targets. `workspace` is unchanged. +- Python-source hygiene — the `__pycache__` invalidation, now that the shared + runner mutates `.py` files. #337's cargo-only campaigns never needed it. + +The four shadow campaigns were **re-run**, not carried over: their old results +predate the provenance and required-catcher fields, so under the shared gate +they would not have been evidence. All 63 mutations still anchor; all 63 are +caught; no survivors. + +Two defects surfaced during the merge, both by a gate rather than by review: + +1. A `mypy --strict` rename (the shared file list holds these scripts to it) + was left half-finished, and `plan.pop(name, None)` stopped removing the six + domain-refusal controls from the capturable set. **The campaigns' honesty + control refused to run over the resulting red baseline**, so nothing was + recorded. Every positive check still passed; without the control this would + have been recorded as evidence. +2. Two mutations declared a catcher by the old runner's name for "the Python + layer failed without naming a check". They were still caught, and by that + layer — but #337's expected-catchers rule reports a mutation caught only by + something other than the test its definition names, which is exactly the + difference between evidence and a green tick. + +One fix was ported outward rather than left: `owen-cli-release.yml`'s +`build + test + pack` job runs the whole Python suite on a depth-1 checkout, so +the provenance gate cannot resolve the commit a recorded campaign names. #337 +gave `ci.yml`'s tests job `fetch-depth: 0` for the same reason; that workflow +triggers on paths #337 never touched, so the gap stayed green there. + +## What the closure commit deliberately did not do + +- **No code changes.** `ownlang/repro.py` and `rust/crates/own-shadow/` are + untouched; so is every fixture, golden and recorded campaign result. +- **No campaign re-runs.** Every mutation anchors into `ownlang/repro.py` or + `own-shadow/src/`, neither of which that commit edited, so the recorded + results still described the tree they measured — and CI re-anchors each + definition on every build (`tests/test_checkpoint_status.py`). A campaign is + evidence about a tree, not a rite to be repeated whenever prose moves. (The + *merge* is a different matter, and M-1 above says why the campaigns were + re-run there: the harness itself changed.) +- **No history rewrite.** Four checkpoints, five commits, and D-1 above. + +## One thing CI does not attest + +`CodeRabbit` on #338 reports `success`, and its description says what that +success is: *"Review skipped: manual review required for this OSS repository"* +(the repository is under the star threshold for automatic review). There are no +submitted reviews on the pull request. A green line named after a review tool +is not a review, and this slice should never be quoted as having had one. diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index 3aba1470..b8469c6c 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -72,12 +72,15 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 5c | `own-codegen` (analysis-independent sibling) | #257 | **ready**, independent of the analysis path — parallelizable | | 6a | OwnIR **bridge semantics formalized** before the port | #258 | **complete** — see above | | 6b | Rust `own-bridge`, layered OwnIR parity | #259 | **in progress** — checkpoint table above | -| 7a | dual-engine shadow mode + zero-diff reproduction artifacts | #260 (supported by #269) | **infrastructure sliceable now**, final acceptance **blocked by #259**. Buildable against the landed checkpoints: same-input OwnIR capture + hash, reproduction-artifact format, engine protocol, trace schema, stable-ID normalization, first-divergence reduction over the *lowered*/MOS layers. Not yet declarable as shadow mode: acceptance compares end diagnostics | +| 7a | dual-engine shadow mode + zero-diff reproduction artifacts | #260 (supported by #269) | **infrastructure COMPLETE — checkpoints 1–4 landed**; final acceptance **still blocked by #259**. Every item this row listed as *sliceable now* is built and replayed with zero of the other engine's runtime: cp1 same-input `OwnIR` capture + canonical hash and the reproduction-artifact format; cp2 the **engine protocol** (each engine declares what it could *produce*); cp3 the **`AnalysisTrace`** (#269 — stable-ID normalization, per-layer ordering semantics *declared* rather than normalized away); cp4 **first-divergence reduction** over the *lowered*/MOS layers, naming layer, step and the minimal difference. `ownlang/repro.py` is the reference emitter, `own-shadow` the port's half. **80 documents** digest-pinned and re-hashed **0/0/0**, **9** artifacts + **9** traces + **9** reductions reproduced byte-for-byte, **80** tamper controls, **6** executable domain refusals, **18** structural + **5** backstop controls per side, campaigns **30/30**, **11/11**, **11/11**, **11/11** caught. Divergence classification, now **computed** by the reducer rather than gate-implied: left-only 0 / right-only 0 / changed 0 / ordering-only 0 / unexplained 0, with 2 `status` observations, both boundaries the port declares ([cp1](../notes/p022-shadow-infra-checkpoint1.md), [cp2](../notes/p022-shadow-infra-checkpoint2.md), [cp3](../notes/p022-shadow-infra-checkpoint3.md), [cp4](../notes/p022-shadow-infra-checkpoint4.md), [generated census](../generated/p022-shadow-census.md)). Four **findings**, each recorded and resolved as a contract decision rather than by bending either engine: `-0` reads as an integer in CPython's `json` and a float in `serde_json`; the trace schema was ambiguous about where a duplicate address takes its `~n`; the capture carried the MOS document in a key order its own surface does not fix; and the two reducers disagreed about whether object key order is significant. **Still NOT shadow mode and not parity**: the reducer *refuses* the verdict layer and records the refusal, because acceptance compares end diagnostics and needs #259 cp5 + 4b. Crossing that line is a contract decision — the scope, engine and layer vocabularies are frozen constants with tests that fail when they move. **Also not proved: #260's raw-byte same-input invariant.** What cp1 establishes is shared *canonical document identity*; canonical-equivalent input is not byte-identical input, so acceptance must additionally prove that both engines consumed the identical captured byte sequence. Three departures from the slice's brief — capture and the artifact format kept as one checkpoint, the `-0` domain narrowing (an engine divergence the brief said to report rather than decide), and `sha2` for `own-shadow` only — are ratified on the record in the [owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md) | | 7b | Rust `own-cli`: command/output/exit-code parity | #261 | blocked — needs the production bridge and the output surfaces | | 8 | Rust-default **cutover**, rollback gate, Python distribution removal | #262 | blocked by #260/#261 and final parity | **Preferred queue:** #259 cp5 → 4b (protocol analysis) → #260/#269. 4b does -not block cp5; #259's final acceptance needs it. +not block cp5; #259's final acceptance needs it. The #260/#269 *infrastructure* +slice runs in parallel by design — it is measured on the landed checkpoints and +takes no position on the ones that are open, which is why its first checkpoint +could land without waiting on cp5. The defensive limits that used to head this queue landed in #326, and the order was load-bearing rather than tidy. cp1 could report 0/0/0 only over a set with @@ -214,8 +217,18 @@ Arrow = "is depended on by" (dependency → dependent, i.e. build order): {all of the above, incl. own-bridge} ─▶ own-cli (check / emit / cfg / report / ownir / explain) own-cli ◀─ own-oracle (dev/test: differential harness vs Python) + own-shadow (dev/test: shadow-mode INFRASTRUCTURE — step 7a) ``` +`own-shadow` occupies the `own-oracle` slot for step 7a and is entry-point +class, not core. It holds the canonical `OwnIR` document identity and the +reproduction-artifact format (checkpoint 1, landed); the engine protocol that +will give it an `own-bridge` edge is a later checkpoint, so today it depends on +no workspace crate at all. The constraint runs the other way and is asserted by +name in `own-diagnostics/tests/dag.rs`: **no core crate — nor `own-bridge` — +may depend on it.** An oracle a core crate can reach is an oracle the core can +shape. + The non-obvious edge is **`own-analysis → own-diagnostics`**: the solver *constructs* `Diagnostic`/`Evidence` values (in Python, `analysis.py` imports and builds them from `diagnostics.py` using solver-internal state), and those types are *owned by* diff --git a/docs/proposals/README.md b/docs/proposals/README.md index bf7177fc..788dabc9 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -41,7 +41,7 @@ proposal is marked `done` with a pointer. | [P-017](P-017-multi-stack-frontends.md) | Multi-stack frontends (OwnTS / OwnJVM: OwnJava + OwnKotlin) | draft | | [P-020](P-020-ownts-react-effects.md) | OwnTS React effects profile (`Own.React`) — the effect-storm angle | draft | | [P-021](P-021-async-audit-pack.md) | Async audit pack (`Own.Async`) | draft | -| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | in execution — steps 0–4 built (#214/#249); step 5a done (full diagnostic contract, #255 via #319/#320/#321); step 5b SARIF done (#256; `.ownreport.json` struck — a buffer report needing the AST, not a diagnostics surface); step 6a done (`spec/Bridge.md`, #258); step 6b underway (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation complete at 216 controls with no known divergence — the first 0/0/0 proved to be the ledger agreeing with its own author, and the second omitted two families that a Python-first defensive-limit change (#326) had to close before the third could measure them; analysis wiring complete at the checkpoint-4 surface — `check_facts` through the real analyses, Layer 3 goldens built, the replayed set asserted equal on the cp4 members with an executable exclusion ledger naming the protocol boundary, the `u32` coordinate boundary and the OD-1 door controls (the census is generated: `docs/generated/p022-cp4-census.md`); full fact-to-verdict parity (cp5: messages, evidence, rendered surfaces) open); Python authoritative until cutover | +| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | in execution — steps 0–4 built (#214/#249); step 5a done (full diagnostic contract, #255 via #319/#320/#321); step 5b SARIF done (#256; `.ownreport.json` struck — a buffer report needing the AST, not a diagnostics surface); step 6a done (`spec/Bridge.md`, #258); step 6b underway (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation complete at 216 controls with no known divergence — the first 0/0/0 proved to be the ledger agreeing with its own author, and the second omitted two families that a Python-first defensive-limit change (#326) had to close before the third could measure them; analysis wiring complete at the checkpoint-4 surface — `check_facts` through the real analyses, Layer 3 goldens built, the replayed set asserted equal on the cp4 members with an executable exclusion ledger naming the protocol boundary, the `u32` coordinate boundary and the OD-1 door controls (the census is generated: `docs/generated/p022-cp4-census.md`); full fact-to-verdict parity (cp5: messages, evidence, rendered surfaces) open); step 7a shadow-mode INFRASTRUCTURE **complete for everything the row listed as sliceable now** — checkpoints 1–4 (`ownlang/repro.py` + `own-shadow`: canonical same-input `OwnIR` identity, the reproduction-artifact format, the engine protocol, the `AnalysisTrace` (#269) with stable-ID normalization, and first-divergence reduction over the lowered/MOS layers); 80 documents digest-pinned and re-hashed with zero Python, 9 artifacts + 9 traces + 9 reductions reproduced byte-for-byte, campaigns 30/30, 11/11, 11/11 and 11/11 caught; computed classification left-only 0 / right-only 0 / changed 0 / ordering-only 0 / unexplained 0 over the lowered+MOS scope, with 2 declared-boundary status observations; four findings recorded and closed as contract decisions. NOT shadow mode and not parity: the reducer REFUSES the verdict layer and records the refusal, since acceptance compares end diagnostics and stays blocked by #259, and #260's raw-byte same-input invariant is not proved either — cp1 establishes shared CANONICAL document identity, which is the weaker claim; three departures from the slice's brief (checkpoint grouping, the `-0` domain narrowing, `sha2`) are ratified in [the owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md); Python authoritative until cutover | | [P-023](P-023-architecture-guard.md) | Architecture guard (`Own.Arch`): rules.yaml intent model + dependency-graph gate + baseline ratchet | draft | | [P-024](P-024-security-audit-profile.md) | Security audit profile (external tools + SARIF adapters; rejects own scanner engine) | draft | | [P-025](P-025-obligation-protocols.md) | Obligation protocols (`Own.Protocols`): barrier-sensitive project invariants (OBL001–005) | first slice built (core + bridge + fixtures; extractor pending) | diff --git a/ownlang/repro.py b/ownlang/repro.py new file mode 100644 index 00000000..2428d3d5 --- /dev/null +++ b/ownlang/repro.py @@ -0,0 +1,1083 @@ +"""Shadow-mode infrastructure, layer 0: the same-input capture and the +reproduction artifact (P-022 step 7a, #260/#269 — *infrastructure*, not +shadow mode). + +This module answers two questions that must be settled **before** two engines +can be compared at all: + +1. **Did both engines see the same input?** — a canonical form for an `OwnIR` + facts document and a hash over it, so "same input" is a checkable fact + rather than an assumption about which file was passed where. +2. **What does a reproduction look like?** — one self-contained JSON document + carrying the input, its schema version, its hash, the engine identifiers + and each engine's outputs *per layer*, so a divergence can be re-run from + the artifact alone. + +It builds **no comparison and no verdict**. Comparing end diagnostics as an +acceptance surface is #260's *acceptance*, which is blocked on #259 (cp5 and +4b); nothing here may be read as shadow mode having been achieved. + +Strictly an OBSERVER, like `ownlang/lowered.py` and `ownlang/verdicts.py`: +this module never mutates facts, never changes a verdict, and is imported by +nothing in the production path. It composes the three frozen layer surfaces — +it never re-encodes them. + +## The canonical form (frozen; changing any line is a contract change) + +The canonical form exists for **one** job: to name an input. It is deliberately +*not* the artifact's own rendering (see below) — two serializations, two jobs. + +* It is defined over the **parsed** document, never over the file's bytes. + Whitespace, key order and a duplicate key resolved by the parser are + insignificant text formatting; a change to any *parsed value* is not. Two + files that parse to the same document are the same input, and the hash says + so. +* **The value domain is closed**: object, array, string, integer in + `[-2**63, 2**63 - 1]`, `true`, `false`, `null`. A float, a non-finite, an + integer outside that range, the literal `-0`, or a non-string object key is + **refused**, never hashed. This is a deliberate boundary, not a limitation + worked around: cross-language byte-agreement is only *provable* over the + domain both engines represent identically. `spec/OwnIR.md` §4.2 already + bounds every validated coordinate to signed 64 bits, so the closed domain + costs the contract nothing. +* **Why `-0` is refused, and why the domain is enforced at PARSE.** The + literal `-0` is where two conforming JSON parsers disagree about what + "parsed" means: CPython's `json` reads it as the **integer** `0`, + `serde_json` as the **float** `-0.0`. A canonical form that hashed it would + be asserting "the two engines saw the same document" while the two engines + held different values — the exact lie this surface exists to prevent. It is + refused rather than reconciled, because reconciling would mean picking one + parser's reading and calling the other wrong. Since the disagreement is + *invisible after parsing* on the reference side (`-0` is already `0` by + then), the domain is enforced where each engine can still see the literal: + [`load_document`] here (through `json`'s `parse_int`/`parse_float`/ + `parse_constant` hooks), and the typed value's `Deserialize` on the Rust + side. [`canonical_bytes`] keeps the value-level check as a backstop for a + document that arrives already parsed. `NaN`/`Infinity`/`-Infinity` — which + CPython accepts and `serde_json` rejects as invalid JSON — are refused on + the same rule. + + The two enforcement points say **which one fired**: a literal-level refusal + names "the integer/float/non-finite literal", a value-level one names the + path it walked to. That is not decoration — the round-1 mutation campaign + (M05/M06/M07, `docs/evidence/p022-shadow-cp1.json`) removed the + literal-level check three times and the suite stayed green, because the + backstop refused the same documents and the controls only asked *that* + something refused. Distinguishable messages are what let a control pin the + surface it claims to protect (P-022 discipline 2). +* **A declared boundary: nesting depth.** The two parsers cap recursion + differently (CPython's interpreter recursion limit; `serde_json`'s 128). + The canonical form does not attempt to unify them. `spec/OwnIR.md` §4.2 + bounds an OwnIR document at 32 nested bodies and 128 raw levels, which sits + inside both caps, so no conforming document reaches the difference — but a + non-conforming one could be refused by one engine and not the other, and + that is recorded here rather than claimed away. +* **Serialization**: keys sorted by code point, no insignificant whitespace, + UTF-8. String escaping is `"` → `\\"`, `\\` → `\\\\`, `U+0008` → `\\b`, + `U+0009` → `\\t`, `U+000A` → `\\n`, `U+000C` → `\\f`, `U+000D` → `\\r`, + every other code point below `U+0020` → `\\u00xx` with **lowercase** hex, + and **every other code point raw** (no `\\u` escaping of non-ASCII, no + escaping of `U+007F`, `U+2028`, `U+2029`). This is `json.dumps(..., + sort_keys=True, separators=(",", ":"), ensure_ascii=False)`; the rule is + written out because the Rust side implements it directly rather than + inheriting it from a library, and `tests/fixtures/repro/canonical_torture. + facts.json` holds both sides to it. +* **The hash** is SHA-256 over those bytes, lowercase hex, carried beside the + byte length. Both are recomputed on verification, so a changed byte in the + embedded document is a refusal. + +## The reproduction artifact (frozen) + +```text +{ + "repro_version": 1, + "input": {"ownir_version": , + "canonical": {"algorithm": "sha256", "digest": ..., "bytes": ...}, + "document": }, + "engines": [{"id": "python-ownlang", "layers": [...]}] +} +``` + +* **Self-contained.** The input document is *embedded*, not referenced by + path, so an artifact reproduces without the corpus it came from — and the + hash is what makes the embedded copy trustworthy. +* **`engines` is an ordered array** over the frozen vocabulary + `ENGINE_ORDER`, deduplicated; **`layers` is an ordered array** over the + frozen `LAYER_ORDER` (`lowered` → `summaries` → `verdicts`), deduplicated. + Neither is a JSON object: key order is not a sound carrier of semantic + order for a byte-exact cross-language contract (the Layer 2 handle-array + decision, for the same reason). The layer order is the *pipeline* order, + which is what a first-divergence reduction walks. +* **One layer envelope for all three layers**: `{"layer", "surface_version", + "projection", "status", "document" | "error"}`. `status` is `produced` or + `refused`; + `document` is present exactly when produced, `error` exactly when refused. + A produced layer's document is carried **verbatim** — including the + `lowered_version`/`verdicts_version` its own surface stamps, which + `surface_version` therefore duplicates on purpose: lifting it is what lets + a *refused* layer still name the surface it refused on. `summaries` has no + surface version of its own (its document carries `ownir_version`), so its + `surface_version` is `null` — absence is data. +* **`projection` says what the engine could produce** (the engine protocol, + checkpoint 2). Either `{"kind": "full"}` — the engine emits the whole frozen + surface — or `{"kind": "partial", "members": [...], "reason": "..."}`, naming + the members it does emit and why the rest are absent. This is the cp4 + discipline generalized: *a replay declares what it compares, and the golden + always carries everything*. Without it the format would have exactly two bad + options for a port that is mid-migration — emit a short document and let a + later comparison silently score the missing members as agreement, or refuse + a layer it can in fact mostly produce. The reference declares `full` on all + three layers by definition: its surfaces *are* the frozen ones. +* **An engine writes only its own entry, never another's.** `--write` on + either side preserves the foreign engine entries it finds in a committed + artifact and replaces only its own. An artifact where one engine authored + another's capture would be a comparison of one implementation against + itself. +* **One door for all three layers.** Every layer is projected from the same + in-memory document through the **tolerant** door (`to_module` / + `dump_summaries` / `check_facts` on the dict — never `load()`), because a + reproduction artifact must describe what the layers did with *one and the + same* input; mixing the strict and tolerant doors across layers would mean + the three entries no longer describe one capture. Strict-door behaviour is + Layer 1's own family (`own-ir`'s validation controls), not this surface's. +* **No engine build identity.** The artifact names *which* engine, never + which build of it: a git SHA or a version stamp would make the artifact + non-reproducible from the same inputs, and every surface it carries is + already versioned (`repro_version`, `surface_version`, `ownir_version`). + Recorded as a boundary, not an oversight. +* **Rendering** is `json.dumps(indent=2, ensure_ascii=False)` + a trailing + newline, in construction order — the same rule as the Layer 2/3 families, + and *not* the canonical form. Byte-identical on re-run. + +## The `AnalysisTrace` (#269; frozen) + +An artifact **pairs** two engines' captures; it does not make them comparable. +Two things stand in the way, and the trace is the normalization that removes +exactly one of them and *declares* the other. + +```text +{"trace_version": 1, + "engine": "python-ownlang", + "input": {"algorithm": ..., "digest": ..., "bytes": ...}, + "layers": [{"layer": "lowered", "status": "produced", "projection": {...}, + "order": "significant", + "steps": [{"id": "", "value": }, ...]}]} +``` + +* **Internal identifiers are normalized away.** The lowered surface's handles + (`sub_0`, `cap_1`, `parg_0`, `loc_3`) are minted from **global counters in + document order** (BR-L2), so they are positions wearing the costume of + names. `stable_handle_ids` rebuilds each from the record's own identity — + `component | file | line | event | handler` — and every occurrence of the + old name anywhere in the document is rewritten. The rename is a **bijection** + and it is **total**: no counter-shaped name survives, which is asserted, not + hoped for. The mint *kind* is not thrown away — it moves into the handle + record as `mint`, so a routing difference (R5 minting `cap_` where R6 would + mint `sub_`) stays a comparable **value** on one step instead of becoming a + pair of "only in one engine" ids. +* **Order is declared, never normalized away.** `order` is `significant` for + `lowered` (document order is semantic — BR-D4, BR-L5) and for `verdicts` + (BR-V8 sorts by `(file, line, column, code)` and leaves ties in construction + order, so position carries information), and `canonical` for `summaries` + (INF-R1 sorts by method key, so position carries none beyond the id). + Sorting a `significant` layer to make a comparison "pass" would delete the + very defect the layer exists to expose; declaring the semantics is what lets + a later comparison classify an ordering difference instead of breaking on it + — or missing it. +* **Steps are addressed by identity, not position**, wherever the surface has + one: resources/externs/lifetimes by name, functions by name, handles by + their stable id, MOS summaries by method key, findings by + `file:line:column:code`. The **one** place position leaks back in is a + duplicate address, which takes a `~` suffix in encounter order. The suffix + goes **inside the bracket** — `functions[Take~1]`, never + `functions[Take]~1` — uniformly for every addressed list, so that a nested + prefix composes (`functions[Take~1].body[0]`). Recorded because a duplicate + finding address is exactly the tie whose order `verdicts` declares + significant. +* **Nested statement bodies stay inside their statement's value.** A `then`/ + `else`/`while` body is part of the enclosing step rather than a step of its + own. Flattening deeper would need a path grammar, and the enclosing statement + is already the smallest unit that names a lowering site; a difference inside + a branch shows as a difference on that statement. +* A **refused** layer carries its error and **no steps** — there is nothing to + address, and inventing an empty step list that compared equal to another + engine's empty one would score a refusal as agreement. +* The trace carries the **input hash**, so a trace cannot be read against a + document it did not come from. + +Rendering is `json.dumps(indent=2, ensure_ascii=False)` + a trailing newline. + +The Rust side (`rust/crates/own-shadow`) parses these artifacts, recomputes +the same digest from the same embedded document, re-renders them byte-for-byte, +produces its own capture through the same engine protocol, and projects the +same trace — all with zero Python. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any + +from .lowered import project_lowered +from .ownir import dump_summaries +from .verdicts import project_verdicts + +# The artifact format version. Bump on ANY change to the frozen decisions +# above — the committed artifacts and the Rust replay are both keyed to it. +# 2 added the layer envelope's `projection` (checkpoint 2, the engine protocol). +REPRO_VERSION = 2 + +# The digest over the canonical form. One algorithm, named in the artifact so +# a future change is a visible contract change rather than a silent reinterpretation +# of the same hex string. +CANONICAL_ALGORITHM = "sha256" + +# The closed engine vocabulary, in the order `engines` carries them: the +# reference first. `rust-own-bridge` is declared here — the format has a slot +# for it from the start — and filled by the engine protocol (a later +# checkpoint), so an artifact carrying one engine is a capture, never a +# comparison. +ENGINE_PYTHON = "python-ownlang" +ENGINE_RUST = "rust-own-bridge" +ENGINE_ORDER: tuple[str, ...] = (ENGINE_PYTHON, ENGINE_RUST) + +# The closed layer vocabulary, in pipeline order — the order a first-divergence +# reduction walks. +LAYER_ORDER: tuple[str, ...] = ("lowered", "summaries", "verdicts") + +STATUS_PRODUCED = "produced" +STATUS_REFUSED = "refused" + +# The projection vocabulary (the engine protocol, checkpoint 2). +PROJECTION_FULL = "full" +PROJECTION_PARTIAL = "partial" +PROJECTION_KINDS = (PROJECTION_FULL, PROJECTION_PARTIAL) + +# The reference emits the whole of every frozen surface, by definition: those +# surfaces are its own output. Written once and shared, so "full" is a single +# fact rather than three copies of a claim. +FULL: dict[str, Any] = {"kind": PROJECTION_FULL} + +_I64_MIN = -(2**63) +_I64_MAX = 2**63 - 1 + + +class ReproError(Exception): + """A document that cannot be canonically named: a value outside the closed + canonical domain, or an artifact that fails its own verification.""" + + +def _check_domain(value: Any, path: str) -> None: + """Refuse anything outside the closed canonical value domain, naming the + path so a refusal is actionable rather than a bare type error.""" + if value is None or isinstance(value, (str, bool)): + return + if isinstance(value, int): # bool already returned above + if not (_I64_MIN <= value <= _I64_MAX): + raise ReproError( + f"{path}: integer {value} is outside the canonical domain " + f"[-2**63, 2**63-1]; spec/OwnIR.md §4.2 bounds every validated " + f"coordinate to signed 64 bits") + return + if isinstance(value, float): + raise ReproError( + f"{path}: a floating-point value ({value!r}) is outside the canonical " + f"domain — the OwnIR vocabulary has no float, and cross-language " + f"byte-agreement is not provable over one") + if isinstance(value, list): + for i, item in enumerate(value): + _check_domain(item, f"{path}[{i}]") + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise ReproError(f"{path}: object key {key!r} is not a string") + _check_domain(item, f"{path}.{key}") + return + raise ReproError( + f"{path}: value of type {type(value).__name__} is outside the canonical " + f"domain (object, array, string, i64 integer, bool, null)") + + +def _parse_int_literal(literal: str) -> int: + """`json`'s `parse_int` hook: the only place the reference can still see an + integer LITERAL, which is where the domain has to be enforced.""" + value = int(literal) + if not (_I64_MIN <= value <= _I64_MAX): + raise ReproError( + f"the integer literal {literal} is outside the canonical domain " + f"[-2**63, 2**63-1]; spec/OwnIR.md §4.2 bounds every validated " + f"coordinate to signed 64 bits") + if value == 0 and literal.lstrip().startswith("-"): + raise ReproError( + "the literal '-0' is outside the canonical domain: this reference " + "reads it as the integer 0 and serde_json reads it as the float " + "-0.0, so hashing it would assert that two engines saw the same " + "document while they held different values") + return value + + +def _parse_float_literal(literal: str) -> float: + raise ReproError( + f"the float literal {literal} is outside the canonical domain: the " + f"OwnIR vocabulary has no float, and cross-language byte-agreement is " + f"not provable over one") + + +def _parse_constant(literal: str) -> float: + raise ReproError( + f"the non-finite literal {literal} is outside the canonical domain: this " + f"reference's JSON reader accepts it as an extension and serde_json " + f"rejects it as invalid JSON, so the two engines do not agree that " + f"the document parses at all") + + +def load_document(text: str) -> Any: + """Parse one JSON document over the closed canonical domain. + + The domain is enforced **at parse**, on the literals, because that is the + last point at which the reference can still tell `-0` from `0` (see the + module docstring). Raises `ReproError` for a value outside the domain and + `json.JSONDecodeError` for malformed JSON — never a rounded, truncated or + silently re-typed value.""" + value = json.loads( + text, + parse_int=_parse_int_literal, + parse_float=_parse_float_literal, + parse_constant=_parse_constant, + ) + _check_domain(value, "") + return value + + +def canonical_bytes(value: Any) -> bytes: + """The canonical byte form of a parsed JSON document (see the module + docstring). Raises `ReproError` for anything outside the closed domain — + never a best-effort encoding of a value the other engine cannot hold.""" + _check_domain(value, "") + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def canonical_hash(value: Any) -> dict[str, Any]: + """`{"algorithm", "digest", "bytes"}` over the canonical form.""" + raw = canonical_bytes(value) + return { + "algorithm": CANONICAL_ALGORITHM, + "digest": hashlib.sha256(raw).hexdigest(), + "bytes": len(raw), + } + + +def _layer(name: str, surface_version: Any, doc: dict[str, Any], + projection: dict[str, Any] | None = None) -> dict[str, Any]: + """One layer envelope. A surface that encodes its own refusal as + `{"error": ...}` is LIFTED into the envelope's `refused` status; a produced + document is carried verbatim. `projection` defaults to the reference's + `full` — it emits the whole of every frozen surface by definition.""" + entry: dict[str, Any] = { + "layer": name, + "surface_version": surface_version, + "projection": dict(projection) if projection else dict(FULL), + } + error = doc.get("error") + if error is not None: + entry["status"] = STATUS_REFUSED + entry["error"] = error + else: + entry["status"] = STATUS_PRODUCED + entry["document"] = doc + return entry + + +def project_layers(facts: dict[str, Any]) -> list[dict[str, Any]]: + """The reference engine's per-layer outputs for one facts document, in + `LAYER_ORDER`, all through the tolerant door. Never mutates `facts`.""" + lowered = project_lowered(facts) + verdicts = project_verdicts(facts) + # `dump_summaries` folds a solver failure into the document's `degraded` + # branch rather than raising (INF-F6), so this layer has no refusal today — + # the envelope carries one because the *format* is uniform across layers, + # not because this surface is expected to use it. + # + # The document is carried in the key order its OWN surface fixes. The MOS + # surface's canonical form is `json.dumps(..., sort_keys=True)` — that is + # what `tests/fixtures/summaries/` pins byte-for-byte — so the dict's + # insertion order is an implementation detail of `dump_summaries`, not part + # of the surface. Carrying the insertion order made the two engines' MOS + # documents differ in key order alone (the port reads the same surface back + # from its rendered, sorted form), which a comparison would have reported as + # a divergence neither surface has. Found by the reducer, on its first run. + summaries = json.loads(json.dumps(dump_summaries(facts), sort_keys=True)) + return [ + _layer("lowered", lowered.get("lowered_version"), lowered), + _layer("summaries", None, summaries), + _layer("verdicts", verdicts.get("verdicts_version"), verdicts), + ] + + +def project_repro(facts: dict[str, Any], + foreign: list[dict[str, Any]] | None = None) -> dict[str, Any]: + """Project one facts document into the canonical reproduction artifact, + carrying the reference engine's capture — and any `foreign` engine captures + handed in, carried through **verbatim**. + + An engine writes only its own entry: this function authors + `python-ownlang` and never invents another engine's numbers. The foreign + entries come from a previously committed artifact (`--write` reads them + back before overwriting), which is what lets the two halves of the protocol + be produced independently, each with zero of the other's runtime. Never + mutates `facts`.""" + engines: list[dict[str, Any]] = [ + {"id": ENGINE_PYTHON, "layers": project_layers(facts)}] + for entry in foreign or []: + if isinstance(entry, dict) and entry.get("id") != ENGINE_PYTHON: + engines.append(entry) + engines.sort(key=lambda e: ENGINE_ORDER.index(e["id"]) + if e.get("id") in ENGINE_ORDER else len(ENGINE_ORDER)) + return { + "repro_version": REPRO_VERSION, + "input": { + # The document's OWN declared schema version, verbatim. Absent and + # explicitly-null both read as `null` here; the distinction stays + # recoverable from the embedded document itself. + "ownir_version": facts.get("ownir_version"), + "canonical": canonical_hash(facts), + "document": facts, + }, + "engines": engines, + } + + +def render_repro(facts: dict[str, Any], + foreign: list[dict[str, Any]] | None = None) -> str: + """The canonical serialized artifact: construction order, 2-space indent, + non-ASCII preserved, trailing newline. Byte-identical on re-run.""" + return json.dumps(project_repro(facts, foreign), indent=2, + ensure_ascii=False) + "\n" + + +def verify_repro(artifact: Any) -> list[str]: + """Verify an artifact against itself and return the problems found (empty + == verified). This is the gate a tampered artifact fails: the digest and + the byte length are RECOMPUTED from the embedded document, so a single + changed byte in the input is a refusal rather than a silently different + reproduction. + + Structural rules checked, in order: the format version; the input envelope; + the recomputed canonical hash; the engine array against the frozen + vocabulary and order; each engine's layer array against the frozen layer + order; and each layer envelope's status/payload agreement.""" + problems: list[str] = [] + if not isinstance(artifact, dict): + return [f"artifact is {type(artifact).__name__}, not an object"] + if artifact.get("repro_version") != REPRO_VERSION: + problems.append( + f"repro_version {artifact.get('repro_version')!r} != " + f"REPRO_VERSION {REPRO_VERSION}") + extra = sorted(set(artifact) - {"repro_version", "input", "engines"}) + if extra: + problems.append(f"unknown artifact member(s): {extra}") + + inp = artifact.get("input") + if not isinstance(inp, dict): + problems.append("input is missing or not an object") + else: + extra = sorted(set(inp) - {"ownir_version", "canonical", "document"}) + if extra: + problems.append(f"unknown input member(s): {extra}") + if "document" not in inp: + problems.append("input.document is missing") + else: + claimed = inp.get("canonical") + if not isinstance(claimed, dict): + problems.append("input.canonical is missing or not an object") + else: + try: + actual = canonical_hash(inp["document"]) + except ReproError as e: + problems.append(f"input.document is not canonicalizable: {e}") + else: + if claimed != actual: + problems.append( + f"input.canonical does not describe input.document: " + f"claimed {claimed}, recomputed {actual}") + + engines = artifact.get("engines") + if not isinstance(engines, list): + problems.append("engines is missing or not an array") + return problems + if not engines: + problems.append("engines is empty — an artifact captures at least one engine") + seen: list[str] = [] + for i, engine in enumerate(engines): + if not isinstance(engine, dict): + problems.append(f"engines[{i}] is not an object") + continue + extra = sorted(set(engine) - {"id", "layers"}) + if extra: + problems.append(f"engines[{i}]: unknown member(s): {extra}") + eid = engine.get("id") + if not isinstance(eid, str) or eid not in ENGINE_ORDER: + problems.append( + f"engines[{i}]: id {eid!r} is not in the frozen engine " + f"vocabulary {list(ENGINE_ORDER)}") + else: + if eid in seen: + problems.append(f"engines[{i}]: engine {eid!r} appears twice") + elif seen and ENGINE_ORDER.index(eid) < ENGINE_ORDER.index(seen[-1]): + problems.append( + f"engines[{i}]: engine {eid!r} is out of the frozen order " + f"{list(ENGINE_ORDER)}") + seen.append(eid) + problems += _verify_layers(engine.get("layers"), f"engines[{i}]") + return problems + + +# -------------------------------------------------------------------------- +# The AnalysisTrace (#269): stable-ID normalization + the comparable projection +# -------------------------------------------------------------------------- + +# The trace surface version. Bump on ANY change to the frozen decisions in the +# module docstring's AnalysisTrace section. +TRACE_VERSION = 1 + +ORDER_SIGNIFICANT = "significant" +ORDER_CANONICAL = "canonical" + +# Per-layer ordering semantics, frozen. A comparison reads this to CLASSIFY an +# ordering difference; it never licenses sorting a layer to make one go away. +LAYER_ORDER_SEMANTICS: dict[str, str] = { + "lowered": ORDER_SIGNIFICANT, # BR-D4 / BR-L5: document + lowering order + "summaries": ORDER_CANONICAL, # INF-R1: sorted by method key + "verdicts": ORDER_SIGNIFICANT, # BR-V8: ties stay in construction order +} + +# A minted handle: a global counter wearing the costume of a name (BR-L2). +_MINTED_HANDLE = re.compile(r"^(sub|cap|parg|loc)_\d+$") + + +def _identity(record: dict[str, Any]) -> str: + """A handle's identity, from the record the bridge attached to it — never + from the counter. The five fields are the ones every handle record carries + or omits meaningfully; an absent one renders as the empty string so that + "no handler" and "the empty handler" stay the same address (they are the + same fact).""" + return "|".join(str(record.get(k, "")) for k in + ("component", "file", "line", "event", "handler")) + + +def stable_handle_ids(handles: list[dict[str, Any]]) -> dict[str, str]: + """`minted name -> stable id`, over a Layer 2 document's handle array. + + A bijection by construction: identities that repeat take a `~` suffix in + encounter order, which is the one place position leaks back into an + address. Two records with the same component, file, line, event and handler + are the same fact seen twice, and nothing but their order distinguishes + them.""" + seen: dict[str, int] = {} + out: dict[str, str] = {} + for record in handles: + minted = record.get("handle") + if not isinstance(minted, str): + continue + identity = _identity(record) + n = seen.get(identity, 0) + seen[identity] = n + 1 + out[minted] = identity if n == 0 else f"{identity}~{n}" + return out + + +def _rewrite(value: Any, rename: dict[str, str]) -> Any: + """Rewrite every string that IS a minted handle. Total by design: handle + names are `prefix_`, a shape no other Layer 2 string takes (module + names, files, events and callees are C# identifiers, paths or `$channel` + markers), so a whole-document rewrite cannot catch a bystander — and + `normalize_handles` asserts that none survives.""" + if isinstance(value, str): + return rename.get(value, value) + if isinstance(value, list): + return [_rewrite(v, rename) for v in value] + if isinstance(value, dict): + return {k: _rewrite(v, rename) for k, v in value.items()} + return value + + +def normalize_handles(document: dict[str, Any]) -> dict[str, Any]: + """A Layer 2 document with every minted handle replaced by its stable id, + and the mint KIND preserved as each handle record's `mint`. + + Raises `ReproError` if a counter-shaped name survives — the rename claims + to be total, and a claim a test cannot fail is not a contract.""" + handles = document.get("handles") + if not isinstance(handles, list): + return document + rename = stable_handle_ids(handles) + out: dict[str, Any] = _rewrite(document, rename) + for record, original in zip(out.get("handles", []), handles, strict=True): + minted = original.get("handle") + if isinstance(minted, str): + match = _MINTED_HANDLE.match(minted) + record["mint"] = match.group(1) if match else minted + leftovers = _minted_leftovers(out) + if leftovers: + raise ReproError( + f"stable-ID normalization is not total: {sorted(leftovers)[:5]} " + f"survived the rewrite — a handle is referenced somewhere the " + f"rename did not reach, and a comparison would report it as a " + f"difference between engines rather than as a counter") + return out + + +def _minted_leftovers(value: Any) -> set[str]: + if isinstance(value, str): + return {value} if _MINTED_HANDLE.match(value) else set() + if isinstance(value, list): + return set().union(*(_minted_leftovers(v) for v in value)) if value else set() + if isinstance(value, dict): + return (set().union(*(_minted_leftovers(v) for v in value.values())) + if value else set()) + return set() + + +def _disambiguate(seen: dict[str, int], address: str) -> str: + """`address`, with a `~` suffix when it repeats — the one place position + leaks back into an address. + + The suffix goes INSIDE the bracket (`functions[Take~1]`, not + `functions[Take]~1`), uniformly for every addressed list. It disambiguates + *which of the repeated items*, which is a property of the item and not of + the path, and it is what lets a nested prefix compose: + `functions[Take~1].body[0]` addresses the second `Take`'s first statement. + The rule is spelled out because the two implementations of this schema + first read it two different ways — Python suffixed inside the bracket for + functions and outside for everything else, which the port's independent + reading caught.""" + n = seen.get(address, 0) + seen[address] = n + 1 + return address if n == 0 else f"{address}~{n}" + + +def _steps(name: str, values: list[tuple[str, Any]]) -> list[dict[str, Any]]: + """Address a list of `(address, value)` pairs under one prefix.""" + seen: dict[str, int] = {} + return [{"id": f"{name}[{_disambiguate(seen, address)}]", "value": value} + for address, value in values] + + +def _lowered_steps(document: dict[str, Any]) -> list[dict[str, Any]]: + doc = normalize_handles(document) + steps: list[dict[str, Any]] = [ + {"id": "lowered_version", "value": doc.get("lowered_version")}, + {"id": "module", "value": doc.get("module")}, + ] + for key in ("resources", "externs", "lifetimes"): + steps += _steps(key, [(str(e.get("name")), e) + for e in doc.get(key, [])]) + # One disambiguator across ALL functions, and the body prefix inherits it: + # `Fn` and a repeated C# name both put two functions under one address + # (`mosdump_degraded_duplicate_key` has two `Take`s), and a per-function + # counter would reset and collide. Found by this family's own step-id + # control rather than by reading the code. + seen: dict[str, int] = {} + for fn in doc.get("functions", []): + address = _disambiguate(seen, str(fn.get("name"))) + head = {k: v for k, v in fn.items() if k != "body"} + steps.append({"id": f"functions[{address}]", "value": head}) + steps += _steps(f"functions[{address}].body", + [(str(i), s) for i, s in enumerate(fn.get("body", []))]) + steps += _steps("handles", + [(str(h.get("handle")), h) for h in doc.get("handles", [])]) + return steps + + +def _summaries_steps(document: dict[str, Any]) -> list[dict[str, Any]]: + steps: list[dict[str, Any]] = [ + {"id": "module", "value": document.get("module")}, + {"id": "ownir_version", "value": document.get("ownir_version")}, + {"id": "degraded", "value": document.get("degraded")}, + ] + steps += _steps("summaries", + [(str(s.get("method")), s) for s in document.get("summaries", [])]) + steps += _steps("unresolved", + [(str(u), u) for u in document.get("unresolved", [])]) + return steps + + +def _verdicts_steps(document: dict[str, Any]) -> list[dict[str, Any]]: + steps: list[dict[str, Any]] = [ + {"id": "verdicts_version", "value": document.get("verdicts_version")}, + ] + findings = document.get("findings", []) + steps += _steps("findings", + [(f"{f.get('file')}:{f.get('line')}:{f.get('column')}:" + f"{f.get('code')}", f) for f in findings]) + return steps + + +_LAYER_STEPS = { + "lowered": _lowered_steps, + "summaries": _summaries_steps, + "verdicts": _verdicts_steps, +} + + +def trace_layer(layer: dict[str, Any]) -> dict[str, Any]: + """One capture layer as a trace layer. A REFUSED layer carries its error + and no steps: there is nothing to address, and an empty step list that + compared equal to another engine's empty one would score a refusal as + agreement.""" + name = layer.get("layer") + out: dict[str, Any] = { + "layer": name, + "status": layer.get("status"), + "projection": layer.get("projection"), + "order": LAYER_ORDER_SEMANTICS.get(str(name), ORDER_SIGNIFICANT), + } + if layer.get("status") == STATUS_REFUSED: + out["error"] = layer.get("error") + out["steps"] = [] + return out + builder = _LAYER_STEPS.get(str(name)) + if builder is None: + raise ReproError( + f"no trace projection for layer {name!r} — a layer added to " + f"LAYER_ORDER must be taught how to address its steps, or a " + f"comparison would silently skip it") + out["steps"] = builder(layer.get("document") or {}) + return out + + +def project_trace(artifact: dict[str, Any], engine_id: str) -> dict[str, Any]: + """Project one engine's capture, out of a reproduction artifact, into the + comparable `AnalysisTrace`. Carries the input hash so a trace cannot be + read against a document it did not come from.""" + engines = artifact.get("engines", []) + for engine in engines: + if isinstance(engine, dict) and engine.get("id") == engine_id: + return { + "trace_version": TRACE_VERSION, + "engine": engine_id, + "input": artifact.get("input", {}).get("canonical"), + "layers": [trace_layer(layer) for layer in engine.get("layers", [])], + } + raise ReproError( + f"the artifact carries no capture for engine {engine_id!r} " + f"(present: {[e.get('id') for e in engines if isinstance(e, dict)]})") + + +def project_traces(artifact: dict[str, Any], case: str) -> dict[str, Any]: + """Every engine's capture in one artifact, projected into traces, in the + artifact's engine order. + + Projecting an engine's capture is not authoring it: the trace is a pure + normalization of a capture somebody else produced, and BOTH sides project + BOTH engines so that the normalization itself is cross-checked. If the two + implementations of the projection ever disagree, that disagreement is a + finding about the projection, not about either engine.""" + return { + "trace_version": TRACE_VERSION, + "case": case, + "traces": [project_trace(artifact, engine["id"]) + for engine in artifact.get("engines", []) + if isinstance(engine, dict) and isinstance(engine.get("id"), str)], + } + + +def render_traces(artifact: dict[str, Any], case: str) -> str: + return json.dumps(project_traces(artifact, case), indent=2, + ensure_ascii=False) + "\n" + + +# -------------------------------------------------------------------------- +# First-divergence reduction (#260 step 7a cp4) +# -------------------------------------------------------------------------- + +REDUCTION_VERSION = 1 + +# The layers this reducer will walk, in pipeline order. `verdicts` is +# DELIBERATELY absent and refused rather than merely skipped: comparing final +# diagnostics is #260's *acceptance*, which is blocked by #259 (cp5 and 4b), +# and infrastructure that would quietly do it on request is infrastructure that +# turns into an unearned shadow-mode claim the first time somebody widens a +# tuple. Widening this set is a contract decision, not a parameter. +REDUCTION_SCOPE: tuple[str, ...] = ("lowered", "summaries") + +# The four content classes, plus the two that are not content differences. +KIND_LEFT_ONLY = "left-only" +KIND_RIGHT_ONLY = "right-only" +KIND_CHANGED = "changed" +KIND_ORDERING_ONLY = "ordering-only" +KIND_STATUS = "status" +KIND_PROJECTION = "projection" +KIND_UNEXPLAINED = "unexplained" + + +def _same(left: Any, right: Any) -> bool: + """Value equality with **object key order significant**, and with `bool` + distinct from `int`. + + Python's `dict` compares order-insensitively and `True == 1`; neither is + right here. The Layer 2 and Layer 3 surfaces fix their field order as part + of a byte-exact contract, so a port emitting the right fields in the wrong + order is a real defect this reducer must name — and the port's own value + type distinguishes both, so an order-insensitive reference reducer would + disagree with it about what "the same" means. It did, on the first run.""" + if isinstance(left, bool) != isinstance(right, bool): + return False + if isinstance(left, dict) and isinstance(right, dict): + return (list(left) == list(right) + and all(_same(left[k], right[k]) for k in left)) + if isinstance(left, list) and isinstance(right, list): + return (len(left) == len(right) + and all(_same(a, b) for a, b in zip(left, right, strict=True))) + if isinstance(left, (dict, list)) or isinstance(right, (dict, list)): + return False + return type(left) is type(right) and bool(left == right) + + +def _minimal_difference(left: Any, right: Any, path: str = "") -> tuple[str, Any, Any]: + """The smallest path at which two values differ, and the two values there. + + "Minimal" is the point: reporting a whole 40-line statement as "changed" + makes the reader diff it themselves, which is how a real difference gets + waved through as formatting.""" + if type(left) is not type(right): + return path, left, right + if isinstance(left, dict) and isinstance(right, dict): + for key in list(left) + [k for k in right if k not in left]: + if key not in left or key not in right or not _same(left[key], right[key]): + if key in left and key in right: + return _minimal_difference(left[key], right[key], f"{path}.{key}") + return f"{path}.{key}", left.get(key), right.get(key) + if list(left) != list(right): + # Every value matches and only the key ORDER differs: name that, + # rather than dumping two identical-looking objects on the reader. + return f"{path}[keys]", list(left), list(right) + if isinstance(left, list) and isinstance(right, list): + # Deliberately NOT strict: unequal lengths are handled below, as a + # `[len]` difference, which reads better than a raised exception. + for i, (a, b) in enumerate(zip(left, right, strict=False)): + if not _same(a, b): + return _minimal_difference(a, b, f"{path}[{i}]") + if len(left) != len(right): + return f"{path}[len]", len(left), len(right) + return path, left, right + + +def _layer_of(trace: dict[str, Any], name: str) -> dict[str, Any] | None: + for layer in trace.get("layers", []): + if isinstance(layer, dict) and layer.get("layer") == name: + return layer + return None + + +def _reduce_layer(name: str, left: dict[str, Any], + right: dict[str, Any]) -> list[dict[str, Any]]: + """Every observation for one layer, in step order — the caller takes the + first. Order matters: the reducer's job is to name the FIRST divergence, so + it walks rather than collects a set.""" + out: list[dict[str, Any]] = [] + if left.get("status") != right.get("status"): + return [{ + "layer": name, "kind": KIND_STATUS, "step": None, "path": None, + "left": left.get("status"), "right": right.get("status"), + "detail": ("the two engines disagree about whether this layer " + "produced at all; the artifacts record every such case " + "as a DECLARED boundary, and this reducer reports it " + "rather than judging it"), + }] + if left.get("status") == STATUS_REFUSED: + # Both refused. A refusal's TEXT is each engine's own — the port's + # map-or-raise wording is not the reference's — so the reducer compares + # that two engines refused, never how they phrased it. Comparing the + # texts would manufacture a divergence out of a known, declared + # difference in message vocabulary. + return [] + if left.get("projection") != right.get("projection"): + return [{ + "layer": name, "kind": KIND_PROJECTION, "step": None, "path": None, + "left": left.get("projection"), "right": right.get("projection"), + "detail": ("the engines declare different projections of this " + "surface, so their step values are not comparable " + "member-for-member; a value comparison here would score " + "an unported member as a difference"), + }] + + left_steps = {s["id"]: s["value"] for s in left.get("steps", [])} + right_steps = {s["id"]: s["value"] for s in right.get("steps", [])} + for step in left.get("steps", []): + sid = step["id"] + if sid not in right_steps: + out.append({"layer": name, "kind": KIND_LEFT_ONLY, "step": sid, + "path": None, "left": step["value"], "right": None, + "detail": "addressed by the left engine only"}) + continue + if not _same(step["value"], right_steps[sid]): + path, a, b = _minimal_difference(step["value"], right_steps[sid]) + out.append({"layer": name, "kind": KIND_CHANGED, "step": sid, + "path": path or ".", "left": a, "right": b, + "detail": "the same address carries different values"}) + for step in right.get("steps", []): + if step["id"] not in left_steps: + out.append({"layer": name, "kind": KIND_RIGHT_ONLY, "step": step["id"], + "path": None, "left": None, "right": step["value"], + "detail": "addressed by the right engine only"}) + if out: + return out + left_order = [s["id"] for s in left.get("steps", [])] + right_order = [s["id"] for s in right.get("steps", [])] + if left_order != right_order: + significant = left.get("order") == ORDER_SIGNIFICANT + out.append({ + "layer": name, "kind": KIND_ORDERING_ONLY, "step": None, "path": None, + "left": left_order, "right": right_order, + "detail": ("the same steps in a different sequence; this layer " + "declares its order SIGNIFICANT, so the sequence is the " + "difference" if significant else + "the same steps in a different sequence on a layer whose " + "order is CANONICAL — one engine did not canonicalize"), + }) + return out + + +def reduce_traces(traces: dict[str, Any]) -> dict[str, Any]: + """Walk two engines' traces in pipeline order and name the FIRST divergence + — its layer, its step address and the minimal difference inside it — plus + a classification over the whole scope. + + Silent by construction on identical data: `outcome` is `identical` and + `first` is `null`. Scope is [`REDUCTION_SCOPE`]; the verdict layer is + refused, not skipped, and the refusal is part of the output so a reader + cannot mistake "not compared" for "compared and agreed".""" + entries = traces.get("traces", []) + if len(entries) < 2: + return { + "reduction_version": REDUCTION_VERSION, + "case": traces.get("case"), + "engines": [e.get("engine") for e in entries], + "scope": list(REDUCTION_SCOPE), + "outcome": "single-engine", + "detail": ("only one engine captured this input, so there is " + "nothing to reduce"), + "classification": {}, "first": None, "out_of_scope": [], + } + left, right = entries[0], entries[1] + observations: list[dict[str, Any]] = [] + for name in LAYER_ORDER: + if name not in REDUCTION_SCOPE: + continue + a, b = _layer_of(left, name), _layer_of(right, name) + if a is None or b is None: + observations.append({ + "layer": name, "kind": KIND_UNEXPLAINED, "step": None, + "path": None, "left": a is not None, "right": b is not None, + "detail": "an engine did not report this layer at all", + }) + continue + observations += _reduce_layer(name, a, b) + counts = {kind: sum(1 for o in observations if o["kind"] == kind) + for kind in (KIND_LEFT_ONLY, KIND_RIGHT_ONLY, KIND_CHANGED, + KIND_ORDERING_ONLY, KIND_STATUS, KIND_PROJECTION, + KIND_UNEXPLAINED)} + return { + "reduction_version": REDUCTION_VERSION, + "case": traces.get("case"), + "engines": [left.get("engine"), right.get("engine")], + "scope": list(REDUCTION_SCOPE), + "outcome": "identical" if not observations else "diverged", + "detail": None, + "classification": counts, + "first": observations[0] if observations else None, + "out_of_scope": [ + {"layer": name, + "reason": ("comparing final diagnostics is #260's ACCEPTANCE and " + "is blocked by #259 (cp5 and 4b); this reducer refuses " + "the layer rather than skipping it, so 'not compared' " + "can never be read as 'compared and agreed'")} + for name in LAYER_ORDER if name not in REDUCTION_SCOPE], + } + + +def render_reduction(traces: dict[str, Any]) -> str: + return json.dumps(reduce_traces(traces), indent=2, ensure_ascii=False) + "\n" + + +def _verify_projection(projection: Any, at: str) -> list[str]: + """The engine protocol's one rule: a layer says what its engine could + produce, and a partial projection must NAME the members it carries and say + why the rest are absent. An unexplained partial is how a comparison would + quietly score an unported member as agreement.""" + problems: list[str] = [] + if not isinstance(projection, dict): + return [f"{at}: projection is missing or not an object — every layer " + f"declares what its engine could produce"] + extra = sorted(set(projection) - {"kind", "members", "reason"}) + if extra: + problems.append(f"{at}.projection: unknown member(s): {extra}") + kind = projection.get("kind") + if kind == PROJECTION_FULL: + for name in ("members", "reason"): + if name in projection: + problems.append(f"{at}.projection: a 'full' projection carries " + f"no {name!r} — it emits the whole surface") + elif kind == PROJECTION_PARTIAL: + members = projection.get("members") + if not (isinstance(members, list) and members + and all(isinstance(m, str) and m for m in members)): + problems.append(f"{at}.projection: a 'partial' projection must NAME " + f"the members it carries") + elif sorted(set(members)) != sorted(members): + problems.append(f"{at}.projection: duplicate member names") + reason = projection.get("reason") + if not (isinstance(reason, str) and reason): + problems.append(f"{at}.projection: a 'partial' projection must say " + f"WHY the remaining members are absent") + else: + problems.append(f"{at}.projection: kind {kind!r} is not one of " + f"{list(PROJECTION_KINDS)}") + return problems + + +def _verify_layers(layers: Any, where: str) -> list[str]: + problems: list[str] = [] + if not isinstance(layers, list): + return [f"{where}.layers is missing or not an array"] + names = [layer.get("layer") if isinstance(layer, dict) else None for layer in layers] + if names != list(LAYER_ORDER): + problems.append( + f"{where}.layers carries {names} — every engine reports exactly the " + f"frozen layers {list(LAYER_ORDER)}, in that order") + for i, layer in enumerate(layers): + at = f"{where}.layers[{i}]" + if not isinstance(layer, dict): + problems.append(f"{at} is not an object") + continue + allowed = {"layer", "surface_version", "projection", "status", + "document", "error"} + extra = sorted(set(layer) - allowed) + if extra: + problems.append(f"{at}: unknown member(s): {extra}") + if "surface_version" not in layer: + problems.append(f"{at}: surface_version is missing (null when the " + f"surface has none)") + problems += _verify_projection(layer.get("projection"), at) + status = layer.get("status") + if status == STATUS_PRODUCED: + if "document" not in layer: + problems.append(f"{at}: status 'produced' without a document") + if "error" in layer: + problems.append(f"{at}: status 'produced' carries an error") + elif status == STATUS_REFUSED: + if not isinstance(layer.get("error"), str) or not layer.get("error"): + problems.append(f"{at}: status 'refused' needs a non-empty error text") + if "document" in layer: + problems.append(f"{at}: status 'refused' carries a document") + else: + problems.append( + f"{at}: status {status!r} is neither {STATUS_PRODUCED!r} nor " + f"{STATUS_REFUSED!r}") + return problems diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c651d0f6..78a9eca1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,12 +2,72 @@ # It is not intended for manual editing. version = 3 +[[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 = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "memchr" version = "2.8.2" @@ -70,6 +130,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "own-shadow" +version = "0.1.0" +dependencies = [ + "own-bridge", + "own-ir", + "own-lowered", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "own-syntax" version = "0.1.0" @@ -139,6 +211,17 @@ dependencies = [ "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 = "2.0.118" @@ -150,6 +233,12 @@ dependencies = [ "unicode-ident", ] +[[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" @@ -162,6 +251,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[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.21" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a943935f..55edfa18 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,7 +8,7 @@ [workspace] resolver = "2" -members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered", "crates/own-bridge"] +members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered", "crates/own-bridge", "crates/own-shadow"] [workspace.package] edition = "2021" @@ -22,6 +22,14 @@ serde_json = "1" # Unicode general categories — the data behind CPython's str.isprintable(), # which py_repr (error-text parity) must reproduce. unicode-rs org, zero deps. unicode-properties = { version = "0.1", default-features = false, features = ["general-category"] } +# SHA-256 for the shadow-mode input digest (own-shadow only; P-022 step 7a). +# A DELIBERATE dependency addition to a deliberately lean workspace: the +# reference side is `hashlib.sha256` (CPython stdlib), and a hand-rolled +# digest in a crate that denies `arithmetic_side_effects` and +# `indexing_slicing` would trade an audited implementation for a page of +# justified suppressions. No CORE crate depends on it — see +# own-diagnostics/tests/dag.rs. +sha2 = { version = "0.10", default-features = false } # Strictness per P-022 §"Compiler strictness" — inherited by every crate via # `[lints] workspace = true`. pedantic/nursery stay WARN (surgical, justified diff --git a/rust/crates/own-diagnostics/tests/dag.rs b/rust/crates/own-diagnostics/tests/dag.rs index 71f309f2..e33a48d4 100644 --- a/rust/crates/own-diagnostics/tests/dag.rs +++ b/rust/crates/own-diagnostics/tests/dag.rs @@ -61,6 +61,22 @@ fn allowed_edges() -> HashMap<&'static str, BTreeSet<&'static str>> { .into_iter() .collect(), ); + // Shadow-mode INFRASTRUCTURE (P-022 step 7a, #260/#269): the same-input + // capture, the reproduction-artifact format, and the engine protocol. It + // sits in the `own-oracle` slot of P-022's crate graph — a dev/oracle + // harness, which is ENTRY-POINT class, not core. The own-bridge edge + // arrived DELIBERATELY with checkpoint 2 (the engine protocol drives the + // port's three layer surfaces to produce this engine's capture): own-ir + // for the typed door upstream of every layer, own-lowered for the Layer 2 + // canonical emitter, own-bridge for the layers themselves. Only + // entry-point crates may depend on own-bridge, and this is one; the + // constraint runs the other way and is asserted by name below. + m.insert( + "own-shadow", + ["own-ir", "own-lowered", "own-bridge"] + .into_iter() + .collect(), + ); // own-analysis CONSTRUCTS diagnostics and consumes the cfg lowering. It reads // the effect type through `own_cfg::Effect`, NOT the parser — so there is no // production own-syntax edge (own-syntax is a dev-only edge for its tests). @@ -210,6 +226,33 @@ fn no_core_crate_depends_on_the_bridge() { } } +#[test] +fn no_core_crate_depends_on_the_shadow_harness() { + // The same constraint as the bridge, for the same reason: own-shadow is a + // dev/oracle harness (P-022 step 7a) that reads the core's surfaces to + // capture and reproduce them. Nothing the core computes may depend on the + // harness that observes it — an oracle a core crate can reach is an + // oracle the core can shape. own-bridge is included: the harness is + // downstream of the bridge, never the reverse. + let actual = workspace_edges(); + for consumer in [ + "own-ir", + "own-syntax", + "own-cfg", + "own-diagnostics", + "own-lowered", + "own-analysis", + "own-bridge", + ] { + let deps = actual.get(consumer).expect("crate is a member"); + assert!( + !deps.contains("own-shadow"), + "{consumer} grew a dependency on own-shadow; the shadow harness observes the \ + core, never the reverse" + ); + } +} + #[test] fn own_ir_is_a_leaf() { let actual = workspace_edges(); diff --git a/rust/crates/own-shadow/Cargo.toml b/rust/crates/own-shadow/Cargo.toml new file mode 100644 index 00000000..af22f0bb --- /dev/null +++ b/rust/crates/own-shadow/Cargo.toml @@ -0,0 +1,41 @@ +# Shadow-mode INFRASTRUCTURE (P-022 step 7a, #260/#269) — layer 0: the +# same-input capture and the reproduction artifact. +# +# Not shadow mode: this crate builds no comparison of two engines' end +# diagnostics. That comparison is #260's acceptance and is blocked on #259 +# (cp5 and 4b). +# +# It is an ENTRY-POINT-class crate in the P-022 fitness sense — a dev/oracle +# harness in the `own-oracle` slot of the crate graph. Checkpoint 2 (the engine +# protocol) is what gives it the own-bridge/own-ir/own-lowered edges: it drives +# the port's three layer surfaces to produce this engine's capture. The +# constraint runs the other way and is asserted by name: no core crate — nor +# own-bridge itself — may depend on it (own-diagnostics/tests/dag.rs). An +# oracle a core crate can reach is an oracle the core can shape. +[package] +name = "own-shadow" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "Shadow-mode infrastructure layer 0: canonical OwnIR document identity (hash) and the reproduction-artifact format, replaying the Python-authored artifacts with zero Python (P-022 step 7a)" + +[dependencies] +serde = { workspace = true } +# The artifact is JSON on the wire; this crate parses it into its OWN +# order-preserving value type (serde_json::Value is a BTreeMap and would lose +# the document order the artifact's rendering carries) and writes both the +# canonical and the pretty form itself. +serde_json = { workspace = true } +sha2 = { workspace = true } +# The engine protocol (checkpoint 2): this crate DRIVES the port's layer +# surfaces to produce its own capture. own-bridge for all three layers, +# own-ir for the typed door upstream of them, own-lowered for the Layer 2 +# canonical emitter. Every arrow points from the harness INTO the port. +own-ir = { path = "../own-ir" } +own-lowered = { path = "../own-lowered" } +own-bridge = { path = "../own-bridge" } + +[lints] +workspace = true diff --git a/rust/crates/own-shadow/src/artifact.rs b/rust/crates/own-shadow/src/artifact.rs new file mode 100644 index 00000000..f9e0a285 --- /dev/null +++ b/rust/crates/own-shadow/src/artifact.rs @@ -0,0 +1,333 @@ +//! The reproduction artifact: its frozen vocabulary, and the verification +//! that makes an artifact describe itself. +//! +//! The format is frozen in `ownlang/repro.py`'s docstring — that module is the +//! authoritative emitter, this is the replaying half. Verification is written +//! against the **parsed document**, not against a typed projection, for the +//! same reason the reference verifies the loaded dict: a typed view would +//! silently accept an artifact whose extra members it dropped, and "unknown +//! member" is one of the things this gate exists to report. + +use std::collections::BTreeSet; + +use crate::canonical::{canonical_hash, CANONICAL_ALGORITHM}; +use crate::json::Json; + +/// The artifact format version. Both engines are keyed to it. 2 added the +/// layer envelope's `projection` (checkpoint 2, the engine protocol). +pub const REPRO_VERSION: i64 = 2; + +/// The reference engine: `ownlang`, which stays authoritative until #262. +/// +/// `ENGINE_ORDER` is the closed vocabulary, in the order `engines` carries +/// them. `rust-own-bridge` is declared from the start — the format has a slot +/// for it — and filled by the engine protocol (a later checkpoint), so an +/// artifact carrying one engine is a capture, never a comparison. +pub const ENGINE_PYTHON: &str = "python-ownlang"; +pub const ENGINE_RUST: &str = "rust-own-bridge"; +pub const ENGINE_ORDER: [&str; 2] = [ENGINE_PYTHON, ENGINE_RUST]; + +/// The closed layer vocabulary, in pipeline order — the order a +/// first-divergence reduction walks. +pub const LAYER_ORDER: [&str; 3] = ["lowered", "summaries", "verdicts"]; + +pub const STATUS_PRODUCED: &str = "produced"; +pub const STATUS_REFUSED: &str = "refused"; + +/// The projection vocabulary (the engine protocol, checkpoint 2): a layer +/// declares whether its engine emitted the whole frozen surface, or names the +/// members it did emit and why the rest are absent. +pub const PROJECTION_FULL: &str = "full"; +pub const PROJECTION_PARTIAL: &str = "partial"; + +/// Verify an artifact against itself; an empty result means verified. +/// +/// The gate a tampered artifact fails: the digest and the byte length are +/// **recomputed** from the embedded document, so a single changed byte in the +/// input is a refusal rather than a silently different reproduction. The +/// structural rules, in order: the format version and member set; the input +/// envelope; the recomputed canonical hash; the engine array against the +/// frozen vocabulary and order; each engine's layer array against the frozen +/// layer order; each layer envelope's status/payload agreement. +/// +/// Deliberately mirrors `ownlang.repro.verify_repro` message-for-message in +/// substance — the two are independent implementations of one rule, so a +/// divergence between them is itself a finding. +#[must_use] +pub fn verify(artifact: &Json) -> Vec { + let mut problems = Vec::new(); + if !matches!(artifact, Json::Object(_)) { + return vec![format!( + "artifact is {}, not an object", + artifact.type_name() + )]; + } + if artifact.get("repro_version").and_then(Json::as_i64) != Some(REPRO_VERSION) { + problems.push(format!( + "repro_version {:?} != REPRO_VERSION {REPRO_VERSION}", + artifact.get("repro_version") + )); + } + unknown_members( + artifact, + &["repro_version", "input", "engines"], + "artifact", + &mut problems, + ); + + match artifact.get("input") { + Some(input @ Json::Object(_)) => verify_input(input, &mut problems), + _ => problems.push("input is missing or not an object".to_owned()), + } + + let Some(engines) = artifact.get("engines").and_then(Json::as_array) else { + problems.push("engines is missing or not an array".to_owned()); + return problems; + }; + if engines.is_empty() { + problems.push("engines is empty — an artifact captures at least one engine".to_owned()); + } + let mut seen: Vec<&str> = Vec::new(); + for (i, engine) in engines.iter().enumerate() { + if !matches!(engine, Json::Object(_)) { + problems.push(format!("engines[{i}] is not an object")); + continue; + } + unknown_members( + engine, + &["id", "layers"], + &format!("engines[{i}]"), + &mut problems, + ); + match engine.get("id").and_then(Json::as_str) { + Some(id) if ENGINE_ORDER.contains(&id) => { + if seen.contains(&id) { + problems.push(format!("engines[{i}]: engine {id:?} appears twice")); + } else if let Some(previous) = seen.last() { + if rank(id) < rank(previous) { + problems.push(format!( + "engines[{i}]: engine {id:?} is out of the frozen order {ENGINE_ORDER:?}" + )); + } + } + seen.push(id); + } + other => problems.push(format!( + "engines[{i}]: id {other:?} is not in the frozen engine vocabulary {ENGINE_ORDER:?}" + )), + } + verify_layers( + engine.get("layers"), + &format!("engines[{i}]"), + &mut problems, + ); + } + problems +} + +fn rank(id: &str) -> usize { + ENGINE_ORDER + .iter() + .position(|e| *e == id) + .unwrap_or(usize::MAX) +} + +fn unknown_members(value: &Json, allowed: &[&str], where_: &str, problems: &mut Vec) { + let mut extra: Vec<&str> = value + .keys() + .into_iter() + .filter(|k| !allowed.contains(k)) + .collect(); + if !extra.is_empty() { + extra.sort_unstable(); + problems.push(format!("{where_}: unknown member(s): {extra:?}")); + } +} + +fn verify_input(input: &Json, problems: &mut Vec) { + unknown_members( + input, + &["ownir_version", "canonical", "document"], + "input", + problems, + ); + let Some(document) = input.get("document") else { + problems.push("input.document is missing".to_owned()); + return; + }; + let Some(claimed) = input.get("canonical") else { + problems.push("input.canonical is missing or not an object".to_owned()); + return; + }; + if !matches!(claimed, Json::Object(_)) { + problems.push("input.canonical is missing or not an object".to_owned()); + return; + } + let actual = canonical_hash(document); + let algorithm = claimed.get("algorithm").and_then(Json::as_str); + let digest = claimed.get("digest").and_then(Json::as_str); + let bytes = claimed.get("bytes").and_then(Json::as_i64); + let matches = algorithm == Some(CANONICAL_ALGORITHM) + && digest == Some(actual.digest.as_str()) + && bytes == i64::try_from(actual.bytes).ok(); + if !matches { + problems.push(format!( + "input.canonical does not describe input.document: claimed \ + {{algorithm: {algorithm:?}, digest: {digest:?}, bytes: {bytes:?}}}, recomputed \ + {{algorithm: {:?}, digest: {:?}, bytes: {}}}", + actual.algorithm, actual.digest, actual.bytes + )); + } +} + +/// The engine protocol's one rule: a layer says what its engine could produce, +/// and a **partial** projection must NAME the members it carries and say why +/// the rest are absent. An unexplained partial is how a comparison would +/// quietly score an unported member as agreement. +fn verify_projection(projection: Option<&Json>, at: &str, problems: &mut Vec) { + let Some(projection) = projection else { + problems.push(format!( + "{at}: projection is missing or not an object — every layer declares what its \ + engine could produce" + )); + return; + }; + if !matches!(projection, Json::Object(_)) { + problems.push(format!( + "{at}: projection is missing or not an object — every layer declares what its \ + engine could produce" + )); + return; + } + unknown_members( + projection, + &["kind", "members", "reason"], + &format!("{at}.projection"), + problems, + ); + match projection.get("kind").and_then(Json::as_str) { + Some(kind) if kind == PROJECTION_FULL => { + for name in ["members", "reason"] { + if projection.has(name) { + problems.push(format!( + "{at}.projection: a 'full' projection carries no {name:?} — it emits \ + the whole surface" + )); + } + } + } + Some(kind) if kind == PROJECTION_PARTIAL => { + match projection.get("members").and_then(Json::as_array) { + Some(members) + if !members.is_empty() + && members + .iter() + .all(|m| m.as_str().is_some_and(|s| !s.is_empty())) => + { + let names: Vec<&str> = members.iter().filter_map(Json::as_str).collect(); + let unique: BTreeSet<&str> = names.iter().copied().collect(); + if unique.len() != names.len() { + problems.push(format!("{at}.projection: duplicate member names")); + } + } + _ => problems.push(format!( + "{at}.projection: a 'partial' projection must NAME the members it carries" + )), + } + if !projection + .get("reason") + .and_then(Json::as_str) + .is_some_and(|r| !r.is_empty()) + { + problems.push(format!( + "{at}.projection: a 'partial' projection must say WHY the remaining \ + members are absent" + )); + } + } + other => problems.push(format!( + "{at}.projection: kind {other:?} is not one of \ + [{PROJECTION_FULL:?}, {PROJECTION_PARTIAL:?}]" + )), + } +} + +fn verify_layers(layers: Option<&Json>, where_: &str, problems: &mut Vec) { + let Some(layers) = layers.and_then(Json::as_array) else { + problems.push(format!("{where_}.layers is missing or not an array")); + return; + }; + let names: Vec> = layers + .iter() + .map(|l| l.get("layer").and_then(Json::as_str)) + .collect(); + let expected: Vec> = LAYER_ORDER.iter().copied().map(Some).collect(); + if names != expected { + problems.push(format!( + "{where_}.layers carries {names:?} — every engine reports exactly the frozen \ + layers {LAYER_ORDER:?}, in that order" + )); + } + for (i, layer) in layers.iter().enumerate() { + let at = format!("{where_}.layers[{i}]"); + if !matches!(layer, Json::Object(_)) { + problems.push(format!("{at} is not an object")); + continue; + } + unknown_members( + layer, + &[ + "layer", + "surface_version", + "projection", + "status", + "document", + "error", + ], + &at, + problems, + ); + if !layer.has("surface_version") { + problems.push(format!( + "{at}: surface_version is missing (null when the surface has none)" + )); + } + verify_projection(layer.get("projection"), &at, problems); + match layer.get("status").and_then(Json::as_str) { + Some(s) if s == STATUS_PRODUCED => { + if !layer.has("document") { + problems.push(format!("{at}: status 'produced' without a document")); + } + if layer.has("error") { + problems.push(format!("{at}: status 'produced' carries an error")); + } + } + Some(s) if s == STATUS_REFUSED => { + if !layer + .get("error") + .and_then(Json::as_str) + .is_some_and(|e| !e.is_empty()) + { + problems.push(format!( + "{at}: status 'refused' needs a non-empty error text" + )); + } + if layer.has("document") { + problems.push(format!("{at}: status 'refused' carries a document")); + } + } + other => problems.push(format!( + "{at}: status {other:?} is neither {STATUS_PRODUCED:?} nor {STATUS_REFUSED:?}" + )), + } + } +} + +/// Render an artifact the way the reference writes it: document order, +/// 2-space indent, non-ASCII preserved, trailing newline. +#[must_use] +pub fn render(artifact: &Json) -> String { + let mut out = artifact.to_pretty(); + out.push('\n'); + out +} diff --git a/rust/crates/own-shadow/src/canonical.rs b/rust/crates/own-shadow/src/canonical.rs new file mode 100644 index 00000000..9ce02029 --- /dev/null +++ b/rust/crates/own-shadow/src/canonical.rs @@ -0,0 +1,61 @@ +//! The canonical document identity: the byte form and the digest over it. +//! +//! One job — to **name an input**, so that "both engines saw the same +//! document" is a checked fact rather than an assumption about which file was +//! passed where. The rule is frozen in `ownlang/repro.py`'s docstring and +//! restated in [`crate::json`]: it is taken over the *parsed* document (so +//! whitespace, key order and a parser-resolved duplicate key are insignificant +//! text formatting), over a closed value domain, with keys sorted by code +//! point and no insignificant whitespace. +//! +//! Unlike the Python side, this side cannot fail: the domain is enforced by +//! [`crate::json::Json`] at parse time, so every value that exists here is +//! canonicalizable. + +use std::fmt::Write as _; + +use sha2::{Digest, Sha256}; + +use crate::json::Json; + +/// The digest algorithm, named in the artifact so that changing it is a +/// visible contract change rather than a silent reinterpretation of the same +/// hex string. +pub const CANONICAL_ALGORITHM: &str = "sha256"; + +/// A document's canonical identity: what the artifact's `input.canonical` +/// carries, and what verification recomputes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalHash { + pub algorithm: &'static str, + /// Lowercase hex. + pub digest: String, + /// The length of the canonical byte form, carried beside the digest so a + /// mismatch says *which* of the two disagrees. + pub bytes: usize, +} + +/// The canonical byte form of a parsed document. +#[must_use] +pub fn canonical_bytes(value: &Json) -> Vec { + value.to_canonical().into_bytes() +} + +/// The canonical identity of a parsed document. +#[must_use] +pub fn canonical_hash(value: &Json) -> CanonicalHash { + let raw = canonical_bytes(value); + let mut hasher = Sha256::new(); + hasher.update(&raw); + let mut digest = String::with_capacity(64); + for byte in hasher.finalize() { + // `write!` into a String is infallible; the Result is discarded rather + // than unwrapped so the crate keeps its no-panic surface. + let _ = write!(digest, "{byte:02x}"); + } + CanonicalHash { + algorithm: CANONICAL_ALGORITHM, + digest, + bytes: raw.len(), + } +} diff --git a/rust/crates/own-shadow/src/engine.rs b/rust/crates/own-shadow/src/engine.rs new file mode 100644 index 00000000..db44c1d2 --- /dev/null +++ b/rust/crates/own-shadow/src/engine.rs @@ -0,0 +1,228 @@ +//! The **engine protocol** (P-022 step 7a, checkpoint 2): how this engine +//! reports its per-layer outputs in the artifact's one format. +//! +//! The reference's half is `ownlang/repro.py::project_layers`. This is the +//! port's half, and the two are deliberately *independent* readings of one +//! frozen format rather than one being a translation of the other. +//! +//! ## What a capture is, and is not +//! +//! It is this engine's answer for one input, per layer, in the shared +//! envelope. It is **not** a comparison: an artifact carrying two captures +//! still compares nothing, and this crate builds no verdict about either. That +//! comparison is #260's acceptance, blocked on #259. +//! +//! ## The projection, and why the format needs one +//! +//! Two of this engine's three layers emit the whole frozen surface — the +//! Layer 2 lowered document and the MOS summaries dump are byte-exact against +//! the reference's own goldens (#259 cp2 and cp3). The third does not: +//! `own_bridge::check_facts` is at the **#259 checkpoint-4 projection**, which +//! carries every `Finding` member except `message`, `related` and `flow` — +//! message synthesis (BR-V4) and the evidence slices are cp5 and are not +//! ported. +//! +//! A format without a projection field would leave a mid-migration port two +//! bad options: emit a short document and let a later comparison score the +//! absent members as agreement, or refuse a layer it can in fact mostly +//! produce. So the envelope carries `{"kind": "partial", "members": [...], +//! "reason": "..."}` and the port says exactly what it produced. This is the +//! cp4 discipline generalized — *a replay declares what it compares, and the +//! golden always carries everything*. +//! +//! ## The typed door is upstream of every layer +//! +//! This engine reaches its layers through the typed [`own_ir::OwnIr`] +//! constructor. When that refuses a document (the #294 OD-1 shapes), no layer +//! runs — so **all three** layers report `refused` with the door's text, and +//! their projections stay `full`: a refusal is complete information about what +//! this engine did, not a partial answer. The alternative — one envelope-level +//! error — would break the format's rule that every engine reports exactly the +//! frozen layers, and would make a door refusal indistinguishable from a +//! missing implementation. + +use own_ir::OwnIr; + +use crate::artifact::{ENGINE_RUST, LAYER_ORDER, STATUS_PRODUCED, STATUS_REFUSED}; +use crate::json::{parse, Json}; + +/// The `Finding` members `own_bridge::check_facts` carries at the #259 +/// checkpoint-4 surface, in `ownir.Finding`'s declaration order. `message`, +/// `related` and `flow` are absent and the projection says so. +const VERDICT_MEMBERS: [&str; 11] = [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column", +]; + +const VERDICT_PROJECTION_REASON: &str = "own_bridge::check_facts is at the #259 checkpoint-4 \ + surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 \ + and are not ported, so this engine does not emit them rather than emitting them empty"; + +fn object(entries: Vec<(&str, Json)>) -> Json { + Json::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_owned(), v)) + .collect(), + ) +} + +fn full_projection() -> Json { + object(vec![("kind", Json::Str("full".to_owned()))]) +} + +fn partial_projection(members: &[&str], reason: &str) -> Json { + object(vec![ + ("kind", Json::Str("partial".to_owned())), + ( + "members", + Json::Array(members.iter().map(|m| Json::Str((*m).to_owned())).collect()), + ), + ("reason", Json::Str(reason.to_owned())), + ]) +} + +fn produced(layer: &str, surface_version: Json, projection: Json, document: Json) -> Json { + object(vec![ + ("layer", Json::Str(layer.to_owned())), + ("surface_version", surface_version), + ("projection", projection), + ("status", Json::Str(STATUS_PRODUCED.to_owned())), + ("document", document), + ]) +} + +fn refused(layer: &str, surface_version: Json, projection: Json, error: &str) -> Json { + object(vec![ + ("layer", Json::Str(layer.to_owned())), + ("surface_version", surface_version), + ("projection", projection), + ("status", Json::Str(STATUS_REFUSED.to_owned())), + ("error", Json::Str(error.to_owned())), + ]) +} + +/// A layer whose own surface stamps a version; the version is read back out of +/// the produced document so the envelope cannot claim one the document does +/// not carry. +fn surface_version_of(document: &Json, key: &str) -> Json { + document.get(key).cloned().unwrap_or(Json::Null) +} + +/// This engine's capture of one facts document: the `engines[]` entry. +/// +/// `facts_text` is the document's **source text**, not a re-serialization of a +/// parsed value: the typed `OwnIr` constructor is the port's real entry point +/// and must see what a producer actually wrote. +/// +/// # Errors +/// A layer's own serialization failing is not modelled as a layer refusal — +/// that would report an internal defect as though the reference had been +/// disagreed with. It is an error out of the whole capture. +pub fn capture(facts_text: &str) -> Result { + let layers = match serde_json::from_str::(facts_text) { + // The typed door is upstream of every layer: when it refuses, no layer + // ran, so all three report the door's refusal. + Err(door) => { + let text = format!("typed door: {door}"); + LAYER_ORDER + .iter() + .map(|layer| refused(layer, Json::Null, full_projection(), &text)) + .collect() + } + Ok(facts) => vec![ + lowered_layer(&facts)?, + summaries_layer(&facts)?, + verdicts_layer(&facts), + ], + }; + Ok(object(vec![ + ("id", Json::Str(ENGINE_RUST.to_owned())), + ("layers", Json::Array(layers)), + ])) +} + +fn lowered_layer(facts: &OwnIr) -> Result { + match own_bridge::lower(facts) { + Ok(document) => { + let text = own_lowered::to_canonical_json(&own_lowered::Surface::Lowered(document)) + .map_err(|e| format!("lowered layer does not serialize: {e}"))?; + let value = + parse(&text).map_err(|e| format!("lowered layer does not re-parse: {e}"))?; + let version = surface_version_of(&value, "lowered_version"); + Ok(produced("lowered", version, full_projection(), value)) + } + // The reference lifts a `{"lowered_version": N, "error": ...}` surface + // refusal into the envelope; this side reaches the same envelope from a + // typed error, and carries the surface version the emitter stamps. + Err(e) => Ok(refused( + "lowered", + Json::Int(i64::from(own_lowered::LOWERED_VERSION)), + full_projection(), + &e.to_string(), + )), + } +} + +fn summaries_layer(facts: &OwnIr) -> Result { + let text = own_bridge::dump_summaries(facts) + .map_err(|e| format!("summaries layer does not serialize: {e}"))?; + let value = parse(&text).map_err(|e| format!("summaries layer does not re-parse: {e}"))?; + // The MOS dump has no surface version of its own (its document carries + // `ownir_version`), and a failed solve is its `degraded` branch rather than + // a refusal (INF-F6) — so this layer never reports `refused` today. + Ok(produced("summaries", Json::Null, full_projection(), value)) +} + +/// Unlike the other two, this layer cannot fail as a whole: `check_facts` +/// either returns findings or a refusal, and both are envelopes. (`lowered` +/// and `summaries` can fail on *serialization*, which is an internal defect +/// rather than a disagreement with the reference, so only they return a +/// `Result`.) +fn verdicts_layer(facts: &OwnIr) -> Json { + let projection = partial_projection(&VERDICT_MEMBERS, VERDICT_PROJECTION_REASON); + // `VERDICTS_VERSION` is the reference's, and this engine replays that + // surface — the projection, not the version, is what differs. + let version = Json::Int(1); + match own_bridge::check_facts(facts) { + Ok(findings) => { + let records = findings + .iter() + .map(|f| { + object(vec![ + ("file", Json::Str(f.file.clone())), + ("line", Json::Int(f.line)), + ("code", Json::Str(f.code.clone())), + ("component", Json::Str(f.component.clone())), + ("event", Json::Str(f.event.clone())), + ("handler", Json::Str(f.handler.clone())), + ("kind", Json::Str(f.kind.clone())), + ("advisory", Json::Bool(f.advisory)), + ("severity", opt_str(f.severity.as_deref())), + ("ignore_reason", opt_str(f.ignore_reason.as_deref())), + ("column", f.column.map_or(Json::Null, Json::Int)), + ]) + }) + .collect(); + let document = object(vec![ + ("verdicts_version", Json::Int(1)), + ("findings", Json::Array(records)), + ]); + produced("verdicts", version, projection, document) + } + Err(e) => refused("verdicts", version, projection, &e.to_string()), + } +} + +fn opt_str(value: Option<&str>) -> Json { + value.map_or(Json::Null, |s| Json::Str(s.to_owned())) +} diff --git a/rust/crates/own-shadow/src/json.rs b/rust/crates/own-shadow/src/json.rs new file mode 100644 index 00000000..433b5c0a --- /dev/null +++ b/rust/crates/own-shadow/src/json.rs @@ -0,0 +1,338 @@ +//! An order-preserving JSON value over the **closed canonical domain**, plus +//! the two writers the shadow-mode surfaces need. +//! +//! Two reasons this is not `serde_json::Value`: +//! +//! * **Order.** `serde_json::Value`'s object is a `BTreeMap`, so parsing and +//! re-serializing sorts the keys. The reproduction artifact renders its +//! embedded document in **document order** (BR-D4: input order is +//! semantic), so a byte-exact round-trip needs a value type that remembers +//! the order it was parsed in. Turning on `serde_json`'s `preserve_order` +//! feature would have done it — and would also have changed every other +//! crate in the workspace, because cargo unifies features across a build; +//! `own-ir`, `own-lowered` and `own-bridge` all have byte-exact output +//! contracts that must not move because a test harness wanted an `IndexMap`. +//! * **Domain.** The canonical domain (object, array, string, `i64` integer, +//! bool, null) is enforced **by the type**: there is no float variant, so a +//! float or an integer outside `i64` is a *parse* refusal and every value +//! that exists is canonicalizable. The Python side checks the same domain at +//! run time because `json` has no such type; one contract, two enforcement +//! points. +//! +//! Duplicate keys follow the reference exactly: the **last** value wins and +//! the key keeps its **first** position, which is what `dict` does for +//! `json.load` — the canonical form is defined over the *parsed* document, so +//! the two parsers have to agree about what parsing means. + +use std::fmt; +use std::fmt::Write as _; + +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; + +/// A parsed JSON document over the closed canonical domain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Json { + Null, + Bool(bool), + /// The only numeric form. `spec/OwnIR.md` §4.2 bounds every validated + /// coordinate to signed 64 bits, so the domain costs the contract nothing. + Int(i64), + Str(String), + Array(Vec), + /// Entries in **document order**, keys unique (last value wins). + Object(Vec<(String, Self)>), +} + +impl Json { + /// The value under `key`, or `None` for a non-object / absent key. + #[must_use] + pub fn get(&self, key: &str) -> Option<&Self> { + match self { + Self::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + /// True when this is an object carrying `key` — the "present, possibly + /// null" question, which `get(..).is_some()` also answers but reads worse. + #[must_use] + pub fn has(&self, key: &str) -> bool { + self.get(key).is_some() + } + + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::Str(s) => Some(s), + _ => None, + } + } + + #[must_use] + pub const fn as_i64(&self) -> Option { + match self { + Self::Int(i) => Some(*i), + _ => None, + } + } + + #[must_use] + pub fn as_array(&self) -> Option<&[Self]> { + match self { + Self::Array(items) => Some(items), + _ => None, + } + } + + /// The object's keys in document order, for "unknown member" reporting. + #[must_use] + pub fn keys(&self) -> Vec<&str> { + match self { + Self::Object(entries) => entries.iter().map(|(k, _)| k.as_str()).collect(), + _ => Vec::new(), + } + } + + /// A short type name, for messages that have to say what was found. + #[must_use] + pub const fn type_name(&self) -> &'static str { + match self { + Self::Null => "null", + Self::Bool(_) => "bool", + Self::Int(_) => "integer", + Self::Str(_) => "string", + Self::Array(_) => "array", + Self::Object(_) => "object", + } + } + + /// The **canonical** form: keys sorted by code point, no insignificant + /// whitespace, UTF-8. This names an input; it is not how an artifact is + /// rendered (see [`Self::to_pretty`]). + #[must_use] + pub fn to_canonical(&self) -> String { + let mut out = String::new(); + self.write_canonical(&mut out); + out + } + + fn write_canonical(&self, out: &mut String) { + match self { + Self::Null => out.push_str("null"), + Self::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Self::Int(i) => out.push_str(&i.to_string()), + Self::Str(s) => write_escaped(s, out), + Self::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i != 0 { + out.push(','); + } + item.write_canonical(out); + } + out.push(']'); + } + Self::Object(entries) => { + // Sorted by the key's code points, which for Rust `str` is the + // byte-wise UTF-8 order — the same order CPython's + // `sort_keys=True` produces. + let mut sorted: Vec<&(String, Self)> = entries.iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(&b.0)); + out.push('{'); + for (i, (key, value)) in sorted.iter().enumerate() { + if i != 0 { + out.push(','); + } + write_escaped(key, out); + out.push(':'); + value.write_canonical(out); + } + out.push('}'); + } + } + } + + /// The **rendering** form: document order, 2-space indent, `": "` after a + /// key — byte-for-byte `json.dumps(..., indent=2, ensure_ascii=False)`. + /// The trailing newline is the caller's (an artifact file carries one). + #[must_use] + pub fn to_pretty(&self) -> String { + let mut out = String::new(); + self.write_pretty(0, &mut out); + out + } + + fn write_pretty(&self, level: usize, out: &mut String) { + let inner = level.saturating_add(1); + match self { + Self::Null | Self::Bool(_) | Self::Int(_) | Self::Str(_) => self.write_canonical(out), + Self::Array(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push_str("[\n"); + for (i, item) in items.iter().enumerate() { + if i != 0 { + out.push_str(",\n"); + } + indent(inner, out); + item.write_pretty(inner, out); + } + out.push('\n'); + indent(level, out); + out.push(']'); + } + Self::Object(entries) => { + if entries.is_empty() { + out.push_str("{}"); + return; + } + out.push_str("{\n"); + for (i, (key, value)) in entries.iter().enumerate() { + if i != 0 { + out.push_str(",\n"); + } + indent(inner, out); + write_escaped(key, out); + out.push_str(": "); + value.write_pretty(inner, out); + } + out.push('\n'); + indent(level, out); + out.push('}'); + } + } + } +} + +fn indent(level: usize, out: &mut String) { + for _ in 0..level.saturating_mul(2) { + out.push(' '); + } +} + +/// The frozen string-escape rule, shared by both writers: `"` and `\` escaped; +/// the five two-character C0 escapes; every other code point below `U+0020` as +/// `\u00xx` with **lowercase** hex; **everything else raw** — no `\u` for +/// non-ASCII, and none for `U+007F`, `U+2028` or `U+2029`. This is +/// `json.dumps(..., ensure_ascii=False)`, written out rather than inherited, +/// so both engines state the same rule; `tests/fixtures/repro/ +/// canonical_torture.facts.json` holds them to it. +fn write_escaped(s: &str, out: &mut String) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\u{c}' => out.push_str("\\f"), + '\r' => out.push_str("\\r"), + c if u32::from(c) < 0x20 => { + // Infallible for a String sink; the Result is discarded rather + // than unwrapped so the crate keeps its no-panic surface. + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out.push('"'); +} + +struct JsonVisitor; + +impl<'de> Visitor<'de> for JsonVisitor { + type Value = Json; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a JSON value over the canonical domain (object, array, string, i64 integer, bool, null)") + } + + fn visit_unit(self) -> Result { + Ok(Json::Null) + } + + fn visit_none(self) -> Result { + Ok(Json::Null) + } + + fn visit_some>(self, d: D) -> Result { + d.deserialize_any(Self) + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Json::Bool(v)) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(Json::Int(v)) + } + + fn visit_u64(self, v: u64) -> Result { + i64::try_from(v).map(Json::Int).map_err(|_| { + de::Error::custom(format!( + "integer {v} is outside the canonical domain [-2**63, 2**63-1]; \ + spec/OwnIR.md §4.2 bounds every validated coordinate to signed 64 bits" + )) + }) + } + + fn visit_f64(self, v: f64) -> Result { + // serde_json reaches here for a genuine float AND for an integer + // literal too large for u64/i64 — both are outside the domain, and + // both must refuse rather than round. + Err(de::Error::custom(format!( + "the value {v} is outside the canonical domain: the OwnIR vocabulary has no \ + float, and an integer beyond signed 64 bits is not representable in both \ + engines — cross-language byte-agreement is not provable over either" + ))) + } + + fn visit_str(self, v: &str) -> Result { + Ok(Json::Str(v.to_owned())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Json::Str(v)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = seq.next_element()? { + items.push(item); + } + Ok(Json::Array(items)) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut entries: Vec<(String, Json)> = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + // Last value wins, first position kept — CPython `dict` semantics + // for a duplicate key, which is what defines the parsed document + // the canonical form is taken over. + if let Some(slot) = entries.iter_mut().find(|(k, _)| *k == key) { + slot.1 = value; + } else { + entries.push((key, value)); + } + } + Ok(Json::Object(entries)) + } +} + +impl<'de> Deserialize<'de> for Json { + fn deserialize>(d: D) -> Result { + d.deserialize_any(JsonVisitor) + } +} + +/// Parse one JSON document over the canonical domain. +/// +/// # Errors +/// The parser's error for malformed JSON, or a domain refusal (a float, or an +/// integer outside `i64`) — never a rounded or truncated value. +pub fn parse(text: &str) -> Result { + serde_json::from_str(text) +} diff --git a/rust/crates/own-shadow/src/lib.rs b/rust/crates/own-shadow/src/lib.rs new file mode 100644 index 00000000..b5e697be --- /dev/null +++ b/rust/crates/own-shadow/src/lib.rs @@ -0,0 +1,69 @@ +//! `own-shadow` — **infrastructure for shadow mode** (P-022 step 7a, +//! #260/#269), layer 0: the same-input capture and the reproduction artifact. +//! +//! Two things have to be settled before two engines can be compared at all, +//! and neither of them is a comparison: +//! +//! * **Did both engines see the same input?** [`canonical_hash`] names an +//! `OwnIR` document by a canonical form taken over the *parsed* document, so +//! "same input" is a checked fact rather than an assumption about which file +//! was passed where. `tests/fixtures/repro/digests.json` carries the +//! reference's digest for every shared corpus document; `tests/repro.rs` +//! recomputes all of them here with **zero Python**. +//! * **What does a reproduction look like?** [`verify`] and [`render`] are +//! this side of the reproduction-artifact format: one self-contained JSON +//! document carrying the input, its schema version, its hash, the engine +//! identifiers and each engine's outputs **per layer**, so a divergence can +//! be re-run from the artifact alone. +//! * **How does an engine report those outputs?** [`capture`] is this engine's +//! half of the engine protocol (checkpoint 2): it drives `own-bridge`'s +//! three layer surfaces and reports each in the shared envelope, declaring +//! per layer what it could **produce** — the verdict layer is at the #259 +//! checkpoint-4 projection and says so, rather than emitting a short +//! document a later comparison would score as agreement. +//! * **What would a comparison have to walk?** [`project_traces`] is the +//! `AnalysisTrace` (#269): each capture normalized so that the Layer 2 +//! handles — global counters wearing the costume of names — become addresses +//! derived from what they identify, while each layer's ORDER semantics are +//! *declared* rather than normalized away. Producing that shape is still not +//! performing a comparison. +//! * **Where do two engines first part company?** [`reduce_traces`] walks the +//! pair in pipeline order over the `lowered` and `summaries` layers and names +//! the layer, the step and the **minimal** difference. The `verdicts` layer +//! is **refused, not skipped** — comparing final diagnostics is #260's +//! acceptance, blocked by #259 — and the refusal is carried in the output, so +//! "not compared" can never be read as "compared and agreed". +//! +//! **This is not shadow mode**, and nothing here may be read as shadow mode +//! having been achieved. Comparing two engines' end diagnostics is #260's +//! acceptance and is blocked on #259 (cp5 and 4b); this crate builds no +//! comparison and no verdict. It also asserts no *parity*: an artifact records +//! one engine's capture and takes no side on another's. +//! +//! `ownlang/repro.py` is the authoritative emitter — the same relationship +//! `own-lowered` has to `ownlang/lowered.py`. The two `verify` +//! implementations are deliberately independent readings of one frozen rule, +//! so a divergence between them is itself a finding. + +mod artifact; +mod canonical; +mod engine; +mod json; +mod reduce; +mod trace; + +pub use artifact::{ + render, verify, ENGINE_ORDER, ENGINE_PYTHON, ENGINE_RUST, LAYER_ORDER, PROJECTION_FULL, + PROJECTION_PARTIAL, REPRO_VERSION, STATUS_PRODUCED, STATUS_REFUSED, +}; +pub use canonical::{canonical_bytes, canonical_hash, CanonicalHash, CANONICAL_ALGORITHM}; +pub use engine::capture; +pub use json::{parse, Json}; +pub use reduce::{ + reduce_traces, KIND_CHANGED, KIND_LEFT_ONLY, KIND_ORDERING_ONLY, KIND_PROJECTION, + KIND_RIGHT_ONLY, KIND_STATUS, KIND_UNEXPLAINED, REDUCTION_SCOPE, REDUCTION_VERSION, +}; +pub use trace::{ + order_semantics, project_trace, project_traces, ORDER_CANONICAL, ORDER_SIGNIFICANT, + TRACE_VERSION, +}; diff --git a/rust/crates/own-shadow/src/reduce.rs b/rust/crates/own-shadow/src/reduce.rs new file mode 100644 index 00000000..f5df5d7b --- /dev/null +++ b/rust/crates/own-shadow/src/reduce.rs @@ -0,0 +1,418 @@ +//! **First-divergence reduction** (P-022 step 7a checkpoint 4, #260): walk two +//! engines' traces in pipeline order and name the *first* place they part +//! company — the layer, the step address, and the **minimal** difference inside +//! that step. +//! +//! The reference's half is `ownlang/repro.py::reduce_traces`; this is the +//! port's independent reading of the same rules, for the same reason the trace +//! is implemented twice: a comparison is the last thing you want to have only +//! one implementation of. +//! +//! ## The scope is a contract, and `verdicts` is refused rather than skipped +//! +//! [`REDUCTION_SCOPE`] is `lowered` and `summaries`. Comparing final +//! diagnostics is #260's **acceptance**, which is blocked by #259 (cp5 and 4b). +//! Infrastructure that would quietly do it on request is infrastructure that +//! becomes an unearned shadow-mode claim the first time somebody widens a +//! constant — so the verdict layer is *refused*, and the refusal is carried in +//! the output. "Not compared" must never be readable as "compared and agreed". +//! +//! ## What is and is not a content difference +//! +//! * `left-only` / `right-only` / `changed` / `ordering-only` are the four +//! content classes. +//! * `status` is a layer-level disagreement about whether the layer produced at +//! all. The reducer reports it; the artifacts are where each such case is +//! recorded as a *declared* boundary, and judging that is not this tool's job. +//! * `projection` means the engines declared different projections of the +//! surface, so their values are not comparable member-for-member. Comparing +//! them anyway would score an unported member as a difference. +//! * When both engines **refused** a layer, the reducer compares *that* they +//! refused and never *how they phrased it*: a refusal's text is each engine's +//! own, and diffing the wordings would manufacture a divergence out of a +//! known difference in message vocabulary. + +use crate::artifact::{LAYER_ORDER, STATUS_REFUSED}; +use crate::json::Json; +use crate::trace::ORDER_SIGNIFICANT; + +pub const REDUCTION_VERSION: i64 = 1; + +/// The layers this reducer walks, in pipeline order. Widening it is a contract +/// decision, not a parameter — see the module docs. +pub const REDUCTION_SCOPE: [&str; 2] = ["lowered", "summaries"]; + +pub const KIND_LEFT_ONLY: &str = "left-only"; +pub const KIND_RIGHT_ONLY: &str = "right-only"; +pub const KIND_CHANGED: &str = "changed"; +pub const KIND_ORDERING_ONLY: &str = "ordering-only"; +pub const KIND_STATUS: &str = "status"; +pub const KIND_PROJECTION: &str = "projection"; +pub const KIND_UNEXPLAINED: &str = "unexplained"; + +const KINDS: [&str; 7] = [ + KIND_LEFT_ONLY, + KIND_RIGHT_ONLY, + KIND_CHANGED, + KIND_ORDERING_ONLY, + KIND_STATUS, + KIND_PROJECTION, + KIND_UNEXPLAINED, +]; + +fn object(entries: Vec<(&str, Json)>) -> Json { + Json::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_owned(), v)) + .collect(), + ) +} + +fn observation( + layer: &str, + kind: &str, + step: Option<&str>, + path: Option<&str>, + left: Json, + right: Json, + detail: &str, +) -> Json { + object(vec![ + ("layer", Json::Str(layer.to_owned())), + ("kind", Json::Str(kind.to_owned())), + ("step", step.map_or(Json::Null, |s| Json::Str(s.to_owned()))), + ("path", path.map_or(Json::Null, |p| Json::Str(p.to_owned()))), + ("left", left), + ("right", right), + ("detail", Json::Str(detail.to_owned())), + ]) +} + +/// The smallest path at which two values differ, and the values there. +/// +/// "Minimal" is the point: reporting a whole statement as "changed" makes the +/// reader diff it by hand, which is how a real difference gets waved through as +/// formatting. +fn minimal_difference(left: &Json, right: &Json, path: &str) -> (String, Json, Json) { + match (left, right) { + (Json::Object(a), Json::Object(b)) => { + let mut keys: Vec<&String> = a.iter().map(|(k, _)| k).collect(); + for (k, _) in b { + if !a.iter().any(|(ak, _)| ak == k) { + keys.push(k); + } + } + for key in keys { + let av = a.iter().find(|(k, _)| k == key).map(|(_, v)| v); + let bv = b.iter().find(|(k, _)| k == key).map(|(_, v)| v); + match (av, bv) { + (Some(x), Some(y)) if x == y => {} + (Some(x), Some(y)) => { + return minimal_difference(x, y, &format!("{path}.{key}")) + } + _ => { + return ( + format!("{path}.{key}"), + av.cloned().unwrap_or(Json::Null), + bv.cloned().unwrap_or(Json::Null), + ) + } + } + } + let (ka, kb): (Vec<&String>, Vec<&String>) = ( + a.iter().map(|(k, _)| k).collect(), + b.iter().map(|(k, _)| k).collect(), + ); + if ka == kb { + (path.to_owned(), left.clone(), right.clone()) + } else { + // Every value matches and only the key ORDER differs: name + // that, rather than dumping two identical-looking objects on + // the reader. + let names = |keys: Vec<&String>| { + Json::Array(keys.into_iter().map(|k| Json::Str(k.clone())).collect()) + }; + (format!("{path}[keys]"), names(ka), names(kb)) + } + } + (Json::Array(a), Json::Array(b)) => { + for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() { + if x != y { + return minimal_difference(x, y, &format!("{path}[{i}]")); + } + } + if a.len() == b.len() { + (path.to_owned(), left.clone(), right.clone()) + } else { + ( + format!("{path}[len]"), + Json::Int(i64::try_from(a.len()).unwrap_or(i64::MAX)), + Json::Int(i64::try_from(b.len()).unwrap_or(i64::MAX)), + ) + } + } + _ => (path.to_owned(), left.clone(), right.clone()), + } +} + +fn layer_of<'a>(trace: &'a Json, name: &str) -> Option<&'a Json> { + trace + .get("layers")? + .as_array()? + .iter() + .find(|l| l.get("layer").and_then(Json::as_str) == Some(name)) +} + +fn steps(layer: &Json) -> &[Json] { + layer.get("steps").and_then(Json::as_array).unwrap_or(&[]) +} + +fn step_id(step: &Json) -> &str { + step.get("id").and_then(Json::as_str).unwrap_or("") +} + +fn step_value(step: &Json) -> Json { + step.get("value").cloned().unwrap_or(Json::Null) +} + +// Six branches, each a distinct classification with its own reasoning; splitting +// them would scatter one decision procedure across six names. +#[allow(clippy::too_many_lines)] +fn reduce_layer(name: &str, left: &Json, right: &Json) -> Vec { + let (ls, rs) = ( + left.get("status").and_then(Json::as_str), + right.get("status").and_then(Json::as_str), + ); + if ls != rs { + return vec![observation( + name, + KIND_STATUS, + None, + None, + left.get("status").cloned().unwrap_or(Json::Null), + right.get("status").cloned().unwrap_or(Json::Null), + "the two engines disagree about whether this layer produced at all; the artifacts \ + record every such case as a DECLARED boundary, and this reducer reports it rather \ + than judging it", + )]; + } + if ls == Some(STATUS_REFUSED) { + return Vec::new(); + } + if left.get("projection") != right.get("projection") { + return vec![observation( + name, + KIND_PROJECTION, + None, + None, + left.get("projection").cloned().unwrap_or(Json::Null), + right.get("projection").cloned().unwrap_or(Json::Null), + "the engines declare different projections of this surface, so their step values \ + are not comparable member-for-member; a value comparison here would score an \ + unported member as a difference", + )]; + } + + let mut out = Vec::new(); + for step in steps(left) { + let id = step_id(step); + match steps(right).iter().find(|s| step_id(s) == id) { + None => out.push(observation( + name, + KIND_LEFT_ONLY, + Some(id), + None, + step_value(step), + Json::Null, + "addressed by the left engine only", + )), + Some(other) => { + let (a, b) = (step_value(step), step_value(other)); + if a != b { + let (path, x, y) = minimal_difference(&a, &b, ""); + out.push(observation( + name, + KIND_CHANGED, + Some(id), + Some(if path.is_empty() { "." } else { &path }), + x, + y, + "the same address carries different values", + )); + } + } + } + } + for step in steps(right) { + let id = step_id(step); + if !steps(left).iter().any(|s| step_id(s) == id) { + out.push(observation( + name, + KIND_RIGHT_ONLY, + Some(id), + None, + Json::Null, + step_value(step), + "addressed by the right engine only", + )); + } + } + if !out.is_empty() { + return out; + } + let order = |layer: &Json| -> Vec { + steps(layer) + .iter() + .map(|s| Json::Str(step_id(s).to_owned())) + .collect() + }; + let (lo, ro) = (order(left), order(right)); + if lo != ro { + let significant = left.get("order").and_then(Json::as_str) == Some(ORDER_SIGNIFICANT); + out.push(observation( + name, + KIND_ORDERING_ONLY, + None, + None, + Json::Array(lo), + Json::Array(ro), + if significant { + "the same steps in a different sequence; this layer declares its order \ + SIGNIFICANT, so the sequence is the difference" + } else { + "the same steps in a different sequence on a layer whose order is CANONICAL — \ + one engine did not canonicalize" + }, + )); + } + out +} + +/// Walk two engines' traces and name the first divergence, with a +/// classification over the whole scope. +/// +/// Silent by construction on identical data: `outcome` is `identical` and +/// `first` is `null`. +// One output document, assembled field by field; the length is the schema's, +// not a missing abstraction. +#[allow(clippy::too_many_lines)] +#[must_use] +pub fn reduce_traces(traces: &Json) -> Json { + let entries = traces.get("traces").and_then(Json::as_array).unwrap_or(&[]); + let scope = Json::Array( + REDUCTION_SCOPE + .iter() + .map(|s| Json::Str((*s).to_owned())) + .collect(), + ); + let out_of_scope = Json::Array( + LAYER_ORDER + .iter() + .filter(|l| !REDUCTION_SCOPE.contains(*l)) + .map(|l| { + object(vec![ + ("layer", Json::Str((*l).to_owned())), + ( + "reason", + Json::Str( + "comparing final diagnostics is #260's ACCEPTANCE and is blocked by \ + #259 (cp5 and 4b); this reducer refuses the layer rather than \ + skipping it, so 'not compared' can never be read as 'compared and \ + agreed'" + .to_owned(), + ), + ), + ]) + }) + .collect(), + ); + let engines = |list: &[Json]| -> Json { + Json::Array( + list.iter() + .map(|t| t.get("engine").cloned().unwrap_or(Json::Null)) + .collect(), + ) + }; + if entries.len() < 2 { + return object(vec![ + ("reduction_version", Json::Int(REDUCTION_VERSION)), + ("case", traces.get("case").cloned().unwrap_or(Json::Null)), + ("engines", engines(entries)), + ("scope", scope), + ("outcome", Json::Str("single-engine".to_owned())), + ( + "detail", + Json::Str( + "only one engine captured this input, so there is nothing to reduce".to_owned(), + ), + ), + ("classification", Json::Object(Vec::new())), + ("first", Json::Null), + ("out_of_scope", out_of_scope), + ]); + } + let mut pair = entries.iter(); + let (Some(left), Some(right)) = (pair.next(), pair.next()) else { + // Unreachable: the length was just checked. Written without indexing + // because the workspace denies a panicking `[i]`, and a reducer is the + // last place to introduce one. + return Json::Null; + }; + let mut observations: Vec = Vec::new(); + for name in LAYER_ORDER { + if !REDUCTION_SCOPE.contains(&name) { + continue; + } + match (layer_of(left, name), layer_of(right, name)) { + (Some(a), Some(b)) => observations.extend(reduce_layer(name, a, b)), + (a, b) => observations.push(observation( + name, + KIND_UNEXPLAINED, + None, + None, + Json::Bool(a.is_some()), + Json::Bool(b.is_some()), + "an engine did not report this layer at all", + )), + } + } + let classification = Json::Object( + KINDS + .iter() + .map(|kind| { + let n = observations + .iter() + .filter(|o| o.get("kind").and_then(Json::as_str) == Some(*kind)) + .count(); + ( + (*kind).to_owned(), + Json::Int(i64::try_from(n).unwrap_or(i64::MAX)), + ) + }) + .collect(), + ); + let first = observations.first().cloned().unwrap_or(Json::Null); + let outcome = if observations.is_empty() { + "identical" + } else { + "diverged" + }; + object(vec![ + ("reduction_version", Json::Int(REDUCTION_VERSION)), + ("case", traces.get("case").cloned().unwrap_or(Json::Null)), + ( + "engines", + Json::Array(vec![ + left.get("engine").cloned().unwrap_or(Json::Null), + right.get("engine").cloned().unwrap_or(Json::Null), + ]), + ), + ("scope", scope), + ("outcome", Json::Str(outcome.to_owned())), + ("detail", Json::Null), + ("classification", classification), + ("first", first), + ("out_of_scope", out_of_scope), + ]) +} diff --git a/rust/crates/own-shadow/src/trace.rs b/rust/crates/own-shadow/src/trace.rs new file mode 100644 index 00000000..d2fc6f91 --- /dev/null +++ b/rust/crates/own-shadow/src/trace.rs @@ -0,0 +1,571 @@ +//! The **`AnalysisTrace`** (P-022 step 7a checkpoint 3, #269): the +//! normalization that turns a *pair* of captures into something a comparison +//! can walk. +//! +//! The schema is frozen in `ownlang/repro.py`'s docstring; this is the port's +//! independent reading of it. Two things stand between an artifact and a +//! comparison, and the trace removes exactly one of them and **declares** the +//! other: +//! +//! * **Internal identifiers are normalized away.** The Layer 2 handles +//! (`sub_0`, `cap_1`, `parg_0`, `loc_3`) are minted from global counters in +//! document order (BR-L2) — positions wearing the costume of names. Each is +//! rebuilt from the record's own identity (`component | file | line | event +//! | handler`), and every occurrence anywhere in the document is rewritten. +//! The mint *kind* is not discarded: it moves onto the handle record as +//! `mint`, so a routing difference stays a comparable **value** on one step +//! instead of splitting into a pair of "only in one engine" addresses. +//! * **Order is declared, never normalized away.** `order` is `significant` +//! for `lowered` (BR-D4/BR-L5) and `verdicts` (BR-V8 leaves ties in +//! construction order), `canonical` for `summaries` (INF-R1). Sorting a +//! significant layer to make a comparison pass would delete the defect the +//! layer exists to expose. +//! +//! This crate still **compares nothing**. The trace is the shape a comparison +//! would need; producing it is not performing one. + +use crate::artifact::{LAYER_ORDER, STATUS_REFUSED}; +use crate::json::Json; + +/// The trace surface version, keyed to the reference's `TRACE_VERSION`. +pub const TRACE_VERSION: i64 = 1; + +pub const ORDER_SIGNIFICANT: &str = "significant"; +pub const ORDER_CANONICAL: &str = "canonical"; + +/// Per-layer ordering semantics, frozen. A comparison reads this to CLASSIFY +/// an ordering difference; it never licenses sorting a layer. +#[must_use] +pub fn order_semantics(layer: &str) -> &'static str { + match layer { + "summaries" => ORDER_CANONICAL, + _ => ORDER_SIGNIFICANT, + } +} + +fn object(entries: Vec<(&str, Json)>) -> Json { + Json::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_owned(), v)) + .collect(), + ) +} + +/// `Some(prefix)` when the string is a minted handle (`prefix_`). +fn minted_prefix(value: &str) -> Option<&str> { + let (prefix, digits) = value.split_once('_')?; + if !matches!(prefix, "sub" | "cap" | "parg" | "loc") { + return None; + } + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some(prefix) +} + +fn text(value: Option<&Json>) -> String { + match value { + Some(Json::Str(s)) => s.clone(), + Some(Json::Int(i)) => i.to_string(), + Some(Json::Bool(b)) => b.to_string(), + Some(Json::Null) | None => String::new(), + Some(other) => other.to_canonical(), + } +} + +/// A handle's identity, from the record the bridge attached to it — never from +/// the counter. An absent field renders empty, so "no handler" and "the empty +/// handler" stay one address: they are the same fact. +fn identity(record: &Json) -> String { + ["component", "file", "line", "event", "handler"] + .iter() + .map(|k| text(record.get(k))) + .collect::>() + .join("|") +} + +/// `minted name -> stable id`. A bijection by construction: a repeated +/// identity takes a `~` suffix in encounter order, the one place position +/// leaks back into an address. +fn stable_handle_ids(handles: &[Json]) -> Vec<(String, String)> { + let mut seen: Vec<(String, usize)> = Vec::new(); + let mut out = Vec::new(); + for record in handles { + let Some(minted) = record.get("handle").and_then(Json::as_str) else { + continue; + }; + let id = identity(record); + let n = if let Some(slot) = seen.iter_mut().find(|(k, _)| *k == id) { + slot.1 = slot.1.saturating_add(1); + slot.1.saturating_sub(1) + } else { + seen.push((id.clone(), 1)); + 0 + }; + let stable = if n == 0 { id } else { format!("{id}~{n}") }; + out.push((minted.to_owned(), stable)); + } + out +} + +fn rewrite(value: &Json, rename: &[(String, String)]) -> Json { + match value { + Json::Str(s) => rename + .iter() + .find(|(from, _)| from == s) + .map_or_else(|| value.clone(), |(_, to)| Json::Str(to.clone())), + Json::Array(items) => Json::Array(items.iter().map(|v| rewrite(v, rename)).collect()), + Json::Object(entries) => Json::Object( + entries + .iter() + .map(|(k, v)| (k.clone(), rewrite(v, rename))) + .collect(), + ), + other => other.clone(), + } +} + +fn minted_leftovers(value: &Json, out: &mut Vec) { + match value { + Json::Str(s) => { + if minted_prefix(s).is_some() { + out.push(s.clone()); + } + } + Json::Array(items) => { + for item in items { + minted_leftovers(item, out); + } + } + Json::Object(entries) => { + for (_, v) in entries { + minted_leftovers(v, out); + } + } + _ => {} + } +} + +/// A Layer 2 document with every minted handle replaced by its stable id, and +/// the mint kind preserved as each handle record's `mint`. +/// +/// # Errors +/// When a counter-shaped name survives the rewrite: the rename claims to be +/// total, and a claim nothing can falsify is not a contract. +fn normalize_handles(document: &Json) -> Result { + let Some(handles) = document.get("handles").and_then(Json::as_array) else { + return Ok(document.clone()); + }; + let rename = stable_handle_ids(handles); + let rewritten = rewrite(document, &rename); + // Stamp `mint` onto each handle record, positionally against the original + // list (the rewrite preserves order and arity). + let stamped = match (&rewritten, handles) { + (Json::Object(entries), originals) => Json::Object( + entries + .iter() + .map(|(k, v)| { + if k != "handles" { + return (k.clone(), v.clone()); + } + let records = v.as_array().unwrap_or(&[]); + let stamped: Vec = records + .iter() + .zip(originals.iter()) + .map(|(record, original)| { + let mint = original + .get("handle") + .and_then(Json::as_str) + .and_then(minted_prefix) + .map_or_else(|| text(original.get("handle")), str::to_owned); + let Json::Object(fields) = record else { + return record.clone(); + }; + let mut fields = fields.clone(); + fields.push(("mint".to_owned(), Json::Str(mint))); + Json::Object(fields) + }) + .collect(); + (k.clone(), Json::Array(stamped)) + }) + .collect(), + ), + _ => rewritten, + }; + let mut leftovers = Vec::new(); + minted_leftovers(&stamped, &mut leftovers); + if leftovers.is_empty() { + Ok(stamped) + } else { + leftovers.sort_unstable(); + leftovers.dedup(); + leftovers.truncate(5); + Err(format!( + "stable-ID normalization is not total: {leftovers:?} survived the rewrite — a \ + handle is referenced somewhere the rename did not reach, and a comparison would \ + report it as a difference between engines rather than as a counter" + )) + } +} + +/// Joins a prefix and an address into a seen-key. `U+0001` cannot appear in a +/// Layer 2 name, a file path or a code, so `a[b]` and `a` + `[b]` cannot +/// collide into one counter. +const SEEN_KEY_SEP: char = '\u{1}'; + +/// Address a list of `(address, value)` pairs under one prefix, disambiguating +/// repeats with `~` in encounter order. +struct Addresser { + seen: Vec<(String, usize)>, + steps: Vec, +} + +impl Addresser { + const fn new() -> Self { + Self { + seen: Vec::new(), + steps: Vec::new(), + } + } + + fn plain(&mut self, id: &str, value: Json) { + self.steps.push(object(vec![ + ("id", Json::Str(id.to_owned())), + ("value", value), + ])); + } + + /// Returns the disambiguated address, so a caller can reuse it as a prefix + /// (a function's body hangs off its own, already-disambiguated, address). + /// + /// The `~` suffix goes **inside the bracket** — `functions[Take~1]`, + /// never `functions[Take]~1` — uniformly for every addressed list: it + /// disambiguates *which of the repeated items*, which is a property of the + /// item rather than of the path, and that is what lets a nested prefix + /// compose. The rule is spelled out because the two implementations of this + /// schema first read it two different ways, and the disagreement surfaced + /// as a trace-golden mismatch rather than as prose. + fn addressed(&mut self, prefix: &str, address: &str, value: Json) -> String { + // The seen-key joins prefix and address on a separator no address can + // contain, so `a[b]` and `a` + `[b]` cannot collide. + let key = format!("{prefix}{SEEN_KEY_SEP}{address}"); + let n = if let Some(slot) = self.seen.iter_mut().find(|(k, _)| *k == key) { + slot.1 = slot.1.saturating_add(1); + slot.1.saturating_sub(1) + } else { + self.seen.push((key, 1)); + 0 + }; + let inner = if n == 0 { + address.to_owned() + } else { + format!("{address}~{n}") + }; + let id = format!("{prefix}[{inner}]"); + self.plain(&id, value); + id + } +} + +fn lowered_steps(document: &Json) -> Result, String> { + let doc = normalize_handles(document)?; + let mut a = Addresser::new(); + a.plain( + "lowered_version", + doc.get("lowered_version").cloned().unwrap_or(Json::Null), + ); + a.plain("module", doc.get("module").cloned().unwrap_or(Json::Null)); + for key in ["resources", "externs", "lifetimes"] { + for entry in doc.get(key).and_then(Json::as_array).unwrap_or(&[]) { + a.addressed(key, &text(entry.get("name")), entry.clone()); + } + } + // One disambiguator across ALL functions, and the body prefix inherits it: + // a repeated C# name puts two functions under one address, and a + // per-function counter would reset and collide. + for function in doc.get("functions").and_then(Json::as_array).unwrap_or(&[]) { + let head = match function { + Json::Object(fields) => Json::Object( + fields + .iter() + .filter(|(k, _)| k != "body") + .cloned() + .collect(), + ), + other => other.clone(), + }; + let address = a.addressed("functions", &text(function.get("name")), head); + let body_prefix = format!("{address}.body"); + for (i, stmt) in function + .get("body") + .and_then(Json::as_array) + .unwrap_or(&[]) + .iter() + .enumerate() + { + a.addressed(&body_prefix, &i.to_string(), stmt.clone()); + } + } + for record in doc.get("handles").and_then(Json::as_array).unwrap_or(&[]) { + a.addressed("handles", &text(record.get("handle")), record.clone()); + } + Ok(a.steps) +} + +fn summaries_steps(document: &Json) -> Vec { + let mut a = Addresser::new(); + for key in ["module", "ownir_version", "degraded"] { + a.plain(key, document.get(key).cloned().unwrap_or(Json::Null)); + } + for entry in document + .get("summaries") + .and_then(Json::as_array) + .unwrap_or(&[]) + { + a.addressed("summaries", &text(entry.get("method")), entry.clone()); + } + for entry in document + .get("unresolved") + .and_then(Json::as_array) + .unwrap_or(&[]) + { + a.addressed("unresolved", &text(Some(entry)), entry.clone()); + } + a.steps +} + +fn verdicts_steps(document: &Json) -> Vec { + let mut a = Addresser::new(); + a.plain( + "verdicts_version", + document + .get("verdicts_version") + .cloned() + .unwrap_or(Json::Null), + ); + for finding in document + .get("findings") + .and_then(Json::as_array) + .unwrap_or(&[]) + { + let anchor = format!( + "{}:{}:{}:{}", + text(finding.get("file")), + text(finding.get("line")), + anchor_column(finding), + text(finding.get("code")), + ); + a.addressed("findings", &anchor, finding.clone()); + } + a.steps +} + +/// `column` renders as the reference's `None`, not as an empty string: the +/// address has to read the same on both sides, and absence is data here. +fn anchor_column(finding: &Json) -> String { + match finding.get("column") { + Some(Json::Int(i)) => i.to_string(), + _ => "None".to_owned(), + } +} + +/// One capture layer as a trace layer. +/// +/// # Errors +/// A layer this projection has not been taught to address, or a lowered +/// document whose handle rename is not total. +fn trace_layer(layer: &Json) -> Result { + let name = text(layer.get("layer")); + let status = text(layer.get("status")); + let mut fields = vec![ + ("layer", Json::Str(name.clone())), + ("status", Json::Str(status.clone())), + ( + "projection", + layer.get("projection").cloned().unwrap_or(Json::Null), + ), + ("order", Json::Str(order_semantics(&name).to_owned())), + ]; + if status == STATUS_REFUSED { + // No steps: there is nothing to address, and an empty step list that + // compared equal to another engine's would score a refusal as + // agreement. + fields.push(("error", layer.get("error").cloned().unwrap_or(Json::Null))); + fields.push(("steps", Json::Array(Vec::new()))); + return Ok(object(fields)); + } + let document = layer.get("document").cloned().unwrap_or(Json::Null); + let steps = match name.as_str() { + "lowered" => lowered_steps(&document)?, + "summaries" => summaries_steps(&document), + "verdicts" => verdicts_steps(&document), + other => { + return Err(format!( + "no trace projection for layer {other:?} — a layer added to {LAYER_ORDER:?} \ + must be taught how to address its steps, or a comparison would silently \ + skip it" + )) + } + }; + fields.push(("steps", Json::Array(steps))); + Ok(object(fields)) +} + +/// Project one engine's capture, out of a reproduction artifact, into the +/// comparable trace. +/// +/// # Errors +/// The artifact carries no capture for that engine, or a layer cannot be +/// addressed. +pub fn project_trace(artifact: &Json, engine_id: &str) -> Result { + let engines = artifact + .get("engines") + .and_then(Json::as_array) + .unwrap_or(&[]); + let engine = engines + .iter() + .find(|e| e.get("id").and_then(Json::as_str) == Some(engine_id)) + .ok_or_else(|| { + let present: Vec<&str> = engines + .iter() + .filter_map(|e| e.get("id").and_then(Json::as_str)) + .collect(); + format!( + "the artifact carries no capture for engine {engine_id:?} (present: {present:?})" + ) + })?; + let layers = engine + .get("layers") + .and_then(Json::as_array) + .unwrap_or(&[]) + .iter() + .map(trace_layer) + .collect::, _>>()?; + Ok(object(vec![ + ("trace_version", Json::Int(TRACE_VERSION)), + ("engine", Json::Str(engine_id.to_owned())), + ( + "input", + artifact + .get("input") + .and_then(|i| i.get("canonical")) + .cloned() + .unwrap_or(Json::Null), + ), + ("layers", Json::Array(layers)), + ])) +} + +/// Every engine's capture in one artifact, projected into traces, in the +/// artifact's engine order. +/// +/// Projecting an engine's capture is not authoring it: the trace is a pure +/// normalization of a capture somebody else produced, and **both** sides +/// project **both** engines so the normalization itself is cross-checked. If +/// the two implementations of it ever disagree, that is a finding about the +/// projection, not about either engine. +/// +/// # Errors +/// Propagated from [`project_trace`]. +pub fn project_traces(artifact: &Json, case: &str) -> Result { + let ids: Vec = artifact + .get("engines") + .and_then(Json::as_array) + .unwrap_or(&[]) + .iter() + .filter_map(|e| e.get("id").and_then(Json::as_str)) + .map(str::to_owned) + .collect(); + let traces = ids + .iter() + .map(|id| project_trace(artifact, id)) + .collect::, _>>()?; + Ok(object(vec![ + ("trace_version", Json::Int(TRACE_VERSION)), + ("case", Json::Str(case.to_owned())), + ("traces", Json::Array(traces)), + ])) +} + +#[cfg(test)] +// A test module asserts; `expect`/`expect_err` ARE the assertion here, and the +// workspace denies them for production code, not for the place a panic is the +// reporting mechanism (same stance as every integration test in this crate). +#[allow(clippy::expect_used)] +mod tests { + use super::{normalize_handles, Json}; + + /// The totality assertion guards a state the corpus cannot reach: every + /// statement references a handle the `handles[]` array lists, because the + /// bridge mints both. So the rule is driven synthetically HERE, at the only + /// level that can reach it — the same resting place #259 cp4 chose for + /// BR-V1's ERROR-only rule, and for the same reason: leaving a normative + /// rule permanently unprovable is worse than proving it off the production + /// path and saying so. + #[test] + fn a_handle_reference_the_rename_cannot_reach_is_refused() { + // `loc_1` is referenced by a statement but absent from `handles[]`, so + // the rewrite has no entry for it and a counter survives. + let document = Json::Object(vec![ + ( + "functions".to_owned(), + Json::Array(vec![Json::Object(vec![( + "body".to_owned(), + Json::Array(vec![Json::Object(vec![( + "handle".to_owned(), + Json::Str("loc_1".to_owned()), + )])]), + )])]), + ), + ( + "handles".to_owned(), + Json::Array(vec![Json::Object(vec![ + ("handle".to_owned(), Json::Str("loc_0".to_owned())), + ("component".to_owned(), Json::Str("M".to_owned())), + ])]), + ), + ]); + let err = normalize_handles(&document) + .expect_err("a surviving counter must be refused, not carried into a comparison"); + assert!( + err.contains("not total") && err.contains("loc_1"), + "refused, but not for the declared reason: {err}" + ); + } + + /// …and the same document with the reference listed normalizes cleanly, so + /// the control above is testing the leak and not merely the shape. + #[test] + fn a_fully_listed_document_normalizes() { + let document = Json::Object(vec![ + ( + "functions".to_owned(), + Json::Array(vec![Json::Object(vec![( + "body".to_owned(), + Json::Array(vec![Json::Object(vec![( + "handle".to_owned(), + Json::Str("loc_0".to_owned()), + )])]), + )])]), + ), + ( + "handles".to_owned(), + Json::Array(vec![Json::Object(vec![ + ("handle".to_owned(), Json::Str("loc_0".to_owned())), + ("component".to_owned(), Json::Str("M".to_owned())), + ])]), + ), + ]); + let out = normalize_handles(&document).expect("normalizes"); + assert_eq!( + out.get("handles") + .and_then(Json::as_array) + .and_then(<[Json]>::first) + .and_then(|h| h.get("mint")) + .and_then(Json::as_str), + Some("loc"), + "the mint kind must survive as a comparable value" + ); + } +} diff --git a/rust/crates/own-shadow/tests/engine.rs b/rust/crates/own-shadow/tests/engine.rs new file mode 100644 index 00000000..064b7857 --- /dev/null +++ b/rust/crates/own-shadow/tests/engine.rs @@ -0,0 +1,252 @@ +//! The engine protocol's acceptance contract (P-022 step 7a, checkpoint 2): +//! +//! ```text +//! facts.json → own_shadow::capture ≡ the committed artifact's +//! `rust-own-bridge` engine entry +//! ``` +//! +//! **An engine writes only its own entry.** The reference authors +//! `python-ownlang` (`python tests/test_repro_fixtures.py --write`) and carries +//! any foreign entry through untouched; this side authors `rust-own-bridge` +//! (`OWN_SHADOW_WRITE=1 cargo test -p own-shadow --test engine`) and touches +//! nothing else. That is what lets the two halves be produced independently, +//! each with zero of the other's runtime — and it is why neither half can +//! quietly become a comparison of one implementation against itself. +//! +//! **This is still not a comparison.** An artifact carrying two captures +//! compares nothing: no test here reads one engine's layer and asserts +//! anything about the other's. Comparing them is #260's acceptance, blocked on +//! #259 (cp5 and 4b), and the reduction that would consume the pairing is a +//! later checkpoint in this same slice. +//! +//! What this suite does assert, beyond "the entry is what it was": +//! * the capture is **deterministic** — the same input twice, byte-identical; +//! * every capture **verifies inside a whole artifact**, so a malformed +//! envelope is caught by the same gate the reference's half goes through; +//! * a layer whose projection is `partial` carries **exactly** the members its +//! documents actually have — a projection that over-claims is the failure +//! this field exists to prevent, and it would otherwise be prose; +//! * the two engines' captures are **structurally comparable**: same layers, +//! in the same order. Structure only — no value is compared. + +#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)] + +use std::collections::{BTreeMap, BTreeSet}; + +use own_shadow::{capture, parse, render, verify, Json, ENGINE_PYTHON, ENGINE_RUST, LAYER_ORDER}; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../tests/fixtures"); + +fn read(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {path}: {e}")) +} + +/// The artifact ledger, and where each named case's facts document lives. +fn artifacts() -> BTreeMap { + let manifest = + parse(&read(&format!("{FIXTURES}/repro/manifest.json"))).expect("repro manifest parses"); + let mut out = BTreeMap::new(); + for entry in manifest + .get("artifacts") + .and_then(Json::as_array) + .expect("manifest carries an 'artifacts' ledger") + { + let name = entry.get("name").and_then(Json::as_str).expect("name"); + let corpus = entry + .get("corpus") + .and_then(Json::as_str) + .expect("an artifact entry names the corpus its facts live in"); + out.insert( + name.to_owned(), + format!("{FIXTURES}/{corpus}/{name}.facts.json"), + ); + } + out +} + +fn engine_entry<'a>(artifact: &'a Json, id: &str) -> Option<&'a Json> { + artifact + .get("engines") + .and_then(Json::as_array)? + .iter() + .find(|e| e.get("id").and_then(Json::as_str) == Some(id)) +} + +/// Replace (or append) this engine's entry, keeping the frozen engine order +/// and every other entry untouched. +fn with_our_entry(artifact: &Json, ours: &Json) -> Json { + let Json::Object(entries) = artifact else { + panic!("artifact is not an object") + }; + let rebuilt = entries + .iter() + .map(|(k, v)| { + if k != "engines" { + return (k.clone(), v.clone()); + } + let mut engines: Vec = v + .as_array() + .expect("engines array") + .iter() + .filter(|e| e.get("id").and_then(Json::as_str) != Some(ENGINE_RUST)) + .cloned() + .collect(); + engines.push(ours.clone()); + engines.sort_by_key(|e| { + e.get("id") + .and_then(Json::as_str) + .and_then(|id| own_shadow::ENGINE_ORDER.iter().position(|o| *o == id)) + .unwrap_or(usize::MAX) + }); + (k.clone(), Json::Array(engines)) + }) + .collect(); + Json::Object(rebuilt) +} + +/// `OWN_SHADOW_WRITE=1` regenerates this engine's entry in every committed +/// artifact. Deliberately opt-in: a suite that rewrites its own expectations +/// on every run proves nothing, and "implementation disagreed with the golden +/// → regenerate → agreement" is the move this whole family exists to make +/// impossible. +fn writing() -> bool { + std::env::var("OWN_SHADOW_WRITE").is_ok_and(|v| v == "1") +} + +/// Cargo runs a target's tests in parallel, so under `OWN_SHADOW_WRITE` the +/// reading tests would race the writer over half-written artifacts. They stand +/// down for that run: a regeneration pass proves nothing, and a flaky red from +/// a self-inflicted race is worse than no signal. +fn stand_down_while_writing() -> bool { + if writing() { + eprintln!("OWN_SHADOW_WRITE=1: regeneration pass, this check stands down"); + return true; + } + false +} + +#[test] +fn this_engine_reproduces_its_committed_capture() { + let mut divergences: Vec = Vec::new(); + for (name, facts_path) in artifacts() { + let artifact_path = format!("{FIXTURES}/repro/{name}.repro.json"); + let artifact = parse(&read(&artifact_path)).expect("artifact parses"); + let facts_text = read(&facts_path); + + let ours = capture(&facts_text) + .unwrap_or_else(|e| panic!("{name}: this engine cannot capture the document: {e}")); + // Determinism: the same input, twice. + assert_eq!( + ours, + capture(&facts_text).expect("second capture"), + "{name}: the capture is not deterministic" + ); + + let rebuilt = with_our_entry(&artifact, &ours); + if writing() { + std::fs::write(&artifact_path, render(&rebuilt)).expect("write artifact"); + continue; + } + match engine_entry(&artifact, ENGINE_RUST) { + None => divergences.push(format!( + "{name}: the committed artifact carries no '{ENGINE_RUST}' entry — regenerate: \ + OWN_SHADOW_WRITE=1 cargo test -p own-shadow --test engine" + )), + Some(committed) if committed != &ours => divergences.push(format!( + "{name}: this engine's capture differs from the committed one\n\ + committed = {committed:#?}\n now = {ours:#?}" + )), + Some(_) => {} + } + // The whole artifact, both engines in it, goes through the same gate + // the reference's half goes through. + assert_eq!( + verify(&rebuilt), + Vec::::new(), + "{name}: an artifact carrying this engine's capture does not verify" + ); + } + assert!( + divergences.is_empty(), + "{} artifact(s) disagree with this engine's capture:\n{}", + divergences.len(), + divergences.join("\n") + ); +} + +#[test] +fn a_partial_projection_names_exactly_the_members_it_carries() { + if stand_down_while_writing() { + return; + } + // The one way a projection can lie: claim members the documents do not + // have, or carry members it did not claim. Without this the field is prose + // and a later comparison would trust it. + for (name, facts_path) in artifacts() { + let ours = capture(&read(&facts_path)).expect("capture"); + for layer in ours.get("layers").and_then(Json::as_array).expect("layers") { + let projection = layer.get("projection").expect("projection"); + if projection.get("kind").and_then(Json::as_str) != Some("partial") { + continue; + } + let claimed: BTreeSet<&str> = projection + .get("members") + .and_then(Json::as_array) + .expect("members") + .iter() + .filter_map(Json::as_str) + .collect(); + assert!( + !claimed.is_empty(), + "{name}: a partial projection names nothing" + ); + let Some(document) = layer.get("document") else { + continue; // a refused layer carries no records to check + }; + // The only partial surface today is the verdict list; its records + // are the things whose members the projection describes. + let Some(records) = document.get("findings").and_then(Json::as_array) else { + continue; + }; + for record in records { + let actual: BTreeSet<&str> = record.keys().into_iter().collect(); + assert_eq!( + actual, claimed, + "{name}: a record's members differ from the projection's claim — a \ + projection that over- or under-claims is exactly what this field exists \ + to prevent" + ); + } + } + } +} + +#[test] +fn both_engines_report_the_same_layers_in_the_same_order() { + if stand_down_while_writing() { + return; + } + // STRUCTURE only: this asserts nothing about either engine's values, and + // is not a comparison. It is the precondition a later reduction needs — + // two captures that do not line up layer-for-layer cannot be walked. + for (name, _facts) in artifacts() { + let artifact = + parse(&read(&format!("{FIXTURES}/repro/{name}.repro.json"))).expect("artifact parses"); + for id in [ENGINE_PYTHON, ENGINE_RUST] { + let entry = + engine_entry(&artifact, id).unwrap_or_else(|| panic!("{name}: no '{id}' entry")); + let layers: Vec<&str> = entry + .get("layers") + .and_then(Json::as_array) + .expect("layers") + .iter() + .filter_map(|l| l.get("layer").and_then(Json::as_str)) + .collect(); + assert_eq!( + layers, + LAYER_ORDER.to_vec(), + "{name}: engine '{id}' does not report the frozen layers in order" + ); + } + } +} diff --git a/rust/crates/own-shadow/tests/reduce.rs b/rust/crates/own-shadow/tests/reduce.rs new file mode 100644 index 00000000..834d6a01 --- /dev/null +++ b/rust/crates/own-shadow/tests/reduce.rs @@ -0,0 +1,549 @@ +//! The first-divergence reduction's acceptance contract (P-022 step 7a +//! checkpoint 4, #260): +//! +//! ```text +//! .trace.json → own_shadow::reduce_traces ≡ .reduction.json +//! ``` +//! +//! byte-for-byte, with **zero Python** — and, more importantly than the +//! goldens, the reducer is shown to *work*: +//! +//! * **silent on unchanged data** — a reducer that reports on agreement is +//! worse than none; +//! * **naming, on a synthetic divergence** — one controlled change introduced +//! into a copy of a real Layer 2 output, and the reducer must name the layer, +//! the step address and the **minimal** path inside it, not the whole step. +//! A reducer that has never reported is a reducer nobody has seen work. +//! +//! The scope is `lowered` + `summaries`. The `verdicts` layer is **refused, not +//! skipped**, and the refusal is asserted here: comparing final diagnostics is +//! #260's acceptance, blocked by #259, and "not compared" must never be +//! readable as "compared and agreed". + +#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)] + +use std::collections::BTreeSet; + +use own_shadow::{ + parse, reduce_traces, Json, KIND_CHANGED, KIND_LEFT_ONLY, KIND_ORDERING_ONLY, KIND_RIGHT_ONLY, + REDUCTION_SCOPE, REDUCTION_VERSION, +}; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../tests/fixtures"); + +fn read(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {path}: {e}")) +} + +fn artifact_names() -> BTreeSet { + let manifest = + parse(&read(&format!("{FIXTURES}/repro/manifest.json"))).expect("repro manifest parses"); + manifest + .get("artifacts") + .and_then(Json::as_array) + .expect("manifest carries an 'artifacts' ledger") + .iter() + .filter_map(|e| e.get("name").and_then(Json::as_str)) + .map(str::to_owned) + .collect() +} + +fn traces_of(case: &str) -> Json { + parse(&read(&format!("{FIXTURES}/repro/{case}.trace.json"))).expect("trace golden parses") +} + +#[test] +fn every_reduction_golden_is_reproduced_byte_for_byte() { + for case in artifact_names() { + let ours = reduce_traces(&traces_of(&case)); + assert_eq!( + ours, + reduce_traces(&traces_of(&case)), + "{case}: the reduction is not deterministic" + ); + assert_eq!( + ours.get("reduction_version").and_then(Json::as_i64), + Some(REDUCTION_VERSION), + "{case}: reduction surface version" + ); + let mut rendered = ours.to_pretty(); + rendered.push('\n'); + assert_eq!( + rendered, + read(&format!("{FIXTURES}/repro/{case}.reduction.json")), + "{case}: this side's reduction differs from the committed one — the two \ + implementations of the comparison disagree, which is a finding about the REDUCER" + ); + } +} + +#[test] +fn the_verdict_layer_is_refused_not_silently_skipped() { + assert!( + !REDUCTION_SCOPE.contains(&"verdicts"), + "the reduction scope now includes 'verdicts' — comparing final diagnostics is #260's \ + acceptance and is blocked by #259; widening the scope is a contract decision, not a \ + parameter" + ); + for case in artifact_names() { + let reduction = reduce_traces(&traces_of(&case)); + let refused = reduction + .get("out_of_scope") + .and_then(Json::as_array) + .expect("out_of_scope") + .iter() + .any(|e| e.get("layer").and_then(Json::as_str) == Some("verdicts")); + assert!( + refused, + "{case}: the reduction does not RECORD that it refused the verdict layer; a reader \ + could take silence for agreement" + ); + } +} + +/// Rebuild `traces` with `f` applied to the right engine's lowered layer. +fn forge(traces: &Json, f: impl Fn(&[Json]) -> Vec) -> Json { + let Json::Object(top) = traces else { + panic!("traces is not an object") + }; + Json::Object( + top.iter() + .map(|(k, v)| { + if k != "traces" { + return (k.clone(), v.clone()); + } + let list = v.as_array().expect("traces array"); + let rebuilt: Vec = list + .iter() + .enumerate() + .map(|(i, trace)| { + if i != 1 { + return trace.clone(); + } + let Json::Object(fields) = trace else { + return trace.clone(); + }; + Json::Object( + fields + .iter() + .map(|(tk, tv)| { + if tk != "layers" { + return (tk.clone(), tv.clone()); + } + let layers: Vec = tv + .as_array() + .expect("layers") + .iter() + .map(|layer| { + if layer.get("layer").and_then(Json::as_str) + != Some("lowered") + { + return layer.clone(); + } + let Json::Object(lf) = layer else { + return layer.clone(); + }; + Json::Object( + lf.iter() + .map(|(lk, lv)| { + if lk != "steps" { + return (lk.clone(), lv.clone()); + } + ( + lk.clone(), + Json::Array(f(lv + .as_array() + .expect("steps"))), + ) + }) + .collect(), + ) + }) + .collect(); + (tk.clone(), Json::Array(layers)) + }) + .collect(), + ) + }) + .collect(); + (k.clone(), Json::Array(rebuilt)) + }) + .collect(), + ) +} + +fn first(reduction: &Json) -> &Json { + reduction.get("first").expect("first") +} + +// Five controls, each a distinct classification with its own forged input. +#[allow(clippy::too_many_lines)] +#[test] +fn the_reducer_is_silent_on_unchanged_data_and_names_a_synthetic_divergence() { + // `canonical_key_order` is the control case because its lowered layer + // carries real flow statements with `line` fields — `di` is DI-only and has + // no line-bearing step, which is how the changed-field control below first + // read as a no-op here while the reference's half (which ADDED the key) + // passed on the wrong thing. The two halves disagreeing is what surfaced it. + let case = "canonical_key_order"; + assert!( + artifact_names().contains(case), + "the control case '{case}' is not a committed artifact" + ); + let base = traces_of(case); + + // 0. Silence. + let quiet = reduce_traces(&base); + assert_eq!( + quiet.get("outcome").and_then(Json::as_str), + Some("identical"), + "the reducer reports a divergence on unchanged data: {:#?}", + first(&quiet) + ); + assert_eq!( + first(&quiet), + &Json::Null, + "a silent reduction names nothing" + ); + + let steps_of = |traces: &Json| -> Vec { + traces + .get("traces") + .and_then(Json::as_array) + .and_then(|t| t.get(1)) + .and_then(|t| t.get("layers")) + .and_then(Json::as_array) + .and_then(<[Json]>::first) + .and_then(|l| l.get("steps")) + .and_then(Json::as_array) + .expect("the right engine's lowered steps") + .to_vec() + }; + let base_steps = steps_of(&base); + let last_id = base_steps + .last() + .and_then(|s| s.get("id")) + .and_then(Json::as_str) + .expect("a last step") + .to_owned(); + // The changed-field control must change an EXISTING field. + let changed_id = base_steps + .iter() + .find(|s| s.get("value").is_some_and(|v| v.has("line"))) + .and_then(|s| s.get("id")) + .and_then(Json::as_str) + .expect("a lowered step carrying a `line` to change") + .to_owned(); + + let check = |label: &str, forged: &Json, kind: &str, step: Option<&str>, path: Option<&str>| { + let reduction = reduce_traces(forged); + assert_eq!( + reduction.get("outcome").and_then(Json::as_str), + Some("diverged"), + "the reducer is SILENT on {label}" + ); + let f = first(&reduction); + assert_eq!( + f.get("kind").and_then(Json::as_str), + Some(kind), + "{label}: wrong classification" + ); + assert_eq!( + f.get("layer").and_then(Json::as_str), + Some("lowered"), + "{label}: wrong layer" + ); + if let Some(step) = step { + assert_eq!( + f.get("step").and_then(Json::as_str), + Some(step), + "{label}: wrong step" + ); + } + if let Some(path) = path { + assert_eq!( + f.get("path").and_then(Json::as_str), + Some(path), + "{label}: the difference must be MINIMAL — the field, not the whole step" + ); + } + }; + + // 1. ONE controlled change, deep inside a step's value. + let changed = forge(&base, |steps| { + steps + .iter() + .map(|s| { + if s.get("id").and_then(Json::as_str) != Some(changed_id.as_str()) { + return s.clone(); + } + let Json::Object(fields) = s else { + return s.clone(); + }; + Json::Object( + fields + .iter() + .map(|(k, v)| { + if k != "value" { + return (k.clone(), v.clone()); + } + let Json::Object(value) = v else { + return (k.clone(), v.clone()); + }; + let mut value = value.clone(); + for entry in &mut value { + if entry.0 == "line" { + entry.1 = Json::Int(999_001); + } + } + (k.clone(), Json::Object(value)) + }) + .collect(), + ) + }) + .collect() + }); + check( + "one changed field", + &changed, + KIND_CHANGED, + Some(&changed_id), + Some(".line"), + ); + + // 2. A step only the reference has. + let dropped = forge(&base, |steps| { + steps + .get(..steps.len().saturating_sub(1)) + .unwrap_or_default() + .to_vec() + }); + check( + "a step only the reference has", + &dropped, + KIND_LEFT_ONLY, + Some(&last_id), + None, + ); + + // 3. A step only the port has. + let added = forge(&base, |steps| { + let mut out = steps.to_vec(); + out.push(Json::Object(vec![ + ( + "id".to_owned(), + Json::Str("handles[synthetic|X.cs|1|E|H]".to_owned()), + ), + ("value".to_owned(), Json::Object(Vec::new())), + ])); + out + }); + check( + "a step only the port has", + &added, + KIND_RIGHT_ONLY, + Some("handles[synthetic|X.cs|1|E|H]"), + None, + ); + + // 4. The same steps in a different sequence — the difference this layer's + // declared `significant` order exists to keep visible. + let swapped = forge(&base, |steps| { + let mut out = steps.to_vec(); + out.swap(0, 1); + out + }); + check( + "the same steps in a different order", + &swapped, + KIND_ORDERING_ONLY, + None, + None, + ); +} + +/// Rebuild `traces` with `f` applied to BOTH engines' lowered layers. +fn forge_layers(traces: &Json, f: impl Fn(usize, &Json) -> Json) -> Json { + let Json::Object(top) = traces else { + panic!("traces is not an object") + }; + Json::Object( + top.iter() + .map(|(k, v)| { + if k != "traces" { + return (k.clone(), v.clone()); + } + let rebuilt: Vec = v + .as_array() + .expect("traces array") + .iter() + .enumerate() + .map(|(side, trace)| { + let Json::Object(fields) = trace else { + return trace.clone(); + }; + Json::Object( + fields + .iter() + .map(|(tk, tv)| { + if tk != "layers" { + return (tk.clone(), tv.clone()); + } + let layers: Vec = tv + .as_array() + .expect("layers") + .iter() + .map(|layer| { + if layer.get("layer").and_then(Json::as_str) + == Some("lowered") + { + f(side, layer) + } else { + layer.clone() + } + }) + .collect(); + (tk.clone(), Json::Array(layers)) + }) + .collect(), + ) + }) + .collect(); + (k.clone(), Json::Array(rebuilt)) + }) + .collect(), + ) +} + +/// Two engines that BOTH refused a layer agree, however differently they +/// phrased it and however their projections were declared. +/// +/// Neither is reachable from the committed corpus — both refusals there carry +/// the same projection — so the rule is driven synthetically at the only level +/// that reaches it. Without this, the short-circuit that states the rule is +/// code no mutation can disturb, and a reducer that grew a refusal-text diff +/// would manufacture a divergence out of message vocabulary. +#[test] +fn two_engines_that_both_refused_a_layer_agree() { + let base = traces_of("canonical_key_order"); + let forged = forge_layers(&base, |side, layer| { + let (error, projection) = if side == 0 { + ( + "the reference's own wording", + Json::Object(vec![("kind".to_owned(), Json::Str("full".to_owned()))]), + ) + } else { + ( + "the port's own wording", + Json::Object(vec![ + ("kind".to_owned(), Json::Str("partial".to_owned())), + ( + "members".to_owned(), + Json::Array(vec![Json::Str("x".to_owned())]), + ), + ( + "reason".to_owned(), + Json::Str("declared elsewhere".to_owned()), + ), + ]), + ) + }; + let Json::Object(fields) = layer else { + panic!("layer is not an object") + }; + let mut out: Vec<(String, Json)> = fields + .iter() + .filter(|(k, _)| !matches!(k.as_str(), "status" | "steps" | "projection" | "error")) + .cloned() + .collect(); + out.push(("status".to_owned(), Json::Str("refused".to_owned()))); + out.push(("projection".to_owned(), projection)); + out.push(("error".to_owned(), Json::Str(error.to_owned()))); + out.push(("steps".to_owned(), Json::Array(Vec::new()))); + Json::Object(out) + }); + let reduction = reduce_traces(&forged); + assert_eq!( + reduction.get("outcome").and_then(Json::as_str), + Some("identical"), + "two engines that both REFUSED a layer are reported as diverging: {:#?}", + reduction.get("first") + ); +} + +/// Object key ORDER is a difference. Nothing in the corpus exercises it any +/// more — the MOS capture was fixed to carry its surface's own order — so it +/// needs a synthetic control or the rule is untested. +#[test] +fn the_same_fields_in_a_different_key_order_are_a_difference() { + let base = traces_of("canonical_key_order"); + let forged = forge_layers(&base, |side, layer| { + if side != 1 { + return layer.clone(); + } + let Json::Object(fields) = layer else { + panic!("layer is not an object") + }; + let mut reversed_one = false; + Json::Object( + fields + .iter() + .map(|(k, v)| { + if k != "steps" { + return (k.clone(), v.clone()); + } + let steps: Vec = v + .as_array() + .expect("steps") + .iter() + .map(|step| { + let Some(Json::Object(value)) = step.get("value") else { + return step.clone(); + }; + if reversed_one || value.len() < 2 { + return step.clone(); + } + reversed_one = true; + let mut flipped = value.clone(); + flipped.reverse(); + let Json::Object(sf) = step else { + return step.clone(); + }; + Json::Object( + sf.iter() + .map(|(sk, sv)| { + if sk == "value" { + (sk.clone(), Json::Object(flipped.clone())) + } else { + (sk.clone(), sv.clone()) + } + }) + .collect(), + ) + }) + .collect(); + (k.clone(), Json::Array(steps)) + }) + .collect(), + ) + }); + let reduction = reduce_traces(&forged); + assert_eq!( + reduction.get("outcome").and_then(Json::as_str), + Some("diverged"), + "the same fields in a different key ORDER are reported as agreement; the surfaces fix \ + their field order byte-exactly, so a port emitting them in the wrong order is a real \ + defect" + ); + let first = reduction.get("first").expect("first"); + assert_eq!( + first.get("kind").and_then(Json::as_str), + Some(KIND_CHANGED), + "a key-order difference must be a content difference" + ); + assert_eq!( + first.get("path").and_then(Json::as_str), + Some("[keys]"), + "the reader should not have to diff two identical-looking objects" + ); +} diff --git a/rust/crates/own-shadow/tests/repro.rs b/rust/crates/own-shadow/tests/repro.rs new file mode 100644 index 00000000..974d01f5 --- /dev/null +++ b/rust/crates/own-shadow/tests/repro.rs @@ -0,0 +1,760 @@ +//! The layer-0 acceptance contract (P-022 step 7a, #260/#269), replayed with +//! **zero Python**: +//! +//! ```text +//! every shared facts document → canonical form → sha256 +//! ≡ tests/fixtures/repro/digests.json (same-input capture) +//! +//! every committed artifact → parse → render +//! ≡ the committed bytes (the format round-trips) +//! → verify (it describes itself) +//! → one changed byte in the embedded document +//! ⇒ refused (the digest is a gate) +//! ``` +//! +//! The digest ledger and the artifacts are Python-authored (regenerate: +//! `python tests/test_repro_fixtures.py --write`) and used here as expected +//! output only, never as an input to construction. +//! +//! **Infrastructure for shadow mode, not shadow mode**: nothing here compares +//! two engines' outputs. The artifacts carry one engine's capture, and this +//! side takes no side on it. +//! +//! Independently enforced here, not outsourced to the Python harness: +//! * the ledger covers exactly the facts documents on disk across all five +//! swept corpora — a corpus document with no digest record, or a record +//! naming a document that is gone, is a red build; +//! the corpus roots are listed here too, so a *new* corpus directory has to +//! be added on both sides deliberately; +//! * every artifact named by the manifest exists, and every `*.repro.json` on +//! disk is named by the manifest; +//! * the canonical form is order-, whitespace- and duplicate-key-independent, +//! and it separates documents that differ in one character; +//! * the manifest's `domain_refusals` ledger is EXECUTABLE here too: every +//! document it declares unnameable must be refused by this engine's parser, +//! for the declared reason where the two engines share one. An entry that +//! stops holding is a red build demanding a decision, never a silently +//! widened domain. + +#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)] + +use std::collections::{BTreeMap, BTreeSet}; + +use own_shadow::{ + canonical_hash, parse, render, verify, Json, CANONICAL_ALGORITHM, ENGINE_ORDER, LAYER_ORDER, + REPRO_VERSION, +}; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../tests/fixtures"); + +/// The swept corpora, mirroring `tests/test_repro_fixtures.py::CORPORA`. Held +/// here as data so that adding a corpus is a deliberate two-sided change. +const CORPORA: [&str; 5] = ["ownir", "lowered", "summaries", "verdicts", "repro"]; + +fn read(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| { + panic!("cannot read {path}: {e} — regenerate: python tests/test_repro_fixtures.py --write") + }) +} + +fn stems(dir: &str, suffix: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for entry in std::fs::read_dir(dir).expect("fixture directory is readable") { + let file = entry.expect("directory entry").file_name(); + let file = file.to_str().expect("fixture filenames are UTF-8"); + if let Some(stem) = file.strip_suffix(suffix) { + out.insert(stem.to_owned()); + } + } + out +} + +/// case → (corpus label, facts path), swept from disk exactly as the reference +/// harness sweeps it. +fn facts_on_disk() -> BTreeMap { + let refusals = domain_refusals(); + let mut plan: BTreeMap = BTreeMap::new(); + for corpus in CORPORA { + let dir = format!("{FIXTURES}/{corpus}"); + for name in stems(&dir, ".facts.json") { + // A refusal control is deliberately NOT a capturable case: it has + // no digest, because the point is that neither engine can name it. + if refusals.iter().any(|(n, _)| n == &name) { + continue; + } + let previous = plan.insert( + name.clone(), + (corpus.to_owned(), format!("{dir}/{name}.facts.json")), + ); + assert!( + previous.is_none(), + "case name '{name}' exists in more than one corpus — names must be unique \ + across the sweep, because one digest ledger serves them all" + ); + } + } + assert!(!plan.is_empty(), "no facts documents swept"); + plan +} + +fn manifest() -> Json { + parse(&read(&format!("{FIXTURES}/repro/manifest.json"))).expect("repro manifest parses") +} + +/// The declared-unnameable documents: `(case, Option)`. +fn domain_refusals() -> Vec<(String, Option)> { + manifest() + .get("domain_refusals") + .and_then(Json::as_array) + .expect("manifest carries a 'domain_refusals' ledger") + .iter() + .map(|e| { + let name = e + .get("name") + .and_then(Json::as_str) + .expect("a refusal entry names its case") + .to_owned(); + assert!( + e.get("reason") + .and_then(Json::as_str) + .is_some_and(|r| !r.is_empty()), + "refusal '{name}' must say WHY the two engines cannot agree on it" + ); + let needle = e + .get("rust_error_contains") + .and_then(Json::as_str) + .map(str::to_owned); + (name, needle) + }) + .collect() +} + +#[test] +fn every_declared_unnameable_document_is_refused() { + let refusals = domain_refusals(); + assert!(!refusals.is_empty(), "the refusal ledger is empty"); + for (name, needle) in &refusals { + let path = format!("{FIXTURES}/repro/{name}.facts.json"); + let text = read(&path); + let err = parse(&text).err().unwrap_or_else(|| { + panic!( + "{name}: this engine ACCEPTS a document the ledger declares unnameable — \ + the control has rotted; promote it or record the decision" + ) + }); + if let Some(needle) = needle { + assert!( + err.to_string().contains(needle.as_str()), + "{name}: refused, but not for the declared reason: expected {needle:?} in {err}" + ); + } + } +} + +fn ledger() -> Json { + parse(&read(&format!("{FIXTURES}/repro/digests.json"))) + .expect("digests.json parses over the canonical domain") +} + +#[test] +fn every_shared_document_hashes_to_the_recorded_digest() { + let ledger = ledger(); + assert_eq!( + ledger.get("repro_version").and_then(Json::as_i64), + Some(REPRO_VERSION), + "digests.json is keyed to a different format version" + ); + assert_eq!( + ledger.get("algorithm").and_then(Json::as_str), + Some(CANONICAL_ALGORITHM), + "digests.json names a different digest algorithm" + ); + + let recorded: BTreeMap = ledger + .get("documents") + .and_then(Json::as_array) + .expect("digests.json carries a 'documents' array") + .iter() + .map(|r| { + let case = r + .get("case") + .and_then(Json::as_str) + .expect("case") + .to_owned(); + let corpus = r + .get("corpus") + .and_then(Json::as_str) + .expect("corpus") + .to_owned(); + let digest = r + .get("digest") + .and_then(Json::as_str) + .expect("digest") + .to_owned(); + let bytes = r.get("bytes").and_then(Json::as_i64).expect("bytes"); + (case, (corpus, digest, bytes)) + }) + .collect(); + + let on_disk = facts_on_disk(); + assert_eq!( + recorded.keys().collect::>(), + on_disk.keys().collect::>(), + "the digest ledger and the swept corpora disagree about which documents exist; \ + regenerate: python tests/test_repro_fixtures.py --write" + ); + + let mut divergences: Vec = Vec::new(); + for (case, (corpus, path)) in &on_disk { + let document = match parse(&read(path)) { + Ok(d) => d, + Err(e) => { + divergences.push(format!( + "{case}: this engine cannot parse the document over the canonical \ + domain, but the reference recorded a digest for it: {e}" + )); + continue; + } + }; + let got = canonical_hash(&document); + // Determinism: the same document, twice. + assert_eq!( + got, + canonical_hash(&document), + "{case}: hash is not deterministic" + ); + let (want_corpus, want_digest, want_bytes) = recorded.get(case).expect("checked above"); + if want_corpus != corpus { + divergences.push(format!( + "{case}: recorded under corpus {want_corpus}, found under {corpus}" + )); + } + if &got.digest != want_digest || i64::try_from(got.bytes).ok() != Some(*want_bytes) { + divergences.push(format!( + "{case}: canonical identity differs\n python = {want_digest} ({want_bytes} bytes)\ + \n rust = {} ({} bytes)", + got.digest, got.bytes + )); + } + } + assert!( + divergences.is_empty(), + "the two engines do not agree on the identity of {} document(s):\n{}", + divergences.len(), + divergences.join("\n") + ); +} + +fn manifest_artifacts() -> BTreeSet { + let manifest = manifest(); + assert_eq!( + manifest.get("repro_version").and_then(Json::as_i64), + Some(REPRO_VERSION), + "the repro manifest is keyed to a different format version" + ); + let mut names = BTreeSet::new(); + for entry in manifest + .get("artifacts") + .and_then(Json::as_array) + .expect("manifest carries an 'artifacts' ledger") + { + let name = entry + .get("name") + .and_then(Json::as_str) + .expect("an artifact entry names its case"); + let pins = entry + .get("pins") + .and_then(Json::as_array) + .expect("an artifact entry says what it pins"); + assert!( + !pins.is_empty(), + "artifact '{name}' must say what it is evidence FOR" + ); + assert!(names.insert(name.to_owned()), "duplicate artifact '{name}'"); + } + assert_eq!( + names, + stems(&format!("{FIXTURES}/repro"), ".repro.json"), + "the manifest's artifact ledger and the *.repro.json files on disk disagree" + ); + names +} + +#[test] +fn every_committed_artifact_round_trips_and_verifies() { + let names = manifest_artifacts(); + assert!(!names.is_empty(), "no artifacts committed"); + let mut carried_refusals = 0_usize; + for name in &names { + let path = format!("{FIXTURES}/repro/{name}.repro.json"); + let bytes = read(&path); + let artifact = + parse(&bytes).unwrap_or_else(|e| panic!("{name}: artifact does not parse: {e}")); + + // The format round-trips: parse then render reproduces the file. + assert_eq!( + render(&artifact), + bytes, + "{name}: the artifact does not round-trip byte-for-byte — this side renders it \ + differently from the reference" + ); + // It describes itself. + assert_eq!( + verify(&artifact), + Vec::::new(), + "{name}: the committed artifact does not verify" + ); + // The frozen vocabularies are actually present, not merely permitted. + let engines = artifact + .get("engines") + .and_then(Json::as_array) + .expect("engines"); + for engine in engines { + let id = engine.get("id").and_then(Json::as_str).expect("engine id"); + assert!(ENGINE_ORDER.contains(&id), "{name}: unknown engine {id}"); + let layers = engine + .get("layers") + .and_then(Json::as_array) + .expect("layers"); + assert_eq!(layers.len(), LAYER_ORDER.len(), "{name}: layer count"); + carried_refusals += layers + .iter() + .filter(|l| l.get("status").and_then(Json::as_str) == Some("refused")) + .count(); + } + } + assert!( + carried_refusals > 0, + "no committed artifact carries a REFUSED layer — the curated set must include the \ + shape a first-divergence reduction has to distinguish from a produced one" + ); +} + +/// Replace the first string leaf (depth-first, document order) with a value +/// differing in exactly one character — the same deterministic mutation the +/// reference harness applies, so the refusal it provokes is reproducible. +fn tamper(value: &Json, done: &mut bool) -> Json { + if *done { + return value.clone(); + } + match value { + Json::Str(s) if !s.is_empty() => { + *done = true; + let mut chars = s.chars(); + let head = chars.next().expect("non-empty"); + let replacement = if head == 'a' { 'b' } else { 'a' }; + let mut out = String::with_capacity(s.len()); + out.push(replacement); + out.extend(chars); + Json::Str(out) + } + Json::Int(i) => { + *done = true; + Json::Int(if *i > 0 { + i.saturating_sub(1) + } else { + i.saturating_add(1) + }) + } + Json::Array(items) => Json::Array(items.iter().map(|v| tamper(v, done)).collect()), + Json::Object(entries) => Json::Object( + entries + .iter() + .map(|(k, v)| (k.clone(), tamper(v, done))) + .collect(), + ), + other => other.clone(), + } +} + +fn replace_document(artifact: &Json, document: &Json) -> Json { + let Json::Object(entries) = artifact else { + panic!("artifact is not an object") + }; + Json::Object( + entries + .iter() + .map(|(k, v)| { + if k != "input" { + return (k.clone(), v.clone()); + } + let Json::Object(input) = v else { + panic!("input is not an object") + }; + let rebuilt = input + .iter() + .map(|(ik, iv)| { + if ik == "document" { + (ik.clone(), document.clone()) + } else { + (ik.clone(), iv.clone()) + } + }) + .collect(); + (k.clone(), Json::Object(rebuilt)) + }) + .collect(), + ) +} + +#[test] +fn a_changed_byte_in_the_embedded_document_is_refused() { + for name in manifest_artifacts() { + let path = format!("{FIXTURES}/repro/{name}.repro.json"); + let artifact = parse(&read(&path)).expect("artifact parses"); + let document = artifact + .get("input") + .and_then(|i| i.get("document")) + .expect("input.document") + .clone(); + + let mut done = false; + let forged_document = tamper(&document, &mut done); + assert!(done, "{name}: the embedded document has no leaf to tamper"); + assert_ne!( + canonical_hash(&forged_document).digest, + canonical_hash(&document).digest, + "{name}: a changed character did not change the digest" + ); + + let forged = replace_document(&artifact, &forged_document); + let problems = verify(&forged); + assert!( + problems + .iter() + .any(|p| p.contains("input.canonical does not describe input.document")), + "{name}: an artifact whose embedded document was changed still verifies — the \ + digest is not a gate. Problems reported: {problems:?}" + ); + } +} + +#[test] +fn the_canonical_form_ignores_only_insignificant_text_formatting() { + // Key order, whitespace and a duplicate key are text formatting: the + // canonical form is taken over the PARSED document, so all three hash the + // same. `dict` semantics for a duplicate key are last-wins, and both + // engines have to agree about that or "same input" means nothing. + let ordered = parse(r#"{"a": 1, "b": [2, 3], "c": {"d": null}}"#).unwrap(); + let shuffled = parse("{\n \"c\" : { \"d\" : null },\n \"b\":[2,3],\n\n \"a\":\t1 }").unwrap(); + let duplicated = parse(r#"{"a": 99, "b": [2, 3], "a": 1, "c": {"d": null}}"#).unwrap(); + assert_eq!(canonical_hash(&ordered), canonical_hash(&shuffled)); + assert_eq!(canonical_hash(&ordered), canonical_hash(&duplicated)); + + // …and one changed character is a different document. + let changed = parse(r#"{"a": 2, "b": [2, 3], "c": {"d": null}}"#).unwrap(); + assert_ne!(canonical_hash(&ordered), canonical_hash(&changed)); + + // The rendering, unlike the hash, keeps document order — the artifact + // carries the input as written (BR-D4: input order is semantic). + assert!(shuffled.to_pretty().starts_with("{\n \"c\": {")); + assert_eq!(ordered.to_canonical(), shuffled.to_canonical()); +} + +#[test] +fn values_outside_the_canonical_domain_are_refused_at_parse() { + for text in [ + r#"{"x": 1.5}"#, + r#"{"x": 1e3}"#, + r#"{"x": 9223372036854775808}"#, + r#"{"x": -9223372036854775809}"#, + ] { + let err = parse(text).expect_err(&format!("{text} must be refused, never rounded")); + assert!( + err.to_string().contains("canonical domain"), + "{text}: refused for the wrong reason: {err}" + ); + } + // The edges themselves are IN the domain. + assert!(parse(r#"{"x": 9223372036854775807}"#).is_ok()); + assert!(parse(r#"{"x": -9223372036854775808}"#).is_ok()); +} + +/// Replace (or insert) one member of an object, keeping document order. +fn with_member(value: &Json, key: &str, replacement: Option) -> Json { + let Json::Object(entries) = value else { + panic!("not an object") + }; + let mut out: Vec<(String, Json)> = Vec::new(); + let mut replaced = false; + for (k, v) in entries { + if k == key { + replaced = true; + if let Some(r) = &replacement { + out.push((k.clone(), r.clone())); + } + } else { + out.push((k.clone(), v.clone())); + } + } + if !replaced { + if let Some(r) = replacement { + out.push((key.to_owned(), r)); + } + } + Json::Object(out) +} + +fn engine0(artifact: &Json) -> Json { + artifact + .get("engines") + .and_then(Json::as_array) + .and_then(<[Json]>::first) + .expect("first engine") + .clone() +} + +fn with_engines(artifact: &Json, engines: Vec) -> Json { + with_member(artifact, "engines", Some(Json::Array(engines))) +} + +fn layers(engine: &Json) -> Vec { + engine + .get("layers") + .and_then(Json::as_array) + .expect("layers") + .to_vec() +} + +/// Rebuild the artifact with the FIRST layer replaced by `f(first)` — an +/// iterator form, because the workspace denies panicking indexing. +fn with_first_layer(artifact: &Json, f: impl Fn(&Json) -> Json) -> Json { + let rebuilt = layers(&engine0(artifact)) + .iter() + .enumerate() + .map(|(i, l)| if i == 0 { f(l) } else { l.clone() }) + .collect(); + with_layers(artifact, rebuilt) +} + +fn with_layers(artifact: &Json, new: Vec) -> Json { + let engine = with_member(&engine0(artifact), "layers", Some(Json::Array(new))); + with_engines(artifact, vec![engine]) +} + +/// Negative controls for [`own_shadow::verify`]: every structural rule it +/// states must have a document that breaks exactly that rule and is refused +/// for it. Without these, `verify` could degrade to "recompute the digest" and +/// every positive check would still pass — the shape P-022 discipline 2 is +/// about. Mirrors `tests/test_repro_fixtures.py::_structural_controls`, case +/// for case: the two are independent readings of one frozen rule. +#[test] +#[allow(clippy::too_many_lines)] // twelve controls, each three lines of data +fn verify_refuses_each_structural_violation() { + let name = "canonical_minimal"; + let artifact = + parse(&read(&format!("{FIXTURES}/repro/{name}.repro.json"))).expect("artifact parses"); + assert_eq!( + verify(&artifact), + Vec::::new(), + "the control base must verify" + ); + + let mut cases: Vec<(&str, &str, Json)> = Vec::new(); + + cases.push(( + "a wrong format version", + "repro_version", + with_member( + &artifact, + "repro_version", + Some(Json::Int(REPRO_VERSION + 1)), + ), + )); + cases.push(( + "an unknown artifact member", + "unknown member", + with_member(&artifact, "extra_member", Some(Json::Int(1))), + )); + let mut short = layers(&engine0(&artifact)); + short.remove(1); + cases.push(("a missing layer", "frozen", with_layers(&artifact, short))); + let mut reversed = layers(&engine0(&artifact)); + reversed.reverse(); + cases.push(( + "layers out of the frozen order", + "frozen", + with_layers(&artifact, reversed), + )); + cases.push(( + "an unknown engine id", + "frozen engine vocabulary", + with_engines( + &artifact, + vec![with_member( + &engine0(&artifact), + "id", + Some(Json::Str("some-other-engine".to_owned())), + )], + ), + )); + cases.push(( + "a repeated engine", + "appears twice", + with_engines(&artifact, vec![engine0(&artifact), engine0(&artifact)]), + )); + cases.push(( + "engines out of the frozen order", + "out of the frozen order", + with_engines( + &artifact, + vec![ + with_member( + &engine0(&artifact), + "id", + Some(Json::Str("rust-own-bridge".to_owned())), + ), + engine0(&artifact), + ], + ), + )); + cases.push(( + "a produced layer carrying an error", + "carries an error", + with_first_layer(&artifact, |l| { + with_member( + l, + "error", + Some(Json::Str("an error beside a document".to_owned())), + ) + }), + )); + cases.push(( + "a refused layer without an error", + "non-empty error text", + with_first_layer(&artifact, |l| { + with_member( + &with_member(l, "document", None), + "status", + Some(Json::Str("refused".to_owned())), + ) + }), + )); + cases.push(( + "a layer without surface_version", + "surface_version is missing", + with_first_layer(&artifact, |l| with_member(l, "surface_version", None)), + )); + cases.push(( + "an unknown layer status", + "is neither", + with_first_layer(&artifact, |l| { + with_member(l, "status", Some(Json::Str("maybe".to_owned()))) + }), + )); + cases.push(( + "a missing canonical block", + "input.canonical is missing", + with_member( + &artifact, + "input", + Some(with_member( + artifact.get("input").expect("input"), + "canonical", + None, + )), + ), + )); + + cases.push(( + "a layer without a projection", + "projection is missing", + with_first_layer(&artifact, |l| with_member(l, "projection", None)), + )); + cases.push(( + "an unknown projection kind", + "is not one of", + with_first_layer(&artifact, |l| { + with_member( + l, + "projection", + Some(Json::Object(vec![( + "kind".to_owned(), + Json::Str("mostly".to_owned()), + )])), + ) + }), + )); + cases.push(( + "a partial projection naming no members", + "must NAME", + with_first_layer(&artifact, |l| { + with_member( + l, + "projection", + Some(Json::Object(vec![ + ("kind".to_owned(), Json::Str("partial".to_owned())), + ( + "reason".to_owned(), + Json::Str("some members are not ported".to_owned()), + ), + ])), + ) + }), + )); + cases.push(( + "a partial projection with no reason", + "must say WHY", + with_first_layer(&artifact, |l| { + with_member( + l, + "projection", + Some(Json::Object(vec![ + ("kind".to_owned(), Json::Str("partial".to_owned())), + ( + "members".to_owned(), + Json::Array(vec![Json::Str("module".to_owned())]), + ), + ])), + ) + }), + )); + cases.push(( + "a partial projection whose reason is empty", + "must say WHY", + with_first_layer(&artifact, |l| { + with_member( + l, + "projection", + Some(Json::Object(vec![ + ("kind".to_owned(), Json::Str("partial".to_owned())), + ( + "members".to_owned(), + Json::Array(vec![Json::Str("module".to_owned())]), + ), + ("reason".to_owned(), Json::Str(String::new())), + ])), + ) + }), + )); + cases.push(( + "a full projection carrying members", + "carries no", + with_first_layer(&artifact, |l| { + with_member( + l, + "projection", + Some(Json::Object(vec![ + ("kind".to_owned(), Json::Str("full".to_owned())), + ( + "members".to_owned(), + Json::Array(vec![Json::Str("module".to_owned())]), + ), + ])), + ) + }), + )); + + assert_eq!( + cases.len(), + 18, + "the structural control set changed — keep it in step with the Python side" + ); + for (label, needle, forged) in cases { + let problems = verify(&forged); + assert!( + problems.iter().any(|p| p.contains(needle)), + "verify accepts {label} (expected a problem naming {needle:?}, got {problems:?})" + ); + } +} diff --git a/rust/crates/own-shadow/tests/trace.rs b/rust/crates/own-shadow/tests/trace.rs new file mode 100644 index 00000000..1258bf60 --- /dev/null +++ b/rust/crates/own-shadow/tests/trace.rs @@ -0,0 +1,306 @@ +//! The `AnalysisTrace` acceptance contract (P-022 step 7a checkpoint 3, #269): +//! +//! ```text +//! .repro.json → own_shadow::project_traces ≡ .trace.json +//! ``` +//! +//! byte-for-byte, with **zero Python**. The reference authors the goldens +//! (`python tests/test_repro_fixtures.py --write`); this side projects the +//! same artifacts through its own independent implementation of the same +//! frozen schema. Both sides project **both** engines' captures — projecting a +//! capture is not authoring it — so the *normalization itself* is +//! cross-checked, and a disagreement between the two implementations is a +//! finding about the projection rather than about either engine. +//! +//! **Nothing here compares the two engines.** The trace is the shape a +//! comparison would need; producing it is not performing one. Reading one +//! engine's steps against the other's is the reduction checkpoint's job. +//! +//! The properties asserted beyond the goldens are the ones the normalization +//! exists for: +//! * **totality** — no counter-shaped handle survives the rewrite anywhere; +//! * **a mint-order shift does not move a stable id** — the whole point; +//! * **order is not normalized away** — the same shift still changes the +//! lowered layer's step order, because that difference is real. + +#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)] + +use std::collections::{BTreeMap, BTreeSet}; + +use own_shadow::{parse, project_traces, Json, ORDER_CANONICAL, ORDER_SIGNIFICANT, TRACE_VERSION}; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../tests/fixtures"); + +fn read(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {path}: {e}")) +} + +fn artifact_names() -> BTreeSet { + let manifest = + parse(&read(&format!("{FIXTURES}/repro/manifest.json"))).expect("repro manifest parses"); + manifest + .get("artifacts") + .and_then(Json::as_array) + .expect("manifest carries an 'artifacts' ledger") + .iter() + .filter_map(|e| e.get("name").and_then(Json::as_str)) + .map(str::to_owned) + .collect() +} + +/// The first differing line, both sides. A failure that only says "they +/// differ" makes the reader do the diff by hand, which for a 2 000-line trace +/// is how a real disagreement gets waved through as "probably formatting". +fn first_difference(committed: &str, ours: &str) -> String { + for (i, (a, b)) in committed.lines().zip(ours.lines()).enumerate() { + if a != b { + let line = i.saturating_add(1); + return format!(" first difference at line {line}:\n committed = {a}\n ours = {b}"); + } + } + format!( + " the shorter side ends first: committed has {} line(s), ours {}", + committed.lines().count(), + ours.lines().count() + ) +} + +#[test] +fn every_trace_golden_is_reproduced_byte_for_byte() { + let names = artifact_names(); + assert!(!names.is_empty(), "no artifacts to trace"); + let mut divergences: Vec = Vec::new(); + for name in &names { + let artifact = + parse(&read(&format!("{FIXTURES}/repro/{name}.repro.json"))).expect("artifact parses"); + let ours = project_traces(&artifact, name) + .unwrap_or_else(|e| panic!("{name}: this side cannot project the trace: {e}")); + // Determinism: the same artifact, twice. + assert_eq!( + ours, + project_traces(&artifact, name).expect("second projection"), + "{name}: the trace projection is not deterministic" + ); + let mut rendered = ours.to_pretty(); + rendered.push('\n'); + let committed = read(&format!("{FIXTURES}/repro/{name}.trace.json")); + if rendered != committed { + divergences.push(format!( + "{name}: this side's trace differs from the committed one — the two \ + implementations of the normalization disagree, which is a finding about the \ + PROJECTION, not about either engine.\n{}", + first_difference(&committed, &rendered) + )); + } + } + assert!( + divergences.is_empty(), + "{} trace(s) differ:\n{}", + divergences.len(), + divergences.join("\n") + ); +} + +#[test] +fn the_declared_order_semantics_are_the_frozen_ones() { + for name in artifact_names() { + let traces = parse(&read(&format!("{FIXTURES}/repro/{name}.trace.json"))) + .expect("trace golden parses"); + assert_eq!( + traces.get("trace_version").and_then(Json::as_i64), + Some(TRACE_VERSION), + "{name}: trace golden is keyed to a different surface version" + ); + for trace in traces + .get("traces") + .and_then(Json::as_array) + .expect("traces") + { + for layer in trace + .get("layers") + .and_then(Json::as_array) + .expect("layers") + { + let layer_name = layer.get("layer").and_then(Json::as_str).expect("layer"); + let order = layer.get("order").and_then(Json::as_str).expect("order"); + let want = if layer_name == "summaries" { + ORDER_CANONICAL + } else { + ORDER_SIGNIFICANT + }; + assert_eq!( + order, want, + "{name}/{layer_name}: order semantics drifted — a comparison reads this to \ + CLASSIFY an ordering difference, and it must never license sorting" + ); + } + } + } +} + +#[test] +fn no_counter_shaped_handle_survives_anywhere_in_a_trace() { + // Totality, checked from the OUTSIDE: the projection asserts it internally, + // and this walks the committed result so the assertion cannot be the only + // thing standing between a leaked counter and a comparison. + fn walk(value: &Json, out: &mut Vec) { + match value { + Json::Str(s) => { + if let Some((prefix, digits)) = s.split_once('_') { + if matches!(prefix, "sub" | "cap" | "parg" | "loc") + && !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit()) + { + out.push(s.clone()); + } + } + } + Json::Array(items) => items.iter().for_each(|v| walk(v, out)), + Json::Object(entries) => entries.iter().for_each(|(_, v)| walk(v, out)), + _ => {} + } + } + for name in artifact_names() { + let traces = parse(&read(&format!("{FIXTURES}/repro/{name}.trace.json"))) + .expect("trace golden parses"); + let mut leaked = Vec::new(); + walk(&traces, &mut leaked); + assert!( + leaked.is_empty(), + "{name}: minted handles survived into the trace: {leaked:?}" + ); + } +} + +/// The property the whole checkpoint exists for, driven through the port's own +/// pipeline: shifting the mint counters must not move a stable id, and must +/// still move the step ORDER. +#[test] +fn a_mint_order_shift_moves_the_order_but_not_the_stable_ids() { + // A document with several components: reversing them reshuffles the global + // handle counters (BR-L2) without changing any record's identity. + let facts_path = format!("{FIXTURES}/lowered/handles_global_counters.facts.json"); + let facts = parse(&read(&facts_path)).expect("facts parse"); + let Json::Object(fields) = &facts else { + panic!("facts is not an object") + }; + let permuted = Json::Object( + fields + .iter() + .map(|(k, v)| { + if k != "components" { + return (k.clone(), v.clone()); + } + let mut items = v.as_array().expect("components").to_vec(); + items.reverse(); + (k.clone(), Json::Array(items)) + }) + .collect(), + ); + + let steps = |document: &Json| -> (Vec, Vec) { + let text = document.to_canonical(); + let capture = own_shadow::capture(&text).expect("capture"); + let artifact = Json::Object(vec![ + ("engines".to_owned(), Json::Array(vec![capture])), + ( + "input".to_owned(), + Json::Object(vec![("canonical".to_owned(), Json::Null)]), + ), + ]); + let traces = project_traces(&artifact, "probe").expect("trace"); + let layer = traces + .get("traces") + .and_then(Json::as_array) + .and_then(<[Json]>::first) + .and_then(|t| t.get("layers")) + .and_then(Json::as_array) + .and_then(<[Json]>::first) + .expect("the lowered layer"); + let ids: Vec = layer + .get("steps") + .and_then(Json::as_array) + .expect("steps") + .iter() + .filter_map(|s| s.get("id").and_then(Json::as_str)) + .map(str::to_owned) + .collect(); + let handles: Vec = ids + .iter() + .filter(|id| id.starts_with("handles[")) + .cloned() + .collect(); + (ids, handles) + }; + + let (order_a, handles_a) = steps(&facts); + let (order_b, handles_b) = steps(&permuted); + + assert!( + !handles_a.is_empty(), + "the probe document mints no handles, so it proves nothing" + ); + let mut sorted_a = handles_a; + let mut sorted_b = handles_b; + sorted_a.sort(); + sorted_b.sort(); + assert_eq!( + sorted_a, sorted_b, + "a mint-order shift moved a stable id — the normalization does not survive the \ + reordering it exists for, and one permuted input would report every handle as a \ + difference between engines" + ); + assert_ne!( + order_a, order_b, + "the permutation did not change the lowered layer's step order, so this case cannot \ + show that order is DECLARED rather than normalized away — pick a document where it does" + ); +} + +#[test] +fn a_refused_layer_carries_no_steps() { + // An empty step list that compared equal to another engine's empty one + // would score a refusal as agreement, so refusals must be visibly refusals. + let mut refused = 0_usize; + let mut by_case: BTreeMap = BTreeMap::new(); + for name in artifact_names() { + let traces = parse(&read(&format!("{FIXTURES}/repro/{name}.trace.json"))) + .expect("trace golden parses"); + for trace in traces + .get("traces") + .and_then(Json::as_array) + .expect("traces") + { + for layer in trace + .get("layers") + .and_then(Json::as_array) + .expect("layers") + { + let status = layer.get("status").and_then(Json::as_str).expect("status"); + let steps = layer.get("steps").and_then(Json::as_array).expect("steps"); + if status == "refused" { + refused = refused.saturating_add(1); + *by_case.entry(name.clone()).or_default() += 1; + assert!( + steps.is_empty(), + "{name}: a refused layer carries {} step(s)", + steps.len() + ); + assert!( + layer.get("error").and_then(Json::as_str).is_some(), + "{name}: a refused layer carries no error text" + ); + } else { + assert!( + !steps.is_empty(), + "{name}: a produced layer carries no steps" + ); + } + } + } + } + assert!( + refused > 0, + "no committed trace carries a refused layer, so this control proves nothing" + ); +} diff --git a/scripts/mutate_campaign.py b/scripts/mutate_campaign.py index d441e865..5f5fbb54 100644 --- a/scripts/mutate_campaign.py +++ b/scripts/mutate_campaign.py @@ -16,9 +16,25 @@ (`scripts/render_checkpoint_status.py`), so the campaign is interpreted once. +A campaign declares **what to run** in one of two ways, and the rest of the +contract is identical for both: + +* `workspace` — every member of that cargo workspace, `cargo test -p + --no-fail-fast`. The packages come from `cargo metadata`, never a typed + list, so a new crate is covered the day it exists. +* `layers` — an explicit list of commands, for a campaign whose catchers do + not all live in one cargo workspace. The shadow-mode campaigns + (`p022-shadow-cp*`) are the case this exists for: their reference half is a + Python harness and their port half is several cargo test targets, and a + campaign that cannot run the layer holding a catcher cannot see it catch + (checkpoint 3 lost a mutation to exactly that). + +Whichever it is, EVERY layer runs for EVERY mutation — discipline 3's no +fail-fast applies across layers, not just within one. + Each mutation is applied to a pristine copy of the file and restored from -memory (never `git checkout`), the tests of every workspace package run with -`--no-fail-fast`, and the outcome is one of: +memory (never `git checkout`), the tests run with `--no-fail-fast`, and the +outcome is one of: caught at least one test failed survived the mutated tree passed (a gap — or, for the control, the point) @@ -52,6 +68,14 @@ rebased or deleted branch is not evidence — re-run the campaign. Neither check depends on HEAD's content: a refactor after the run leaves it valid. +A mutation that edits a **Python** source needs one more thing the cargo-only +case never did: CPython validates a cached `.pyc` by the source's integer +mtime and size, so restoring a same-size mutation leaves the MUTATED bytecode +in place and every later run measures the leftovers. The runner invalidates +the cache on every write and runs Python layers with `PYTHONDONTWRITEBYTECODE`. +This is not hypothetical: one same-size mutation (`indent=2` -> `indent=4`) +made fifteen later mutations report a Python catcher that could not exist. + Usage: python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4-mutations.json --validate python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4-mutations.json --run @@ -77,6 +101,7 @@ _RUNNING = re.compile(r"^\s+Running (?:unittests )?(\S+) \(\S+\)$") _DOCTESTS = re.compile(r"^\s+Doc-tests (\S+)$") _FAILED = re.compile(r"^test (.+?) \.\.\. FAILED$") +_PY_FAIL = re.compile(r"^FAIL\[([^\]]+)\]:", re.M) @dataclass(frozen=True) @@ -90,16 +115,38 @@ class Mutation: rule: str | None = None +@dataclass(frozen=True) +class Layer: + """One command whose failures are catchers. `parser` says how to read them: + + cargo cargo's merged output — `test ... FAILED` under the + `Running ` header that precedes it. + python-fail a harness that prints `FAIL[]: ` lines, so a + catcher is named by the CHECK it violated rather than by the + case that happened to trip first. A non-zero exit with no such + line is still a catch, recorded under a name that says so. + """ + + id: str + cwd: str + command: tuple[str, ...] + parser: str + + +PARSERS = ("cargo", "python-fail") + + @dataclass(frozen=True) class Definition: campaign: str description: str - workspace: str control_id: str control_description: str mutations: tuple[Mutation, ...] path: str sha256: str + workspace: str | None = None + layers: tuple[Layer, ...] = () @dataclass(frozen=True) @@ -121,6 +168,12 @@ class Result: packages: tuple[str, ...] control: Outcome mutations: tuple[Outcome, ...] + layers: tuple[str, ...] = () + + @property + def ran(self) -> tuple[str, ...]: + """What the run actually exercised — cargo packages or declared layers.""" + return self.layers or self.packages @dataclass @@ -193,6 +246,7 @@ def load_definition(path: str) -> Definition: expected_catchers=tuple(str(c) for c in catchers), rule=rule, )) + workspace, layers = _target(data, path) ids = [m.id for m in mutations] control_id = _str(control, "id", f"{path}: control") if len(set(ids)) != len(ids) or control_id in ids: @@ -200,15 +254,50 @@ def load_definition(path: str) -> Definition: return Definition( campaign=_str(data, "campaign", path), description=_str(data, "description", path), - workspace=_str(data, "workspace", path), control_id=control_id, control_description=_str(control, "description", f"{path}: control"), mutations=tuple(mutations), path=path, sha256=_sha256(path), + workspace=workspace, + layers=layers, ) +def _layer(obj: object, where: str) -> Layer: + if not isinstance(obj, dict): + raise CampaignError(f"{where}: not an object") + command = obj.get("command") + if not (isinstance(command, list) and command + and all(isinstance(c, str) and c for c in command)): + raise CampaignError(f"{where}: 'command' must be a non-empty array of strings") + parser = _str(obj, "parser", where) + if parser not in PARSERS: + raise CampaignError(f"{where}: unknown parser {parser!r} (one of {list(PARSERS)})") + return Layer(id=_str(obj, "id", where), cwd=str(obj.get("cwd", ".")), + command=tuple(str(c) for c in command), parser=parser) + + +def _target(data: dict[str, object], path: str) -> tuple[str | None, tuple[Layer, ...]]: + """`workspace` or `layers`, never both and never neither: a campaign that + does not say what to run cannot be replayed, and one that says it twice + leaves the reader guessing which half was measured.""" + raw = data.get("layers") + workspace = data.get("workspace") + if (raw is None) == (workspace is None): + raise CampaignError(f"{path}: declare exactly one of 'workspace' (every member of a " + f"cargo workspace) or 'layers' (explicit commands)") + if workspace is not None: + return _str(data, "workspace", path), () + if not (isinstance(raw, list) and raw): + raise CampaignError(f"{path}: 'layers' must be a non-empty array") + layers = tuple(_layer(item, f"{path}: layers[{i}]") for i, item in enumerate(raw)) + ids = [x.id for x in layers] + if len(set(ids)) != len(ids): + raise CampaignError(f"{path}: layer ids must be unique (got {ids})") + return None, layers + + def _outcome_from(obj: object, where: str) -> Outcome: if not isinstance(obj, dict): raise CampaignError(f"{where}: not an object") @@ -243,6 +332,12 @@ def load_result(path: str) -> Result: packages = data.get("packages", []) if not (isinstance(packages, list) and all(isinstance(p, str) for p in packages)): raise CampaignError(f"{path}: 'packages' must be an array of strings") + layers = data.get("layers", []) + if not (isinstance(layers, list) and all(isinstance(x, str) for x in layers)): + raise CampaignError(f"{path}: 'layers' must be an array of strings") + if bool(packages) == bool(layers): + raise CampaignError(f"{path}: a result names either the cargo 'packages' or the " + f"'layers' it ran, never both and never neither") dirty = data.get("dirty", False) if not isinstance(dirty, bool): raise CampaignError(f"{path}: 'dirty' must be a boolean") @@ -255,6 +350,7 @@ def load_result(path: str) -> Result: packages=tuple(str(p) for p in packages), control=_outcome_from(data.get("control"), f"{path}: control"), mutations=tuple(_outcome_from(m, f"{path}: mutations[{i}]") for i, m in enumerate(raw)), + layers=tuple(str(x) for x in layers), ) @@ -279,6 +375,14 @@ def summarize(definition: Definition, result: Result) -> Summary: f"the unmutated tree — the run measured nothing") if result.dirty: problems.append("the result was recorded on a dirty tree — not evidence") + declared = tuple(x.id for x in definition.layers) + if declared and tuple(result.layers) != declared: + problems.append(f"the result ran layers {list(result.layers)} but the definition " + f"declares {list(declared)} — a campaign that did not run the layer " + f"holding a catcher cannot have seen it catch; re-run the campaign") + if definition.workspace is not None and not result.packages: + problems.append("the definition names a cargo workspace but the result records no " + "packages — re-run the campaign") recorded = {o.id: o for o in result.mutations} missing = [m.id for m in definition.mutations if m.id not in recorded] extra = [i for i in recorded if i not in {m.id for m in definition.mutations}] @@ -399,26 +503,91 @@ def parse_test_output(package: str, out: str) -> tuple[list[str], bool]: return catchers, compile_error -def run_tests(workspace: str, packages: list[str]) -> tuple[list[str], bool, list[str]]: - """Run every package's tests, no fail-fast, streams merged so `Running` - headers (stderr) and results (stdout) keep their interleaving. +def parse_python_output(layer: str, out: str) -> tuple[list[str], bool]: + """(catching check ids, compile error seen) from a `FAIL[]: …` harness. + + Naming the CHECK rather than the case keeps a campaign's evidence stable + under fixture churn, and says which rule the mutation broke.""" + catchers = [f"{layer}::{name}" for name in + sorted({m.group(1) for m in _PY_FAIL.finditer(out)})] + compile_error = ("SyntaxError:" in out or "IndentationError:" in out + or "TabError:" in out) + return catchers, compile_error + + +def _run_layer(layer: Layer) -> tuple[list[str], bool, list[str]]: + env = dict(os.environ) + if layer.parser == "python-fail": + # Never leave a .pyc behind: see the cache note in the module docstring. + env["PYTHONDONTWRITEBYTECODE"] = "1" + r = subprocess.run(list(layer.command), cwd=os.path.join(ROOT, layer.cwd), env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if layer.parser == "cargo": + found, ce = parse_test_output(layer.id, r.stdout) + else: + found, ce = parse_python_output(layer.id, r.stdout) + unparsed: list[str] = [] + if r.returncode != 0 and not found and not ce: + # A layer that failed without naming a check still caught something; it + # is recorded under a name that says the runner could not attribute it, + # never dropped into "survived". + found = [f"{layer.id}::"] + unparsed.append(f"{layer.id}: exited {r.returncode} without a parseable failure:\n" + + "\n".join(r.stdout.splitlines()[-15:])) + return found, ce, unparsed + + +def run_tests(definition: Definition) -> tuple[list[str], bool, list[str]]: + """Run every layer — a cargo workspace's members, or the declared commands — + with no fail-fast, streams merged so `Running` headers (stderr) and results + (stdout) keep their interleaving. Returns (catchers, compile error seen, unparsed failures).""" catchers: list[str] = [] compile_error = False unparsed: list[str] = [] - for pkg in packages: - r = subprocess.run(["cargo", "test", "-p", pkg, "--no-fail-fast"], - cwd=os.path.join(ROOT, workspace), - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - found, ce = parse_test_output(pkg, r.stdout) + for layer in _layers_of(definition): + found, ce, un = _run_layer(layer) catchers.extend(found) compile_error = compile_error or ce - if r.returncode != 0 and not found and not ce: - unparsed.append(f"{pkg}: cargo test exited {r.returncode} without a parseable " - f"failure:\n" + "\n".join(r.stdout.splitlines()[-15:])) + unparsed.extend(un) return catchers, compile_error, unparsed +def _layers_of(definition: Definition) -> tuple[Layer, ...]: + """The declared layers, or one cargo layer per workspace member.""" + if definition.layers: + return definition.layers + workspace = definition.workspace + assert workspace is not None # load_definition enforces one or the other + return tuple( + Layer(id=pkg, cwd=workspace, parser="cargo", + command=("cargo", "test", "-p", pkg, "--no-fail-fast")) + for pkg in workspace_packages(workspace)) + + +def write_source(target: str, text: str) -> None: + """Write a mutated (or restored) source and drop any cached bytecode for it. + + CPython validates a `.pyc` by the source's integer mtime and size, so a + same-size rewrite inside the same second leaves the stale bytecode valid + and the interpreter runs the file that is no longer on disk.""" + path = os.path.join(ROOT, target) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + if not target.endswith(".py"): + return + directory, name = os.path.split(path) + cache = os.path.join(directory, "__pycache__") + stem = name[:-3] + "." + if os.path.isdir(cache): + for entry in os.listdir(cache): + if entry.startswith(stem) and entry.endswith(".pyc"): + try: + os.remove(os.path.join(cache, entry)) + except OSError: # pragma: no cover - a cache we cannot clear + pass + + def apply(m: Mutation, pristine: str) -> tuple[str, str | None]: """(mutated text, problem) — the pattern must match exactly once and change something.""" try: @@ -466,7 +635,8 @@ def run_campaign(definition: Definition, allow_dirty: bool) -> Result: "remove them first, or --allow-dirty for a dev run whose result " f"is NOT evidence:\n{baseline}") commit = _git("rev-parse", "HEAD") - packages = workspace_packages(definition.workspace) + layers = _layers_of(definition) + packages = [] if definition.layers else [x.id for x in layers] targets = sorted({m.target for m in definition.mutations}) pristine: dict[str, str] = {} for t in targets: @@ -475,12 +645,12 @@ def run_campaign(definition: Definition, allow_dirty: bool) -> Result: def restore() -> None: for t, text in pristine.items(): - with open(os.path.join(ROOT, t), "w", encoding="utf-8") as f: - f.write(text) + write_source(t, text) print(f"{definition.control_id}: {definition.control_description}", flush=True) + print(f" layers: {', '.join(x.id for x in layers)}", flush=True) t0 = time.monotonic() - catchers, ce, unparsed = run_tests(definition.workspace, packages) + catchers, ce, unparsed = run_tests(definition) outcome, detail = _classify(catchers, ce, unparsed) control = Outcome(definition.control_id, outcome, tuple(catchers), round(time.monotonic() - t0, 1), detail) @@ -498,11 +668,10 @@ def restore() -> None: outcomes.append(Outcome(m.id, "invalid-mutation", (), 0.0, problem)) print(f" -> invalid-mutation: {problem}", flush=True) continue - with open(os.path.join(ROOT, m.target), "w", encoding="utf-8") as f: - f.write(mutated) + write_source(m.target, mutated) t0 = time.monotonic() try: - catchers, ce, unparsed = run_tests(definition.workspace, packages) + catchers, ce, unparsed = run_tests(definition) finally: restore() assert_tree_unchanged(baseline, f"during {m.id}") @@ -533,6 +702,7 @@ def restore() -> None: packages=tuple(packages), control=control, mutations=tuple(outcomes), + layers=tuple(x.id for x in layers) if definition.layers else (), ) @@ -559,11 +729,15 @@ def write_result(result: Result, definition: Definition, path: str) -> None: "source_commit": result.source_commit, "dirty": result.dirty, "recorded_at": result.recorded_at, - "packages": list(result.packages), - "command": "cargo test -p --no-fail-fast, for every workspace member", - "control": _outcome_json(result.control), - "mutations": [_outcome_json(o) for o in result.mutations], } + if result.layers: + doc["layers"] = list(result.layers) + doc["command"] = "every layer the definition declares, for every mutation" + else: + doc["packages"] = list(result.packages) + doc["command"] = "cargo test -p --no-fail-fast, for every workspace member" + doc["control"] = _outcome_json(result.control) + doc["mutations"] = [_outcome_json(o) for o in result.mutations] with open(path, "w", encoding="utf-8") as f: json.dump(doc, f, indent=2, ensure_ascii=False) f.write("\n") diff --git a/scripts/render_checkpoint_status.py b/scripts/render_checkpoint_status.py index 0fa3f7e4..be5272bc 100644 --- a/scripts/render_checkpoint_status.py +++ b/scripts/render_checkpoint_status.py @@ -12,6 +12,14 @@ from `docs/evidence/p022-cp4-mutations.json` and its `.result.json`, through `scripts/mutate_campaign.summarize()` (the same interpretation the runner prints). +* `docs/generated/p022-shadow-census.md` — the step-7a (#260/#269) + shadow-mode INFRASTRUCTURE census, from + `tests/shadow_census.compute_shadow_census()` over the committed + reproduction artifacts, traces and reductions. +* `docs/generated/p022-shadow-mutations.md` — that slice's four recorded + campaigns, through the same `summarize()` as cp4's. One interpreter for + every campaign in the tree: two readings of one run is how two documents + come to disagree about it. Determinism: nothing in a fragment depends on HEAD, the clock or the environment, so an unrelated commit never changes one. The campaign fragment @@ -49,13 +57,25 @@ provenance_problems, summarize, ) +from shadow_census import ShadowCensus, ShadowCensusError, compute_shadow_census # noqa: E402 from verdict_census import Census, CensusError, compute_verdict_census # noqa: E402 GENERATED = os.path.join(ROOT, "docs", "generated") +EVIDENCE = os.path.join(ROOT, "docs", "evidence") CENSUS_MD = "p022-cp4-census.md" MUTATIONS_MD = "p022-cp4-mutations.md" -CAMPAIGN = os.path.join(ROOT, "docs", "evidence", "p022-cp4-mutations.json") -RESULT = os.path.join(ROOT, "docs", "evidence", "p022-cp4-mutations.result.json") +SHADOW_CENSUS_MD = "p022-shadow-census.md" +SHADOW_MUTATIONS_MD = "p022-shadow-mutations.md" +CAMPAIGN = os.path.join(EVIDENCE, "p022-cp4-mutations.json") +RESULT = os.path.join(EVIDENCE, "p022-cp4-mutations.result.json") +# One campaign per shadow checkpoint: each stays frozen at what it measured, so +# a later checkpoint cannot quietly restate an earlier one's numbers. +SHADOW_CAMPAIGNS = ( + ("checkpoint 1 — same-input capture and the reproduction artifact", "p022-shadow-cp1"), + ("checkpoint 2 — the engine protocol", "p022-shadow-cp2"), + ("checkpoint 3 — the AnalysisTrace and stable-ID normalization", "p022-shadow-cp3"), + ("checkpoint 4 — first-divergence reduction", "p022-shadow-cp4"), +) SELF = "scripts/render_checkpoint_status.py" @@ -126,18 +146,20 @@ def render_census(c: Census) -> str: # --- mutation campaign ---------------------------------------------------- -def _load_campaign() -> tuple[Definition | None, Result | None, list[str]]: +def _load_campaign(campaign: str = CAMPAIGN, + result_path: str = RESULT) -> tuple[Definition | None, Result | None, + list[str]]: problems: list[str] = [] definition: Definition | None = None result: Result | None = None - if os.path.exists(CAMPAIGN): + if os.path.exists(campaign): try: - definition = load_definition(CAMPAIGN) + definition = load_definition(campaign) except (CampaignError, OSError, ValueError) as e: problems.append(f"campaign definition unreadable: {e}") - if definition is not None and os.path.exists(RESULT): + if definition is not None and os.path.exists(result_path): try: - result = load_result(RESULT) + result = load_result(result_path) except (CampaignError, OSError, ValueError) as e: problems.append(f"campaign result unreadable: {e}") return definition, result, problems @@ -145,8 +167,15 @@ def _load_campaign() -> tuple[Definition | None, Result | None, list[str]]: def render_mutations(definition: Definition | None, result: Result | None, summary: Summary | None) -> str: - lines = [_header(f"{_rel(CAMPAIGN)} and {_rel(RESULT)}"), - "# P-022 checkpoint 4 — mutation campaign", ""] + return _header(f"{_rel(CAMPAIGN)} and {_rel(RESULT)}") + "\n" + _mutation_section( + "# P-022 checkpoint 4 — mutation campaign", definition, result, summary, + CAMPAIGN, RESULT) + + +def _mutation_section(heading: str, definition: Definition | None, result: Result | None, + summary: Summary | None, campaign_path: str, result_path: str) -> str: + CAMPAIGN, RESULT = campaign_path, result_path + lines = [heading, ""] if definition is None: lines += ["No campaign definition is committed (expected at " f"`{_rel(CAMPAIGN)}`).", ""] @@ -164,10 +193,11 @@ def render_mutations(definition: Definition | None, result: Result | None, lines += [f"**No recorded run** is committed (expected at `{_rel(RESULT)}`): the " "campaign has a definition but no evidence. Nothing below is a number.", ""] return "\n".join(lines) + ran = ("layers run (every one, for every mutation)" if result.layers + else "packages tested (every workspace member, `--no-fail-fast`)") rows: list[tuple[str, str]] = [ ("recorded at commit", f"`{summary.source_commit}`"), - ("packages tested (every workspace member, `--no-fail-fast`)", - ", ".join(f"`{p}`" for p in result.packages)), + (ran, ", ".join(f"`{p}`" for p in result.ran)), ("mutations", str(summary.total)), ("caught", str(summary.caught)), ("survived", str(summary.survived)), @@ -202,6 +232,203 @@ def render_mutations(definition: Definition | None, result: Result | None, return "\n".join(lines) +# --- step 7a: the shadow-mode infrastructure slice ------------------------ + + +def _shadow_paths(campaign: str) -> tuple[str, str]: + return (os.path.join(EVIDENCE, f"{campaign}.json"), + os.path.join(EVIDENCE, f"{campaign}.result.json")) + + +def render_shadow_mutations() -> tuple[str, list[str]]: + """The slice's four campaigns, one document, the same interpreter as cp4's.""" + sources = ", ".join(_rel(_shadow_paths(c)[0]) for _, c in SHADOW_CAMPAIGNS) + parts = [_header(f"{sources} and their .result.json"), + "# P-022 step 7a — shadow-mode infrastructure: mutation campaigns", + "", + "Every mutation edits a **production** surface (P-022 discipline 2) and every " + "declared layer runs for every mutation (discipline 3: no fail-fast). Each " + "campaign stays frozen at what it measured; the counts below are derived from " + "the recorded runs by `scripts/mutate_campaign.summarize()`, never typed.", + ""] + problems: list[str] = [] + for title, campaign in SHADOW_CAMPAIGNS: + definition_path, result_path = _shadow_paths(campaign) + definition, result, load_problems = _load_campaign(definition_path, result_path) + problems.extend(f"{campaign}: {p}" for p in load_problems) + summary = summarize(definition, result) if definition and result else None + if summary is not None and result is not None: + problems.extend(f"{campaign}: {p}" for p in summary.problems) + problems.extend(f"{campaign}: {p}" for p in provenance_problems(result)) + parts.append(_mutation_section(f"## {title}", definition, result, summary, + definition_path, result_path)) + return "\n".join(parts), problems + + +def render_shadow_census(c: ShadowCensus) -> str: + corpus_rows = "\n".join(f"| `tests/fixtures/{corpus}` | {n} |" for corpus, n in c.by_corpus) + engine_rows = "\n".join(f"| `{eid}` | {produced} | {refused} | {full} | {partial} |" + for eid, produced, refused, full, partial in c.engines) + differ_rows = ("\n".join(f"| `{case}` | `{layer}` | {shown} |" + for case, layer, shown in c.status_differs) + or "| — | — | the two engines' statuses agree everywhere |") + gate_rows = "\n".join(f"- `own-shadow/tests/{target}::{name}`" for target, name in c.gates) + scope = list(c.scope) + return f"""{_header("tests/fixtures/repro/ (artifacts, traces, reductions)")} +# P-022 step 7a — shadow-mode infrastructure: census + +**Infrastructure for shadow mode, not shadow mode.** Nothing measured here +compares two engines' end diagnostics — or any of their layer *contents*. That +comparison is #260's acceptance and is blocked on #259 (cp5 and 4b). Nothing +here is a parity claim either. + +This document is the **live view** of the slice as it stands; the recorded +mutation campaigns are their own fragment +([`{SHADOW_MUTATIONS_MD}`]({SHADOW_MUTATIONS_MD})), each frozen at what it +measured. Where the slice departed from the brief it was given — the checkpoint +grouping, the `-0` domain decision, the `sha2` dependency — the departures are +decisions on the record in +[the owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md), +which also states the byte-level boundary repeated in the unmeasured set below. + +## The measured set — same-input capture (checkpoint 1) + +| corpus | documents | +|---|---| +{corpus_rows} +| **total** | **{c.documents}** | + +Every one of those documents is canonicalized and hashed by the reference +(`ownlang/repro.py`) and re-hashed from the same file by the port +(`own-shadow`), which is what makes "both engines saw the same input" a +checked fact rather than an assumption — **at the level of canonical document +identity**. That is a weaker statement than #260's acceptance invariant, and +the difference is named in the unmeasured set below. + +| surface | count | +|---|---| +| documents captured and digest-pinned | {c.documents} | +| tamper controls (one changed character per document, refusal required) | {c.documents} | +| documents both engines must REFUSE to name (`domain_refusals`) | {c.domain_refusals} | +| reproduction artifacts committed and replayed byte-for-byte | {c.artifacts} | +| structural negative controls on `verify` (each side) | {c.structural_controls} | +| value-level domain backstop controls | {c.domain_backstop_controls} | + +## The engine protocol (checkpoint 2) + +Each engine authors only its own `engines[]` entry, and declares per layer what +it could **produce**. Over the committed artifacts: + +| engine | layers produced | layers refused | projection `full` | projection `partial` | +|---|---|---|---|---| +{engine_rows} + +The port's `partial` layers are its verdict surface: `own_bridge::check_facts` +is at the #259 checkpoint-4 projection, which carries every `Finding` member +except `message`, `related` and `flow`. It says so in the artifact rather than +emitting a short document a later comparison would score as agreement, and a +test asserts the claim matches the records byte for byte. + +**Layer envelopes where the two engines' status differs** — structural +accounting, not a content comparison, and every one of them a boundary the port +declares rather than a disagreement it stumbled into: + +| case | layer | statuses | +|---|---|---| +{differ_rows} + +## The AnalysisTrace (checkpoint 3) + +Each capture is normalized into a walkable shape: internal identifiers are +replaced by addresses derived from what they identify, and each layer's +ordering semantics are **declared** rather than normalized away. + +| surface | count | +|---|---| +| trace layers projected (both engines, every artifact) | {c.trace_layers} | +| addressed steps | {c.trace_steps} | +| of those, handle addresses standing in for a mint counter | {c.stable_id_steps} | + +The normalization is proven on the property it exists for, over the whole +captured corpus: permuting a document's components reshuffles the global mint +counters (BR-L2) so the raw handle names change wholesale, and the **stable +ids must not move** — while the lowered layer's step **order** must still +change, because that difference is real. Both halves are asserted; a trace that +hid the second would delete the defect the layer exists to expose. + +## First-divergence reduction (checkpoint 4), and the classification + +The reducer walks the pair in pipeline order over **{scope}** and names the +first place they part company: the layer, the step address and the *minimal* +difference inside it. The `verdicts` layer is **refused, not skipped** — +comparing final diagnostics is #260's acceptance, blocked by #259 — and the +refusal is carried in every reduction, so "not compared" can never be read as +"compared and agreed". + +Over the {c.reductions} committed reductions, {c.identical} are +`identical`. The counters below are **computed** by the reducer, not implied by +a green build: + +| class | count | +|---|---| +| Python-only (`left-only`) | **{c.by_class["left-only"]}** | +| Rust-only (`right-only`) | **{c.by_class["right-only"]}** | +| Changed | **{c.by_class["changed"]}** | +| Ordering-only | **{c.by_class["ordering-only"]}** | +| Unexplained | **{c.by_class["unexplained"]}** | +| *status* (a layer-level disagreement, each a declared boundary) | {c.by_class["status"]} | +| *projection* (surfaces not comparable member-for-member) | {c.by_class["projection"]} | + +`status` and `projection` are counted apart from the four content classes on +purpose: neither is a difference in what an engine *computed*. Every `status` +row in the table above is a boundary the port declares in its own error text — +the unported obligation-protocol analysis, and the typed door. + +The same-input layer carries its own counters, and those remain gate-enforced +rather than computed: the port asserts per-document equality of the canonical +identity and byte-exact equality of every committed artifact and trace, so a +non-zero counter there is not representable as a passing build. The gates: + +{gate_rows} + +## The unmeasured set, named + +- **#260's raw-byte same-input invariant.** #260 asks that the `OwnIR` + document be produced or loaded exactly once, that the **raw bytes** be + hashed, and that *those exact bytes* reach both engines. What this slice + proves is shared **canonical document identity**: each engine parses the + file and agrees on the canonical form's digest. Canonical-equivalent input + is not byte-identical input — two files differing in whitespace, in object + key order, or in duplicate-key resolution share one canonical identity, + because ignoring exactly those differences is the canonical form's job. + Acceptance must therefore prove the byte-level invariant separately; until + it does, "same input" here means canonical identity and nothing stronger + ([owner decision B-1](../notes/p022-shadow-infra-owner-decisions.md)). +- **End diagnostics compared as an acceptance surface** — #260's acceptance, + blocked by #259 (cp5 and 4b). Not attempted, not approximated. +- **The verdict layer.** Refused by the reducer, and recorded as refused in + every reduction. This is the same blocker as the row above, stated where a + tool could otherwise have quietly crossed it. +- **Nested statement bodies as individual steps.** A `then`/`else`/`while` body + is part of its enclosing statement's step, so a difference inside a branch is + reported on that statement rather than on the branch's own address. +- **Rendered-byte parity of the three layer surfaces.** The artifact carries + layer outputs as JSON *values*, so a rendering difference (indent, + `ensure_ascii`) is invisible here. That contract stays with each layer's own + fixture family (`tests/test_lowered_fixtures.py`, + `tests/test_summaries_fixtures.py`, `tests/test_verdict_fixtures.py`). +- **The strict door.** Every layer in an artifact is projected through the + **tolerant** door, so that the three entries describe one capture. Strict-door + behaviour is Layer 1's own family (`own-ir`'s validation controls). +- **Engine build identity.** The artifact names *which* engine, never which + build of it — a version stamp would make an artifact non-reproducible from + the same inputs. +- **Nesting-depth agreement.** CPython's recursion limit and `serde_json`'s + 128-level cap differ; `spec/OwnIR.md` §4.2 bounds a conforming document + well inside both, so no conforming document reaches the difference. +""" + + # --- fragments ------------------------------------------------------------ @@ -224,6 +451,13 @@ def fragments() -> tuple[dict[str, str], list[str]]: problems.extend(f"mutation campaign: {p}" for p in summary.problems) problems.extend(f"mutation campaign: {p}" for p in provenance_problems(result)) out[MUTATIONS_MD] = render_mutations(definition, result, summary) + try: + out[SHADOW_CENSUS_MD] = render_shadow_census(compute_shadow_census()) + except ShadowCensusError as e: + problems.extend(f"shadow census: {p}" for p in e.problems) + shadow, shadow_problems = render_shadow_mutations() + out[SHADOW_MUTATIONS_MD] = shadow + problems.extend(f"mutation campaign {p}" for p in shadow_problems) return out, problems @@ -267,8 +501,8 @@ def main(argv: list[str]) -> int: if problems: return 1 if argv: - print(f"checkpoint status fragments OK: {CENSUS_MD}, {MUTATIONS_MD} " - f"in sync with the evidence") + print(f"checkpoint status fragments OK: {CENSUS_MD}, {MUTATIONS_MD}, " + f"{SHADOW_CENSUS_MD}, {SHADOW_MUTATIONS_MD} in sync with the evidence") return 0 diff --git a/spec/Bridge.md b/spec/Bridge.md index 67fc35db..f42437c0 100644 --- a/spec/Bridge.md +++ b/spec/Bridge.md @@ -390,6 +390,52 @@ committed regeneration path and a zero-Python steady state: reason and an expectation the replay executes, so an exclusion cannot rot. The `summaries` dump (INF-R1) covers the MOS sub-surface. +**Composing the three layers: the reproduction artifact.** The three layers +above are each frozen on their own, and step 7a (#260/#269) needs them +*together* — one input, one document carrying what every layer concluded about +it. That composition is `ownlang/repro.py` + `rust/crates/own-shadow` +(checkpoints 1–4, landed): a canonical form and hash naming the input; an +artifact carrying the input, its schema version, its hash, the engine +identifiers and each engine's per-layer output; the `AnalysisTrace` (#269) +that normalizes those outputs into a walkable shape; and a reducer that names +the first place two engines part company. + +Five points belong to this spec rather than to those notes. + +1. Every layer in an artifact is projected through the **tolerant** door on one + in-memory document — mixing doors across layers would mean the three entries + no longer describe one capture. +2. The artifact **composes** these surfaces and never re-encodes them: a + produced layer's document is carried verbatim, and a surface's own + `{"error": …}` refusal is lifted into the envelope's status so a refused + layer can still name the surface it refused on. "Verbatim" means **in the + key order the layer's own surface fixes** — INF-R1 + ([Inference.md](Inference.md) §8) makes the MOS dump's field order part of + the contract, and the concrete order is the sorted rendering that + `tests/fixtures/summaries/` pins byte-for-byte, not `dump_summaries`' dict + insertion order. Carrying the insertion order made two engines' MOS + documents differ in key order alone — a difference neither surface has. +3. Each layer declares the **projection** its engine could produce, so a port + mid-migration neither emits a short document a comparison would score as + agreement, nor refuses a layer it can mostly produce. The artifact compares + layer outputs as JSON *values*; **rendered-byte** parity stays with each + layer's own family above. +4. The reducer's scope is the **Layer 2 lowered document and the MOS + `summaries` sub-surface only**. **Layer 3** — the final diagnostics — is + *refused*, and the refusal is recorded in every reduction, so "not compared" + can never be read as "compared and agreed". +5. "Same input" in an artifact means **canonical document identity**: both + engines parsed the file and agree on the digest of its canonical form. It + does **not** mean the two engines consumed identical bytes, and the + canonical form cannot be made to mean that — ignoring insignificant + whitespace, object key order and duplicate-key resolution is precisely what + it is for. #260's acceptance invariant is the byte-level one (hash the raw + bytes, feed *those* bytes to both engines), so acceptance must prove it + separately; this composition does not. + +Nothing there is shadow mode: comparing end diagnostics as an acceptance +surface is #260's acceptance and is blocked on #259 (cp5 and 4b). + Regeneration: each layer gets a `--write` mode mirroring `tests/test_cfg_fixtures.py`; a stale committed fixture is a red build; the Rust side replays the same files (`rust/crates/own-*/tests/parity.rs` diff --git a/tests/fixtures/repro/canonical_key_order.facts.json b/tests/fixtures/repro/canonical_key_order.facts.json new file mode 100644 index 00000000..9c843ef4 --- /dev/null +++ b/tests/fixtures/repro/canonical_key_order.facts.json @@ -0,0 +1,14 @@ +{ + "module": "KeyOrder", + "components": [ + { + "subscriptions": [ + {"released": false, "resource": "subscribe", "line": 3, "event": "b.Zeta", "handler": "OnZeta"}, + {"event": "a.Alpha", "line": 4, "resource": "subscribe", "released": true, "handler": "OnAlpha"} + ], + "file": "KeyOrder.cs", + "name": "Zed" + } + ], + "ownir_version": 0 +} diff --git a/tests/fixtures/repro/canonical_key_order.reduction.json b/tests/fixtures/repro/canonical_key_order.reduction.json new file mode 100644 index 00000000..6b8845d6 --- /dev/null +++ b/tests/fixtures/repro/canonical_key_order.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "canonical_key_order", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/canonical_key_order.repro.json b/tests/fixtures/repro/canonical_key_order.repro.json new file mode 100644 index 00000000..a8774b5f --- /dev/null +++ b/tests/fixtures/repro/canonical_key_order.repro.json @@ -0,0 +1,446 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "e48d4893337f2a3180388946f64524119451eecf4b55c84b9d1cdd6ede17e0cb", + "bytes": 283 + }, + "document": { + "module": "KeyOrder", + "components": [ + { + "subscriptions": [ + { + "released": false, + "resource": "subscribe", + "line": 3, + "event": "b.Zeta", + "handler": "OnZeta" + }, + { + "event": "a.Alpha", + "line": 4, + "resource": "subscribe", + "released": true, + "handler": "OnAlpha" + } + ], + "file": "KeyOrder.cs", + "name": "Zed" + } + ], + "ownir_version": 0 + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "KeyOrder", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "Zed", + "lifetime": null, + "params": [], + "ret": null, + "body": [ + { + "stmt": "acquire", + "handle": "sub_0", + "resource": "Subscription", + "line": 3 + }, + { + "stmt": "acquire", + "handle": "sub_1", + "resource": "Subscription", + "line": 4 + }, + { + "stmt": "release", + "handle": "sub_1", + "line": 4 + } + ] + } + ], + "handles": [ + { + "handle": "sub_0", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 3, + "event": "b.Zeta", + "handler": "OnZeta", + "resource": "subscribe", + "released": false + }, + { + "handle": "sub_1", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 4, + "event": "a.Alpha", + "handler": "OnAlpha", + "resource": "subscribe", + "released": true + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "KeyOrder", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "KeyOrder.cs", + "line": 3, + "code": "OWN001", + "component": "Zed", + "event": "b.Zeta", + "handler": "OnZeta", + "message": "the result of 'b.Zeta' is ignored — the IDisposable subscription is never disposed, leaking 'Zed' (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] + } + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "KeyOrder", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "Zed", + "lifetime": null, + "params": [], + "ret": null, + "body": [ + { + "stmt": "acquire", + "handle": "sub_0", + "resource": "Subscription", + "line": 3 + }, + { + "stmt": "acquire", + "handle": "sub_1", + "resource": "Subscription", + "line": 4 + }, + { + "stmt": "release", + "handle": "sub_1", + "line": 4 + } + ] + } + ], + "handles": [ + { + "handle": "sub_0", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 3, + "event": "b.Zeta", + "handler": "OnZeta", + "resource": "subscribe", + "released": false + }, + { + "handle": "sub_1", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 4, + "event": "a.Alpha", + "handler": "OnAlpha", + "resource": "subscribe", + "released": true + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "KeyOrder", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "KeyOrder.cs", + "line": 3, + "code": "OWN001", + "component": "Zed", + "event": "b.Zeta", + "handler": "OnZeta", + "kind": "subscription token", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + } + ] + } + } + ] + } + ] +} diff --git a/tests/fixtures/repro/canonical_key_order.trace.json b/tests/fixtures/repro/canonical_key_order.trace.json new file mode 100644 index 00000000..4e73d00a --- /dev/null +++ b/tests/fixtures/repro/canonical_key_order.trace.json @@ -0,0 +1,519 @@ +{ + "trace_version": 1, + "case": "canonical_key_order", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "e48d4893337f2a3180388946f64524119451eecf4b55c84b9d1cdd6ede17e0cb", + "bytes": 283 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "KeyOrder" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[Zed]", + "value": { + "name": "Zed", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "functions[Zed].body[0]", + "value": { + "stmt": "acquire", + "handle": "Zed|KeyOrder.cs|3|b.Zeta|OnZeta", + "resource": "Subscription", + "line": 3 + } + }, + { + "id": "functions[Zed].body[1]", + "value": { + "stmt": "acquire", + "handle": "Zed|KeyOrder.cs|4|a.Alpha|OnAlpha", + "resource": "Subscription", + "line": 4 + } + }, + { + "id": "functions[Zed].body[2]", + "value": { + "stmt": "release", + "handle": "Zed|KeyOrder.cs|4|a.Alpha|OnAlpha", + "line": 4 + } + }, + { + "id": "handles[Zed|KeyOrder.cs|3|b.Zeta|OnZeta]", + "value": { + "handle": "Zed|KeyOrder.cs|3|b.Zeta|OnZeta", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 3, + "event": "b.Zeta", + "handler": "OnZeta", + "resource": "subscribe", + "released": false, + "mint": "sub" + } + }, + { + "id": "handles[Zed|KeyOrder.cs|4|a.Alpha|OnAlpha]", + "value": { + "handle": "Zed|KeyOrder.cs|4|a.Alpha|OnAlpha", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 4, + "event": "a.Alpha", + "handler": "OnAlpha", + "resource": "subscribe", + "released": true, + "mint": "sub" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "KeyOrder" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[KeyOrder.cs:3:None:OWN001]", + "value": { + "file": "KeyOrder.cs", + "line": 3, + "code": "OWN001", + "component": "Zed", + "event": "b.Zeta", + "handler": "OnZeta", + "message": "the result of 'b.Zeta' is ignored — the IDisposable subscription is never disposed, leaking 'Zed' (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + } + ] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "e48d4893337f2a3180388946f64524119451eecf4b55c84b9d1cdd6ede17e0cb", + "bytes": 283 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "KeyOrder" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[Zed]", + "value": { + "name": "Zed", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "functions[Zed].body[0]", + "value": { + "stmt": "acquire", + "handle": "Zed|KeyOrder.cs|3|b.Zeta|OnZeta", + "resource": "Subscription", + "line": 3 + } + }, + { + "id": "functions[Zed].body[1]", + "value": { + "stmt": "acquire", + "handle": "Zed|KeyOrder.cs|4|a.Alpha|OnAlpha", + "resource": "Subscription", + "line": 4 + } + }, + { + "id": "functions[Zed].body[2]", + "value": { + "stmt": "release", + "handle": "Zed|KeyOrder.cs|4|a.Alpha|OnAlpha", + "line": 4 + } + }, + { + "id": "handles[Zed|KeyOrder.cs|3|b.Zeta|OnZeta]", + "value": { + "handle": "Zed|KeyOrder.cs|3|b.Zeta|OnZeta", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 3, + "event": "b.Zeta", + "handler": "OnZeta", + "resource": "subscribe", + "released": false, + "mint": "sub" + } + }, + { + "id": "handles[Zed|KeyOrder.cs|4|a.Alpha|OnAlpha]", + "value": { + "handle": "Zed|KeyOrder.cs|4|a.Alpha|OnAlpha", + "component": "Zed", + "file": "KeyOrder.cs", + "line": 4, + "event": "a.Alpha", + "handler": "OnAlpha", + "resource": "subscribe", + "released": true, + "mint": "sub" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "KeyOrder" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[KeyOrder.cs:3:None:OWN001]", + "value": { + "file": "KeyOrder.cs", + "line": 3, + "code": "OWN001", + "component": "Zed", + "event": "b.Zeta", + "handler": "OnZeta", + "kind": "subscription token", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + } + } + ] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/canonical_minimal.facts.json b/tests/fixtures/repro/canonical_minimal.facts.json new file mode 100644 index 00000000..46c14f81 --- /dev/null +++ b/tests/fixtures/repro/canonical_minimal.facts.json @@ -0,0 +1,4 @@ +{ + "ownir_version": 0, + "module": "Minimal" +} diff --git a/tests/fixtures/repro/canonical_minimal.reduction.json b/tests/fixtures/repro/canonical_minimal.reduction.json new file mode 100644 index 00000000..c5222f0a --- /dev/null +++ b/tests/fixtures/repro/canonical_minimal.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "canonical_minimal", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/canonical_minimal.repro.json b/tests/fixtures/repro/canonical_minimal.repro.json new file mode 100644 index 00000000..454e0fc7 --- /dev/null +++ b/tests/fixtures/repro/canonical_minimal.repro.json @@ -0,0 +1,299 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "c5387976673a8a5a1cc3078398935ae907cddf9c0fc9fdf9cb1c63ed74722313", + "bytes": 38 + }, + "document": { + "ownir_version": 0, + "module": "Minimal" + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Minimal", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [], + "handles": [] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Minimal", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [] + } + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Minimal", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [], + "handles": [] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Minimal", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [] + } + } + ] + } + ] +} diff --git a/tests/fixtures/repro/canonical_minimal.trace.json b/tests/fixtures/repro/canonical_minimal.trace.json new file mode 100644 index 00000000..7c1fcad3 --- /dev/null +++ b/tests/fixtures/repro/canonical_minimal.trace.json @@ -0,0 +1,358 @@ +{ + "trace_version": 1, + "case": "canonical_minimal", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "c5387976673a8a5a1cc3078398935ae907cddf9c0fc9fdf9cb1c63ed74722313", + "bytes": 38 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Minimal" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Minimal" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + } + ] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "c5387976673a8a5a1cc3078398935ae907cddf9c0fc9fdf9cb1c63ed74722313", + "bytes": 38 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Minimal" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Minimal" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + } + ] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/canonical_torture.facts.json b/tests/fixtures/repro/canonical_torture.facts.json new file mode 100644 index 00000000..2ac2851a --- /dev/null +++ b/tests/fixtures/repro/canonical_torture.facts.json @@ -0,0 +1,36 @@ +{ + "ownir_version": 0, + "module": "Tortureé漢字😀", + "canonical_probe": { + "i64_max": 9223372036854775807, + "i64_min": -9223372036854775808, + "zero": 0, + "booleans": [true, false], + "null": null, + "empty_object": {}, + "empty_array": [], + "empty_string": "", + "two_char_escapes": "\b\t\n\f\r", + "other_c0_controls": "\u0000\u0001\u001f", + "delete_is_not_escaped": "\u007f", + "quote_and_backslash": "he said \"hi\" \\ back", + "nbsp": " ", + "line_and_paragraph_separators": "

", + "byte_order_mark": "", + "astral": "😀", + "😀": "an astral key sorts after every ASCII key", + "é": "an accented key sorts after every ASCII key", + "": "the empty key sorts first", + "Z": "uppercase Z sorts before lowercase a", + "a": "lowercase a" + }, + "components": [ + { + "name": "TortureView", + "file": "Törture.cs", + "subscriptions": [ + {"event": "bus.Subscribe", "handler": "On漢", "line": 7, "released": false, "resource": "subscribe"} + ] + } + ] +} diff --git a/tests/fixtures/repro/canonical_torture.reduction.json b/tests/fixtures/repro/canonical_torture.reduction.json new file mode 100644 index 00000000..3c0fde74 --- /dev/null +++ b/tests/fixtures/repro/canonical_torture.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "canonical_torture", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/canonical_torture.repro.json b/tests/fixtures/repro/canonical_torture.repro.json new file mode 100644 index 00000000..94e405a0 --- /dev/null +++ b/tests/fixtures/repro/canonical_torture.repro.json @@ -0,0 +1,423 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "239383fae8175fd42dc773922af7e3dcf26c2148f99a365f8e4404db58224edd", + "bytes": 834 + }, + "document": { + "ownir_version": 0, + "module": "Tortureé漢字😀", + "canonical_probe": { + "i64_max": 9223372036854775807, + "i64_min": -9223372036854775808, + "zero": 0, + "booleans": [ + true, + false + ], + "null": null, + "empty_object": {}, + "empty_array": [], + "empty_string": "", + "two_char_escapes": "\b\t\n\f\r", + "other_c0_controls": "\u0000\u0001\u001f", + "delete_is_not_escaped": "", + "quote_and_backslash": "he said \"hi\" \\ back", + "nbsp": " ", + "line_and_paragraph_separators": "

", + "byte_order_mark": "", + "astral": "😀", + "😀": "an astral key sorts after every ASCII key", + "é": "an accented key sorts after every ASCII key", + "": "the empty key sorts first", + "Z": "uppercase Z sorts before lowercase a", + "a": "lowercase a" + }, + "components": [ + { + "name": "TortureView", + "file": "Törture.cs", + "subscriptions": [ + { + "event": "bus.Subscribe", + "handler": "On漢", + "line": 7, + "released": false, + "resource": "subscribe" + } + ] + } + ] + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Tortureé漢字😀", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "TortureView", + "lifetime": null, + "params": [], + "ret": null, + "body": [ + { + "stmt": "acquire", + "handle": "sub_0", + "resource": "Subscription", + "line": 7 + } + ] + } + ], + "handles": [ + { + "handle": "sub_0", + "component": "TortureView", + "file": "Törture.cs", + "line": 7, + "event": "bus.Subscribe", + "handler": "On漢", + "resource": "subscribe", + "released": false + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Tortureé漢字😀", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "Törture.cs", + "line": 7, + "code": "OWN001", + "component": "TortureView", + "event": "bus.Subscribe", + "handler": "On漢", + "message": "the result of 'bus.Subscribe' is ignored — the IDisposable subscription is never disposed, leaking 'TortureView' (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] + } + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Tortureé漢字😀", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "TortureView", + "lifetime": null, + "params": [], + "ret": null, + "body": [ + { + "stmt": "acquire", + "handle": "sub_0", + "resource": "Subscription", + "line": 7 + } + ] + } + ], + "handles": [ + { + "handle": "sub_0", + "component": "TortureView", + "file": "Törture.cs", + "line": 7, + "event": "bus.Subscribe", + "handler": "On漢", + "resource": "subscribe", + "released": false + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Tortureé漢字😀", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "Törture.cs", + "line": 7, + "code": "OWN001", + "component": "TortureView", + "event": "bus.Subscribe", + "handler": "On漢", + "kind": "subscription token", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + } + ] + } + } + ] + } + ] +} diff --git a/tests/fixtures/repro/canonical_torture.trace.json b/tests/fixtures/repro/canonical_torture.trace.json new file mode 100644 index 00000000..8a4d13db --- /dev/null +++ b/tests/fixtures/repro/canonical_torture.trace.json @@ -0,0 +1,457 @@ +{ + "trace_version": 1, + "case": "canonical_torture", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "239383fae8175fd42dc773922af7e3dcf26c2148f99a365f8e4404db58224edd", + "bytes": 834 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Tortureé漢字😀" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[TortureView]", + "value": { + "name": "TortureView", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "functions[TortureView].body[0]", + "value": { + "stmt": "acquire", + "handle": "TortureView|Törture.cs|7|bus.Subscribe|On漢", + "resource": "Subscription", + "line": 7 + } + }, + { + "id": "handles[TortureView|Törture.cs|7|bus.Subscribe|On漢]", + "value": { + "handle": "TortureView|Törture.cs|7|bus.Subscribe|On漢", + "component": "TortureView", + "file": "Törture.cs", + "line": 7, + "event": "bus.Subscribe", + "handler": "On漢", + "resource": "subscribe", + "released": false, + "mint": "sub" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Tortureé漢字😀" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[Törture.cs:7:None:OWN001]", + "value": { + "file": "Törture.cs", + "line": 7, + "code": "OWN001", + "component": "TortureView", + "event": "bus.Subscribe", + "handler": "On漢", + "message": "the result of 'bus.Subscribe' is ignored — the IDisposable subscription is never disposed, leaking 'TortureView' (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + } + ] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "239383fae8175fd42dc773922af7e3dcf26c2148f99a365f8e4404db58224edd", + "bytes": 834 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Tortureé漢字😀" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[TortureView]", + "value": { + "name": "TortureView", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "functions[TortureView].body[0]", + "value": { + "stmt": "acquire", + "handle": "TortureView|Törture.cs|7|bus.Subscribe|On漢", + "resource": "Subscription", + "line": 7 + } + }, + { + "id": "handles[TortureView|Törture.cs|7|bus.Subscribe|On漢]", + "value": { + "handle": "TortureView|Törture.cs|7|bus.Subscribe|On漢", + "component": "TortureView", + "file": "Törture.cs", + "line": 7, + "event": "bus.Subscribe", + "handler": "On漢", + "resource": "subscribe", + "released": false, + "mint": "sub" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Tortureé漢字😀" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[Törture.cs:7:None:OWN001]", + "value": { + "file": "Törture.cs", + "line": 7, + "code": "OWN001", + "component": "TortureView", + "event": "bus.Subscribe", + "handler": "On漢", + "kind": "subscription token", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + } + } + ] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/di.reduction.json b/tests/fixtures/repro/di.reduction.json new file mode 100644 index 00000000..172f1e48 --- /dev/null +++ b/tests/fixtures/repro/di.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "di", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/di.repro.json b/tests/fixtures/repro/di.repro.json new file mode 100644 index 00000000..dcf95a2f --- /dev/null +++ b/tests/fixtures/repro/di.repro.json @@ -0,0 +1,457 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "1dd40fe8691e32cbab0252b749d17fecfc905382907a1ea48b2a9eae8ae9d929", + "bytes": 767 + }, + "document": { + "ownir_version": 0, + "module": "DiDemo", + "components": [], + "services": [ + { + "name": "EmailSender", + "lifetime": "singleton", + "file": "Startup.cs", + "line": 12, + "deps": [ + "AppDbContext" + ], + "ctor_file": "EmailSender.cs", + "ctor_line": 5, + "ctor_type": "EmailSender" + }, + { + "name": "AppDbContext", + "lifetime": "scoped", + "file": "Startup.cs", + "line": 13, + "deps": [] + }, + { + "name": "Clock", + "lifetime": "singleton", + "file": "Startup.cs", + "line": 14, + "deps": [] + }, + { + "name": "ReportService", + "lifetime": "singleton", + "file": "Startup.cs", + "line": 15, + "deps": [ + "UnitOfWork" + ], + "ctor_file": "ReportService.cs", + "ctor_line": 7, + "ctor_type": "ReportService" + }, + { + "name": "UnitOfWork", + "lifetime": "transient", + "file": "Startup.cs", + "line": 16, + "deps": [ + "AppDbContext" + ] + }, + { + "name": "RequestLog", + "lifetime": "scoped", + "file": "Startup.cs", + "line": 17, + "deps": [ + "AppDbContext" + ] + } + ] + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "DiDemo", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [], + "handles": [] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "DiDemo", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "Startup.cs", + "line": 12, + "code": "DI001", + "component": "EmailSender", + "event": "AppDbContext", + "handler": "", + "message": "singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [consumed by the 'EmailSender' constructor at EmailSender.cs:5]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "EmailSender.cs", + 5, + "consuming constructor of 'EmailSender'" + ] + ], + "flow": [ + [ + "Startup.cs", + 12, + "singleton 'EmailSender' (captor)" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Startup.cs", + "line": 15, + "code": "DI001", + "component": "ReportService", + "event": "AppDbContext", + "handler": "", + "message": "singleton 'ReportService' captures scoped service 'AppDbContext' (captive dependency: ReportService -> UnitOfWork -> AppDbContext) [consumed by the 'ReportService' constructor at ReportService.cs:7]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "ReportService.cs", + 7, + "consuming constructor of 'ReportService'" + ] + ], + "flow": [ + [ + "Startup.cs", + 15, + "singleton 'ReportService' (captor)" + ], + [ + "Startup.cs", + 16, + "via 'UnitOfWork'" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], + "ignore_reason": null, + "column": null + } + ] + } + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "DiDemo", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [], + "handles": [] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "DiDemo", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "Startup.cs", + "line": 12, + "code": "DI001", + "component": "EmailSender", + "event": "AppDbContext", + "handler": "", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + }, + { + "file": "Startup.cs", + "line": 15, + "code": "DI001", + "component": "ReportService", + "event": "AppDbContext", + "handler": "", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + } + ] + } + } + ] + } + ] +} diff --git a/tests/fixtures/repro/di.trace.json b/tests/fixtures/repro/di.trace.json new file mode 100644 index 00000000..4dd3e246 --- /dev/null +++ b/tests/fixtures/repro/di.trace.json @@ -0,0 +1,467 @@ +{ + "trace_version": 1, + "case": "di", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "1dd40fe8691e32cbab0252b749d17fecfc905382907a1ea48b2a9eae8ae9d929", + "bytes": 767 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "DiDemo" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "DiDemo" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[Startup.cs:12:None:DI001]", + "value": { + "file": "Startup.cs", + "line": 12, + "code": "DI001", + "component": "EmailSender", + "event": "AppDbContext", + "handler": "", + "message": "singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [consumed by the 'EmailSender' constructor at EmailSender.cs:5]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "EmailSender.cs", + 5, + "consuming constructor of 'EmailSender'" + ] + ], + "flow": [ + [ + "Startup.cs", + 12, + "singleton 'EmailSender' (captor)" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], + "ignore_reason": null, + "column": null + } + }, + { + "id": "findings[Startup.cs:15:None:DI001]", + "value": { + "file": "Startup.cs", + "line": 15, + "code": "DI001", + "component": "ReportService", + "event": "AppDbContext", + "handler": "", + "message": "singleton 'ReportService' captures scoped service 'AppDbContext' (captive dependency: ReportService -> UnitOfWork -> AppDbContext) [consumed by the 'ReportService' constructor at ReportService.cs:7]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "ReportService.cs", + 7, + "consuming constructor of 'ReportService'" + ] + ], + "flow": [ + [ + "Startup.cs", + 15, + "singleton 'ReportService' (captor)" + ], + [ + "Startup.cs", + 16, + "via 'UnitOfWork'" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], + "ignore_reason": null, + "column": null + } + } + ] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "1dd40fe8691e32cbab0252b749d17fecfc905382907a1ea48b2a9eae8ae9d929", + "bytes": 767 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "DiDemo" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "DiDemo" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[Startup.cs:12:None:DI001]", + "value": { + "file": "Startup.cs", + "line": 12, + "code": "DI001", + "component": "EmailSender", + "event": "AppDbContext", + "handler": "", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + } + }, + { + "id": "findings[Startup.cs:15:None:DI001]", + "value": { + "file": "Startup.cs", + "line": 15, + "code": "DI001", + "component": "ReportService", + "event": "AppDbContext", + "handler": "", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "ignore_reason": null, + "column": null + } + } + ] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/digests.json b/tests/fixtures/repro/digests.json new file mode 100644 index 00000000..8e0d2f14 --- /dev/null +++ b/tests/fixtures/repro/digests.json @@ -0,0 +1,487 @@ +{ + "comment": "The canonical hash of every shared facts document (P-022 step 7a, #260/#269). Generated: python tests/test_repro_fixtures.py --write. The Rust own-shadow recomputes every digest from the same documents with zero Python, which is what makes 'both engines saw the same input' a checked fact rather than an assumption. Records are sorted by case and depend on nothing but their own case, so inserting a case churns no existing record.", + "repro_version": 2, + "algorithm": "sha256", + "documents": [ + { + "case": "alias_join_cases", + "corpus": "lowered", + "digest": "f3ac08b8799096024dc96f9c740cdeb8838102c485440a4338da02bc1cf11a5f", + "bytes": 377 + }, + { + "case": "canonical_key_order", + "corpus": "repro", + "digest": "e48d4893337f2a3180388946f64524119451eecf4b55c84b9d1cdd6ede17e0cb", + "bytes": 283 + }, + { + "case": "canonical_minimal", + "corpus": "repro", + "digest": "c5387976673a8a5a1cc3078398935ae907cddf9c0fc9fdf9cb1c63ed74722313", + "bytes": 38 + }, + { + "case": "canonical_torture", + "corpus": "repro", + "digest": "239383fae8175fd42dc773922af7e3dcf26c2148f99a365f8e4404db58224edd", + "bytes": 834 + }, + { + "case": "capture", + "corpus": "ownir", + "digest": "a3e8dbaa4f3bb7acd77fcd870ef8bec725e705197c219bcc02638e6acc65daf0", + "bytes": 696 + }, + { + "case": "contract_inference", + "corpus": "ownir", + "digest": "4441c9b0bc8bdcf1a16c9c157214988a80c8fe962dd32c560cd41c596e58c109", + "bytes": 785 + }, + { + "case": "di", + "corpus": "ownir", + "digest": "1dd40fe8691e32cbab0252b749d17fecfc905382907a1ea48b2a9eae8ae9d929", + "bytes": 767 + }, + { + "case": "di_capture", + "corpus": "ownir", + "digest": "acd247ddd2d7ffdd4ebaae20eba8bdb43408ae3ef0914e2a9bb5e9481bb67005", + "bytes": 684 + }, + { + "case": "disposable", + "corpus": "ownir", + "digest": "9a3942afd5b77c8930e43d9b931ca84dc7722b801907775a41381aab6c7e8b34", + "bytes": 430 + }, + { + "case": "flow_column_anchors", + "corpus": "ownir", + "digest": "a31d297c8484e461a8dd2ba9d6ddc7d28c5a5b023ad110cc3c116d2dd2e9d58e", + "bytes": 2288 + }, + { + "case": "flow_finally_switch", + "corpus": "ownir", + "digest": "455d7bb9e579975b883274656ddef2f4b2c5283245053255f0cb2ebbc17043d5", + "bytes": 1675 + }, + { + "case": "flow_kill_on_rebind", + "corpus": "lowered", + "digest": "495304a931624ee28c79d8b2a6910d06b53b193165afd7ac965419ee99c00335", + "bytes": 229 + }, + { + "case": "flow_leak_on_else", + "corpus": "ownir", + "digest": "d0ebae6b7d708fb28904932181eba56e8c6edd91488a3c7f0650ff1eb91f517f", + "bytes": 268 + }, + { + "case": "flow_leak_two_exits", + "corpus": "ownir", + "digest": "da3952837918230ce159c9d320b3820854a4fbbcdd013b8a3e0a130c86f092b4", + "bytes": 315 + }, + { + "case": "flow_nested_throw", + "corpus": "ownir", + "digest": "f009d8950f48fd37302564747c364d58f49a64430f2c37978bfb6c2c2fd1460b", + "bytes": 1667 + }, + { + "case": "flow_pool_partial", + "corpus": "ownir", + "digest": "59c43eda99a86ee30cb2a4c7781b3044142844c62f8042dbf100233ac8e0c8e6", + "bytes": 1426 + }, + { + "case": "flow_unmapped_refs", + "corpus": "lowered", + "digest": "8f19e263ab72914d43f5ae18da36d4196f9142f5b97810374074740eec3328f9", + "bytes": 245 + }, + { + "case": "flow_while", + "corpus": "ownir", + "digest": "2c67be045fad2a800d27121117af04b1f58ac42fe69ef3f63c1fabeaab5f0b94", + "bytes": 255 + }, + { + "case": "fn_params_ordering", + "corpus": "lowered", + "digest": "5a639b7c8ac76e5d87cb33b6196c8baf67b4f055266367123207ad924d42d260", + "bytes": 479 + }, + { + "case": "handles_global_counters", + "corpus": "lowered", + "digest": "cd5112cae07211e5c99f4b4d30c73612d61d8e1a7298b2396d5242253635965b", + "bytes": 500 + }, + { + "case": "handles_null_metadata", + "corpus": "lowered", + "digest": "fa2b63f3f16a0ab9a2d73b43dd666f79ea8690c3eb2754de071d5ee90d83826b", + "bytes": 304 + }, + { + "case": "handoff_contract", + "corpus": "ownir", + "digest": "a85e62876839d352a5d31d6cab2a23be7bd06cd40793a13fddb40816e06f27a8", + "bytes": 658 + }, + { + "case": "hoist_neg_early_return", + "corpus": "lowered", + "digest": "e2ad1e5c33eb5cdfe18b057c059d59e1097217e58b4a70b708794e4f1078c9a1", + "bytes": 226 + }, + { + "case": "hoist_neg_nested_depth", + "corpus": "lowered", + "digest": "d588e0d8103c327b2fad4418906b3cfe38eaf97769b17963274bf3c85b75e2b1", + "bytes": 242 + }, + { + "case": "hoist_neg_while_body", + "corpus": "lowered", + "digest": "d2ae2309b105c985d6eeda13a2eb53b72bcd56fe895ac6919023ecdca72ea1e4", + "bytes": 195 + }, + { + "case": "hoist_pool_kind", + "corpus": "lowered", + "digest": "578e1892bfa7932d76846c4216e0f2e749782e43afcf3d2dddabfbfbd28f4cb1", + "bytes": 271 + }, + { + "case": "hoist_positive_release", + "corpus": "lowered", + "digest": "cf6479caae0c2bb8122ceffc3056de9ac7bc58da78f33dd440e4a9b4c313bdb2", + "bytes": 233 + }, + { + "case": "hoist_positive_use_only", + "corpus": "lowered", + "digest": "0d02a23d958c3444865c17fd50e4b1cb29ea89bb3a1f382546bbb4d26d512038", + "bytes": 197 + }, + { + "case": "lines_preserved", + "corpus": "lowered", + "digest": "48b075d0a88ecf2d5d6d96f310e9a94f31e571fc7d70b2ea6f52723d459d319b", + "bytes": 383 + }, + { + "case": "local_disposable", + "corpus": "ownir", + "digest": "e5472c04b60a3916349df8fbb9e36d804be4a2548518de2f539c67e7fe2f589b", + "bytes": 319 + }, + { + "case": "mos_call_channel_overload_sig", + "corpus": "lowered", + "digest": "1ae342b45f6fadead130794925ddc7d0d96788dadf2c1b6cc531888d566ce3b6", + "bytes": 601 + }, + { + "case": "mos_call_direct_consume", + "corpus": "lowered", + "digest": "d6657c687e33de4fd69be1e1b61c3bf37dc09dd75d1f9b49a3d96e936f8bbf60", + "bytes": 269 + }, + { + "case": "mos_call_unknown_drop", + "corpus": "lowered", + "digest": "bc99586d256861fcf585c68b53b5899a52fbee1c42deea91ce53ccfc1c58c5bd", + "bytes": 245 + }, + { + "case": "mos_fresh_mint", + "corpus": "lowered", + "digest": "caef5f8298cf98addbbece7a24487b831dbd5bc806ac0b4225391486c9840a7e", + "bytes": 409 + }, + { + "case": "mos_killsite_toplevel", + "corpus": "lowered", + "digest": "b54f9fa40ee79929fe93cf51bdf1bb754b38fae729b53b8717684026fb29bec2", + "bytes": 403 + }, + { + "case": "mos_untrack_inbranch", + "corpus": "lowered", + "digest": "603a39cdf16770f6bdb843b5b6af1e174d018b0842cab149cf245335587e3fc3", + "bytes": 410 + }, + { + "case": "mosdump_degraded_duplicate_key", + "corpus": "summaries", + "digest": "92f052ccef71d87b1b80ff9e9bcd9c74ec9db30b7ad34c883af0096a83088fdd", + "bytes": 376 + }, + { + "case": "mosdump_explicit_effects", + "corpus": "summaries", + "digest": "0c052dc3634aed1263f4ec1eff3019d27052617390dcc0f18fc61bc5982003a6", + "bytes": 461 + }, + { + "case": "mosdump_nonascii_escaping", + "corpus": "summaries", + "digest": "75932fdf06e0fbbca45369c8d86672f0c252415695e5c41224f9b5c130b82037", + "bytes": 219 + }, + { + "case": "mosdump_overload_merge_tiebreaks", + "corpus": "summaries", + "digest": "c84e2e6bfac8830df855205fca738c3a8a14d667cc96a30198f753cf7102059c", + "bytes": 403 + }, + { + "case": "mosdump_partial_and_guarded", + "corpus": "summaries", + "digest": "fe5b033ed5e348728bcb9ae6223ffaddc72d1388bffe581275ac73117f6c0fc6", + "bytes": 525 + }, + { + "case": "mosdump_return_chains", + "corpus": "summaries", + "digest": "233213ac3f8dd75194e25a66a5d784e9622be282d88f1fe4a546d9b8f66dd36d", + "bytes": 894 + }, + { + "case": "mosdump_scc_cycles", + "corpus": "summaries", + "digest": "15f6a6b43a638b766baef273e5fcaa086f57ab469c44664edd15eea3a6c7fc0e", + "bytes": 655 + }, + { + "case": "mosdump_sig_vocabulary", + "corpus": "summaries", + "digest": "b9683de7109d84886fe703cc9a32c63e27149a976d0447121d49cbc13e81224f", + "bytes": 514 + }, + { + "case": "mosdump_sink_channels", + "corpus": "summaries", + "digest": "a71c3f96df86155c36ecc1b1bf7450636b19d18cb9395f56bfe1cad4c967478d", + "bytes": 440 + }, + { + "case": "pool", + "corpus": "ownir", + "digest": "79cb88310fefc2641559688cf9851f6199927c5ee4eed43ada7711f2547667ae", + "bytes": 241 + }, + { + "case": "protocol_isloaded_clean", + "corpus": "ownir", + "digest": "cc7539104ac5357e0ed76599177da95f8ea271751e64fa94adff49a399f0a95f", + "bytes": 1193 + }, + { + "case": "protocol_isloaded_violation", + "corpus": "ownir", + "digest": "f821b58b0da7e5ecb94798d1868db377876d945242a4e4d013d6499d7af7ee13", + "bytes": 1217 + }, + { + "case": "routing_r1_unresolved", + "corpus": "lowered", + "digest": "d1b7da34ce0ad0069211e3fa05d8881ab8ed1da2a5d5c5c4d472a6f14d9633a0", + "bytes": 171 + }, + { + "case": "routing_r2_subscribe_self", + "corpus": "lowered", + "digest": "25f7d643e602b10195cfe7358b2715f9c511bb8feb5049265fde6c6c826e6bf8", + "bytes": 208 + }, + { + "case": "routing_r3_capture_static", + "corpus": "lowered", + "digest": "41cf768f49af1bf0179db32a31870f0e7a2bcdc6e0f732627664935a576becc5", + "bytes": 421 + }, + { + "case": "routing_r4_returned_fresh", + "corpus": "lowered", + "digest": "6b4b0ef2a9a66fe9917a78fa64adcb120e152eabb8164dd2c1aa54bffbcd87e1", + "bytes": 358 + }, + { + "case": "routing_r5_di_capture", + "corpus": "lowered", + "digest": "da3a45a5961c42e43b75f486c081a69bb129c00d4a60e8d7210877467d41e125", + "bytes": 511 + }, + { + "case": "routing_r6_token_kinds", + "corpus": "lowered", + "digest": "4ada77267b035f2148303ed7174761f04d96bb36fa5bd28f4ffd7c34b0bafcf0", + "bytes": 675 + }, + { + "case": "sample", + "corpus": "ownir", + "digest": "fff7156e1bb4e3de0e54b88042a8ed3f9fac9be535c339d4699f916621484db1", + "bytes": 375 + }, + { + "case": "subscribe", + "corpus": "ownir", + "digest": "c883235a21110fc4f4271b40453e6f786fbe3efdea32cecab065e774f174ec31", + "bytes": 216 + }, + { + "case": "timer", + "corpus": "ownir", + "digest": "62804547e0b08ed25035805e4a7ecbc69e46fdde977d6ea12683c6165fac1044", + "bytes": 379 + }, + { + "case": "tolerant_unknown_kind", + "corpus": "lowered", + "digest": "b33cea96ebe7256e92543b8fc5c131a9b0be52157ee73300ca00a1f092d958d9", + "bytes": 150 + }, + { + "case": "unitofwork_flow", + "corpus": "ownir", + "digest": "2aa3bdb9963c1a74d7559ddf30ecacbf03b8a73f86979454bbe903c2ac956862", + "bytes": 324 + }, + { + "case": "unresolved", + "corpus": "ownir", + "digest": "3475a764d4449355270566bc27f7457535bd465e8294e40519ea8748525c4cbc", + "bytes": 309 + }, + { + "case": "verdict_boundary_effect_line_negative", + "corpus": "verdicts", + "digest": "0f55de6ce780977587f3684db6aafd955dc9b3e655adef6aaea451e048d75301", + "bytes": 361 + }, + { + "case": "verdict_boundary_line_above_u32", + "corpus": "verdicts", + "digest": "788dbce914bfc6cdb3afd68ecc712c8f30e4f388282aa2fbf8ae773cf21bd605", + "bytes": 294 + }, + { + "case": "verdict_boundary_line_negative", + "corpus": "verdicts", + "digest": "94ccd117455ca0cc0c4ae33b15669dc8107d7b34444b461788ae5644a6117ca3", + "bytes": 424 + }, + { + "case": "verdict_boundary_service_line_negative", + "corpus": "verdicts", + "digest": "f168053ba21a64d7d4b72a1ce8644d63308ec58f9c265fcdd266c63f23eb0b5d", + "bytes": 418 + }, + { + "case": "verdict_column_sort_order", + "corpus": "verdicts", + "digest": "33f0ab59bd9c1dadc09aa5b2c4f110b9540116f9b51a4e4d2e0c0085e9c96169", + "bytes": 960 + }, + { + "case": "verdict_di004_call_site_anchor", + "corpus": "verdicts", + "digest": "09be7af7d79b67f5d79e462b3b9a9a5ac04eb313c1021344fdf16caf081b8aeb", + "bytes": 1410 + }, + { + "case": "verdict_di005_cache_site_anchor", + "corpus": "verdicts", + "digest": "ae041b3d718acac511214765f00efaac3ef77b70d2f0296ff7b1abeaf17f6292", + "bytes": 1137 + }, + { + "case": "verdict_di_duplicate_sites_last_wins", + "corpus": "verdicts", + "digest": "33aff7fd9961eed97afe9bd78b0271d0333ab27734df86bccfee2e892a226fb6", + "bytes": 1888 + }, + { + "case": "verdict_di_graph_families", + "corpus": "verdicts", + "digest": "f6f1dd6bc8ac40468e2252b10e6856dc510be0a38ac4c426fb656c25ecffd457", + "bytes": 1077 + }, + { + "case": "verdict_di_tolerant_typed_coercions", + "corpus": "verdicts", + "digest": "db35f35c937b9014c3c3ca14c983ad9f0504925b233cbfbb457885fd60e9ddf0", + "bytes": 901 + }, + { + "case": "verdict_door_effect_deps_not_strings", + "corpus": "verdicts", + "digest": "9ac5b570e4a2956f2d771aa0cc5de5e1a7584683d0feefe453df6da4d5007c7b", + "bytes": 622 + }, + { + "case": "verdict_door_service_unknown_lifetime", + "corpus": "verdicts", + "digest": "f812c4f014338788c67b3e3f29f035d074d84714f048b0ff8b696b57432a3752", + "bytes": 541 + }, + { + "case": "verdict_eff001_storm_and_memo", + "corpus": "verdicts", + "digest": "68d132b1eb434c314635ace94f186ea4f080cb04e79fc26d0d2feb3846f80fb5", + "bytes": 1054 + }, + { + "case": "verdict_multi_file_ordering", + "corpus": "verdicts", + "digest": "ba871ddc6cc81bd4e3185e51855561ab4b9ed5bd3b400f308a87987bad80d2a0", + "bytes": 1036 + }, + { + "case": "verdict_own051_gates", + "corpus": "verdicts", + "digest": "ff3d7627f6143425985ad86aedb3f6fb962118c72de6e1b047ba8bf0026238ec", + "bytes": 1262 + }, + { + "case": "verdict_pool_view_anchor", + "corpus": "verdicts", + "digest": "67a62185aec92d3afdd043816def76b1d69fce13a3fa5339c61cefb69c823504", + "bytes": 493 + }, + { + "case": "verdict_same_site_distinct_events", + "corpus": "verdicts", + "digest": "657cb50c6df9feecf3c9791f02a6555aed08810d622b182313ca507623e37c41", + "bytes": 1543 + }, + { + "case": "verdict_severity_tiers_and_suppression", + "corpus": "verdicts", + "digest": "10ffbd221bd11913531b3b672dabd966dee52983330856941358b0d9416819ad", + "bytes": 1916 + }, + { + "case": "verdict_skip_list_artifacts", + "corpus": "verdicts", + "digest": "7cec84e88b7002fc0dced5d9c086cbf5763f6b26a6d4f5b8236f47bb67646299", + "bytes": 1460 + }, + { + "case": "vocab_unknown_op", + "corpus": "lowered", + "digest": "c07494fb952154bcb0f026969754a20bf856c5c12b54a1c9b2aca0468315838d", + "bytes": 108 + } + ] +} diff --git a/tests/fixtures/repro/domain_exponent.facts.json b/tests/fixtures/repro/domain_exponent.facts.json new file mode 100644 index 00000000..e2f26a41 --- /dev/null +++ b/tests/fixtures/repro/domain_exponent.facts.json @@ -0,0 +1,5 @@ +{ + "ownir_version": 0, + "module": "DomainExponent", + "canonical_probe": 1e3 +} diff --git a/tests/fixtures/repro/domain_float.facts.json b/tests/fixtures/repro/domain_float.facts.json new file mode 100644 index 00000000..7ed98021 --- /dev/null +++ b/tests/fixtures/repro/domain_float.facts.json @@ -0,0 +1,5 @@ +{ + "ownir_version": 0, + "module": "DomainFloat", + "canonical_probe": 1.5 +} diff --git a/tests/fixtures/repro/domain_int_above_i64.facts.json b/tests/fixtures/repro/domain_int_above_i64.facts.json new file mode 100644 index 00000000..f9393931 --- /dev/null +++ b/tests/fixtures/repro/domain_int_above_i64.facts.json @@ -0,0 +1,5 @@ +{ + "ownir_version": 0, + "module": "DomainIntAboveI64", + "canonical_probe": 9223372036854775808 +} diff --git a/tests/fixtures/repro/domain_int_below_i64.facts.json b/tests/fixtures/repro/domain_int_below_i64.facts.json new file mode 100644 index 00000000..6f9efdca --- /dev/null +++ b/tests/fixtures/repro/domain_int_below_i64.facts.json @@ -0,0 +1,5 @@ +{ + "ownir_version": 0, + "module": "DomainIntBelowI64", + "canonical_probe": -9223372036854775809 +} diff --git a/tests/fixtures/repro/domain_negative_zero.facts.json b/tests/fixtures/repro/domain_negative_zero.facts.json new file mode 100644 index 00000000..62e554f3 --- /dev/null +++ b/tests/fixtures/repro/domain_negative_zero.facts.json @@ -0,0 +1,5 @@ +{ + "ownir_version": 0, + "module": "DomainNegativeZero", + "canonical_probe": -0 +} diff --git a/tests/fixtures/repro/domain_non_finite.facts.json b/tests/fixtures/repro/domain_non_finite.facts.json new file mode 100644 index 00000000..8dd18dbb --- /dev/null +++ b/tests/fixtures/repro/domain_non_finite.facts.json @@ -0,0 +1,5 @@ +{ + "ownir_version": 0, + "module": "DomainNonFinite", + "canonical_probe": NaN +} diff --git a/tests/fixtures/repro/hoist_neg_while_body.reduction.json b/tests/fixtures/repro/hoist_neg_while_body.reduction.json new file mode 100644 index 00000000..4d3fcae8 --- /dev/null +++ b/tests/fixtures/repro/hoist_neg_while_body.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "hoist_neg_while_body", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/hoist_neg_while_body.repro.json b/tests/fixtures/repro/hoist_neg_while_body.repro.json new file mode 100644 index 00000000..d7cd0f07 --- /dev/null +++ b/tests/fixtures/repro/hoist_neg_while_body.repro.json @@ -0,0 +1,415 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "d2ae2309b105c985d6eeda13a2eb53b72bcd56fe895ac6919023ecdca72ea1e4", + "bytes": 195 + }, + "document": { + "ownir_version": 0, + "module": "HoistNeg2", + "functions": [ + { + "name": "M", + "file": "F.cs", + "body": [ + { + "op": "while", + "line": 2, + "body": [ + { + "op": "acquire", + "var": "r", + "line": 3 + } + ] + }, + { + "op": "release", + "var": "r", + "line": 5 + } + ] + } + ] + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "HoistNeg2", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "M", + "lifetime": null, + "params": [], + "ret": null, + "body": [ + { + "stmt": "while", + "cond": "?", + "body": [ + { + "stmt": "acquire", + "handle": "loc_0", + "resource": "Disposable", + "line": 3 + } + ], + "line": 2 + }, + { + "stmt": "release", + "handle": "loc_0", + "line": 5 + } + ] + } + ], + "handles": [ + { + "handle": "loc_0", + "component": "M", + "file": "F.cs", + "line": 3, + "event": "r", + "resource": "flow-local", + "ever_released": true, + "pool": false + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "HoistNeg2", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "refused", + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message=\"undefined name 'loc_0'\"). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding." + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "HoistNeg2", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "M", + "lifetime": null, + "params": [], + "ret": null, + "body": [ + { + "stmt": "while", + "cond": "?", + "body": [ + { + "stmt": "acquire", + "handle": "loc_0", + "resource": "Disposable", + "line": 3 + } + ], + "line": 2 + }, + { + "stmt": "release", + "handle": "loc_0", + "line": 5 + } + ] + } + ], + "handles": [ + { + "handle": "loc_0", + "component": "M", + "file": "F.cs", + "line": 3, + "event": "r", + "resource": "flow-local", + "ever_released": true, + "pool": false + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "HoistNeg2", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "refused", + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message='undefined name'). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding." + } + ] + } + ] +} diff --git a/tests/fixtures/repro/hoist_neg_while_body.trace.json b/tests/fixtures/repro/hoist_neg_while_body.trace.json new file mode 100644 index 00000000..bec6e406 --- /dev/null +++ b/tests/fixtures/repro/hoist_neg_while_body.trace.json @@ -0,0 +1,470 @@ +{ + "trace_version": 1, + "case": "hoist_neg_while_body", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "d2ae2309b105c985d6eeda13a2eb53b72bcd56fe895ac6919023ecdca72ea1e4", + "bytes": 195 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "HoistNeg2" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[M]", + "value": { + "name": "M", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "functions[M].body[0]", + "value": { + "stmt": "while", + "cond": "?", + "body": [ + { + "stmt": "acquire", + "handle": "M|F.cs|3|r|", + "resource": "Disposable", + "line": 3 + } + ], + "line": 2 + } + }, + { + "id": "functions[M].body[1]", + "value": { + "stmt": "release", + "handle": "M|F.cs|3|r|", + "line": 5 + } + }, + { + "id": "handles[M|F.cs|3|r|]", + "value": { + "handle": "M|F.cs|3|r|", + "component": "M", + "file": "F.cs", + "line": 3, + "event": "r", + "resource": "flow-local", + "ever_released": true, + "pool": false, + "mint": "loc" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "HoistNeg2" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + }, + { + "id": "summaries[M]", + "value": { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + } + ] + }, + { + "layer": "verdicts", + "status": "refused", + "projection": { + "kind": "full" + }, + "order": "significant", + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message=\"undefined name 'loc_0'\"). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding.", + "steps": [] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "d2ae2309b105c985d6eeda13a2eb53b72bcd56fe895ac6919023ecdca72ea1e4", + "bytes": 195 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "HoistNeg2" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[M]", + "value": { + "name": "M", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "functions[M].body[0]", + "value": { + "stmt": "while", + "cond": "?", + "body": [ + { + "stmt": "acquire", + "handle": "M|F.cs|3|r|", + "resource": "Disposable", + "line": 3 + } + ], + "line": 2 + } + }, + { + "id": "functions[M].body[1]", + "value": { + "stmt": "release", + "handle": "M|F.cs|3|r|", + "line": 5 + } + }, + { + "id": "handles[M|F.cs|3|r|]", + "value": { + "handle": "M|F.cs|3|r|", + "component": "M", + "file": "F.cs", + "line": 3, + "event": "r", + "resource": "flow-local", + "ever_released": true, + "pool": false, + "mint": "loc" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "HoistNeg2" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + }, + { + "id": "summaries[M]", + "value": { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + } + ] + }, + { + "layer": "verdicts", + "status": "refused", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message='undefined name'). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding.", + "steps": [] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/manifest.json b/tests/fixtures/repro/manifest.json new file mode 100644 index 00000000..c9f956d2 --- /dev/null +++ b/tests/fixtures/repro/manifest.json @@ -0,0 +1,140 @@ +{ + "comment": "The frozen ledger for shadow-mode infrastructure layer 0 (P-022 step 7a, #260/#269): the same-input capture and the reproduction artifact. Hand-maintained: adding or removing a case is a deliberate contract change, and the harness requires manifest == facts == goldens exactly. 'synthetic_cases' are the canonical-form controls the shared corpora have no reason to carry; 'domain_refusals' are documents BOTH engines must refuse at parse, each with the reason (an executable ledger: the day either engine starts accepting one, its suite goes red demanding a decision); 'artifacts' is the CURATED set whose full reproduction artifact is committed and replayed by the Rust own-shadow — the properties (determinism, byte-exact round-trip, self-verification, tamper refusal) run over every swept case, while the goldens pin the format on these. Format version 2 added the layer envelope's 'projection' (the engine protocol): every layer declares what its engine could produce, so a port mid-migration neither emits a short document a comparison would score as agreement nor refuses a layer it can mostly produce. This is infrastructure FOR shadow mode: nothing here compares two engines' end diagnostics.", + "repro_version": 2, + "synthetic_cases": [ + { + "name": "canonical_torture", + "pins": [ + "the canonical string-escape rule: the five two-character escapes, other C0 controls as lowercase \\u00xx, and U+007F / U+00A0 / U+2028 / U+2029 / U+FEFF emitted RAW", + "the canonical number domain at its accepted edges: i64 min, i64 max and zero", + "code-point key ordering across the ASCII/non-ASCII boundary, including the empty key, an accented key and an astral key", + "non-ASCII and astral text surviving the capture with ensure_ascii disabled" + ] + }, + { + "name": "canonical_key_order", + "pins": [ + "the canonical form is defined over the PARSED document: a file whose keys are written out of order hashes identically to the same document written in order", + "the artifact's own rendering keeps DOCUMENT order (BR-D4: input order is semantic) while the hash sorts — two serializations, two jobs" + ] + }, + { + "name": "canonical_minimal", + "pins": [ + "the smallest capturable document: every layer still reports, and the artifact still verifies", + "the insertion-stability probe's stand-in document (P-022 discipline 4)" + ] + } + ], + "domain_refusals": [ + { + "name": "domain_negative_zero", + "reason": "the literal -0: this reference reads it as the integer 0, serde_json as the float -0.0. Refused rather than reconciled — a canonical form that hashed it would assert 'both engines saw the same document' while the two engines held different values. Recorded as a finding in the checkpoint note.", + "python_error_contains": "the literal '-0' is outside the canonical domain", + "rust_error_contains": "canonical domain", + "python_error_note": "the needle names the LITERAL-level refusal on purpose: the value-level backstop refuses the same documents, so a needle matching either would let the parse-boundary check be deleted with the suite still green (round-1 survivors M05/M06/M07)" + }, + { + "name": "domain_float", + "reason": "a fractional literal: the OwnIR vocabulary has no float, and cross-language byte-agreement over one is not provable", + "python_error_contains": "the float literal 1.5 is outside the canonical domain", + "rust_error_contains": "canonical domain", + "python_error_note": "the needle names the LITERAL-level refusal on purpose: the value-level backstop refuses the same documents, so a needle matching either would let the parse-boundary check be deleted with the suite still green (round-1 survivors M05/M06/M07)" + }, + { + "name": "domain_exponent", + "reason": "an exponent literal: both parsers read it as a float, so it is refused on the float rule even though its value is integral", + "python_error_contains": "the float literal 1e3 is outside the canonical domain", + "rust_error_contains": "canonical domain", + "python_error_note": "the needle names the LITERAL-level refusal on purpose: the value-level backstop refuses the same documents, so a needle matching either would let the parse-boundary check be deleted with the suite still green (round-1 survivors M05/M06/M07)" + }, + { + "name": "domain_int_above_i64", + "reason": "an integer above 2**63-1: this reference's integers are unbounded, serde_json without arbitrary_precision silently reads it as an f64. spec/OwnIR.md 4.2 already bounds every validated coordinate to signed 64 bits", + "python_error_contains": "the integer literal 9223372036854775808 is outside the canonical domain", + "rust_error_contains": "canonical domain", + "python_error_note": "the needle names the LITERAL-level refusal on purpose: the value-level backstop refuses the same documents, so a needle matching either would let the parse-boundary check be deleted with the suite still green (round-1 survivors M05/M06/M07)" + }, + { + "name": "domain_int_below_i64", + "reason": "an integer below -2**63, for the same reason as the upper bound — the domain is closed at both ends, not just where a coordinate is likely", + "python_error_contains": "the integer literal -9223372036854775809 is outside the canonical domain", + "rust_error_contains": "canonical domain", + "python_error_note": "the needle names the LITERAL-level refusal on purpose: the value-level backstop refuses the same documents, so a needle matching either would let the parse-boundary check be deleted with the suite still green (round-1 survivors M05/M06/M07)" + }, + { + "name": "domain_non_finite", + "reason": "NaN: this reference's JSON reader accepts it as an extension and serde_json rejects it as invalid JSON, so the two engines do not agree that the document parses at all", + "python_error_contains": "the non-finite literal NaN is outside the canonical domain", + "rust_error_contains": null, + "rust_refusal_note": "serde_json rejects NaN as a JSON syntax error, not as a domain violation, so no shared substring is required — the refusal itself is the contract", + "python_error_note": "the needle names the LITERAL-level refusal on purpose: the value-level backstop refuses the same documents, so a needle matching either would let the parse-boundary check be deleted with the suite still green (round-1 survivors M05/M06/M07)" + } + ], + "artifacts": [ + { + "name": "canonical_torture", + "corpus": "repro", + "pins": [ + "an artifact carrying raw U+007F / U+2028 / U+2029 / U+FEFF round-trips byte-for-byte through parse and serialize" + ] + }, + { + "name": "canonical_key_order", + "corpus": "repro", + "pins": [ + "the embedded document is rendered in document order while its digest is order-independent" + ] + }, + { + "name": "canonical_minimal", + "corpus": "repro", + "pins": [ + "the minimum artifact shape: three layer envelopes, all produced" + ] + }, + { + "name": "di", + "corpus": "ownir", + "pins": [ + "a real corpus document with all three layers produced and a non-empty verdict list" + ] + }, + { + "name": "vocab_unknown_op", + "corpus": "lowered", + "pins": [ + "a fail-loud lowering: the 'lowered' and 'verdicts' layers carry status 'refused' with the reference's OwnIRError text lifted into the envelope, while 'summaries' still reports" + ] + }, + { + "name": "hoist_neg_while_body", + "corpus": "lowered", + "pins": [ + "a layer-selective refusal: 'lowered' produces, 'verdicts' refuses (the BR-V3 map-or-raise class) — the shape a first-divergence reduction has to distinguish" + ] + }, + { + "name": "mosdump_degraded_duplicate_key", + "corpus": "summaries", + "pins": [ + "the MOS 'degraded' branch is carried as a PRODUCED layer, not a refusal (INF-F6: a failed solve is a document, not an error)" + ] + }, + { + "name": "protocol_isloaded_violation", + "corpus": "ownir", + "pins": [ + "a document the Rust bridge refuses by a declared #259 boundary (the obligation-protocol analysis is not ported) but the reference captures in full — the artifact records one engine's capture and takes no side on the other's" + ] + }, + { + "name": "verdict_door_effect_deps_not_strings", + "corpus": "verdicts", + "pins": [ + "a document the port's TYPED DOOR refuses (#294 OD-1): the door is upstream of every layer, so all three layers report refused with the door's text while the reference captures all three in full", + "the shape a first-divergence reduction must not mistake for a layer-level disagreement" + ] + } + ] +} diff --git a/tests/fixtures/repro/mosdump_degraded_duplicate_key.reduction.json b/tests/fixtures/repro/mosdump_degraded_duplicate_key.reduction.json new file mode 100644 index 00000000..95dce29f --- /dev/null +++ b/tests/fixtures/repro/mosdump_degraded_duplicate_key.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "mosdump_degraded_duplicate_key", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/mosdump_degraded_duplicate_key.repro.json b/tests/fixtures/repro/mosdump_degraded_duplicate_key.repro.json new file mode 100644 index 00000000..195ec49d --- /dev/null +++ b/tests/fixtures/repro/mosdump_degraded_duplicate_key.repro.json @@ -0,0 +1,523 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "92f052ccef71d87b1b80ff9e9bcd9c74ec9db30b7ad34c883af0096a83088fdd", + "bytes": 376 + }, + "document": { + "ownir_version": 0, + "module": "Degraded", + "functions": [ + { + "name": "Take", + "sig": "System.IO.Stream", + "file": "t1.cs", + "params": [ + { + "name": "p", + "line": 1 + } + ], + "body": [ + { + "op": "release", + "var": "p", + "line": 2 + } + ] + }, + { + "name": "Take", + "sig": "System.String", + "file": "t2.cs", + "params": [ + { + "name": "p", + "line": 5 + } + ], + "body": [ + { + "op": "use", + "var": "p", + "line": 6 + } + ] + }, + { + "name": "Take(System.IO.Stream)", + "file": "clash.cs", + "body": [] + } + ] + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Degraded", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "parg_0", + "type": { + "name": "Disposable", + "borrowed": false, + "mutable": false + }, + "line": 1, + "lifetime": null + } + ], + "ret": null, + "body": [ + { + "stmt": "release", + "handle": "parg_0", + "line": 2 + } + ] + }, + { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "parg_1", + "type": { + "name": "Disposable", + "borrowed": true, + "mutable": false + }, + "line": 5, + "lifetime": null + } + ], + "ret": null, + "body": [ + { + "stmt": "use", + "handle": "parg_1", + "line": 6 + } + ] + }, + { + "name": "Take(System.IO.Stream)", + "lifetime": null, + "params": [], + "ret": null, + "body": [] + } + ], + "handles": [ + { + "handle": "parg_0", + "component": "Take", + "file": "t1.cs", + "line": 1, + "event": "p", + "resource": "flow-local", + "ever_released": true + }, + { + "handle": "parg_1", + "component": "Take", + "file": "t2.cs", + "line": 5, + "event": "p", + "resource": "flow-local", + "ever_released": false + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": "ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)", + "module": "Degraded", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "?", + "line": 0, + "code": "OWN052", + "component": "Degraded", + "event": "", + "handler": "", + "message": "interprocedural summary inference failed (ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)); method summaries skipped — cross-method ownership transfer was not checked this run", + "kind": "method summaries", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] + } + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Degraded", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "parg_0", + "type": { + "name": "Disposable", + "borrowed": false, + "mutable": false + }, + "line": 1, + "lifetime": null + } + ], + "ret": null, + "body": [ + { + "stmt": "release", + "handle": "parg_0", + "line": 2 + } + ] + }, + { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "parg_1", + "type": { + "name": "Disposable", + "borrowed": true, + "mutable": false + }, + "line": 5, + "lifetime": null + } + ], + "ret": null, + "body": [ + { + "stmt": "use", + "handle": "parg_1", + "line": 6 + } + ] + }, + { + "name": "Take(System.IO.Stream)", + "lifetime": null, + "params": [], + "ret": null, + "body": [] + } + ], + "handles": [ + { + "handle": "parg_0", + "component": "Take", + "file": "t1.cs", + "line": 1, + "event": "p", + "resource": "flow-local", + "ever_released": true + }, + { + "handle": "parg_1", + "component": "Take", + "file": "t2.cs", + "line": 5, + "event": "p", + "resource": "flow-local", + "ever_released": false + } + ] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": "ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)", + "module": "Degraded", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "?", + "line": 0, + "code": "OWN052", + "component": "Degraded", + "event": "", + "handler": "", + "kind": "method summaries", + "advisory": true, + "severity": null, + "ignore_reason": null, + "column": null + } + ] + } + } + ] + } + ] +} diff --git a/tests/fixtures/repro/mosdump_degraded_duplicate_key.trace.json b/tests/fixtures/repro/mosdump_degraded_duplicate_key.trace.json new file mode 100644 index 00000000..8dd511f2 --- /dev/null +++ b/tests/fixtures/repro/mosdump_degraded_duplicate_key.trace.json @@ -0,0 +1,575 @@ +{ + "trace_version": 1, + "case": "mosdump_degraded_duplicate_key", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "92f052ccef71d87b1b80ff9e9bcd9c74ec9db30b7ad34c883af0096a83088fdd", + "bytes": 376 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Degraded" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[Take]", + "value": { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "Take|t1.cs|1|p|", + "type": { + "name": "Disposable", + "borrowed": false, + "mutable": false + }, + "line": 1, + "lifetime": null + } + ], + "ret": null + } + }, + { + "id": "functions[Take].body[0]", + "value": { + "stmt": "release", + "handle": "Take|t1.cs|1|p|", + "line": 2 + } + }, + { + "id": "functions[Take~1]", + "value": { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "Take|t2.cs|5|p|", + "type": { + "name": "Disposable", + "borrowed": true, + "mutable": false + }, + "line": 5, + "lifetime": null + } + ], + "ret": null + } + }, + { + "id": "functions[Take~1].body[0]", + "value": { + "stmt": "use", + "handle": "Take|t2.cs|5|p|", + "line": 6 + } + }, + { + "id": "functions[Take(System.IO.Stream)]", + "value": { + "name": "Take(System.IO.Stream)", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "handles[Take|t1.cs|1|p|]", + "value": { + "handle": "Take|t1.cs|1|p|", + "component": "Take", + "file": "t1.cs", + "line": 1, + "event": "p", + "resource": "flow-local", + "ever_released": true, + "mint": "parg" + } + }, + { + "id": "handles[Take|t2.cs|5|p|]", + "value": { + "handle": "Take|t2.cs|5|p|", + "component": "Take", + "file": "t2.cs", + "line": 5, + "event": "p", + "resource": "flow-local", + "ever_released": false, + "mint": "parg" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Degraded" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": "ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)" + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[?:0:None:OWN052]", + "value": { + "file": "?", + "line": 0, + "code": "OWN052", + "component": "Degraded", + "event": "", + "handler": "", + "message": "interprocedural summary inference failed (ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)); method summaries skipped — cross-method ownership transfer was not checked this run", + "kind": "method summaries", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + } + ] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "92f052ccef71d87b1b80ff9e9bcd9c74ec9db30b7ad34c883af0096a83088fdd", + "bytes": 376 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Degraded" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + }, + { + "id": "functions[Take]", + "value": { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "Take|t1.cs|1|p|", + "type": { + "name": "Disposable", + "borrowed": false, + "mutable": false + }, + "line": 1, + "lifetime": null + } + ], + "ret": null + } + }, + { + "id": "functions[Take].body[0]", + "value": { + "stmt": "release", + "handle": "Take|t1.cs|1|p|", + "line": 2 + } + }, + { + "id": "functions[Take~1]", + "value": { + "name": "Take", + "lifetime": null, + "params": [ + { + "handle": "Take|t2.cs|5|p|", + "type": { + "name": "Disposable", + "borrowed": true, + "mutable": false + }, + "line": 5, + "lifetime": null + } + ], + "ret": null + } + }, + { + "id": "functions[Take~1].body[0]", + "value": { + "stmt": "use", + "handle": "Take|t2.cs|5|p|", + "line": 6 + } + }, + { + "id": "functions[Take(System.IO.Stream)]", + "value": { + "name": "Take(System.IO.Stream)", + "lifetime": null, + "params": [], + "ret": null + } + }, + { + "id": "handles[Take|t1.cs|1|p|]", + "value": { + "handle": "Take|t1.cs|1|p|", + "component": "Take", + "file": "t1.cs", + "line": 1, + "event": "p", + "resource": "flow-local", + "ever_released": true, + "mint": "parg" + } + }, + { + "id": "handles[Take|t2.cs|5|p|]", + "value": { + "handle": "Take|t2.cs|5|p|", + "component": "Take", + "file": "t2.cs", + "line": 5, + "event": "p", + "resource": "flow-local", + "ever_released": false, + "mint": "parg" + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Degraded" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": "ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)" + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[?:0:None:OWN052]", + "value": { + "file": "?", + "line": 0, + "code": "OWN052", + "component": "Degraded", + "event": "", + "handler": "", + "kind": "method summaries", + "advisory": true, + "severity": null, + "ignore_reason": null, + "column": null + } + } + ] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/protocol_isloaded_violation.reduction.json b/tests/fixtures/repro/protocol_isloaded_violation.reduction.json new file mode 100644 index 00000000..58deb72c --- /dev/null +++ b/tests/fixtures/repro/protocol_isloaded_violation.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "protocol_isloaded_violation", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/protocol_isloaded_violation.repro.json b/tests/fixtures/repro/protocol_isloaded_violation.repro.json new file mode 100644 index 00000000..6a27c487 --- /dev/null +++ b/tests/fixtures/repro/protocol_isloaded_violation.repro.json @@ -0,0 +1,426 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "f821b58b0da7e5ecb94798d1868db377876d945242a4e4d013d6499d7af7ee13", + "bytes": 1217 + }, + "document": { + "ownir_version": 0, + "module": "Extracted", + "components": [], + "protocols": [ + { + "name": "DocumentLoading", + "description": "The document tree is inconsistent while IsLoaded == false; publishing Document/Rows/Totals in that window hands bindings a broken object.", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows", + "Totals", + "SelectedItem" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsLoaded", + "IsBusy", + "Progress", + "StatusText" + ] + } + ], + "exit_barriers": true, + "scope": { + "methods": [ + "BigDocumentViewModel.LoadBigDocument" + ] + } + } + ], + "protocol_functions": [ + { + "name": "Broker.BigDocumentViewModel.LoadBigDocument", + "file": "BigDocumentViewModel.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 184 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "arg": "IsBusy", + "line": 185 + }, + { + "ev": "call", + "callee": "RebuildIndexes", + "line": 190 + }, + { + "ev": "if", + "line": 220, + "then": [ + { + "ev": "call", + "callee": "OnPropertyChanged", + "arg": "Document", + "line": 241 + } + ], + "else": [] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 260 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "arg": "Document", + "line": 261 + } + ] + } + ] + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Extracted", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [], + "handles": [] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Extracted", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "BigDocumentViewModel.cs", + "line": 241, + "code": "OBL001", + "component": "BigDocumentViewModel", + "event": "DocumentLoading", + "handler": "LoadBigDocument", + "message": "obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.BigDocumentViewModel.LoadBigDocument' — 'IsLoaded = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BigDocumentViewModel.cs", + 184, + "obligation 'DocumentLoading' opens here (IsLoaded = false)" + ], + [ + "BigDocumentViewModel.cs", + 241, + "barrier 'OnPropertyChanged(Document)' fires while it is open" + ], + [ + "BigDocumentViewModel.cs", + 260, + "closed here — after the barrier has already fired" + ] + ], + "ignore_reason": null, + "column": null + } + ] + } + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Extracted", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [], + "handles": [] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Extracted", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "refused", + "error": "this document declares 1 obligation protocol(s), and the protocol analysis (OBL001–005, ownlang/obligations.py) is not wired into this core yet — refusing rather than returning a verdict list with a family missing (#259 boundary; the verdict fixture ledger records the excluded reference documents)" + } + ] + } + ] +} diff --git a/tests/fixtures/repro/protocol_isloaded_violation.trace.json b/tests/fixtures/repro/protocol_isloaded_violation.trace.json new file mode 100644 index 00000000..4d419342 --- /dev/null +++ b/tests/fixtures/repro/protocol_isloaded_violation.trace.json @@ -0,0 +1,389 @@ +{ + "trace_version": 1, + "case": "protocol_isloaded_violation", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "f821b58b0da7e5ecb94798d1868db377876d945242a4e4d013d6499d7af7ee13", + "bytes": 1217 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Extracted" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Extracted" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[BigDocumentViewModel.cs:241:None:OBL001]", + "value": { + "file": "BigDocumentViewModel.cs", + "line": 241, + "code": "OBL001", + "component": "BigDocumentViewModel", + "event": "DocumentLoading", + "handler": "LoadBigDocument", + "message": "obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.BigDocumentViewModel.LoadBigDocument' — 'IsLoaded = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BigDocumentViewModel.cs", + 184, + "obligation 'DocumentLoading' opens here (IsLoaded = false)" + ], + [ + "BigDocumentViewModel.cs", + 241, + "barrier 'OnPropertyChanged(Document)' fires while it is open" + ], + [ + "BigDocumentViewModel.cs", + 260, + "closed here — after the barrier has already fired" + ] + ], + "ignore_reason": null, + "column": null + } + } + ] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "f821b58b0da7e5ecb94798d1868db377876d945242a4e4d013d6499d7af7ee13", + "bytes": 1217 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Extracted" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Extracted" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "refused", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "error": "this document declares 1 obligation protocol(s), and the protocol analysis (OBL001–005, ownlang/obligations.py) is not wired into this core yet — refusing rather than returning a verdict list with a family missing (#259 boundary; the verdict fixture ledger records the excluded reference documents)", + "steps": [] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/verdict_door_effect_deps_not_strings.reduction.json b/tests/fixtures/repro/verdict_door_effect_deps_not_strings.reduction.json new file mode 100644 index 00000000..4c23ff92 --- /dev/null +++ b/tests/fixtures/repro/verdict_door_effect_deps_not_strings.reduction.json @@ -0,0 +1,38 @@ +{ + "reduction_version": 1, + "case": "verdict_door_effect_deps_not_strings", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "diverged", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 2, + "projection": 0, + "unexplained": 0 + }, + "first": { + "layer": "lowered", + "kind": "status", + "step": null, + "path": null, + "left": "produced", + "right": "refused", + "detail": "the two engines disagree about whether this layer produced at all; the artifacts record every such case as a DECLARED boundary, and this reducer reports it rather than judging it" + }, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/verdict_door_effect_deps_not_strings.repro.json b/tests/fixtures/repro/verdict_door_effect_deps_not_strings.repro.json new file mode 100644 index 00000000..40468c8f --- /dev/null +++ b/tests/fixtures/repro/verdict_door_effect_deps_not_strings.repro.json @@ -0,0 +1,252 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "9ac5b570e4a2956f2d771aa0cc5de5e1a7584683d0feefe453df6da4d5007c7b", + "bytes": 622 + }, + "document": { + "ownir_version": 0, + "module": "Door", + "components": [], + "_doc": [ + "#294 OD-1: BR-D2's skip-not-coerce on the tolerant door. The reference SKIPS", + "the effect whose deps is a bare string (no spurious EFF001) and reports the", + "well-formed sibling; the Rust tolerant entry is the typed constructor, which", + "refuses the document before the bridge's skip rule can run." + ], + "effects": [ + { + "component": "X", + "file": "X.tsx", + "line": 1, + "io": true, + "deps": "a", + "bindings": [ + { + "name": "a", + "init": "object", + "refs": [], + "line": 1 + } + ] + }, + { + "component": "Ok", + "file": "Ok.tsx", + "line": 9, + "io": true, + "deps": [ + "o" + ], + "bindings": [ + { + "name": "o", + "init": "object", + "refs": [], + "line": 8 + } + ] + } + ] + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "lowered_version": 1, + "module": "Door", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [], + "handles": [] + } + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Door", + "ownir_version": 0, + "summaries": [], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "Ok.tsx", + "line": 9, + "code": "EFF001", + "component": "Ok", + "event": "o", + "handler": "", + "message": "effect re-runs on every render: dependency 'o' is an object literal created in render scope, so its identity changes on every render; the effect performs IO, which can become a request storm — stabilise 'o' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Ok.tsx", + 9, + "effect re-runs here on 'o'" + ], + [ + "Ok.tsx", + 8, + "'o' gets a fresh identity here — stabilise with useMemo" + ] + ], + "ignore_reason": null, + "column": null + } + ] + } + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "refused", + "error": "typed door: invalid type: string \"a\", expected a sequence at line 17 column 17" + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "refused", + "error": "typed door: invalid type: string \"a\", expected a sequence at line 17 column 17" + }, + { + "layer": "verdicts", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "refused", + "error": "typed door: invalid type: string \"a\", expected a sequence at line 17 column 17" + } + ] + } + ] +} diff --git a/tests/fixtures/repro/verdict_door_effect_deps_not_strings.trace.json b/tests/fixtures/repro/verdict_door_effect_deps_not_strings.trace.json new file mode 100644 index 00000000..32aa03b5 --- /dev/null +++ b/tests/fixtures/repro/verdict_door_effect_deps_not_strings.trace.json @@ -0,0 +1,246 @@ +{ + "trace_version": 1, + "case": "verdict_door_effect_deps_not_strings", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "9ac5b570e4a2956f2d771aa0cc5de5e1a7584683d0feefe453df6da4d5007c7b", + "bytes": 622 + }, + "layers": [ + { + "layer": "lowered", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "lowered_version", + "value": 1 + }, + { + "id": "module", + "value": "Door" + }, + { + "id": "resources[Subscription]", + "value": { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[Timer]", + "value": { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + } + }, + { + "id": "resources[Disposable]", + "value": { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + } + }, + { + "id": "resources[PooledBuffer]", + "value": { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + }, + { + "id": "externs[$consume]", + "value": { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow]", + "value": { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + } + }, + { + "id": "externs[$borrow_mut]", + "value": { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + } + ] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Door" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + } + ] + }, + { + "layer": "verdicts", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "significant", + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[Ok.tsx:9:None:EFF001]", + "value": { + "file": "Ok.tsx", + "line": 9, + "code": "EFF001", + "component": "Ok", + "event": "o", + "handler": "", + "message": "effect re-runs on every render: dependency 'o' is an object literal created in render scope, so its identity changes on every render; the effect performs IO, which can become a request storm — stabilise 'o' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Ok.tsx", + 9, + "effect re-runs here on 'o'" + ], + [ + "Ok.tsx", + 8, + "'o' gets a fresh identity here — stabilise with useMemo" + ] + ], + "ignore_reason": null, + "column": null + } + } + ] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "9ac5b570e4a2956f2d771aa0cc5de5e1a7584683d0feefe453df6da4d5007c7b", + "bytes": 622 + }, + "layers": [ + { + "layer": "lowered", + "status": "refused", + "projection": { + "kind": "full" + }, + "order": "significant", + "error": "typed door: invalid type: string \"a\", expected a sequence at line 17 column 17", + "steps": [] + }, + { + "layer": "summaries", + "status": "refused", + "projection": { + "kind": "full" + }, + "order": "canonical", + "error": "typed door: invalid type: string \"a\", expected a sequence at line 17 column 17", + "steps": [] + }, + { + "layer": "verdicts", + "status": "refused", + "projection": { + "kind": "full" + }, + "order": "significant", + "error": "typed door: invalid type: string \"a\", expected a sequence at line 17 column 17", + "steps": [] + } + ] + } + ] +} diff --git a/tests/fixtures/repro/vocab_unknown_op.reduction.json b/tests/fixtures/repro/vocab_unknown_op.reduction.json new file mode 100644 index 00000000..f68855aa --- /dev/null +++ b/tests/fixtures/repro/vocab_unknown_op.reduction.json @@ -0,0 +1,30 @@ +{ + "reduction_version": 1, + "case": "vocab_unknown_op", + "engines": [ + "python-ownlang", + "rust-own-bridge" + ], + "scope": [ + "lowered", + "summaries" + ], + "outcome": "identical", + "detail": null, + "classification": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "unexplained": 0 + }, + "first": null, + "out_of_scope": [ + { + "layer": "verdicts", + "reason": "comparing final diagnostics is #260's ACCEPTANCE and is blocked by #259 (cp5 and 4b); this reducer refuses the layer rather than skipping it, so 'not compared' can never be read as 'compared and agreed'" + } + ] +} diff --git a/tests/fixtures/repro/vocab_unknown_op.repro.json b/tests/fixtures/repro/vocab_unknown_op.repro.json new file mode 100644 index 00000000..9dc6514a --- /dev/null +++ b/tests/fixtures/repro/vocab_unknown_op.repro.json @@ -0,0 +1,141 @@ +{ + "repro_version": 2, + "input": { + "ownir_version": 0, + "canonical": { + "algorithm": "sha256", + "digest": "c07494fb952154bcb0f026969754a20bf856c5c12b54a1c9b2aca0468315838d", + "bytes": 108 + }, + "document": { + "ownir_version": 0, + "module": "Vocab", + "functions": [ + { + "name": "M", + "file": "F.cs", + "body": [ + { + "op": "try", + "line": 2 + } + ] + } + ] + } + }, + "engines": [ + { + "id": "python-ownlang", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "refused", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Vocab", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "refused", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" + } + ] + }, + { + "id": "rust-own-bridge", + "layers": [ + { + "layer": "lowered", + "surface_version": 1, + "projection": { + "kind": "full" + }, + "status": "refused", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" + }, + { + "layer": "summaries", + "surface_version": null, + "projection": { + "kind": "full" + }, + "status": "produced", + "document": { + "degraded": null, + "module": "Vocab", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] + } + }, + { + "layer": "verdicts", + "surface_version": 1, + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "status": "refused", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" + } + ] + } + ] +} diff --git a/tests/fixtures/repro/vocab_unknown_op.trace.json b/tests/fixtures/repro/vocab_unknown_op.trace.json new file mode 100644 index 00000000..45a43cb5 --- /dev/null +++ b/tests/fixtures/repro/vocab_unknown_op.trace.json @@ -0,0 +1,152 @@ +{ + "trace_version": 1, + "case": "vocab_unknown_op", + "traces": [ + { + "trace_version": 1, + "engine": "python-ownlang", + "input": { + "algorithm": "sha256", + "digest": "c07494fb952154bcb0f026969754a20bf856c5c12b54a1c9b2aca0468315838d", + "bytes": 108 + }, + "layers": [ + { + "layer": "lowered", + "status": "refused", + "projection": { + "kind": "full" + }, + "order": "significant", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)", + "steps": [] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Vocab" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + }, + { + "id": "summaries[M]", + "value": { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + } + ] + }, + { + "layer": "verdicts", + "status": "refused", + "projection": { + "kind": "full" + }, + "order": "significant", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)", + "steps": [] + } + ] + }, + { + "trace_version": 1, + "engine": "rust-own-bridge", + "input": { + "algorithm": "sha256", + "digest": "c07494fb952154bcb0f026969754a20bf856c5c12b54a1c9b2aca0468315838d", + "bytes": 108 + }, + "layers": [ + { + "layer": "lowered", + "status": "refused", + "projection": { + "kind": "full" + }, + "order": "significant", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)", + "steps": [] + }, + { + "layer": "summaries", + "status": "produced", + "projection": { + "kind": "full" + }, + "order": "canonical", + "steps": [ + { + "id": "module", + "value": "Vocab" + }, + { + "id": "ownir_version", + "value": 0 + }, + { + "id": "degraded", + "value": null + }, + { + "id": "summaries[M]", + "value": { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + } + ] + }, + { + "layer": "verdicts", + "status": "refused", + "projection": { + "kind": "partial", + "members": [ + "file", + "line", + "code", + "component", + "event", + "handler", + "kind", + "advisory", + "severity", + "ignore_reason", + "column" + ], + "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + }, + "order": "significant", + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)", + "steps": [] + } + ] + } + ] +} diff --git a/tests/shadow_census.py b/tests/shadow_census.py new file mode 100644 index 00000000..59525094 --- /dev/null +++ b/tests/shadow_census.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Compute the P-022 step 7a (shadow-mode infrastructure) census from evidence. + +The counterpart to `tests/verdict_census.py` for the #260/#269 slice: the one +interpretation of the committed reproduction artifacts, traces and reductions, +so the status renderer and any test that quotes a number read the same thing. +Nothing here interprets a mutation campaign — that is +`scripts/mutate_campaign.summarize()`, and interpreting it twice is how two +documents come to disagree about one run. + +What it deliberately does NOT compute: a divergence counter over the same-input +layer. That classification is enforced by a **gate** — the port asserts +per-document equality of the canonical identity and byte-exact equality of +every artifact and trace, so a non-zero counter there is not representable as a +passing build. The reduction layer is different: those counters ARE computed, +by the reducer, and this reads them off the committed reductions. + +Run: python tests/shadow_census.py (prints the computed census as JSON) +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass, field +from typing import Any + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIXDIR = os.path.join(ROOT, "tests", "fixtures", "repro") +RUST_TESTS = ("repro.rs", "engine.rs", "trace.rs", "reduce.rs") +CLASSES = ("left-only", "right-only", "changed", "ordering-only", + "status", "projection", "unexplained") + + +class ShadowCensusError(Exception): + def __init__(self, problems: list[str]) -> None: + super().__init__("; ".join(problems)) + self.problems = problems + + +@dataclass +class ShadowCensus: + """Every figure the step-7a status surfaces may show, and nothing else.""" + + documents: int = 0 + by_corpus: tuple[tuple[str, int], ...] = () + domain_refusals: int = 0 + artifacts: int = 0 + structural_controls: int = 0 + domain_backstop_controls: int = 0 + engines: tuple[tuple[str, int, int, int, int], ...] = () + status_differs: tuple[tuple[str, str, str], ...] = () + trace_layers: int = 0 + trace_steps: int = 0 + stable_id_steps: int = 0 + reductions: int = 0 + identical: int = 0 + scope: tuple[str, ...] = () + by_class: dict[str, int] = field(default_factory=lambda: dict.fromkeys(CLASSES, 0)) + gates: tuple[tuple[str, str], ...] = () + + +def _load(path: str) -> Any: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _rust_test_names() -> tuple[tuple[str, str], ...]: + """The gates' own test names, read from the source. A renamed or deleted + test makes the census stale, so a document cannot go on naming a gate that + no longer exists.""" + out: list[tuple[str, str]] = [] + for target in RUST_TESTS: + path = os.path.join(ROOT, "rust", "crates", "own-shadow", "tests", target) + with open(path, encoding="utf-8") as f: + source = f.read() + out += [(target, name) for name in sorted( + re.findall(r"#\[test\][^\n]*\n(?:\s*#\[[^\n]*\n)*fn (\w+)\(", source))] + return tuple(out) + + +def compute_shadow_census() -> ShadowCensus: + problems: list[str] = [] + for name in ("digests.json", "manifest.json"): + if not os.path.exists(os.path.join(FIXDIR, name)): + problems.append(f"missing evidence: tests/fixtures/repro/{name}") + if problems: + raise ShadowCensusError(problems) + + import test_repro_fixtures as harness + + digests = _load(os.path.join(FIXDIR, "digests.json")) + manifest = _load(os.path.join(FIXDIR, "manifest.json")) + documents = digests["documents"] + + per_corpus: dict[str, int] = {} + for record in documents: + per_corpus[record["corpus"]] = per_corpus.get(record["corpus"], 0) + 1 + + # Per-engine, per-status accounting over the committed artifacts, plus the + # layer envelopes where the two engines' STATUS differs. That last number is + # the closest thing this slice has to a cross-engine measurement, and it is + # structural: no layer's content is compared here. + tallies: dict[str, dict[str, int]] = {} + projections: dict[str, dict[str, int]] = {} + differs: list[tuple[str, str, str]] = [] + for entry in manifest["artifacts"]: + artifact = _load(os.path.join(FIXDIR, f"{entry['name']}.repro.json")) + by_layer: dict[str, dict[str, str]] = {} + for engine in artifact["engines"]: + eid = engine["id"] + tally = tallies.setdefault(eid, {"produced": 0, "refused": 0}) + proj = projections.setdefault(eid, {"full": 0, "partial": 0}) + for layer in engine["layers"]: + tally[layer["status"]] = tally.get(layer["status"], 0) + 1 + kind = layer["projection"]["kind"] + proj[kind] = proj.get(kind, 0) + 1 + by_layer.setdefault(layer["layer"], {})[eid] = layer["status"] + for layer_name, statuses in sorted(by_layer.items()): + if len(set(statuses.values())) > 1: + shown = ", ".join(f"{e}: {s}" for e, s in sorted(statuses.items())) + differs.append((entry["name"], layer_name, shown)) + + trace_layers = trace_steps = stable_ids = 0 + for entry in manifest["artifacts"]: + path = os.path.join(FIXDIR, f"{entry['name']}.trace.json") + if not os.path.exists(path): + continue + for trace in _load(path)["traces"]: + for layer in trace["layers"]: + trace_layers += 1 + trace_steps += len(layer["steps"]) + stable_ids += sum(1 for s in layer["steps"] + if s["id"].startswith("handles[")) + + by_class = dict.fromkeys(CLASSES, 0) + reductions = identical = 0 + scope: tuple[str, ...] = () + for entry in manifest["artifacts"]: + path = os.path.join(FIXDIR, f"{entry['name']}.reduction.json") + if not os.path.exists(path): + continue + reduction = _load(path) + reductions += 1 + scope = tuple(reduction["scope"]) + if reduction["outcome"] == "identical": + identical += 1 + for name, count in reduction.get("classification", {}).items(): + if name not in by_class: + problems.append(f"{entry['name']}.reduction.json: unknown class {name!r}") + continue + by_class[name] += count + if problems: + raise ShadowCensusError(problems) + + return ShadowCensus( + documents=len(documents), + by_corpus=tuple(sorted(per_corpus.items())), + domain_refusals=len(manifest["domain_refusals"]), + artifacts=len(manifest["artifacts"]), + structural_controls=harness.STRUCTURAL_CONTROL_COUNT, + domain_backstop_controls=harness.DOMAIN_BACKSTOP_COUNT, + engines=tuple((eid, t["produced"], t["refused"], + projections[eid]["full"], projections[eid]["partial"]) + for eid, t in sorted(tallies.items())), + status_differs=tuple(differs), + trace_layers=trace_layers, + trace_steps=trace_steps, + stable_id_steps=stable_ids, + reductions=reductions, + identical=identical, + scope=scope, + by_class=by_class, + gates=_rust_test_names(), + ) + + +def main() -> int: + """A hand run: print what the committed evidence says, without the renderer + in the way. The gate is `tests/test_checkpoint_status.py`.""" + try: + census = compute_shadow_census() + except ShadowCensusError as e: + for p in e.problems: + print(f"FAIL[shadow-census]: {p}") + return 1 + print(f"shadow census OK: {census.documents} documents, {census.artifacts} artifacts, " + f"{census.reductions} reductions, {len(census.gates)} named gates") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_checkpoint_status.py b/tests/test_checkpoint_status.py index aa9675fe..d317dfd1 100644 --- a/tests/test_checkpoint_status.py +++ b/tests/test_checkpoint_status.py @@ -1,14 +1,28 @@ #!/usr/bin/env python3 -"""Gate: the generated checkpoint status fragments equal their projection. +"""Gate: the generated checkpoint status fragments equal their projection, and +every recorded mutation campaign can still be replayed. -`docs/generated/p022-cp4-*.md` are rendered from the evidence in the tree by -`scripts/render_checkpoint_status.py` (the verdict ledger census through -`tests/verdict_census.py`; the recorded mutation campaign through -`scripts/mutate_campaign.py`). This module runs its `--check` in-process, so -a change to the evidence without regenerating the fragments — or a campaign -result that no longer matches its definition — turns the existing Python -gate red on every interpreter the suite runs on. Regenerate with -`python scripts/render_checkpoint_status.py`. +`docs/generated/p022-cp4-*.md` and `docs/generated/p022-shadow-*.md` are +rendered from the evidence in the tree by `scripts/render_checkpoint_status.py` +(the verdict ledger census through `tests/verdict_census.py`; the step-7a +census through `tests/shadow_census.py`; every recorded mutation campaign +through `scripts/mutate_campaign.py`). This module runs its `--check` +in-process, so a change to the evidence without regenerating the fragments — or +a campaign result that no longer matches its definition, was taken on a dirty +tree, missed a required catcher, or names a commit this tree does not descend +from — turns the existing Python gate red on every interpreter the suite runs +on. Regenerate with `python scripts/render_checkpoint_status.py`. + +The second gate is deliberately separate from the first. A fragment's content +never depends on HEAD (that is what makes a recorded run stay valid across an +unrelated refactor), but a campaign *definition* is replay instructions, and +those rot: a mutation is an exact rewrite of a production file, so when that +code moves the pattern silently matches nothing and the definition describes a +tree that no longer exists. `--validate` re-anchors every mutation against the +current tree without running anything; it caught two rotted anchors when an +earlier checkpoint reshaped a surface a previous campaign had measured. A +failure here does not falsify the recorded evidence — it says the campaign can +no longer be re-run as written, and the definition needs re-anchoring. Run: python tests/test_checkpoint_status.py python tests/run_tests.py (runs it in the suite) @@ -19,18 +33,57 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts")) +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, os.path.join(HERE, "..", "scripts")) + +from mutate_campaign import CampaignError, load_definition, validate # noqa: E402 +from render_checkpoint_status import ( # noqa: E402 + CAMPAIGN, + CENSUS_MD, + MUTATIONS_MD, + SHADOW_CAMPAIGNS, + SHADOW_CENSUS_MD, + SHADOW_MUTATIONS_MD, + check, +) + +EVIDENCE = os.path.join(ROOT, "docs", "evidence") +# Every campaign definition in the tree, gated for replayability. A campaign +# nobody listed is a campaign nobody re-anchors. +DEFINITIONS = (CAMPAIGN, *(os.path.join(EVIDENCE, f"{campaign}.json") + for _, campaign in SHADOW_CAMPAIGNS)) + -from render_checkpoint_status import CENSUS_MD, MUTATIONS_MD, check +def _anchors() -> list[str]: + problems: list[str] = [] + for path in DEFINITIONS: + rel = os.path.relpath(path, ROOT).replace(os.sep, "/") + if not os.path.exists(path): + problems.append(f"{rel}: missing — every campaign this gate lists must exist") + continue + try: + definition = load_definition(path) + except (CampaignError, OSError, ValueError) as e: + problems.append(f"{rel}: unreadable: {e}") + continue + problems.extend(f"{rel}: {p}" for p in validate(definition)) + return problems def run() -> int: problems = check() for p in problems: - print(f"FAIL: checkpoint status {p}") - if problems: + print(f"FAIL[checkpoint-status]: {p}") + anchors = _anchors() + for p in anchors: + print(f"FAIL[campaign-anchor]: {p} — the definition no longer applies to this " + f"tree; re-anchor it (the recorded result stays valid for the commit it names)") + if problems or anchors: return 1 - print(f"checkpoint status fragments OK: {CENSUS_MD}, {MUTATIONS_MD} in sync with the evidence") + print(f"checkpoint status fragments OK: {CENSUS_MD}, {MUTATIONS_MD}, {SHADOW_CENSUS_MD}, " + f"{SHADOW_MUTATIONS_MD} in sync with the evidence; " + f"{len(DEFINITIONS)} campaign definitions still anchor") return 0 diff --git a/tests/test_repro_fixtures.py b/tests/test_repro_fixtures.py new file mode 100644 index 00000000..fbd6b9c2 --- /dev/null +++ b/tests/test_repro_fixtures.py @@ -0,0 +1,1090 @@ +#!/usr/bin/env python3 +"""Shadow-mode infrastructure, layer 0 (P-022 step 7a, #260/#269): the +same-input capture and the reproduction artifact. + +**Infrastructure for shadow mode, not shadow mode.** Nothing here compares two +engines' end diagnostics; that comparison is #260's acceptance and is blocked +on #259 (cp5 and 4b). What this family proves is the two things a comparison +would otherwise have to assume: that both engines can *name the same input*, +and that a reproduction has *one format*. + +Two committed surfaces, one ledger: + +* **`tests/fixtures/repro/digests.json`** — the canonical hash of **every** + facts document in the shared corpora (`tests/fixtures/{ownir,lowered, + summaries,verdicts}`) plus the canonical-form controls beside the manifest. + This is the same-input capture surface: the Rust `own-shadow` recomputes + every digest from the same documents with zero Python + (`rust/crates/own-shadow/tests/repro.rs`), so "both engines saw the same + input" is a checked fact. The array is sorted by case name and each record + depends on nothing but its own case — the insertion-stability rule (P-022 + discipline §4) is asserted here on **both** its lines, `churn == 0` and + `delta == 1`. +* **`tests/fixtures/repro/.repro.json`** — full reproduction artifacts + for a curated set of cases, listed exhaustively in the manifest with what + each one pins. The artifact set is deliberately *not* the whole corpus: + every artifact embeds its input plus three layer documents that already + live in the tree, so committing 81 of them would triple the corpus to prove + nothing the curated set does not. The **properties** (determinism, + byte-exact round-trip, self-verification, tamper refusal) run over all 81 + swept cases; the **goldens** pin the format on the curated set, and the + Rust side replays those byte-for-byte. + +What each check is evidence for: + +* `render_repro` twice, byte-identical — determinism of the capture. +* `json.loads(golden)` re-rendered == the golden bytes — the artifact + round-trips byte-for-byte through parse/serialize, which is what makes it a + *format* rather than one program's output. +* `verify_repro(golden) == []` — the artifact describes itself: the digest and + byte length are recomputed from the embedded document. +* one changed character in the embedded document — the digest changes and + `verify_repro` refuses, naming the mismatch. Run over every swept case, not + a sample. + +Python is authoritative: `python tests/test_repro_fixtures.py --write` +regenerates `digests.json` and every artifact. + +Run: python tests/test_repro_fixtures.py (verify) + python tests/test_repro_fixtures.py --write (regenerate) + python tests/run_tests.py (runs it in the suite) +""" + +from __future__ import annotations + +import copy +import json +import os +import sys +from typing import Any + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.lowered import project_lowered +from ownlang.repro import ( + CANONICAL_ALGORITHM, + ENGINE_PYTHON, + LAYER_ORDER_SEMANTICS, + REDUCTION_SCOPE, + REPRO_VERSION, + ReproError, + canonical_hash, + load_document, + normalize_handles, + project_repro, + project_traces, + reduce_traces, + render_reduction, + render_repro, + render_traces, + stable_handle_ids, + verify_repro, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +FIXDIR = os.path.join(HERE, "fixtures", "repro") +MANIFEST = os.path.join(FIXDIR, "manifest.json") +DIGESTS = os.path.join(FIXDIR, "digests.json") + +# The shared facts corpora, swept automatically in a fixed order. `repro` is +# this family's own directory: the canonical-form controls the other corpora +# have no reason to carry. +CORPORA: tuple[tuple[str, str], ...] = ( + ("ownir", os.path.join(HERE, "fixtures", "ownir")), + ("lowered", os.path.join(HERE, "fixtures", "lowered")), + ("summaries", os.path.join(HERE, "fixtures", "summaries")), + ("verdicts", os.path.join(HERE, "fixtures", "verdicts")), + ("repro", FIXDIR), +) + + +def _facts_cases(directory: str) -> list[str]: + if not os.path.isdir(directory): + return [] + return sorted(n[: -len(".facts.json")] for n in os.listdir(directory) + if n.endswith(".facts.json")) + + +def _manifest() -> tuple[list[dict[str, Any]], list[dict[str, Any]], + list[dict[str, Any]], list[str]]: + """(synthetic case entries, domain-refusal entries, artifact entries, + ledger problems).""" + problems: list[str] = [] + if not os.path.exists(MANIFEST): + return [], [], [], [f"manifest missing: {MANIFEST}"] + with open(MANIFEST, encoding="utf-8") as f: + data = json.load(f) + if data.get("repro_version") != REPRO_VERSION: + problems.append( + f"manifest repro_version {data.get('repro_version')!r} != " + f"emitter REPRO_VERSION {REPRO_VERSION}") + synthetic = data.get("synthetic_cases", []) + refusals = data.get("domain_refusals", []) + artifacts = data.get("artifacts", []) + for label, entries in (("synthetic_cases", synthetic), ("artifacts", artifacts)): + if not isinstance(entries, list) or not entries: + problems.append(f"manifest '{label}' must be a non-empty array") + continue + names: list[str] = [] + for e in entries: + name, pins = e.get("name"), e.get("pins") + if not (isinstance(name, str) and name): + problems.append(f"{label}: entry without a name: {e!r}") + continue + names.append(name) + if not (isinstance(pins, list) and pins + and all(isinstance(p, str) and p for p in pins)): + problems.append(f"{label} '{name}': 'pins' must be a non-empty " + f"array of non-empty strings saying what the case " + f"is evidence FOR") + if len(set(names)) != len(names): + problems.append(f"{label} contains duplicate names") + # The refusal ledger has its own shape: a reason, and the substring each + # engine's refusal must carry (null on the Rust side where the two + # parsers refuse for different reasons — see the entry's note). + seen: list[str] = [] + if not isinstance(refusals, list) or not refusals: + problems.append("manifest 'domain_refusals' must be a non-empty array") + else: + for e in refusals: + name, reason = e.get("name"), e.get("reason") + if not (isinstance(name, str) and name): + problems.append(f"domain_refusals: entry without a name: {e!r}") + continue + seen.append(name) + if not (isinstance(reason, str) and reason): + problems.append(f"domain_refusals '{name}': 'reason' must say WHY the " + f"two engines cannot agree on this document") + needle = e.get("python_error_contains") + if not (isinstance(needle, str) and needle): + problems.append(f"domain_refusals '{name}': 'python_error_contains' " + f"must be a non-empty substring of the refusal text") + if len(set(seen)) != len(seen): + problems.append("domain_refusals contains duplicate names") + return list(synthetic), list(refusals), list(artifacts), problems + + +def _plan() -> tuple[dict[str, tuple[str, str]], dict[str, dict[str, Any]], + list[str], list[str]]: + """The plan: capturable cases (name -> corpus label, facts path), the + domain-refusal controls (name -> ledger entry), the curated artifact names, + and the ledger problems. Names must be unique across every corpus — one + digest ledger and one golden tree serve them all. A refusal control is + deliberately NOT a capturable case: it has no digest, because the whole + point is that neither engine can name it.""" + synthetic, refusals, artifacts, problems = _manifest() + plan: dict[str, tuple[str, str]] = {} + for label, directory in CORPORA: + for name in _facts_cases(directory): + if name in plan: + problems.append( + f"case name '{name}' exists in BOTH the {plan[name][0]} and " + f"{label} corpora — names must be unique across the sweep") + continue + plan[name] = (label, os.path.join(directory, f"{name}.facts.json")) + refusal_names = {e["name"] for e in refusals if isinstance(e.get("name"), str)} + listed = sorted(e["name"] for e in synthetic if isinstance(e.get("name"), str)) + on_disk = [n for n in _facts_cases(FIXDIR) if n not in refusal_names] + for missing in sorted(set(listed) - set(on_disk)): + problems.append(f"manifest synthetic case '{missing}' has no " + f"{missing}.facts.json under fixtures/repro") + for unlisted in sorted(set(on_disk) - set(listed)): + problems.append(f"'{unlisted}.facts.json' is not in manifest.json — add " + f"the case to synthetic_cases (name, pins)") + refusal_entries: dict[str, dict[str, Any]] = {} + for e in refusals: + entry_name = e.get("name") + if not isinstance(entry_name, str): + continue + refusal_entries[entry_name] = e + path = os.path.join(FIXDIR, f"{entry_name}.facts.json") + if not os.path.exists(path): + problems.append(f"domain_refusals '{entry_name}' has no " + f"{entry_name}.facts.json under fixtures/repro") + # A refusal control must not also be a capturable case: `plan` swept + # the directory, so remove it and say so if it was never there. + plan.pop(entry_name, None) + artifact_names = [e["name"] for e in artifacts if isinstance(e.get("name"), str)] + for phantom in sorted(set(artifact_names) - set(plan)): + problems.append(f"manifest artifacts names '{phantom}', which is not a " + f"planned case") + return plan, refusal_entries, sorted(artifact_names), problems + + +def _foreign_engines(golden_path: str) -> list[dict[str, Any]]: + """The engine captures already committed in an artifact that this side did + NOT author. `--write` reads them back and carries them through, because an + engine writes only its own entry: an artifact where one implementation + authored another's capture would be a comparison of one thing against + itself.""" + if not os.path.exists(golden_path): + return [] + try: + with open(golden_path, encoding="utf-8") as f: + committed = json.load(f) + except (OSError, json.JSONDecodeError): + return [] + engines = committed.get("engines") + if not isinstance(engines, list): + return [] + return [e for e in engines + if isinstance(e, dict) and e.get("id") != ENGINE_PYTHON] + + +def _load(path: str) -> Any: + """Read one facts document through the canonical loader — the domain is + enforced on the LITERALS, which is the only place the reference can still + tell `-0` from `0`.""" + with open(path, encoding="utf-8") as f: + return load_document(f.read()) + + +def _tamper(document: Any) -> Any: + """A deep copy of `document` with exactly one changed character (or, when + it holds no string, one changed integer) at the first leaf a depth-first + walk reaches. Deterministic, so the refusal it provokes is reproducible. + Returns `None` when the document has no mutable leaf at all.""" + changed = False + + def walk(value: Any) -> Any: + nonlocal changed + if changed: + return value + if isinstance(value, str) and value: + changed = True + head = "a" if value[0] != "a" else "b" + return head + value[1:] + if isinstance(value, bool): + return value + if isinstance(value, int): + changed = True + return value - 1 if value > 0 else value + 1 + if isinstance(value, list): + return [walk(v) for v in value] + if isinstance(value, dict): + return {k: walk(v) for k, v in value.items()} + return value + + out = walk(copy.deepcopy(document)) + return out if changed else None + + +def _digest_records(plan: dict[str, tuple[str, str]]) -> list[dict[str, Any]]: + """The digest ledger's records, sorted by case name. Each record is a pure + function of its own case — no ordinal, no neighbour — so inserting a case + churns nothing (P-022 discipline §4).""" + records: list[dict[str, Any]] = [] + for case in sorted(plan): + corpus, path = plan[case] + digest = canonical_hash(_load(path)) + records.append({ + "case": case, + "corpus": corpus, + "digest": digest["digest"], + "bytes": digest["bytes"], + }) + return records + + +def _render_digests(plan: dict[str, tuple[str, str]]) -> str: + return json.dumps({ + "comment": ( + "The canonical hash of every shared facts document (P-022 step 7a, " + "#260/#269). Generated: python tests/test_repro_fixtures.py --write. " + "The Rust own-shadow recomputes every digest from the same documents " + "with zero Python, which is what makes 'both engines saw the same " + "input' a checked fact rather than an assumption. Records are sorted " + "by case and depend on nothing but their own case, so inserting a " + "case churns no existing record."), + "repro_version": REPRO_VERSION, + "algorithm": CANONICAL_ALGORITHM, + "documents": _digest_records(plan), + }, indent=2, ensure_ascii=False) + "\n" + + +# The negative controls, counted so the summary reports what actually ran +# rather than what the reader assumes did. Public because +# `scripts/render_checkpoint_status.py` derives the census from them rather +# than from a number somebody typed into a document. +STRUCTURAL_CONTROL_COUNT = 18 +DOMAIN_BACKSTOP_COUNT = 5 + + +def _forge(artifact: dict[str, Any], mutate: Any) -> Any: + """A deep copy of `artifact` with one structural rule broken.""" + forged = copy.deepcopy(artifact) + mutate(forged) + return forged + + +def _structural_controls(artifact: dict[str, Any]) -> list[str]: + """Negative controls for `verify_repro`: every structural rule it states + must have a document that breaks exactly that rule and is refused for it. + Without these, `verify_repro` could degrade to "recompute the digest" and + every positive check would still pass — the shape P-022 discipline 2 is + about (a rule with no control is a rule nothing tests).""" + fails: list[str] = [] + + def expect(label: str, needle: str, mutate: Any) -> None: + problems = verify_repro(_forge(artifact, mutate)) + if not any(needle in p for p in problems): + fails.append(f"verify_repro accepts {label} (expected a problem " + f"naming {needle!r}, got {problems})") + + def set_version(a: dict[str, Any]) -> None: + a["repro_version"] = REPRO_VERSION + 1 + + def add_member(a: dict[str, Any]) -> None: + a["extra_member"] = 1 + + def drop_layer(a: dict[str, Any]) -> None: + del a["engines"][0]["layers"][1] + + def reorder_layers(a: dict[str, Any]) -> None: + a["engines"][0]["layers"].reverse() + + def unknown_engine(a: dict[str, Any]) -> None: + a["engines"][0]["id"] = "some-other-engine" + + def duplicate_engine(a: dict[str, Any]) -> None: + a["engines"].append(copy.deepcopy(a["engines"][0])) + + def engines_out_of_order(a: dict[str, Any]) -> None: + rust = copy.deepcopy(a["engines"][0]) + rust["id"] = "rust-own-bridge" + a["engines"].insert(0, rust) + + def produced_with_error(a: dict[str, Any]) -> None: + a["engines"][0]["layers"][0]["error"] = "an error beside a document" + + def refused_without_error(a: dict[str, Any]) -> None: + layer = a["engines"][0]["layers"][0] + layer["status"] = "refused" + layer.pop("document", None) + + def drop_surface_version(a: dict[str, Any]) -> None: + del a["engines"][0]["layers"][0]["surface_version"] + + def unknown_status(a: dict[str, Any]) -> None: + a["engines"][0]["layers"][0]["status"] = "maybe" + + def drop_canonical(a: dict[str, Any]) -> None: + del a["input"]["canonical"] + + def drop_projection(a: dict[str, Any]) -> None: + del a["engines"][0]["layers"][0]["projection"] + + def unknown_projection_kind(a: dict[str, Any]) -> None: + a["engines"][0]["layers"][0]["projection"] = {"kind": "mostly"} + + def unnamed_partial(a: dict[str, Any]) -> None: + a["engines"][0]["layers"][0]["projection"] = { + "kind": "partial", "reason": "some members are not ported"} + + def unexplained_partial(a: dict[str, Any]) -> None: + a["engines"][0]["layers"][0]["projection"] = { + "kind": "partial", "members": ["module"]} + + def empty_reason_partial(a: dict[str, Any]) -> None: + a["engines"][0]["layers"][0]["projection"] = { + "kind": "partial", "members": ["module"], "reason": ""} + + def full_with_members(a: dict[str, Any]) -> None: + a["engines"][0]["layers"][0]["projection"] = { + "kind": "full", "members": ["module"]} + + expect("a wrong format version", "repro_version", set_version) + expect("an unknown artifact member", "unknown artifact member", add_member) + expect("a missing layer", "frozen layers", drop_layer) + expect("layers out of the frozen order", "frozen layers", reorder_layers) + expect("an unknown engine id", "frozen engine vocabulary", unknown_engine) + expect("a repeated engine", "appears twice", duplicate_engine) + expect("engines out of the frozen order", "out of the frozen order", + engines_out_of_order) + expect("a produced layer carrying an error", "carries an error", + produced_with_error) + expect("a refused layer without an error", "non-empty error text", + refused_without_error) + expect("a layer without surface_version", "surface_version is missing", + drop_surface_version) + expect("an unknown layer status", "is neither", unknown_status) + expect("a missing canonical block", "input.canonical is missing", + drop_canonical) + expect("a layer without a projection", "projection is missing", + drop_projection) + expect("an unknown projection kind", "is not one of", unknown_projection_kind) + expect("a partial projection naming no members", "must NAME", unnamed_partial) + expect("a partial projection with no reason", "must say WHY", + unexplained_partial) + expect("a partial projection whose reason is empty", "must say WHY", + empty_reason_partial) + expect("a full projection carrying members", "carries no", full_with_members) + return fails + + +def _domain_backstop_controls() -> list[str]: + """`canonical_bytes` keeps a VALUE-level domain check behind + `load_document`'s literal-level one, for a document that arrives already + parsed (the observer API takes a dict). It needs its own control, or the + backstop is untested code that a mutation would walk straight through.""" + fails: list[str] = [] + for label, value in ( + ("a float", {"x": 1.5}), + ("an integer above the domain", {"x": 2**63}), + ("an integer below the domain", {"x": -(2**63) - 1}), + ("a non-string object key", {1: "x"}), + ("a value of an unsupported type", {"x": {1, 2}}), + ): + try: + canonical_hash(value) + except ReproError: + continue + fails.append(f"canonical_hash accepts {label} — the value-level domain " + f"backstop is not enforcing the closed domain") + return fails + + +def _trace_controls(plan: dict[str, tuple[str, str]]) -> list[tuple[str, str]]: + """The AnalysisTrace's own properties (#269), over EVERY captured document + rather than the artifact subset — the normalization has to hold on the + corpus, not on a sample. + + 1. **Totality.** No counter-shaped handle survives the rewrite. Asserted + inside `normalize_handles`, exercised here on every lowered document. + 2. **Bijection.** Distinct minted handles get distinct stable ids, so the + rewrite cannot fuse two facts into one address. + 3. **The property the whole checkpoint exists for**: a mint-order shift + must NOT move a stable id. Permuting a document's components reshuffles + the global counters (BR-L2), so the raw names change wholesale; the + stable ids must be the same set. Without this, one reordered input would + report every handle as a difference between engines. + 4. **Order is NOT normalized away.** The same permutation must still change + the lowered layer's step order — a trace that hid it would delete the + defect the layer exists to expose. + """ + fails: list[tuple[str, str]] = [] + shifted = 0 + for case in sorted(plan): + _corpus, path = plan[case] + facts = _load(path) + document = project_lowered(facts) + if document.get("error") is not None: + continue + handles = document.get("handles", []) + rename = stable_handle_ids(handles) + try: + normalize_handles(document) + except ReproError as e: + fails.append(("trace-normalization", f"{case}: {e}")) + continue + if len(set(rename.values())) != len(rename): + fails.append(("trace-normalization", + f"{case}: two minted handles share a stable id — the " + f"rewrite fuses two facts into one address")) + components = facts.get("components") + if not (isinstance(components, list) and len(components) > 1 and handles): + continue + permuted = copy.deepcopy(facts) + permuted["components"] = list(reversed(permuted["components"])) + other = project_lowered(permuted) + if other.get("error") is not None: + continue + raw_a = sorted(h["handle"] for h in handles) + raw_b = sorted(h["handle"] for h in other.get("handles", [])) + stable_a = sorted(stable_handle_ids(handles).values()) + stable_b = sorted(stable_handle_ids(other.get("handles", [])).values()) + if stable_a != stable_b: + fails.append(("trace-normalization", + f"{case}: a mint-order shift moved a stable id " + f"({stable_a} != {stable_b}) — the normalization does " + f"not survive the reordering it exists for")) + continue + if raw_a == raw_b: + continue # the permutation did not shift the counters here + shifted += 1 + if not shifted: + fails.append(("trace-normalization", + "no case in the corpus actually shifts the mint counters " + "under permutation, so property 3 was never exercised")) + # 5. Totality guards a state the corpus cannot reach — every statement + # references a handle the array lists, because the bridge mints both. So + # the rule is driven synthetically here, at the only level that reaches + # it, rather than left permanently unprovable (the resting place #259 + # cp4 chose for BR-V1's ERROR-only rule, for the same reason). + dangling = { + "functions": [{"body": [{"handle": "loc_1"}]}], + "handles": [{"handle": "loc_0", "component": "M"}], + } + try: + normalize_handles(dangling) + except ReproError as e: + if "not total" not in str(e) or "loc_1" not in str(e): + fails.append(("trace-normalization", + f"a surviving counter was refused for the wrong " + f"reason: {e}")) + else: + fails.append(("trace-normalization", + "a handle reference the rename cannot reach was carried " + "into the trace — a comparison would report a counter as " + "a difference between engines")) + listed = { + "functions": [{"body": [{"handle": "loc_0"}]}], + "handles": [{"handle": "loc_0", "component": "M"}], + } + if normalize_handles(listed)["handles"][0].get("mint") != "loc": + fails.append(("trace-normalization", + "the mint kind did not survive as a comparable value")) + return fails + + +def _trace_shape_controls(artifact_names: list[str]) -> list[tuple[str, str]]: + """The trace's structural rules, over the committed artifacts.""" + fails: list[tuple[str, str]] = [] + for case in artifact_names: + path = os.path.join(FIXDIR, f"{case}.repro.json") + if not os.path.exists(path): + continue + with open(path, encoding="utf-8") as f: + artifact = json.load(f) + for trace in project_traces(artifact, case)["traces"]: + for layer in trace["layers"]: + where = f"{case}/{trace['engine']}/{layer['layer']}" + want = LAYER_ORDER_SEMANTICS.get(layer["layer"]) + if layer["order"] != want: + fails.append(("trace-shape", f"{where}: order " + f"{layer['order']!r} != the frozen {want!r}")) + if layer["status"] == "refused" and layer["steps"]: + fails.append(("trace-shape", f"{where}: a refused layer " + f"carries steps; an empty step list that " + f"compared equal would score a refusal as " + f"agreement")) + if layer["status"] == "produced" and not layer["steps"]: + fails.append(("trace-shape", f"{where}: a produced layer " + f"carries no steps")) + ids = [s["id"] for s in layer["steps"]] + if len(set(ids)) != len(ids): + fails.append(("trace-shape", f"{where}: duplicate step ids " + f"survived disambiguation")) + return fails + + +def _reduction_controls(artifact_names: list[str]) -> list[tuple[str, str]]: + """The reducer, proven on a SYNTHETIC divergence and on its silence. + + The shape the checkpoint owes: take a real Layer 2 output, introduce **one** + controlled change into a copy, and show the reducer names the layer, the + step and the minimal difference — and that it says nothing on the unchanged + data. A reducer that has never reported is a reducer nobody has seen work; + a reducer that reports on unchanged data is worse than none.""" + fails: list[tuple[str, str]] = [] + # `canonical_key_order` is the control case because its lowered layer + # carries real flow statements with `line` fields — `di` is DI-only and has + # no line-bearing step, which is how the changed-field control below first + # ended up adding a key instead of changing one. + case = next((c for c in artifact_names if c == "canonical_key_order"), None) + if case is None: + return [("reduction-control", + "the control case 'canonical_key_order' is not a committed " + "artifact; pick another with line-bearing lowered steps")] + with open(os.path.join(FIXDIR, f"{case}.repro.json"), encoding="utf-8") as f: + artifact = json.load(f) + base = project_traces(artifact, case) + + # 0. Silence on unchanged data. + quiet = reduce_traces(base) + if quiet["outcome"] != "identical" or quiet["first"] is not None: + fails.append(("reduction-control", + f"{case}: the reducer reports a divergence on unchanged " + f"data: {quiet['first']}")) + + def lowered_of(doc: dict[str, Any], side: int) -> dict[str, Any]: + for layer in doc["traces"][side]["layers"]: + if layer["layer"] == "lowered": + found: dict[str, Any] = layer + return found + raise AssertionError("no lowered layer") + + def expect(label: str, kind: str, mutate: Any, + step: str | None = None, path: str | None = None) -> None: + forged = copy.deepcopy(base) + mutate(lowered_of(forged, 1)) + result = reduce_traces(forged) + first = result["first"] + if result["outcome"] != "diverged" or first is None: + fails.append(("reduction-control", + f"{case}: the reducer is SILENT on {label}")) + return + if first["kind"] != kind: + fails.append(("reduction-control", + f"{case}: {label} classified {first['kind']!r}, " + f"expected {kind!r}")) + if first["layer"] != "lowered": + fails.append(("reduction-control", + f"{case}: {label} named layer {first['layer']!r}, " + f"expected 'lowered'")) + if step is not None and first["step"] != step: + fails.append(("reduction-control", + f"{case}: {label} named step {first['step']!r}, " + f"expected {step!r}")) + if path is not None and first["path"] != path: + fails.append(("reduction-control", + f"{case}: {label} named path {first['path']!r}, " + f"expected {path!r} — the difference must be MINIMAL, " + f"not the whole step")) + + # The control changes an EXISTING field. Picking the last step blindly once + # picked `externs[$borrow_mut]`, which carries no `line` — so the "change" + # added a key instead, the reference passed on the wrong thing and the port + # (which replaced in place) saw a no-op and stayed silent. The two halves + # disagreeing is what surfaced it. + _steps = lowered_of(base, 1)["steps"] + _changeable = next((s for s in _steps + if isinstance(s["value"], dict) and "line" in s["value"]), None) + if _changeable is None: + return [("reduction-control", + f"{case}: no lowered step carries a `line` to change, so the " + f"changed-field control cannot run")] + target = _changeable["id"] + dropped_target = _steps[-1]["id"] + + def change_one_field(layer: dict[str, Any]) -> None: + # ONE controlled change to an existing field, deep inside a step's + # value: the reducer must name the field, not the step body. + for step in layer["steps"]: + if step["id"] == target: + step["value"]["line"] = 999_001 + return + + def drop_a_step(layer: dict[str, Any]) -> None: + layer["steps"] = layer["steps"][:-1] + + def add_a_step(layer: dict[str, Any]) -> None: + layer["steps"].append({"id": "handles[synthetic|X.cs|1|E|H]", "value": {}}) + + def swap_two_steps(layer: dict[str, Any]) -> None: + steps = layer["steps"] + steps[0], steps[1] = steps[1], steps[0] + + expect("one changed field", "changed", change_one_field, target, ".line") + expect("a step only the reference has", "left-only", drop_a_step, + dropped_target) + expect("a step only the port has", "right-only", add_a_step, + "handles[synthetic|X.cs|1|E|H]") + expect("the same steps in a different order", "ordering-only", swap_two_steps) + + # 5. Two engines that BOTH refused a layer agree, however differently they + # phrased it and however their projections were declared. Neither is + # reachable from the committed corpus (both refusals there carry the same + # projection), so the rule is driven synthetically at the only level that + # reaches it — otherwise the short-circuit that states it is code no + # mutation can disturb. + both_refused = copy.deepcopy(base) + for side, (err, proj) in enumerate(( + ("the reference's own wording", {"kind": "full"}), + ("the port's own wording", {"kind": "partial", "members": ["x"], + "reason": "declared elsewhere"}))): + layer = lowered_of(both_refused, side) + layer["status"] = "refused" + layer["error"] = err + layer["projection"] = proj + layer["steps"] = [] + quiet_refusal = reduce_traces(both_refused) + if quiet_refusal["outcome"] != "identical": + fails.append(("reduction-control", + f"{case}: two engines that both REFUSED a layer are " + f"reported as diverging ({quiet_refusal['first']}) — a " + f"refusal's text and projection are each engine's own, and " + f"comparing them manufactures a divergence out of message " + f"vocabulary")) + + # 6. Object key ORDER is a difference. Nothing in the corpus exercises it + # any more (the MOS capture was fixed to carry its surface's own order), + # so it needs a synthetic control or the rule is untested. + reordered = copy.deepcopy(base) + layer = lowered_of(reordered, 1) + victim = next((s for s in layer["steps"] + if isinstance(s["value"], dict) and len(s["value"]) > 1), None) + if victim is None: + fails.append(("reduction-control", + f"{case}: no lowered step has two fields to reorder")) + else: + victim["value"] = dict(reversed(list(victim["value"].items()))) + result = reduce_traces(reordered) + first = result["first"] or {} + if result["outcome"] != "diverged" or first.get("kind") != "changed": + fails.append(("reduction-control", + f"{case}: the same fields in a different key ORDER are " + f"reported as agreement; the surfaces fix their field " + f"order byte-exactly, so a port emitting them in the " + f"wrong order is a real defect")) + elif first.get("path") != "[keys]": + fails.append(("reduction-control", + f"{case}: a key-order difference reported path " + f"{first.get('path')!r}, expected '[keys]' — the reader " + f"should not have to diff two identical-looking objects")) + + # The verdict layer is REFUSED, not skipped — "not compared" must never be + # readable as "compared and agreed". + if "verdicts" in REDUCTION_SCOPE: + fails.append(("reduction-control", + "the reduction scope now includes 'verdicts' — comparing " + "final diagnostics is #260's acceptance and is blocked by " + "#259; widening the scope is a contract decision")) + refused = [o["layer"] for o in quiet["out_of_scope"]] + if "verdicts" not in refused: + fails.append(("reduction-control", + "the reduction does not RECORD that it refused the " + "verdict layer; a reader could take silence for agreement")) + return fails + + +def _reduction_goldens() -> set[str]: + if not os.path.isdir(FIXDIR): + return set() + return {n[: -len(".reduction.json")] for n in os.listdir(FIXDIR) + if n.endswith(".reduction.json")} + + +def _trace_goldens() -> set[str]: + if not os.path.isdir(FIXDIR): + return set() + return {n[: -len(".trace.json")] for n in os.listdir(FIXDIR) + if n.endswith(".trace.json")} + + +def _artifact_goldens() -> set[str]: + if not os.path.isdir(FIXDIR): + return set() + return {n[: -len(".repro.json")] for n in os.listdir(FIXDIR) + if n.endswith(".repro.json")} + + +def _check_insertion_stability(plan: dict[str, tuple[str, str]], + records: list[dict[str, Any]]) -> list[str]: + """P-022 discipline §4, both normative lines: inserting one synthetic + member must churn **zero** existing records and add **exactly one**. + Enforced here on the generator itself, in memory — the ledger this family + commits is derived from a vocabulary (the swept corpora), which is exactly + the shape the rule exists for.""" + probe = "zzz_insertion_probe_not_a_committed_case" + if probe in plan: + return [f"the insertion probe name '{probe}' collides with a real case"] + widened = dict(plan) + widened[probe] = ("repro", os.path.join(FIXDIR, "canonical_minimal.facts.json")) + after = _digest_records(widened) + before_by_case = {r["case"]: r for r in records} + after_by_case = {r["case"]: r for r in after} + churn = [c for c, r in before_by_case.items() if after_by_case.get(c) != r] + delta = sorted(set(after_by_case) - set(before_by_case)) + problems: list[str] = [] + if churn: + problems.append(f"insertion churn == {len(churn)}, must be 0 " + f"(first: {churn[:3]})") + if delta != [probe]: + problems.append(f"insertion delta == {delta}, must be exactly ['{probe}']") + return problems + + +def run() -> int: + plan, refusals, artifact_names, ledger_problems = _plan() + # (check tag, detail) — the tag is what a mutation campaign attributes a + # catch to; the detail is what a human needs to fix it. + fails: list[tuple[str, str]] = [("ledger", m) for m in ledger_problems] + if not plan and not fails: + fails.append(("plan", "no cases planned (no *.facts.json under the swept corpora)")) + + # 1. Determinism of the capture, and of the canonical hash, over EVERY case. + n_refused_layers = 0 + for case in sorted(plan): + _corpus, path = plan[case] + facts = _load(path) + try: + first = render_repro(facts) + except ReproError as e: + fails.append(("capture", f"{case}: not capturable: {e}")) + continue + if render_repro(_load(path)) != first: + fails.append(( + "capture-determinism", + f"{case}: the reproduction artifact is non-deterministic" + )) + continue + if canonical_hash(facts) != canonical_hash(_load(path)): + fails.append(("hash-determinism", f"{case}: the canonical hash is non-deterministic")) + continue + problems = verify_repro(json.loads(first)) + if problems: + fails.append(( + "capture-verify", + f"{case}: the freshly built artifact does not verify: {problems}" + )) + continue + # 2. A changed character in the input is a REFUSAL, not a different + # reproduction — over every case, not a sample. + tampered = _tamper(facts) + if tampered is None: + fails.append(( + "tamper-control", + f"{case}: has no leaf to tamper — the tamper control cannot run, so this case " + f"proves nothing about it" + )) + continue + if canonical_hash(tampered)["digest"] == canonical_hash(facts)["digest"]: + fails.append(( + "tamper-digest", + f"{case}: a changed character did not change the digest" + )) + continue + forged = json.loads(first) + forged["input"]["document"] = tampered + if not verify_repro(forged): + fails.append(( + "tamper-refusal", + f"{case}: an artifact whose embedded document was changed still verifies — the " + f"digest is not a gate" + )) + n_refused_layers += sum( + 1 for e in json.loads(first)["engines"] for lyr in e["layers"] + if lyr["status"] == "refused") + + # 3. The domain-refusal controls: documents NEITHER engine may name. The + # ledger is executable — the day this reference starts accepting one, + # the suite goes red demanding a decision rather than quietly widening + # the domain. + for case in sorted(refusals): + entry = refusals[case] + path = os.path.join(FIXDIR, f"{case}.facts.json") + if not os.path.exists(path): + continue # already reported by the ledger check + with open(path, encoding="utf-8") as f: + text = f.read() + try: + load_document(text) + except ReproError as e: + needle = entry.get("python_error_contains") + if isinstance(needle, str) and needle not in str(e): + fails.append(( + "domain-refusal-reason", + f"{case}: refused, but not for the declared reason: expected {needle!r} in {e}" + )) + except json.JSONDecodeError as e: + fails.append(( + "domain-refusal-kind", + f"{case}: refused as malformed JSON ({e}) rather than as a domain violation — the " + f"control no longer tests the domain rule it was written for" + )) + else: + fails.append(( + "domain-refusal", + f"{case}: the canonical loader ACCEPTS a document the ledger declares unnameable " + f"({entry.get('reason')}); the control has rotted — promote it or record the " + f"decision" + )) + + # 4. The digest ledger is complete, in sync, and insertion-stable. + records = _digest_records(plan) if plan else [] + if not os.path.exists(DIGESTS): + fails.append(( + "digest-ledger", + "digests.json missing; regenerate with 'python tests/test_repro_fixtures.py --write'" + )) + else: + with open(DIGESTS, encoding="utf-8") as f: + committed = f.read() + if committed != _render_digests(plan): + fails.append(( + "digest-ledger", + "digests.json is stale (a corpus document changed, or a case was added/removed); " + "regenerate with 'python tests/test_repro_fixtures.py --write' and re-run the Rust " + "side (cd rust && cargo test)" + )) + fails += [("insertion-stability", m) for m in _check_insertion_stability(plan, records)] + + # 5. The curated artifacts: golden in sync, byte-exact round-trip, verified. + for case in artifact_names: + golden_path = os.path.join(FIXDIR, f"{case}.repro.json") + if case not in plan: + continue # already reported by the ledger check + expected = render_repro(_load(plan[case][1]), + _foreign_engines(golden_path)) + if not os.path.exists(golden_path): + fails.append(( + "artifact-golden", + f"{case}: artifact golden missing; regenerate with 'python " + f"tests/test_repro_fixtures.py --write'" + )) + continue + with open(golden_path, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + fails.append(( + "artifact-golden", + f"{case}: artifact golden is stale (a layer output or the format changed); " + f"regenerate with 'python tests/test_repro_fixtures.py --write' and re-run the " + f"Rust side (cd rust && cargo test)" + )) + continue + parsed = json.loads(actual) + if json.dumps(parsed, indent=2, ensure_ascii=False) + "\n" != actual: + fails.append(( + "artifact-roundtrip", + f"{case}: the artifact does not round-trip byte-for-byte through parse/serialize" + )) + problems = verify_repro(parsed) + if problems: + fails.append(( + "artifact-verify", + f"{case}: committed artifact does not verify: {problems}" + )) + for orphan in sorted(_artifact_goldens() - set(artifact_names)): + fails.append(( + "artifact-orphan", + f"{orphan}: orphaned artifact golden (not in the manifest's 'artifacts' ledger); " + f"remove it or list the case" + )) + + # 6. The AnalysisTrace (#269): goldens in sync, and the normalization's + # own properties over the whole captured corpus. + for case in artifact_names: + golden_path = os.path.join(FIXDIR, f"{case}.trace.json") + artifact_path = os.path.join(FIXDIR, f"{case}.repro.json") + if not os.path.exists(artifact_path): + continue + with open(artifact_path, encoding="utf-8") as f: + artifact = json.load(f) + expected = render_traces(artifact, case) + if not os.path.exists(golden_path): + fails.append(( + "trace-golden", + f"{case}: trace golden missing; regenerate with " + f"'python tests/test_repro_fixtures.py --write'")) + continue + with open(golden_path, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + fails.append(( + "trace-golden", + f"{case}: trace golden is stale (a capture or the trace " + f"projection changed); regenerate with 'python " + f"tests/test_repro_fixtures.py --write' and re-run the Rust " + f"side (cd rust && cargo test)")) + for orphan in sorted(_trace_goldens() - set(artifact_names)): + fails.append(( + "trace-orphan", + f"{orphan}: orphaned trace golden; remove it or list the case")) + fails += _trace_controls(plan) + fails += _trace_shape_controls(artifact_names) + + # 6b. First-divergence reduction (#260 cp4): goldens in sync, plus the + # reducer proven on a synthetic divergence and on its silence. + for case in artifact_names: + trace_path = os.path.join(FIXDIR, f"{case}.trace.json") + golden_path = os.path.join(FIXDIR, f"{case}.reduction.json") + if not os.path.exists(trace_path): + continue + with open(trace_path, encoding="utf-8") as f: + expected = render_reduction(json.load(f)) + if not os.path.exists(golden_path): + fails.append(( + "reduction-golden", + f"{case}: reduction golden missing; regenerate with " + f"'python tests/test_repro_fixtures.py --write'")) + continue + with open(golden_path, encoding="utf-8") as f: + if f.read() != expected: + fails.append(( + "reduction-golden", + f"{case}: reduction golden is stale; regenerate with " + f"'python tests/test_repro_fixtures.py --write' and re-run " + f"the Rust side (cd rust && cargo test)")) + for orphan in sorted(_reduction_goldens() - set(artifact_names)): + fails.append(( + "reduction-orphan", + f"{orphan}: orphaned reduction golden; remove it or list the case")) + fails += _reduction_controls(artifact_names) + + # 7. Negative controls for the two gates the positive checks cannot reach. + n_structural = 0 + if artifact_names and artifact_names[0] in plan: + reference = project_repro(_load(plan[artifact_names[0]][1])) + controls = _structural_controls(reference) + n_structural = STRUCTURAL_CONTROL_COUNT + fails += [("structural-control", f"{artifact_names[0]}: {f_}") for f_ in controls] + else: + fails.append(( + "structural-control", + "no artifact case available to drive the structural controls" + )) + fails += [("domain-backstop", m) for m in _domain_backstop_controls()] + + if fails: + for check, detail in fails: + print(f"FAIL[{check}]: repro fixture {detail}") + return 1 + print(f"repro (shadow-mode infrastructure, layer 0) fixtures OK: " + f"{len(plan)} documents captured and digest-pinned, " + f"{len(artifact_names)} artifacts round-tripped and verified, " + f"{n_refused_layers} refused layer envelope(s) across all captures, " + f"{len(plan)} tamper controls refused, " + f"{len(refusals)} domain-refusal controls held, " + f"{n_structural} structural + {DOMAIN_BACKSTOP_COUNT} domain-backstop " + f"controls refused, " + f"{len(artifact_names)} traces projected and normalization held over " + f"all {len(plan)} documents, " + f"{len(artifact_names)} reductions over {list(REDUCTION_SCOPE)} with " + f"6 synthetic-divergence controls named and 2 silence controls held") + return 0 + + +def write() -> int: + plan, _refusals, artifact_names, problems = _plan() + if problems: + for p in problems: + print(f"ERROR: {p}") + return 1 + with open(DIGESTS, "w", encoding="utf-8") as f: + f.write(_render_digests(plan)) + print(f"wrote {DIGESTS} ({len(plan)} documents)") + for case in artifact_names: + out = os.path.join(FIXDIR, f"{case}.repro.json") + artifact = project_repro(_load(plan[case][1]), _foreign_engines(out)) + remaining = verify_repro(artifact) + if remaining: + print(f"ERROR: {case}: refusing to write an artifact that does not " + f"verify: {remaining}") + return 1 + with open(out, "w", encoding="utf-8") as f: + f.write(json.dumps(artifact, indent=2, ensure_ascii=False) + "\n") + print(f"wrote {out}") + for case in artifact_names: + artifact_path = os.path.join(FIXDIR, f"{case}.repro.json") + with open(artifact_path, encoding="utf-8") as f: + artifact = json.load(f) + out = os.path.join(FIXDIR, f"{case}.trace.json") + traces = project_traces(artifact, case) + with open(out, "w", encoding="utf-8") as f: + f.write(render_traces(artifact, case)) + print(f"wrote {out}") + out = os.path.join(FIXDIR, f"{case}.reduction.json") + with open(out, "w", encoding="utf-8") as f: + f.write(render_reduction(traces)) + print(f"wrote {out}") + for orphan in sorted(_artifact_goldens() - set(artifact_names)): + path = os.path.join(FIXDIR, f"{orphan}.repro.json") + os.remove(path) + print(f"removed orphaned {path}") + for orphan in sorted(_trace_goldens() - set(artifact_names)): + path = os.path.join(FIXDIR, f"{orphan}.trace.json") + os.remove(path) + print(f"removed orphaned {path}") + for orphan in sorted(_reduction_goldens() - set(artifact_names)): + path = os.path.join(FIXDIR, f"{orphan}.reduction.json") + os.remove(path) + print(f"removed orphaned {path}") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + raise SystemExit(write()) + raise SystemExit(run())