Skip to content

P-022 step 7a: add dual-engine shadow mode and zero-diff reproduction artifacts #260

Description

@PhysShell

Parent: #250
Recommended tier: strong/local-agent collaboration
Blocked by: nothing — final acceptance reached (PR #343); the two owner decisions it waited on were ratified and landed (PR #342)
Preferably after: #256 (satisfied)

Checkpoint status

Per the status-drift rule in #250, this body is a status surface and moves together with docs/proposals/P-022-rust-core-migration.md (row 7a) and the proposals index. Reconciled at 4520a543e0c47a886217d065b3d80920199d4f93 (PR #343 merge) and at f48780630780b7f38f239469a1d7d0a87f30d356 (PR #344 merge, the CI re-record of the sweep).

checkpoint status
1 — same-input OwnIR capture + canonical hash, the reproduction-artifact format complete (PR #338)
2 — the engine protocol (each engine declares what it could produce) complete (PR #338)
3 — the AnalysisTrace (#269) complete (PR #338)
4 — first-divergence reduction complete (PR #338)
5a — raw-byte same-input attestation (artifact v3: input.raw, per-engine consumed, B-2/B-3) complete over the committed corpus (PR #342)
5b — the verdict layer in reduction scope; acceptance as a field under a frozen boundary policy (D-4, D-5, D-7) complete over the committed corpus (PR #342)
5c — canonical SARIF as a derived zero-diff surface (D-6) complete over the committed corpus (PR #342)
5d — the own-shadow-engine adapter and the compare driver (R-1, R-2) complete over the committed corpus (PR #342)
5e — compare mode as a CI gate over the committed corpus and the C# samples' OwnIR complete (PR #342)
final acceptance reached — the five pinned OSS repositories at their verified pins, the largest classic solution of every target that has one, the examples/ tree and the Windows path forms, recorded in docs/notes/p022-shadow-sweep.md with every count in p022-shadow-sweep.md (PR #343); the record on file has since been re-taken from a CI run of the workflow (PR #344)

Both blocking decisions are resolved. They were ratified as D-4..D-7, B-2, B-3, R-1 and R-2 and are recorded verbatim in the owner-decision ledger; the work that lands them over the committed corpus is the acceptance note.

What compare mode reports over the committed corpus: zero acceptance-unexplained at all three layers and on the derived SARIF, on byte-attested same input, with the two #294 OD-1 typed-door boundaries declared by policy. The policy has three entries — one (layer, status, OD-1) per layer, because the typed door refuses every layer at once — and the two OD-1 documents therefore produce six declared-boundary observations and nothing unexplained. Counts live only in p022-shadow-census.md and p022-shadow-mutations.md.

The C# samples gate measured rather than predicted: the OwnIR the wpf-extractor job already produces is handed to the driver through one upload-artifact handshake — no second extraction — and the one sample document compared clean. One document is a sample, not a sweep.

This issue's acceptance is reached; shadow mode is still not claimed. The test matrix below is taken in full: the committed corpus and the C# samples by the two ci.yml gates, and the five pinned OSS repositories, the large-solution controls and the examples/ tree by the recorded sweep. What compare mode reports over that matrix: zero acceptance-unexplained at all three layers and on the derived SARIF, on byte-attested same input, with the two #294 OD-1 typed-door boundaries declared by policy. It is still not "shadow mode achieved", not "P-022 done" and not "Rust is the default" — that is #262's cutover behind #261, Python remains the public engine, and no production behaviour changed.

A repository is not covered because its extraction succeeded: coverage is a recorded, non-empty set of documents fed byte-identically to both engines and judged, per target, with the denominator on the record. The driver fails a run that compared zero documents and a declared target it never reached; the denominators are in the generated fragment. The sweep is a committed definition plus one recorded run: the run names its Own.NET commit and the adapter it executed by sha256 and byte length, and tests/shadow_sweep.py refuses a run that is short, long, or measured at another commit, in another mode or by another command than the definition declares.

Taking the measurement found defects in the harnesses and none in either engine — a cross-drive relpath that killed the driver on a label, a timeout that never returned when the adapter had children, a control group that could not execute on Windows at all, and three in the mutation harness that between them meant no campaign could be recorded anywhere but Linux. Each is written out in the sweep note's §5. MaterialDesignInXamlToolkit carries only an .slnx solution at its pin, which the extractor's classic-format resolver does not read: that target is covered by its directory walk alone, and the record says so per target rather than averaging it away.

What the byte-level measurement settled (recorded in the note's §2.1 and in tests/fixtures/repro/variants/byte_variants.json, one column per engine): CRLF, trailing-newline, whitespace and key-order variants are accepted by both readers with one canonical identity and distinct raw identities; a UTF-8 BOM, invalid UTF-8 and truncated JSON are refused by both, and are negative compare-driver controls rather than artifacts.

Goal

Run the Python and Rust cores against the exact same OwnIR bytes, normalize their outputs, and fail on any unexplained difference while preserving a standalone reproduction artifact.

This is the safety gate before Rust CLI/default-engine work.

Engine modes

Provide internal/dev surfaces equivalent to:

python
rust
compare

The final flag names may differ, but semantics must not:

  • python: current reference behavior;
  • rust: Rust bridge/core only;
  • compare: extract OwnIR once, feed identical bytes to both engines, compare normalized outputs.

Python remains the public default in this issue. (Landed as scripts/shadow_compare.py --engine python|rust|compare, a dev surface, with the dev-only own-shadow-engine adapter — stdin bytes in, one capture out, no paths, no fallback.)

Same-input invariant

Compare mode must:

  1. Produce or load OwnIR exactly once.
  2. Hash the raw OwnIR bytes.
  3. Feed those exact bytes to Python and Rust.
  4. Record the hash in the diff artifact.

Running the extractor twice is forbidden because frontend nondeterminism would contaminate the comparison.

(Landed as artifact v3: input.raw carries the byte-exact input, and each engine independently records the digest and length of what it consumed; both must equal input.raw, and the raw bytes must parse back to the existing canonical identity. Every consumed claim comes from an actual run of that engine — B-2, B-3.)

Normalized comparison

Compare at least:

path
line
code
severity
message
subject
resource_kind
ordered Evidence

When #256 is complete, also compare:

canonical SARIF

(canonical .ownreport.json was listed here originally; it was struck at #256 — measured, it is a buffer-storage report needing the AST, not a diagnostics surface, and the project had already refused that shape. See #250's #256 entry. Canonical SARIF is compared as a derived zero-diff surface, not as an AnalysisTrace layer — D-6.)

Classify differences as:

  • Python-only;
  • Rust-only;
  • changed diagnostic;
  • ordering-only;
  • explicitly ignored volatile metadata;
  • unexplained.

(Landed as two orthogonal axes — observation kind and acceptance — with missing-layer as a kind, every content difference acceptance-unexplained, and status/projection explained only by an exact (layer, kind, class) entry of a frozen policy — D-5.)

Reproduction artifact

On mismatch, emit a self-contained artifact containing:

{
  "input": "...",
  "ownir_sha256": "...",
  "python_engine": "...",
  "rust_engine": "...",
  "python_only": [],
  "rust_only": [],
  "changed": [],
  "ordering_only": [],
  "ignored_metadata": [],
  "unexplained": []
}

Include the OwnIR input or a stable artifact reference sufficient to replay without the original source repository.

(A crash, timeout or non-zero exit of an engine is a run-level hard failure with a failure report — raw input, engine, exit code, signal, stderr — never a layer refusal and never a synthetic capture — R-2.)

Test matrix

Run compare mode over:

The five-repository sweep may use a local/corpus-capable agent, but its commands, commits and output artifacts must be recorded.

Reliability controls

  • Rust panic/crash is a hard failure, never a Python-fallback success.
  • Python crash is also visible and classified.
  • Timeout/cancellation behavior is explicit.
  • Ordering instability is treated as a bug.
  • Re-running the same OwnIR produces byte-identical normalized outputs.
  • Compare mode never mutates user source or caches in a way that changes the second engine's input.

CI strategy

Fast PR CI:

  • committed fixtures;
  • C# samples;
  • generated compact controls.

Scheduled/manual broader gate:

  • five pinned OSS repos;
  • large-solution controls;
  • reproduction artifacts uploaded on failure.

(The fast gate is landed as two ci.yml jobs: shadow compare (committed corpus) and shadow compare (C# samples). The scheduled/manual gate is .github/workflows/shadow-sweep.yml (PR #343): one leg per document, the pin verified before anything runs, one extraction per document, OWN_SHADOW_ENGINE always explicit and the adapter named by digest, and an aggregation that checks the assembled run against the committed sweep definition rather than trusting the legs' ticks. It had not executed when this issue closed — a workflow_dispatch workflow is dispatchable only from the default branch — so the first record was a local one, which this issue allows. It has since executed, and the record on file is a CI run of the workflow (PR #344): workflow_run_url names it, every leg ran on GitHub-hosted runners, and each leg names its document by id rather than by a runner path — the workflow's very first execution had recorded the runner's temp path as every document's source, a constant dressed as provenance, and was superseded without being committed.)

Guardrails

  • No Rust-default cutover.
  • No removal of Python.
  • No new diagnostics.
  • No separate extraction per engine.
  • No normalization that deletes semantic fields.
  • No automatic acceptance of a diff because one engine produced fewer findings.

Tails carried here from #259's final-acceptance review

Three of the four are closed by PR #342: one pub(crate) tolerant line reader in own-bridge (with p022-coord-2 re-anchored and re-run); the §3.2 diff-stat label in docs/notes/p022-bridge-verdict-final-acceptance.md; and scripts/mutate_campaign.py --validate resolving every named catcher, refusing a Python mutation that does not parse, with the re-run rule written into the acceptance note.

The fourth closed in PR #343: the header of docs/notes/p022-bridge-verdict-final-acceptance.md now reads final acceptance reached, as its §7 records.

The #342 review's suggestion landed in PR #343: every result and failure report names the adapter by sha256 and byte length as well as by path, taken from the file that ran (shadow_compare_version 2), so a stale build can never stand in for the engine that was meant. The digest names the build, not the port's source — measured: two release builds of one unchanged tree gave two digests at one byte length — which is why the record carries the set of adapters a run executed.

Tails carried out of this issue, to #250

None blocks the acceptance; each is small and named so it is not assumed done:

  • the sweep evidence carries no dirty bit — the driver records HEAD only; the run summary, the collector and the interpreter should carry and require one, as the campaign harness already does;
  • tests/shadow_sweep.py does not verify the record's definition_sha256 against the definition's bytes; it should, as the campaign renderer does for a campaign's definition;
  • byte-sensitive evidence depends on the operator's checkout settings (core.autocrlf changes the on-disk digest of a definition without changing a character): a researched .gitattributes for docs/evidence, docs/generated and tests/fixtures, never an operator's global configuration;
  • derived-status hygiene: the P-022 row 7a and the proposals index carry definition-level counts in prose;
  • (closed by PR docs(p022): re-record the #260 sweep from a CI run of its workflow #344) re-record the sweep from a CI run of .github/workflows/shadow-sweep.yml — done: the record on file is a CI run with its workflow_run_url, and each leg names its document by id rather than by a runner path;
  • the .slnx solution format is an extractor gap outside P-022;
  • process: work committed from a local machine enters the tree under a repository identity (repo-local git config), and a pre-push report compares recorded artifacts and commit metadata as bytes, never as console text.

Acceptance

  • Compare mode feeds identical OwnIR bytes to both engines — read once, as bytes, no engine ever sees a path; attested by input.raw against each engine's consumed.
  • Fast CI reports zero unexplained differences — shadow compare (committed corpus) and shadow compare (C# samples) in ci.yml.
  • Five-repository sweep reports zero unexplained differences — every document of the committed sweep definition, each repository at its verified pin, recorded in docs/evidence/p022-shadow-sweep.result.json and rendered to docs/generated/p022-shadow-sweep.md; the scheduled/manual gate is .github/workflows/shadow-sweep.yml.
  • Mismatches produce replayable artifacts — artifact v3 on divergence, a failure report on an execution failure, both naming the adapter by sha256.
  • Rust crashes cannot be hidden by fallback — a crash, timeout or non-zero exit is a run-level hard failure (R-2), and the driver has no path to the reference's answer.
  • Python remains the default engine.
  • Commands and normalization policy are documented — docs/notes/p022-shadow-sweep.md, docs/notes/p022-shadow-acceptance.md, spec/Bridge.md §6, and the commands carried as data in docs/evidence/p022-shadow-sweep.json.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions