Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
python-version: "3.12"
- name: Dependency-free v3 fixture contract
run: python -I tests/fixture_contract/test_v3_fixture_contract.py
- name: Dependency-free ADR 0007 action-snapshot fixture contract
run: python -I tests/test_adr0007_action_fixture.py

web:
runs-on: ubuntu-latest
Expand Down
10 changes: 10 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,16 @@ not a v4 owner or release gate. See ADR 0007.
legacy-oracle commit. **P4.D1 remains open:** the redistributable fixture and its
owner-selected license have not yet been published, so the shipped verifier foundation
alone does not complete the slice.
- **ADR 0007 action-snapshot evidence overlay IMPLEMENTED (2026-07-18; pending
merge):** DesignRNG independently authors a two-entity test overlay for the
Arms NG action-lifecycle GameTest. The public JSON-RPC surface reproduces
`load -> validate -> export -> reload -> validate`, then pins the exact
two-file canonical runtime diff and its hashes. The overlay's shotgun barrel
forces the runtime projectile path; its `while_ads` affix changes damage,
reload duration, and capacity. A dependency-free contract runs in required
CI, while the full exporter reproduction remains a local/sidecar integration
gate. This artifact selects no external-content license and explicitly does
**not** complete P4.D1.
- **P2.3a handling mirror IMPLEMENTED (2026-07-16; rebased onto canonical identifiers
2026-07-18; pending independent grill):** Arms NG `dfb76e5` is the shipped/green consumer.
DesignRNG now owns native part ordering, binary32 handling composition, hard
Expand Down
57 changes: 57 additions & 0 deletions docs/evidence/2026-07-18-adr0007-action-snapshot-fixture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# ADR 0007 action-snapshot evidence overlay

Date: 2026-07-18

Status: implementation complete; merge and consumer pin pending.

## Scope

DesignRNG independently owns the source overlay under
`tests/fixtures/adr0007_action_snapshot/source_overlay`. It adds:

- `gunrng:focused_cycle`, gated by `gunrng:while_ads`, with
`damage_mult=2`, `reload_time_mult=0.25`, and `magazine_size_add=4`; and
- `gunrng:action_snapshot_barrel`, compatible with the demo shotgun, with
zero authored spread multiplier and the runtime bullet-entity selector.

The existing Arms NG demo pack is a test dependency only. It is not copied
into this repository and is not part of DesignRNG's authorship claim.

## Reproduction

`python scripts/verify_adr0007_action_fixture.py` stages the pinned base twice,
applies the source overlay to one copy, and drives only DesignRNG's public
JSON-RPC methods:

`load -> validate -> export -> load exported pack -> validate`

The canonical export diff contains exactly:

- `affix_pools/action_snapshot_pool.json`
- `gun_parts/barrel/action_snapshot_barrel.json`

Measured runtime-overlay hash:

`14b1d008e0aad06500fab3b1d086e23b05432c190c790c8ef4a44a055bf1f90a`

Both baseline and candidate validate with zero warnings. The committed
runtime bytes and manifest reproduce without `--bless`; a byte or manifest
mutation makes the verifier/contract fail.

## Gates

- dependency-free fixture contract: 3/3
- root pytest fixture contract: 3/3
- public-sidecar reproduction: PASS
- sidecar pytest selection: 39/39
- web CI-equivalent: typecheck, lint, 607/607 Vitest, production build
- dependency-free v3 fixture contract: 20 tests, 2 platform skips

The dependency-free overlay contract is an always-on GitHub CI step. Full
sidecar reproduction remains an integration gate because the transitional v3
sidecar still requires sibling compatibility inputs.

## Explicit non-claim

This fixture chooses no license for external content and is not the licensed,
redistributable P4.D1 fixture-manifest artifact. P4.D1 remains open.
291 changes: 291 additions & 0 deletions scripts/verify_adr0007_action_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
"""Reproduce the ADR 0007 action-snapshot runtime overlay via DesignRNG.

This verifier talks only to DesignRNG's public JSON-RPC sidecar surface. It
stages the pinned Arms NG demo pack twice, applies the independently authored
source overlay to one copy, then runs load -> validate -> export -> reload ->
validate for both. The byte-diff between the two canonical exports is the
small runtime overlay consumed by the dedicated Arms NG GameTest smoke.

Usage:
python scripts/verify_adr0007_action_fixture.py
python scripts/verify_adr0007_action_fixture.py --base-pack <path>
python scripts/verify_adr0007_action_fixture.py --bless
"""

from __future__ import annotations

