From 8dfb1189c13f0fce99901e1ffbba152d813f9006 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 15:53:20 -0700 Subject: [PATCH] feat(ground-study): persist seeded variation requests (#4273) --- AGENT_HANDOFF.md | 39 +++ SPEC.md | 24 +- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 28 ++ docs/release/rate_of_closure_campaign.v1.json | 8 +- src/rate_of_closure/AGENT_HANDOFF.md | 31 +++ src/rate_of_closure/application/__init__.py | 16 ++ .../regional_ground_variation_request.py | 262 ++++++++++++++++++ ...t_regional_ground_variation_persistence.py | 232 ++++++++++++++++ 8 files changed, 636 insertions(+), 4 deletions(-) create mode 100644 src/rate_of_closure/application/regional_ground_variation_request.py create mode 100644 tests/rate_of_closure/test_regional_ground_variation_persistence.py diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index bf4016cb0..86b660833 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,5 +1,44 @@ # AGENT_HANDOFF — Tools +## 2026-08-11 local #4273 seeded-request persistence + +The unpublished `codex/4273-ground-variation-persistence` branch starts from +exact published PR #4365 documentation head +`27d2a68d3738d61307af9235f3f97f7bd400e0f3`. A read-only persistence audit +found a clean composition seam: the existing immutable seeded request, +`VariationPlan` and regional-plan object serializers, shared canonical numeric +JSON, duplicate-key-rejecting ground parser, bounded UTF-8 snapshot reader, and +native atomic text writer. No alternate storage or physics contract was needed. + +The new UI-neutral v1 envelope persists the exact variation plan, exact +regional material plan, result/source/series identifiers, and row cap. It has a +1 MiB UTF-8 bound and deterministic compact canonical text suitable for the +existing browser-download model. Import requires exact fields and current +schema versions before delegating nested objects to their existing parsers. +Duplicate fields, nonfinite or cross-runtime-unsafe numbers, Boolean numeric +substitutes, surrogate text, malformed identifiers/caps, oversized documents, +and invalid nested plans fail closed. Import registers the two Rate extension +variables explicitly; merely importing the module still does not mutate the +shared registry. + +Native reads take one sentinel-bounded strict UTF-8 snapshot. Writes serialize +and validate before reusing the existing flush/fsync/atomic-replace seam; +cancellation is a no-op and a failed replacement preserves the last-known-good +file. No UI, browser filesystem claim, or physics execution is added. + +RED captured the missing persistence module. Twenty-two focused tests and 82 +composition tests pass. The relevant Rate adapter/file plus complete shared +flight/ground/variation selection passes 545 tests with six expected +missing-Rust-wheel skips and one environment-only Hypothesis warning. Ruff, +import-skipping MyPy, Bandit, campaign-manifest validation and its eight tests, +documentation governance, blocking-quality, minimum-test, module-size, +changed-test assertion, placeholder, structural, and diff gates are green. + +This is not a #4273/#4267 completion claim. UI/editor integration, workspace +embedding, regional-overlay variation, solver/capability consumption, wind, +compiled/downstream parity, protected review, publication, and release remain +open. No branch was pushed and no GitHub state was changed. + ## 2026-08-11 PR #4365 seeded regional-ground material variation Ready PR [#4365](https://github.com/D-sorganization/Tools/pull/4365) is stacked diff --git a/SPEC.md b/SPEC.md index e8e542a51..62c1da886 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,8 +26,8 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.14.56 | -| **Spec Version** | 1.14.56 | +| **Current Version** | 1.14.57 | +| **Spec Version** | 1.14.57 | | **Last Spec Update** | 2026-08-11 | ## 2. Purpose & Mission @@ -46,6 +46,26 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific ## 3. Goals & Non-Goals +### 2026-08-11 Seeded Regional-Ground Request Persistence + +- A strict v1 application envelope stores the existing immutable seeded + variation request: current `VariationPlan`, exact regional material plan, + result/source/optional-series identifiers, and maximum output rows. It does + not define another simulation or storage model. +- Deterministic compact canonical JSON enforces cross-runtime-safe numbers, + Unicode scalar text, exact nested shapes, duplicate-key rejection, current + schema versions, and a 1 MiB UTF-8 limit. The same text is suitable for + browser downloads without claiming browser atomic replacement. +- Native files reuse the shared sentinel-bounded UTF-8 reader and + flush/fsync/atomic-replace writer. Cancellation changes nothing, and failed + replacement preserves the prior file. +- Loading explicitly registers the two Rate-owned ground variables before the + existing variation parser runs, constructs the existing exact request, and + never executes physics. +- This slice excludes UI/editor wiring, workspace embedding, regional-overlay + variation, solver/capability use, wind, compiled/downstream parity, protected + release, and issue/epic completion. + ### 2026-08-11 Seeded Regional-Ground Material Variation - A UI-neutral adapter samples base-surface normal restitution and rolling diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 221a1f741..3659d6636 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,33 @@ # Rate of Closure Ball-Flight Campaign Handoff +## 2026-08-11 local #4273 seeded-request persistence + +- The unpublished `codex/4273-ground-variation-persistence` branch starts from + exact published PR #4365 docs head + `27d2a68d3738d61307af9235f3f97f7bd400e0f3`. Audit confirmed that the + immutable seeded request, existing nested serializers, canonical safe-number + JSON, strict duplicate-key parser, bounded reader, and atomic writer form a + complete persistence seam without a parallel schema or storage mechanism. +- A UI-neutral v1 envelope persists the exact variation and regional plans, + result/source/series identities, and row cap as deterministic compact JSON. + The 1 MiB UTF-8 contract is portable to existing browser-download behavior; + native reads and writes reuse sentinel-bounded snapshots and atomic replace. +- Exact field and current-version checks precede existing nested parsers. + Duplicate keys, unsafe/nonfinite/Boolean numbers, surrogate text, malformed + identities/caps, invalid nested contracts, and oversize payloads fail closed. + Parsing explicitly registers the Rate ground variables and never runs + physics. +- RED captured the absent module. Twenty-two focused, 82 composition, and 545 + relevant Rate/shared tests pass. The broad run has six expected missing-Rust- + wheel skips and one environment-only warning. Ruff, import-skipping MyPy, + Bandit, campaign manifest and eight tests, documentation, blocking-quality, + minimum-test, module-size, changed-test assertion, placeholder, structural, + and diff gates pass. +- This candidate is local and unpushed. UI/editor wiring, workspace embedding, + browser filesystem claims, overlay variation, solver/capability use, wind, + compiled/downstream parity, protected review, publication, and release remain + open; #4273/#4267 are not complete. + ## 2026-08-11 PR #4365 seeded regional-ground material variation - Ready PR [#4365](https://github.com/D-sorganization/Tools/pull/4365) starts diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json index d39147c09..b0f942846 100644 --- a/docs/release/rate_of_closure_campaign.v1.json +++ b/docs/release/rate_of_closure_campaign.v1.json @@ -1390,6 +1390,10 @@ "kind": "repository_path", "value": "src/rate_of_closure/variation/regional_ground_variation.py" }, + { + "kind": "repository_path", + "value": "src/rate_of_closure/application/regional_ground_variation_request.py" + }, { "kind": "repository_path", "value": "src/rate_of_closure/simulation/ground_playback.py" @@ -1443,12 +1447,12 @@ "ground-regional-ui-helper-local-d217", "ground-regional-io-local-8e1c" ], - "evidence_gap": "The contract, flight-transfer, impact/bounce, static-plane skid/roll, regional-surface, regional-plan wire, regional-editor, regional request-I/O, regional execution, evidence-readback, complete-result, ledger-inspection, trajectory-export, repeated-bounce result-wire, repeated-bounce request-wire, UI-neutral repeated-bounce execution, flight-to-bounce composition, full flight-through-regional-ground composition, qualified study-adapter, matched ground-playback, post-ground target, and seeded base-material variation carriers remain open on PRs 4285, 4288, 4302, 4304, 4332, 4335, 4339, 4342, 4350, 4351, 4352, 4353, 4354, 4355, 4356, 4357, 4359, 4360, 4361, 4363, 4364, and 4365. PR 4361 adds a UI-neutral complete-rest-only adapter into existing flight metrics and scalar ensembles while retaining partial, censored, cancelled, failed, non-settled, missing-summary, and transfer-failure outcomes as typed nullable rows. PR 4363 adds matched import-only PyQt6 and React playback with phase-safe absolute time, bounded presentation, and no physics. PR 4364 maps only qualified final x/z through the sole target frame, substitutes declared surface elevation once, reuses SpatialTarget miss geometry, and emits bounded typed target rows. PR 4365 varies only base normal restitution and rolling resistance with deterministic plan-bound provenance and qualified nullable scalar outputs. Fresh protected evidence, regional-overlay variation, solver/capability invocation, variation UI, UI pipeline invocation, direct editor handoff, persistence, workspace model-input persistence, measured calibration, changing geometry, rendered visual QA, camera presets/tracking, compiled-runtime physics, and downstream parity remain absent.", + "evidence_gap": "The contract, flight-transfer, impact/bounce, static-plane skid/roll, regional-surface, regional-plan wire, regional-editor, regional request-I/O, regional execution, evidence-readback, complete-result, ledger-inspection, trajectory-export, repeated-bounce result-wire, repeated-bounce request-wire, UI-neutral repeated-bounce execution, flight-to-bounce composition, full flight-through-regional-ground composition, qualified study-adapter, matched ground-playback, post-ground target, and seeded base-material variation carriers remain open on PRs 4285, 4288, 4302, 4304, 4332, 4335, 4339, 4342, 4350, 4351, 4352, 4353, 4354, 4355, 4356, 4357, 4359, 4360, 4361, 4363, 4364, and 4365. PR 4361 adds a UI-neutral complete-rest-only adapter into existing flight metrics and scalar ensembles while retaining partial, censored, cancelled, failed, non-settled, missing-summary, and transfer-failure outcomes as typed nullable rows. PR 4363 adds matched import-only PyQt6 and React playback with phase-safe absolute time, bounded presentation, and no physics. PR 4364 maps only qualified final x/z through the sole target frame, substitutes declared surface elevation once, reuses SpatialTarget miss geometry, and emits bounded typed target rows. PR 4365 varies only base normal restitution and rolling resistance with deterministic plan-bound provenance and qualified nullable scalar outputs. An unpublished exact-#4365 local child adds strict canonical 1 MiB seeded-request JSON plus bounded native atomic persistence, but has no UI, browser file operation, carrier, or protected evidence. Fresh protected evidence, regional-overlay variation, solver/capability invocation, variation UI, UI pipeline invocation, direct editor handoff, workspace embedding, measured calibration, changing geometry, rendered visual QA, camera presets/tracking, compiled-runtime physics, and downstream parity remain absent.", "limitations": [ "Airborne flight must first terminate against physical terrain and preserve terminal angular velocity.", "Current UpstreamDrift terrain and putting implementations are reference inputs rather than the qualified authority.", "The #4270 rigid impact law does not consume firmness, grass, compressibility, moisture, or rolling-resistance fields.", - "Published open PR #4365 supports seeded variation only on the base surface; regional overlays, UI, persistence, and solver/capability consumption remain absent.", + "Published open PR #4365 supports seeded variation only on the base surface; an unpublished child adds canonical request persistence only, while regional overlays, UI, workspace embedding, browser file operations, and solver/capability consumption remain absent.", "Published open PR #4304 qualifies one immutable planar profile and optional finite tangent-axis edge; open PR #4332 adds bounded coplanar material overlays; open PR #4335 adds a strict Python/TypeScript plan wire boundary and Python resolver; open PR #4339 adds matched PyQt6/React request editors; open PR #4342 adds editor-qualified canonical request import/export with native atomic semantics and explicit browser limitations; open PR #4350 adds UI-neutral Python regional execution plus an embedded-plan-bound result/provenance envelope with validated transition crossings; open PR #4351 adds matched strict plan-bound evidence import/readback; open PR #4352 completes all frozen summary/result fields, warnings, calibration, provider identity, and observed-phase readback; open PR #4353 adds bounded ground-event and regional-transition ledger inspection; open PR #4354 adds bounded raw trajectory inspection plus native-atomic and browser-download canonical evidence export; open PR #4355 adds strict import-only Python/TypeScript RepeatedBounceResult evidence serialization and parsing with exact nested invariants, a 1 MiB bound, and canonical SHA parity; open PR #4356 adds the strict paired request envelope; open PR #4357 adds the UI-neutral Python request-to-solver binding; open PR #4359 adds strict composition from qualified flight output through the existing transfer and request boundaries to that executor; open PR #4360 composes the existing flight, bounce, regional skid/roll, and complete-result authorities behind one strict non-fabricating evidence contract; open PR #4361 adds a complete-rest-only Python adapter into existing metric and scalar-ensemble contracts without promoting censored numeric endpoints; open PR #4363 adds strict phase-safe import-only playback to matched PyQt6/React workspaces; and open PR #4364 evaluates only qualified landing targets while retaining typed nulls for aerial and every unqualified outcome. Changing normals, height or velocity discontinuities, terrain deformation, torsional-spin damping, roll-to-skid transitions, direct editor handoff, persistence, aerial target trajectory evaluation, measured calibration, workspace model-input persistence, rendered visual QA, camera presets/tracking, compiled or TypeScript regional physics, solver/capability invocation, variation UI, wind strategy, and downstream surface adapters remain unimplemented." ], "depends_on_issues": [ diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 1d5eca89a..3c2f8b23e 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,5 +1,36 @@ # AGENT_HANDOFF — rate_of_closure +## 2026-08-11 local #4273 seeded-request persistence + +The unpublished `codex/4273-ground-variation-persistence` child starts from +exact PR #4365 docs head `27d2a68d3738d61307af9235f3f97f7bd400e0f3`. +The new application-layer contract composes existing authorities into one +strict v1 seeded-study request envelope: exact `VariationPlan`, exact regional +plan, result/source/series identifiers, and the bounded row cap. Canonical +numeric JSON is compact, deterministic, cross-runtime safe, and suitable for a +browser download; native persistence reuses the existing bounded UTF-8 reader +and atomic writer. + +Import rejects duplicate or unknown fields, unsupported outer or nested +versions, nonfinite/unsafe/Boolean numbers, surrogate text, malformed +identifiers and caps, invalid nested contracts, and payloads above 1 MiB. It +explicitly registers the Rate ground variables only when parsing a request. +Successful import returns the existing immutable request and executes no +physics. Native cancellation is a no-op; failed replacement retains the prior +file and removes its temporary file. + +RED captured the absent module. Twenty-two focused tests, 82 composition tests, +and 545 relevant Rate/shared flight-ground-variation tests pass; the broad set +has six expected missing-Rust-wheel skips and one environment warning. Ruff, +import-skipping MyPy, Bandit, campaign manifest and its eight tests, +documentation, blocking-quality, minimum-test, module-size, changed-test +assertion, placeholder, structural, and diff gates pass. + +No UI/editor, browser filesystem behavior, workspace embedding, overlay +variation, solver/capability invocation, wind, compiled runtime, downstream +parity, protected review, publication, or release is included. Keep #4273 and +#4267 open. This branch has not been pushed. + ## 2026-08-11 PR #4365 seeded regional-ground material variation Ready PR [#4365](https://github.com/D-sorganization/Tools/pull/4365) is stacked diff --git a/src/rate_of_closure/application/__init__.py b/src/rate_of_closure/application/__init__.py index cf3ca435d..0f609feb4 100644 --- a/src/rate_of_closure/application/__init__.py +++ b/src/rate_of_closure/application/__init__.py @@ -6,6 +6,15 @@ CommandAvailability, CommandUnavailableError, ) +from .regional_ground_variation_request import ( + MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES, + REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA, + REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA_VERSION, + read_regional_ground_variation_request, + regional_ground_variation_request_from_json, + regional_ground_variation_request_to_json, + write_regional_ground_variation_request_atomic, +) from .regional_surface_plan_files import ( read_regional_surface_plan_request, write_regional_surface_plan_request_atomic, @@ -24,6 +33,9 @@ __all__ = [ "APP_COMMAND_IDS", + "MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES", + "REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA", + "REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA_VERSION", "WORKSPACE_SCHEMA", "WORKSPACE_SCHEMA_VERSION", "AppCommandId", @@ -34,9 +46,13 @@ "WorkspaceLayout", "WorkspaceMetadata", "read_workspace", + "read_regional_ground_variation_request", "read_regional_surface_plan_request", "workspace_from_json", "workspace_to_json", + "regional_ground_variation_request_from_json", + "regional_ground_variation_request_to_json", "write_workspace_atomic", "write_regional_surface_plan_request_atomic", + "write_regional_ground_variation_request_atomic", ] diff --git a/src/rate_of_closure/application/regional_ground_variation_request.py b/src/rate_of_closure/application/regional_ground_variation_request.py new file mode 100644 index 000000000..bb3733b17 --- /dev/null +++ b/src/rate_of_closure/application/regional_ground_variation_request.py @@ -0,0 +1,262 @@ +"""Canonical bounded persistence for seeded regional-ground variation requests.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any, cast + +from rate_of_closure.variation.regional_ground_variation import ( + GroundRegionalVariationRequest, + register_ground_variation_variables, +) +from shared.python.swing_sim.canonical_numeric_json import canonical_numeric_json +from shared.python.swing_sim.ground.regional_plan_wire import ( + regional_material_plan_request_from_dict, +) +from shared.python.swing_sim.ground.strict_json import strict_json_object +from shared.python.swing_sim.variation.spec import SCHEMA_VERSION, VariationPlan + +from ._workspace_validation import exact_mapping +from .atomic_text_files import write_utf8_text_atomic +from .bounded_text_files import read_bounded_utf8 + +REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA = ( + "rate-of-closure/regional-ground-variation-request/v1" +) +REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA_VERSION = 1 +MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES = 1_048_576 + +_ROOT_FIELDS = frozenset( + { + "schema", + "schema_version", + "variation_plan", + "regional_plan", + "result_id", + "source_provenance", + "max_rows", + "series_id", + } +) +_VARIATION_FIELDS = frozenset( + { + "schema_version", + "mode", + "base_variables", + "noise", + "n_runs", + "seed", + "flight_model", + "groups", + } +) +_NOISE_FIELDS = frozenset( + { + "variable_key", + "distribution", + "scale", + "lower", + "upper", + "spec_id", + "time_window_s", + "point_ids", + } +) +_GROUP_FIELDS = frozenset({"group_id", "spec_ids", "matrix_kind", "matrix"}) + + +def _sequence(value: object, name: str) -> Sequence[object]: + if not isinstance(value, list): + raise TypeError(f"{name} must be a JSON array") + return value + + +def _text(value: object, name: str, *, nullable: bool = False) -> str | None: + if nullable and value is None: + return None + if type(value) is not str or not value.strip(): + raise ValueError(f"{name} must be nonblank text") + return value + + +def _number(value: object, name: str, *, nullable: bool = False) -> None: + if nullable and value is None: + return + if type(value) not in (int, float): + raise TypeError(f"{name} must be a JSON number") + if not math.isfinite(cast(int | float, value)): + raise ValueError(f"{name} must be finite") + + +def _integer(value: object, name: str, *, nonnegative: bool = False) -> int: + if type(value) is not int: + raise TypeError(f"{name} must be an integer") + if nonnegative and value < 0: + raise ValueError(f"{name} must be nonnegative") + return value + + +def _noise_shape(value: object, index: int) -> None: + name = f"variation_plan noise[{index}]" + data = exact_mapping(value, _NOISE_FIELDS, name) + for field in ("variable_key", "distribution", "spec_id"): + _text(data[field], f"{name} {field}") + _number(data["scale"], f"{name} scale") + _number(data["lower"], f"{name} lower", nullable=True) + _number(data["upper"], f"{name} upper", nullable=True) + window = data["time_window_s"] + if window is not None: + values = _sequence(window, f"{name} time_window_s") + if len(values) != 2: + raise ValueError(f"{name} time_window_s must contain two numbers") + for item in values: + _number(item, f"{name} time_window_s") + for point in _sequence(data["point_ids"], f"{name} point_ids"): + _text(point, f"{name} point_id") + + +def _group_shape(value: object, index: int) -> None: + name = f"variation_plan groups[{index}]" + data = exact_mapping(value, _GROUP_FIELDS, name) + _text(data["group_id"], f"{name} group_id") + _text(data["matrix_kind"], f"{name} matrix_kind") + for spec_id in _sequence(data["spec_ids"], f"{name} spec_ids"): + _text(spec_id, f"{name} spec_id") + for row in _sequence(data["matrix"], f"{name} matrix"): + for item in _sequence(row, f"{name} matrix row"): + _number(item, f"{name} matrix value") + + +def _base_shape(value: object) -> None: + if not isinstance(value, Mapping): + raise TypeError("variation_plan base_variables must be a JSON object") + for key, item in value.items(): + _text(key, "variation_plan base_variables key") + _number(item, f"variation_plan base_variables[{key!r}]") + + +def _variation_plan(value: object) -> VariationPlan: + data = exact_mapping(value, _VARIATION_FIELDS, "variation_plan") + version = _integer(data["schema_version"], "variation_plan schema_version") + if version != SCHEMA_VERSION: + raise ValueError(f"unsupported variation_plan schema_version {version!r}") + _text(data["mode"], "variation_plan mode") + _text(data["flight_model"], "variation_plan flight_model") + _base_shape(data["base_variables"]) + _integer(data["n_runs"], "variation_plan n_runs") + _integer(data["seed"], "variation_plan seed", nonnegative=True) + for index, item in enumerate(_sequence(data["noise"], "variation_plan noise")): + _noise_shape(item, index) + for index, item in enumerate(_sequence(data["groups"], "variation_plan groups")): + _group_shape(item, index) + register_ground_variation_variables() + return VariationPlan.from_json_dict(data) + + +def _request_payload(request: GroundRegionalVariationRequest) -> dict[str, Any]: + if type(request) is not GroundRegionalVariationRequest: + raise TypeError("request must be an exact GroundRegionalVariationRequest") + return { + "schema": REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA, + "schema_version": REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA_VERSION, + "variation_plan": request.plan.to_json_dict(), + "regional_plan": request.regional_plan.to_dict(), + "result_id": request.result_id, + "source_provenance": request.source_provenance, + "max_rows": request.max_rows, + "series_id": request.series_id, + } + + +def _bounded_utf8(text: object) -> str: + if type(text) is not str: + raise TypeError("regional-ground variation request JSON must be text") + try: + encoded = text.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError( + "regional-ground variation request must be valid UTF-8" + ) from exc + if len(encoded) > MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES: + raise ValueError("regional-ground variation request exceeds maximum wire size") + return text + + +def regional_ground_variation_request_to_json( + request: GroundRegionalVariationRequest, +) -> str: + """Return deterministic, browser-portable canonical request JSON.""" + text = str(canonical_numeric_json(_request_payload(request))) + return _bounded_utf8(text) + + +def regional_ground_variation_request_from_json( + text: str, +) -> GroundRegionalVariationRequest: + """Parse one bounded exact request without executing any physics.""" + payload = strict_json_object(_bounded_utf8(text)) + canonical_numeric_json(payload) + data = exact_mapping(payload, _ROOT_FIELDS, "regional-ground variation request") + if data["schema"] != REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA: + raise ValueError("unsupported regional-ground variation request schema") + version = _integer(data["schema_version"], "schema_version") + if version != REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA_VERSION: + raise ValueError(f"unsupported schema_version {version!r}") + result_id = cast(str, _text(data["result_id"], "result_id")) + provenance = cast(str, _text(data["source_provenance"], "source_provenance")) + series_id = _text(data["series_id"], "series_id", nullable=True) + max_rows = _integer(data["max_rows"], "max_rows") + return GroundRegionalVariationRequest( + _variation_plan(data["variation_plan"]), + regional_material_plan_request_from_dict(data["regional_plan"]), + result_id, + provenance, + max_rows, + series_id, + ) + + +def read_regional_ground_variation_request( + source: str | Path, +) -> GroundRegionalVariationRequest: + """Read one bounded UTF-8 snapshot and completely validate it.""" + path = Path(source) + if not path.is_file(): + raise FileNotFoundError( + f"regional-ground variation request does not exist: {path}" + ) + text = read_bounded_utf8( + path, + MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES, + "regional-ground variation request", + ) + return regional_ground_variation_request_from_json(text) + + +def write_regional_ground_variation_request_atomic( + request: GroundRegionalVariationRequest, + destination: str | Path | None, +) -> bool: + """Atomically replace a native request file, or return false on cancel.""" + if destination is None: + return False + text = regional_ground_variation_request_to_json(request) + write_succeeded: bool = write_utf8_text_atomic( + text, + destination, + document_name="regional-ground variation request", + ) + return write_succeeded + + +__all__ = [ + "MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES", + "REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA", + "REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA_VERSION", + "read_regional_ground_variation_request", + "regional_ground_variation_request_from_json", + "regional_ground_variation_request_to_json", + "write_regional_ground_variation_request_atomic", +] diff --git a/tests/rate_of_closure/test_regional_ground_variation_persistence.py b/tests/rate_of_closure/test_regional_ground_variation_persistence.py new file mode 100644 index 000000000..e499e8868 --- /dev/null +++ b/tests/rate_of_closure/test_regional_ground_variation_persistence.py @@ -0,0 +1,232 @@ +"""Strict persistence contracts for seeded regional-ground variation requests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from rate_of_closure.application.regional_ground_variation_request import ( + MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES, + REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA, + read_regional_ground_variation_request, + regional_ground_variation_request_from_json, + regional_ground_variation_request_to_json, + write_regional_ground_variation_request_atomic, +) +from rate_of_closure.variation.regional_ground_variation import ( + GROUND_NORMAL_RESTITUTION_KEY, + GROUND_ROLLING_RESISTANCE_KEY, + GroundRegionalVariationRequest, + register_ground_variation_variables, +) +from shared.python.swing_sim.flight.tests._regional_ground_pipeline_support import ( + _plan, +) +from shared.python.swing_sim.variation import NoiseSpec, VariationPlan +from shared.python.swing_sim.variation import registry as variation_registry + +pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] + + +@pytest.fixture(autouse=True) +def _isolated_ground_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the Rate-owned extension from leaking into shared registry tests.""" + monkeypatch.setattr( + variation_registry, "_REGISTRY", dict(variation_registry.variable_registry()) + ) + register_ground_variation_variables() + + +def _request() -> GroundRegionalVariationRequest: + plan = VariationPlan( + mode="launch", + base_variables={ + GROUND_NORMAL_RESTITUTION_KEY: 0.4, + GROUND_ROLLING_RESISTANCE_KEY: 0.04, + }, + noise=( + NoiseSpec( + GROUND_ROLLING_RESISTANCE_KEY, + distribution="uniform", + scale=0.02, + lower=0.02, + upper=0.08, + spec_id="ground-rolling-resistance", + ), + ), + n_runs=4, + seed=1729, + ) + return GroundRegionalVariationRequest( + plan, + _plan(), + "seeded-ground-study", + "pytest/exact-parent-27d2a68d", + 8, + "driver", + ) + + +def _payload() -> dict[str, object]: + return json.loads(regional_ground_variation_request_to_json(_request())) + + +def test_canonical_round_trip_is_exact_deterministic_and_composed() -> None: + request = _request() + + first = regional_ground_variation_request_to_json(request) + second = regional_ground_variation_request_to_json(request) + payload = json.loads(first) + + assert first == second + assert "\n" not in first + assert regional_ground_variation_request_from_json(first) == request + assert payload["schema"] == REGIONAL_GROUND_VARIATION_REQUEST_SCHEMA + assert payload["variation_plan"] == request.plan.to_json_dict() + assert payload["regional_plan"] == request.regional_plan.to_dict() + + +def test_native_file_round_trip_writes_exact_canonical_bytes(tmp_path: Path) -> None: + request = _request() + target = tmp_path / "ground-variation-request.json" + + assert write_regional_ground_variation_request_atomic(request, target) + + assert target.read_bytes() == regional_ground_variation_request_to_json( + request + ).encode("utf-8") + assert read_regional_ground_variation_request(target) == request + + +def test_cancelled_write_is_a_no_op(tmp_path: Path) -> None: + assert write_regional_ground_variation_request_atomic(_request(), None) is False + assert list(tmp_path.iterdir()) == [] + + +def test_replace_failure_preserves_last_known_good( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from rate_of_closure.application import atomic_text_files + + target = tmp_path / "ground-variation-request.json" + target.write_text("last-known-good", encoding="utf-8") + monkeypatch.setattr( + atomic_text_files.os, + "replace", + lambda _source, _target: (_ for _ in ()).throw(OSError("replace failed")), + ) + + with pytest.raises(OSError, match="replace failed"): + write_regional_ground_variation_request_atomic(_request(), target) + + assert target.read_text(encoding="utf-8") == "last-known-good" + assert not list(tmp_path.glob(".*.tmp")) + + +@pytest.mark.parametrize( + "mutator, message", + [ + (lambda value: value.update(schema="unsupported/v2"), "schema"), + (lambda value: value.update(schema_version=2), "schema_version"), + (lambda value: value.update(unknown=True), "fields mismatch"), + ( + lambda value: value["variation_plan"].update(schema_version=1), + "variation_plan schema_version", + ), + ( + lambda value: value["variation_plan"].update(unknown=True), + "variation_plan fields mismatch", + ), + ( + lambda value: value["regional_plan"].update(unknown=True), + "regional material plan request fields", + ), + ], + ids=( + "schema", + "schema-version", + "outer-extra", + "variation-version", + "variation-extra", + "regional-extra", + ), +) +def test_schema_versions_and_fields_fail_closed(mutator, message: str) -> None: + payload = _payload() + mutator(payload) + + with pytest.raises((TypeError, ValueError), match=message): + regional_ground_variation_request_from_json(json.dumps(payload)) + + +@pytest.mark.parametrize( + "mutator, message", + [ + (lambda value: value.update(max_rows=True), "max_rows"), + (lambda value: value.update(result_id=""), "result_id"), + (lambda value: value.update(series_id=7), "series_id"), + ( + lambda value: value["variation_plan"].update(n_runs=True), + "n_runs", + ), + ( + lambda value: value["variation_plan"]["noise"][0].update(scale=True), + "noise.*scale", + ), + ( + lambda value: value["variation_plan"]["base_variables"].update( + {GROUND_ROLLING_RESISTANCE_KEY: True} + ), + "base_variables", + ), + ], + ids=("bool-cap", "blank-id", "series-type", "bool-runs", "bool-scale", "bool-base"), +) +def test_identifier_cap_and_numeric_types_fail_closed(mutator, message: str) -> None: + payload = _payload() + mutator(payload) + + with pytest.raises((TypeError, ValueError), match=message): + regional_ground_variation_request_from_json(json.dumps(payload)) + + +@pytest.mark.parametrize( + "text, message", + [ + ('{"schema":"one","schema":"two"}', "duplicate"), + ('{"value":NaN}', "finite"), + ('{"value":"\\ud800"}', "surrogate"), + ], + ids=("duplicate", "nonfinite", "surrogate"), +) +def test_json_safety_failures_are_rejected(text: str, message: str) -> None: + with pytest.raises((TypeError, ValueError), match=message): + regional_ground_variation_request_from_json(text) + + +def test_unsafe_cross_runtime_integer_is_rejected() -> None: + payload = _payload() + payload["max_rows"] = 9_007_199_254_740_992 + + with pytest.raises(ValueError, match="safe range"): + regional_ground_variation_request_from_json(json.dumps(payload)) + + +def test_utf8_wire_bound_and_invalid_file_encoding_fail_closed(tmp_path: Path) -> None: + oversized = "é" * (MAX_REGIONAL_GROUND_VARIATION_REQUEST_BYTES // 2 + 1) + with pytest.raises(ValueError, match="maximum wire size"): + regional_ground_variation_request_from_json(oversized) + + invalid = tmp_path / "invalid.json" + invalid.write_bytes(b"\xff") + with pytest.raises(ValueError, match="UTF-8"): + read_regional_ground_variation_request(invalid) + + +def test_serializer_and_file_reader_require_exact_valid_inputs(tmp_path: Path) -> None: + with pytest.raises(TypeError, match="exact GroundRegionalVariationRequest"): + regional_ground_variation_request_to_json(object()) # type: ignore[arg-type] + with pytest.raises(FileNotFoundError, match="does not exist"): + read_regional_ground_variation_request(tmp_path / "missing.json")