import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "tests" / "fixtures" / "adr0007_action_snapshot"
SOURCE_OVERLAY = FIXTURE / "source_overlay"
RUNTIME_OVERLAY = FIXTURE / "runtime_overlay"
MANIFEST = FIXTURE / "fixture-manifest.json"
EXPORT_MARKER = ".designrng-export.json"
EXPECTED_RUNTIME_FILES = (
"affix_pools/action_snapshot_pool.json",
"gun_parts/barrel/action_snapshot_barrel.json",
)


def tree_files(root: Path, *, exclude: set[str] | None = None) -> dict[str, bytes]:
excluded = exclude or set()
return {
path.relative_to(root).as_posix(): path.read_bytes()
for path in sorted(root.rglob("*"))
if path.is_file() and path.relative_to(root).as_posix() not in excluded
}


def tree_sha256(files: dict[str, bytes]) -> str:
"""DesignRNG's sorted, length-delimited regular-file content hash."""
digest = hashlib.sha256()
for relative, content in sorted(files.items()):
name = relative.encode("utf-8")
digest.update(len(name).to_bytes(8, "big"))
digest.update(name)
digest.update(len(content).to_bytes(8, "big"))
digest.update(content)
return digest.hexdigest()


class Sidecar:
def __init__(self) -> None:
self._next_id = 1
self._process = subprocess.Popen(
[sys.executable, str(ROOT / "sidecar" / "server.py")],
cwd=ROOT,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
)

def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
assert self._process.stdin is not None
assert self._process.stdout is not None
request_id = self._next_id
self._next_id += 1
self._process.stdin.write(
json.dumps({"id": request_id, "method": method, "params": params or {}})
+ "\n"
)
self._process.stdin.flush()
line = self._process.stdout.readline()
if not line:
assert self._process.stderr is not None
stderr = self._process.stderr.read()
raise RuntimeError(f"DesignRNG sidecar exited without a response: {stderr}")
response = json.loads(line)
if response.get("id") != request_id:
raise RuntimeError(f"DesignRNG sidecar response id mismatch: {response}")
if "error" in response:
raise RuntimeError(
f"DesignRNG sidecar {method} failed: {response['error'].get('message')}"
)
return response["result"]

def close(self) -> None:
if self._process.stdin is not None:
self._process.stdin.close()
try:
self._process.wait(timeout=10)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=5)

def __enter__(self) -> "Sidecar":
return self

def __exit__(self, *_: object) -> None:
self.close()


def validate_and_export(source: Path, destination: Path) -> dict[str, Any]:
with Sidecar() as sidecar:
loaded = sidecar.call("load", {"packPath": str(source)})
session = loaded["session"]
validation = sidecar.call("validate", {"expectedSession": session})
if validation.get("exportBlocked") is not False:
raise RuntimeError(
f"DesignRNG validation blocked {source}: {validation.get('issues')}"
)
exported = sidecar.call(
"export",
{
"destPath": str(destination),
"expectedSession": session,
"confirmOverwrite": False,
},
)
if exported.get("success") is not True:
raise RuntimeError(
f"DesignRNG export failed for {source}: {exported.get('errors')}"
)
reloaded = sidecar.call("load", {"packPath": str(destination)})
reload_validation = sidecar.call(
"validate", {"expectedSession": reloaded["session"]}
)
if reload_validation.get("exportBlocked") is not False:
raise RuntimeError(
"DesignRNG rejected its own canonical export: "
f"{reload_validation.get('issues')}"
)
return {
"sourceSession": session,
"validation": validation,
"export": exported,
"reloadValidation": reload_validation,
}


def copy_source_base(source: Path, destination: Path) -> None:
shutil.copytree(
source,
destination,
ignore=shutil.ignore_patterns("PROVENANCE.md", EXPORT_MARKER),
)


def apply_overlay(overlay: Path, destination: Path) -> None:
for relative, content in tree_files(overlay).items():
target = destination / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)


def git_head() -> str:
return subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
).stdout.strip()


def write_runtime_overlay(files: dict[str, bytes]) -> None:
if RUNTIME_OVERLAY.exists():
shutil.rmtree(RUNTIME_OVERLAY)
for relative, content in files.items():
target = RUNTIME_OVERLAY / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--base-pack",
type=Path,
default=ROOT.parent / "Arms NG" / "demo_pack",
help="path to the Arms NG demo_pack dependency",
)
parser.add_argument(
"--bless",
action="store_true",
help="replace the runtime overlay and its fixture-local hash manifest",
)
args = parser.parse_args()
base_pack = args.base_pack.resolve()
if not (base_pack / "manifest.json").is_file():
raise SystemExit(f"base pack is missing manifest.json: {base_pack}")
if not SOURCE_OVERLAY.is_dir():
raise SystemExit(f"source overlay is missing: {SOURCE_OVERLAY}")

with tempfile.TemporaryDirectory(prefix="designrng-adr0007-") as temporary:
temp = Path(temporary)
baseline_source = temp / "baseline-source"
candidate_source = temp / "candidate-source"
baseline_export = temp / "baseline-export"
candidate_export = temp / "candidate-export"
copy_source_base(base_pack, baseline_source)
copy_source_base(base_pack, candidate_source)
apply_overlay(SOURCE_OVERLAY, candidate_source)

baseline_report = validate_and_export(baseline_source, baseline_export)
candidate_report = validate_and_export(candidate_source, candidate_export)
baseline_files = tree_files(baseline_export, exclude={EXPORT_MARKER})
candidate_files = tree_files(candidate_export, exclude={EXPORT_MARKER})
changed = {
relative: content
for relative, content in candidate_files.items()
if baseline_files.get(relative) != content
}
removed = sorted(set(baseline_files) - set(candidate_files))
if removed:
raise RuntimeError(f"the source overlay unexpectedly removes runtime files: {removed}")
if tuple(sorted(changed)) != EXPECTED_RUNTIME_FILES:
raise RuntimeError(
"runtime overlay surface drifted: "
f"expected {EXPECTED_RUNTIME_FILES}, got {tuple(sorted(changed))}"
)

values = {
"format": "designrng-adr0007-test-fixture",
"version": 1,
"basePack": {
"id": "gunrng_demo_pack",
"treeSha256": tree_sha256(tree_files(baseline_source)),
},
"sourceOverlaySha256": tree_sha256(tree_files(SOURCE_OVERLAY)),
"runtimeOverlaySha256": tree_sha256(changed),
"baselineExportSha256": tree_sha256(baseline_files),
"composedExportSha256": tree_sha256(candidate_files),
"exportedWithDesignRngCommit": git_head(),
"runtimeFiles": list(EXPECTED_RUNTIME_FILES),
}

if args.bless:
print("BLESS: replacing ADR 0007 runtime overlay and fixture-local hashes")
write_runtime_overlay(changed)
MANIFEST.write_text(
json.dumps(values, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
else:
expected = json.loads(MANIFEST.read_text(encoding="utf-8"))
committed_runtime = tree_files(RUNTIME_OVERLAY)
if committed_runtime != changed:
raise RuntimeError(
"committed runtime overlay differs from the public-sidecar export; "
"review the diff and use --bless intentionally"
)
# The exporter commit is historical evidence, not a requirement to
# stay on an old checkout. Every other field must reproduce now.
current_commit = values.pop("exportedWithDesignRngCommit")
recorded_commit = expected.pop("exportedWithDesignRngCommit")
if expected != values:
raise RuntimeError(
"fixture manifest does not match the reproduced hashes:\n"
f"expected={json.dumps(expected, indent=2)}\n"
f"actual={json.dumps(values, indent=2)}"
)
print(
"DesignRNG exporter checkout: "
f"recorded={recorded_commit}, current={current_commit}"
)

print(
"ADR 0007 fixture PASS: "
f"baseline warnings={len(baseline_report['validation'].get('warnings', []))}, "
f"candidate warnings={len(candidate_report['validation'].get('warnings', []))}, "
f"runtime files={len(changed)}, "
f"runtime sha256={values['runtimeOverlaySha256']}"
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
17 changes: 17 additions & 0 deletions tests/fixtures/adr0007_action_snapshot/PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# ADR 0007 action-snapshot test overlay provenance

The files under `source_overlay/` are independently authored test data owned
by the DesignRNG project. They add one conditional affix and one test-only gun
part to the pinned Arms NG demo-pack fixture. The pre-existing demo pack is a
test dependency and is not included in this repository or in this authorship
claim.

`runtime_overlay/` is generated exclusively through DesignRNG's public
sidecar load → validate → export surface by
`scripts/verify_adr0007_action_fixture.py`. The script exports both the clean
base fixture and the overlaid fixture, then retains only their two-file
canonical runtime diff. `fixture-manifest.json` uses a fixture-local hash
format; it is not the P4.D1 licensed fixture-manifest contract.

This test data does not choose or assert a license for any external content.
P4.D1 remains open and is not claimed, implemented, or closed by this fixture.
Loading
Loading