From e39edf4b50b1fb9811b0032bec4758c7a08c9b74 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 00:34:30 -0700 Subject: [PATCH 1/7] feat(rate): add canonical regional plan file I/O --- AGENT_HANDOFF.md | 38 ++++ SPEC.md | 22 +- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 37 ++++ docs/release/rate_of_closure_campaign.v1.json | 4 +- docs/specs/GROUND_SKID_ROLL.md | 15 +- src/rate_of_closure/AGENT_HANDOFF.md | 38 ++++ src/rate_of_closure/application/__init__.py | 6 + .../application/atomic_text_files.py | 55 +++++ .../application/regional_surface_plan.py | 99 +++++++++ .../regional_surface_plan_files.py | 47 +++++ .../application/workspace_files.py | 31 +-- .../ui/pyqt6/regional_surface_plan_io.py | 101 ++++++++++ .../ui/pyqt6/regional_surface_plan_tab.py | 190 ++++++------------ .../ui/pyqt6/regional_surface_plan_widgets.py | 152 ++++++++++++++ .../RegionalSurfacePlanPanel.test.tsx | 132 +++++++++++- .../components/RegionalSurfacePlanPanel.tsx | 64 +++++- .../web/src/model/regionalSurfacePlan.ts | 79 ++++++++ .../web/src/model/regionalSurfacePlanFiles.ts | 40 ++++ .../test_regional_surface_plan.py | 154 ++++++++++++++ .../test_regional_surface_plan_io.py | 140 +++++++++++++ tests/rate_of_closure/test_workspace_files.py | 4 +- 21 files changed, 1266 insertions(+), 182 deletions(-) create mode 100644 src/rate_of_closure/application/atomic_text_files.py create mode 100644 src/rate_of_closure/application/regional_surface_plan_files.py create mode 100644 src/rate_of_closure/ui/pyqt6/regional_surface_plan_io.py create mode 100644 src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py create mode 100644 src/rate_of_closure/web/src/model/regionalSurfacePlanFiles.ts create mode 100644 tests/rate_of_closure/test_regional_surface_plan_io.py diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 04eb42277..a1a721473 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -19,6 +19,44 @@ persistence boundary; fresh protected CI is still required before merge. > Update this file in every implementation commit and every push to `main`. > Last updated: 2026-08-10. +## 2026-08-10 issue #4274 canonical regional request I/O local child + +Local branch `feat/4274-regional-plan-io` started directly from published PR +#4339 head `a9ace5052bcd54b78f79b62d5d9ac26debedb4b1`, then fast-forwarded +normally to corrected exact parent `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. +The parent's stale-validation invalidation and tooltip follow-up are preserved. +This child has not been pushed, opened as a PR, reviewed, merged, or released. + +The matched Ground Surfaces editors now import and export the canonical +`ground-regional-material-plan-request/v1` document. PyQt6 uses native Open +and Save As dialogs plus a shared flush/fsync/atomic-replace UTF-8 writer; +cancel is a no-op, failed reads and writes preserve the last-known-good editor +or file, and the recent path advances only after success. React validates a +bounded browser File before committing state and downloads exact canonical +bytes while revoking its object URL. Browser filesystem limitations are +explicit, and workspace persistence remains separate. + +Import accepts only editor producer/provider v1 evidence, the fixed target +frame origin/downrange axis, and one to eight rows. It never relabels external +evidence. An unchanged import round-trips the exact request and provenance; +after any edit, validation binds a fresh draft digest. No measured calibration +is synthesized and neither path executes physics. + +RED-first coverage now includes exact-byte round-trip, native cancel and +atomic rollback, corruption, duplicate keys, oversize input, unsupported +producer/axis qualification, stale-provenance rebinding, transactional PyQt6 +population, browser rollback, URL cleanup, and pre-allocation size rejection. +Focused evidence is 25 Python/PyQt tests and 12 React tests passing. With +adjacent wire and manifest coverage, 44 Python and 16 React tests pass. +Focused MyPy, Ruff, TypeScript, zero-warning ESLint, the 199-module production +build, release-manifest validation, and module budgets are clean. + +Issue #4274 remains open. Measured calibration, workspace model-input +persistence, execution/playback, result interchange, visualization, changing +geometry, compiled regional physics, downstream parity, protected CI/review, +integration of this child into the protected stack, and release remain explicit +gaps. + ## 2026-08-10 issue #4274 matched regional surface editor local child Local branch `feat/4274-regional-surface-ui` started from published PR #4335 diff --git a/SPEC.md b/SPEC.md index 6b9630a6e..cf6fbab4e 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.28 | -| **Spec Version** | 1.14.28 | +| **Current Version** | 1.14.29 | +| **Spec Version** | 1.14.29 | | **Last Spec Update** | 2026-08-10 | ## 2. Purpose & Mission @@ -46,7 +46,7 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific ## 3. Goals & Non-Goals -### 2026-08-10 Regional Surface-Plan Editor First Slice +### 2026-08-10 Regional Surface-Plan Canonical Request I/O - The standalone PyQt6 and React shells expose matched `Ground Surfaces` modules for editing one SI base domain and one to eight coplanar static @@ -60,9 +60,18 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific errors preserve the draft and provide accessible error state; successful validation exposes schema, SI-unit, provenance, and canonical request readback. -- This first slice is session-only. Ground execution/playback, result import, - export, measured calibration workflows, model-input persistence, compiled - regional physics, and UpstreamDrift consumers remain explicit non-goals. +- Native PyQt6 Open/Save As commands use strict complete-read validation and + atomic UTF-8 replacement. Browser import/download uses the same bounded + canonical request and discloses that browser downloads cannot guarantee a + path, atomic replacement, or recent-file access. Workspace persistence is a + separate contract. +- Imports accept only editor-v1 provenance, providers, geometry/axis + qualification, and row capacity. Unchanged imports preserve the exact + request and provenance; edits create a fresh draft digest. External or + differently qualified requests are rejected without coercion. +- Ground execution/playback, result interchange, measured calibration + workflows, model-input workspace persistence, compiled regional physics, + and UpstreamDrift consumers remain explicit non-goals. ### 2026-08-06 Impact-to-Flight Solution-Family Foundation @@ -2916,6 +2925,7 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-10 | 1.14.29 | feat(rate-of-closure, #4274): add strict matched PyQt6 native atomic Open/Save As and React bounded import/canonical download for editor-qualified regional requests; preserve exact unchanged provenance, rebind edited drafts, reject unsupported qualifications without coercion, keep workspace persistence separate, and retain execution, calibration, protected evidence, and release as open work. | | 2026-08-10 | 1.14.28 | feat(rate-of-closure, #4274): add matched PyQt6 and React regional surface-plan editors with explicit SI material inputs, one-to-eight bounded overlays, illustrative/unvalidated and persistence/execution qualifications, accessible strict validation/readback, provenance bound to the actual draft, and delegation to the separate regional-plan v1 wire contract; retain playback, execution, result interchange, measured calibration, workspace model-input persistence, other runtimes, protected evidence, and release as open work. | | 2026-08-10 | 1.14.27 | feat(ground-roll, #4271): add separate strict Python/TypeScript `ground-regional-material-plan-request/v1` and result/v1 wire contracts with exact keys, SI units, explicit coplanar/static limitations, finite bounded base/overlay intervals, bounded region/document counts, unique region/precedence/surface identities, canonical cross-runtime JSON/SHA-256 evidence, fail-closed no-fabrication result binding, and a Python-only adapter into the existing regional resolver; preserve the frozen flight-to-ground v1 request/result and retain TypeScript/compiled regional physics, UI, changing geometry, internal transition-ledger export, downstream parity, protected CI, review, and release as open work. | | 2026-08-10 | 1.14.26 | feat(ground-roll, #4271): add bounded Python-reference support for finite coplanar material overlays with unique explicit precedence, exact quadratic boundary splitting, base-edge precedence, state/energy continuity, strict Python/TypeScript `surface_transition` events, internal from/to identity evidence, typed transition limits, and randomized piecewise-analytic qualification; reject changing normals, height/velocity discontinuities, and overlays owning the impact handoff rather than fabricating impulses or geometry transitions, while retaining UI, compiled regional physics, and downstream parity as open work. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 0351899ec..111b65b14 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -16,6 +16,43 @@ and diff checks are clean. The known polynomial-generator empty-legend warning is unrelated. This changes no wire schema, provenance digest, physics, or persistence boundary; fresh protected CI is still required before merge. +## 2026-08-10 issue #4274 canonical regional request I/O local child + +Local branch `feat/4274-regional-plan-io` started directly from published PR +#4339 head `a9ace5052bcd54b78f79b62d5d9ac26debedb4b1`, then fast-forwarded +normally to corrected exact parent `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. +The parent's stale-validation invalidation and tooltip follow-up are preserved. +This child has not been pushed, opened as a PR, reviewed, merged, or released. + +The matched Ground Surfaces editors now import and export the canonical +`ground-regional-material-plan-request/v1` document. PyQt6 uses native Open +and Save As dialogs plus a shared flush/fsync/atomic-replace UTF-8 writer; +cancel is a no-op, failed reads and writes preserve the last-known-good editor +or file, and the recent path advances only after success. React validates a +bounded browser File before committing state and downloads exact canonical +bytes while revoking its object URL. Browser filesystem limitations are +explicit, and workspace persistence remains separate. + +Import accepts only editor producer/provider v1 evidence, the fixed target +frame origin/downrange axis, and one to eight rows. It never relabels external +evidence. An unchanged import round-trips the exact request and provenance; +after any edit, validation binds a fresh draft digest. No measured calibration +is synthesized and neither path executes physics. + +RED-first coverage now includes exact-byte round-trip, native cancel and +atomic rollback, corruption, duplicate keys, oversize input, unsupported +producer/axis qualification, stale-provenance rebinding, transactional PyQt6 +population, browser rollback, URL cleanup, and pre-allocation size rejection. +Focused evidence is 25 Python/PyQt tests and 12 React tests passing. With +adjacent wire and manifest coverage, 44 Python and 16 React tests pass. +Focused MyPy, Ruff, TypeScript, zero-warning ESLint, the 199-module production +build, release-manifest validation, and module budgets are clean. + +Issue #4274 remains open. Measured calibration, workspace model-input +persistence, execution/playback, result interchange, visualization, changing +geometry, compiled regional physics, downstream parity, protected CI/review, +integration of this child into the protected stack, and release remain explicit +gaps. ## 2026-08-10 issue #4274 matched regional surface editor local child Local branch `feat/4274-regional-surface-ui` started from published PR #4335 diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json index 9fcbccc95..f5265d35b 100644 --- a/docs/release/rate_of_closure_campaign.v1.json +++ b/docs/release/rate_of_closure_campaign.v1.json @@ -1123,12 +1123,12 @@ "ground-impact-local-63a6", "ground-skid-roll-local-f475" ], - "evidence_gap": "The contract, flight-transfer, impact/bounce, and static-plane skid/roll carriers are open. Issues #4270 and #4271 are published on draft PRs 4302 and 4304 with exact local evidence but no protected evidence. Local unprotected children add coplanar regional-material reference physics, a strict Python/TypeScript plan wire boundary, and matched first-slice PyQt6/React request editors. Protected editor evidence, execution/playback, result interchange, workspace model-input persistence, changing geometry, compiled-runtime physics, and downstream parity remain absent.", + "evidence_gap": "The contract, flight-transfer, impact/bounce, and static-plane skid/roll carriers are open. Issues #4270 and #4271 are published on draft PRs 4302 and 4304 with exact local evidence but no protected evidence. Local unprotected children add coplanar regional-material reference physics, a strict Python/TypeScript plan wire boundary, matched PyQt6/React request editors, and strict editor-qualified canonical request import/export with native atomic semantics or explicit browser limitations. Protected editor/I/O evidence, execution/playback, result interchange, workspace model-input persistence, measured calibration, changing geometry, 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 PR #4304 qualifies one immutable planar profile and optional finite tangent-axis edge. Local unprotected children qualify bounded coplanar material overlays, a separate strict Python/TypeScript plan request/result wire boundary with Python resolver binding, and matched session-only request editor/readback modules. Changing normals, height or velocity discontinuities, terrain deformation, torsional-spin damping, roll-to-skid transitions, internal transition-ledger export, execution/playback, result interchange, measured calibration, workspace model-input persistence, compiled or TypeScript regional physics, and downstream adapters remain unimplemented." + "Published PR #4304 qualifies one immutable planar profile and optional finite tangent-axis edge. Local unprotected children qualify bounded coplanar material overlays, a separate strict Python/TypeScript plan request/result wire boundary with Python resolver binding, matched request editors, and editor-qualified canonical request import/export. Browser export does not provide native atomic or recent-path semantics. Changing normals, height or velocity discontinuities, terrain deformation, torsional-spin damping, roll-to-skid transitions, internal transition-ledger export, execution/playback, result interchange, measured calibration, workspace model-input persistence, compiled or TypeScript regional physics, and downstream adapters remain unimplemented." ], "depends_on_issues": [ 4191 diff --git a/docs/specs/GROUND_SKID_ROLL.md b/docs/specs/GROUND_SKID_ROLL.md index c8b7e1a28..d74a26c6b 100644 --- a/docs/specs/GROUND_SKID_ROLL.md +++ b/docs/specs/GROUND_SKID_ROLL.md @@ -203,11 +203,16 @@ schema, unit, provenance, and request readback. The regional v1 wire schema has no calibration record, so the editor does not invent one or widen that schema. `unvalidated` is explicit presentation/source -qualification and is included in the source digest. This first slice is -session-only: current web workspace file commands do not persist model inputs, -and neither client claims request export/import, execution, result playback, -or measured-course calibration. Those capabilities require separate contracts -and acceptance evidence. +qualification and is included in the source digest. PyQt6 native Open/Save As +and React browser import/download persist only the canonical regional request; +workspace model-input persistence remains a separate contract. Import is +transactional and accepts only the editor producer/provider v1, fixed qualified +axis/geometry, and editor row capacity. An unchanged import retains the exact +request and provenance; editing rebinds the draft digest. Browser downloads +cannot promise a native path, atomic replacement, or recent-file access. +Neither client claims execution, result playback, or measured-course +calibration. Those capabilities require separate contracts and acceptance +evidence. Skid and roll distances are accumulated separately from centre speed relative to the moving surface. Collinear constant-acceleration segments use the exact diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index f2f7e0b54..b362bccd7 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -19,6 +19,44 @@ persistence boundary; fresh protected CI is still required before merge. > **Update this file with every PR and every push to main.** > Last updated: 2026-08-10 +## 2026-08-10 issue #4274 canonical regional request I/O local child + +Local branch `feat/4274-regional-plan-io` started directly from published PR +#4339 head `a9ace5052bcd54b78f79b62d5d9ac26debedb4b1`, then fast-forwarded +normally to corrected exact parent `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. +The parent's stale-validation invalidation and tooltip follow-up are preserved. +This child has not been pushed, opened as a PR, reviewed, merged, or released. + +The matched Ground Surfaces editors now import and export the canonical +`ground-regional-material-plan-request/v1` document. PyQt6 uses native Open +and Save As dialogs plus a shared flush/fsync/atomic-replace UTF-8 writer; +cancel is a no-op, failed reads and writes preserve the last-known-good editor +or file, and the recent path advances only after success. React validates a +bounded browser File before committing state and downloads exact canonical +bytes while revoking its object URL. Browser filesystem limitations are +explicit, and workspace persistence remains separate. + +Import accepts only editor producer/provider v1 evidence, the fixed target +frame origin/downrange axis, and one to eight rows. It never relabels external +evidence. An unchanged import round-trips the exact request and provenance; +after any edit, validation binds a fresh draft digest. No measured calibration +is synthesized and neither path executes physics. + +RED-first coverage now includes exact-byte round-trip, native cancel and +atomic rollback, corruption, duplicate keys, oversize input, unsupported +producer/axis qualification, stale-provenance rebinding, transactional PyQt6 +population, browser rollback, URL cleanup, and pre-allocation size rejection. +Focused evidence is 25 Python/PyQt tests and 12 React tests passing. With +adjacent wire and manifest coverage, 44 Python and 16 React tests pass. +Focused MyPy, Ruff, TypeScript, zero-warning ESLint, the 199-module production +build, release-manifest validation, and module budgets are clean. + +Issue #4274 remains open. Measured calibration, workspace model-input +persistence, execution/playback, result interchange, visualization, changing +geometry, compiled regional physics, downstream parity, protected CI/review, +integration of this child into the protected stack, and release remain explicit +gaps. + ## 2026-08-10 issue #4274 matched regional surface editor local child Local branch `feat/4274-regional-surface-ui` started from published PR #4335 diff --git a/src/rate_of_closure/application/__init__.py b/src/rate_of_closure/application/__init__.py index 20fd60d88..cf3ca435d 100644 --- a/src/rate_of_closure/application/__init__.py +++ b/src/rate_of_closure/application/__init__.py @@ -6,6 +6,10 @@ CommandAvailability, CommandUnavailableError, ) +from .regional_surface_plan_files import ( + read_regional_surface_plan_request, + write_regional_surface_plan_request_atomic, +) from .workspace_document import ( WORKSPACE_SCHEMA, WORKSPACE_SCHEMA_VERSION, @@ -30,7 +34,9 @@ "WorkspaceLayout", "WorkspaceMetadata", "read_workspace", + "read_regional_surface_plan_request", "workspace_from_json", "workspace_to_json", "write_workspace_atomic", + "write_regional_surface_plan_request_atomic", ] diff --git a/src/rate_of_closure/application/atomic_text_files.py b/src/rate_of_closure/application/atomic_text_files.py new file mode 100644 index 000000000..852ce12b8 --- /dev/null +++ b/src/rate_of_closure/application/atomic_text_files.py @@ -0,0 +1,55 @@ +"""Small atomic UTF-8 text-file boundary shared by application documents.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + + +def _destination_path(destination: str | Path, document_name: str) -> Path: + path = Path(destination) + if not path.name: + raise ValueError(f"{document_name} destination must name a file") + if not path.parent.is_dir(): + raise FileNotFoundError( + f"{document_name} parent directory does not exist: {path.parent}" + ) + if path.exists() and not path.is_file(): + raise IsADirectoryError(f"{document_name} destination is not a file: {path}") + return path + + +def write_utf8_text_atomic( + text: str, + destination: str | Path | None, + *, + document_name: str, +) -> bool: + """Flush and atomically replace one UTF-8 text file. + + ``None`` represents a cancelled chooser. Serialization must happen before + this function is called so a validation failure cannot touch the target. + """ + if destination is None: + return False + if not isinstance(text, str): + raise TypeError("atomic file content must be text") + path = _destination_path(destination, document_name) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + return True + + +__all__ = ["write_utf8_text_atomic"] diff --git a/src/rate_of_closure/application/regional_surface_plan.py b/src/rate_of_closure/application/regional_surface_plan.py index 34d5ecb89..1ffce376f 100644 --- a/src/rate_of_closure/application/regional_surface_plan.py +++ b/src/rate_of_closure/application/regional_surface_plan.py @@ -6,11 +6,13 @@ from dataclasses import asdict, dataclass from shared.python.swing_sim.canonical_numeric_json import canonical_numeric_json +from shared.python.swing_sim.ground.contract_types import GroundSurfaceProfile from shared.python.swing_sim.ground.regional_plan_records import ( REGIONAL_PLAN_GEOMETRY_MODEL, REGIONAL_PLAN_LIMITATIONS, REGIONAL_PLAN_REQUEST_SCHEMA_VERSION, GroundRegionalMaterialPlanRequest, + GroundRegionalMaterialRegion, ) from shared.python.swing_sim.ground.regional_plan_wire import ( regional_material_plan_request_from_dict, @@ -158,6 +160,99 @@ def _region_payload(region: RegionalOverlayDraft) -> dict[str, object]: } +def _surface_draft(surface: GroundSurfaceProfile) -> SurfaceMaterialDraft: + """Project one already-validated wire surface into editable SI values.""" + return SurfaceMaterialDraft( + surface.surface_id, + surface.normal_restitution, + surface.static_friction, + surface.kinetic_friction, + surface.rolling_resistance, + surface.firmness_pa, + surface.hardness_fraction, + surface.grass_height_m, + surface.compressibility_fraction, + surface.compression_damping_fraction, + surface.turf_density_kg_m3, + surface.moisture_fraction, + ) + + +def _assert_editor_surface(surface: GroundSurfaceProfile) -> None: + """Reject material evidence not authored under the editor v1 contract.""" + if ( + surface.provider_id != EDITOR_PROVIDER_ID + or surface.provider_version != EDITOR_PROVIDER_VERSION + ): + raise ValueError("surface is not qualified by the editor provider v1") + + +def _overlay_draft(region: GroundRegionalMaterialRegion) -> RegionalOverlayDraft: + return RegionalOverlayDraft( + region.region_id, + region.precedence, + region.lower_coordinate_m, + region.upper_coordinate_m, + _surface_draft(region.surface), + ) + + +def editor_draft_from_regional_surface_plan_request( + request: GroundRegionalMaterialPlanRequest, +) -> RegionalSurfacePlanDraft: + """Project one fully validated, editor-qualified v1 request into the editor. + + The editor provider v1 contract is explicitly unvalidated. Requests from a + different producer, material provider, axis qualification, or row capacity + are therefore rejected rather than relabelled or coerced. + """ + if type(request) is not GroundRegionalMaterialPlanRequest: + raise TypeError("request must be an exact GroundRegionalMaterialPlanRequest") + provenance = request.provenance + if ( + provenance.producer != EDITOR_PROVIDER_ID + or provenance.producer_version != EDITOR_PROVIDER_VERSION + ): + raise ValueError("request is not qualified by the editor producer v1") + if request.axis_origin_m != (0.0, 0.0, 0.0) or request.axis_unit != ( + 1.0, + 0.0, + 0.0, + ): + raise ValueError("request uses an unsupported editor axis qualification") + if len(request.regions) > MAX_EDITOR_REGIONS: + raise ValueError(f"editor supports one to at most {MAX_EDITOR_REGIONS} regions") + _assert_editor_surface(request.base_surface) + for region in request.regions: + _assert_editor_surface(region.surface) + draft = RegionalSurfacePlanDraft( + request.request_id, + request.lower_coordinate_m, + request.upper_coordinate_m, + provenance.source_revision, + "unvalidated", + _surface_draft(request.base_surface), + tuple(_overlay_draft(region) for region in request.regions), + ) + if provenance.input_sha256 != _source_digest(draft): + raise ValueError("editor provenance digest does not match the editable request") + return draft + + +def regional_surface_plan_request_for_draft( + draft: RegionalSurfacePlanDraft, + imported_request: GroundRegionalMaterialPlanRequest | None = None, +) -> GroundRegionalMaterialPlanRequest: + """Preserve an unchanged import exactly; otherwise bind fresh provenance.""" + if imported_request is not None: + imported_draft = editor_draft_from_regional_surface_plan_request( + imported_request + ) + if draft == imported_draft: + return imported_request + return validate_regional_surface_plan_draft(draft) + + def validate_regional_surface_plan_draft( draft: RegionalSurfacePlanDraft, ) -> GroundRegionalMaterialPlanRequest: @@ -191,10 +286,14 @@ def validate_regional_surface_plan_draft( __all__ = [ + "EDITOR_PROVIDER_ID", + "EDITOR_PROVIDER_VERSION", "MAX_EDITOR_REGIONS", "RegionalOverlayDraft", "RegionalSurfacePlanDraft", "SurfaceMaterialDraft", + "editor_draft_from_regional_surface_plan_request", "illustrative_regional_surface_plan_draft", + "regional_surface_plan_request_for_draft", "validate_regional_surface_plan_draft", ] diff --git a/src/rate_of_closure/application/regional_surface_plan_files.py b/src/rate_of_closure/application/regional_surface_plan_files.py new file mode 100644 index 000000000..861160f0e --- /dev/null +++ b/src/rate_of_closure/application/regional_surface_plan_files.py @@ -0,0 +1,47 @@ +"""Strict native file adapter for canonical regional surface-plan requests.""" + +from __future__ import annotations + +from pathlib import Path + +from shared.python.swing_sim.ground.regional_plan_records import ( + MAX_REGIONAL_PLAN_WIRE_BYTES, + GroundRegionalMaterialPlanRequest, +) +from shared.python.swing_sim.ground.regional_plan_wire import ( + regional_material_plan_request_from_json, +) + +from .atomic_text_files import write_utf8_text_atomic + + +def read_regional_surface_plan_request( + source: str | Path, +) -> GroundRegionalMaterialPlanRequest: + """Read and completely validate one canonical request before returning it.""" + path = Path(source) + if not path.is_file(): + raise FileNotFoundError(f"regional surface plan does not exist: {path}") + if path.stat().st_size > MAX_REGIONAL_PLAN_WIRE_BYTES: + raise ValueError("regional material plan exceeds maximum wire size") + return regional_material_plan_request_from_json(path.read_text(encoding="utf-8")) + + +def write_regional_surface_plan_request_atomic( + request: GroundRegionalMaterialPlanRequest, + destination: str | Path | None, +) -> bool: + """Write exact canonical request bytes, or return false after cancellation.""" + if destination is None: + return False + if type(request) is not GroundRegionalMaterialPlanRequest: + raise TypeError("request must be an exact GroundRegionalMaterialPlanRequest") + return write_utf8_text_atomic( + request.to_json(), destination, document_name="regional surface plan" + ) + + +__all__ = [ + "read_regional_surface_plan_request", + "write_regional_surface_plan_request_atomic", +] diff --git a/src/rate_of_closure/application/workspace_files.py b/src/rate_of_closure/application/workspace_files.py index 58b6f2ebb..fa627902d 100644 --- a/src/rate_of_closure/application/workspace_files.py +++ b/src/rate_of_closure/application/workspace_files.py @@ -2,10 +2,9 @@ from __future__ import annotations -import os -import tempfile from pathlib import Path +from .atomic_text_files import write_utf8_text_atomic from .workspace_document import ( WorkspaceDocument, workspace_from_json, @@ -13,18 +12,6 @@ ) -def _destination_path(destination: str | Path) -> Path: - path = Path(destination) - if not path.name: - raise ValueError("workspace destination must name a file") - parent = path.parent - if not parent.is_dir(): - raise FileNotFoundError(f"workspace parent directory does not exist: {parent}") - if path.exists() and not path.is_file(): - raise IsADirectoryError(f"workspace destination is not a file: {path}") - return path - - def read_workspace(source: str | Path) -> WorkspaceDocument: """Read and fully validate one workspace before returning it. @@ -59,21 +46,7 @@ def write_workspace_atomic( if destination is None: return False serialized = workspace_to_json(document) - path = _destination_path(destination) - descriptor, temporary_name = tempfile.mkstemp( - prefix=f".{path.name}.", suffix=".tmp", dir=path.parent - ) - temporary = Path(temporary_name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: - handle.write(serialized) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary, path) - except Exception: - temporary.unlink(missing_ok=True) - raise - return True + return write_utf8_text_atomic(serialized, destination, document_name="workspace") __all__ = ["read_workspace", "write_workspace_atomic"] diff --git a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_io.py b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_io.py new file mode 100644 index 000000000..db39e5183 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_io.py @@ -0,0 +1,101 @@ +"""Native Open/Save As controller for the regional surface-plan editor.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + +from PyQt6.QtWidgets import QFileDialog, QLabel, QPlainTextEdit, QWidget + +from rate_of_closure.application.regional_surface_plan_files import ( + read_regional_surface_plan_request, + write_regional_surface_plan_request_atomic, +) +from shared.python.swing_sim.ground.regional_plan_records import ( + GroundRegionalMaterialPlanRequest, +) + + +class RegionalSurfacePlanFileHost(Protocol): + """Minimal host surface needed by the native file controller.""" + + status_label: QLabel + readback: QPlainTextEdit + + def current_request(self) -> GroundRegionalMaterialPlanRequest: ... + + def apply_imported_request( + self, request: GroundRegionalMaterialPlanRequest + ) -> None: ... + + +class RegionalSurfacePlanFileActions: + """Transactional native file commands with one successful recent path.""" + + def __init__(self, host: RegionalSurfacePlanFileHost, parent: QWidget) -> None: + self._host = host + self._parent = parent + self.recent_path: Path | None = None + + def open(self) -> None: + """Choose, fully validate, and only then replace the editor draft.""" + selected, _filter = QFileDialog.getOpenFileName( + self._parent, + "Open Regional Surface Plan", + self._initial_location(), + "JSON files (*.json)", + ) + if not selected: + return + path = Path(selected) + try: + request = read_regional_surface_plan_request(path) + self._host.apply_imported_request(request) + except (OSError, TypeError, ValueError) as exc: + self._set_error(f"Open failed: {exc}") + return + self.recent_path = path + self._host.readback.setPlainText(request.to_json()) + self._set_success(f"Opened {path.name}. No physics executed.") + + def save_as(self) -> None: + """Validate before choosing a destination, then atomically replace it.""" + try: + request = self._host.current_request() + except (TypeError, ValueError) as exc: + self._set_error(f"Save failed: {exc}") + return + selected, _filter = QFileDialog.getSaveFileName( + self._parent, + "Save Regional Surface Plan As", + self._initial_location("regional-surface-plan.json"), + "JSON files (*.json)", + ) + if not selected: + return + path = Path(selected) + try: + write_regional_surface_plan_request_atomic(request, path) + except OSError as exc: + self._set_error(f"Save failed: {exc}") + return + self.recent_path = path + self._host.readback.setPlainText(request.to_json()) + self._set_success(f"Saved {path.name} atomically. No physics executed.") + + def _initial_location(self, filename: str = "") -> str: + if self.recent_path is None: + return filename + parent = self.recent_path.parent + return str(parent / filename) if filename else str(parent) + + def _set_error(self, message: str) -> None: + self._host.status_label.setText(message) + self._host.status_label.setAccessibleName("Regional surface plan file error") + + def _set_success(self, message: str) -> None: + self._host.status_label.setText(message) + self._host.status_label.setAccessibleName("Regional surface plan file success") + + +__all__ = ["QFileDialog", "RegionalSurfacePlanFileActions"] diff --git a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py index b8ba4d8ac..807e3f5f0 100644 --- a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py +++ b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py @@ -2,13 +2,10 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import replace -from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QComboBox, - QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, @@ -17,7 +14,6 @@ QPlainTextEdit, QPushButton, QScrollArea, - QSpinBox, QVBoxLayout, QWidget, ) @@ -26,132 +22,21 @@ MAX_EDITOR_REGIONS, RegionalOverlayDraft, RegionalSurfacePlanDraft, - SurfaceMaterialDraft, + editor_draft_from_regional_surface_plan_request, illustrative_regional_surface_plan_draft, - validate_regional_surface_plan_draft, + regional_surface_plan_request_for_draft, ) - -_MATERIAL_FIELDS = ( - ("normal_restitution", "Normal restitution", "", 0.01, 0.0, 1.0), - ("static_friction", "Static friction", "", 0.01, 0.0, 5.0), - ("kinetic_friction", "Kinetic friction", "", 0.01, 0.0, 5.0), - ("rolling_resistance", "Rolling resistance", "", 0.01, 0.0, 1.0), - ("firmness_pa", "Firmness", " Pa", 1_000.0, 0.001, 1e9), - ("hardness_fraction", "Hardness", " fraction", 0.01, 0.0, 1.0), - ("grass_height_m", "Grass height", " m", 0.001, 0.0, 1.0), - ("compressibility_fraction", "Compressibility", " fraction", 0.01, 0.0, 1.0), - ( - "compression_damping_fraction", - "Compression damping", - " fraction", - 0.01, - 0.0, - 1.0, - ), - ("turf_density_kg_m3", "Turf density", " kg/m³", 1.0, 0.0, 10_000.0), - ("moisture_fraction", "Moisture", " fraction", 0.01, 0.0, 1.0), +from rate_of_closure.ui.pyqt6.regional_surface_plan_io import ( + RegionalSurfacePlanFileActions, +) +from rate_of_closure.ui.pyqt6.regional_surface_plan_widgets import ( + MaterialEditor, + RegionalOverlayRow, + number_input, +) +from shared.python.swing_sim.ground.regional_plan_records import ( + GroundRegionalMaterialPlanRequest, ) - - -def _number_input( - name: str, - value: float, - suffix: str = "", - step: float = 0.1, - minimum: float = -1e9, - maximum: float = 1e9, -) -> QDoubleSpinBox: - """Create one consistently configured accessible SI number input.""" - field = QDoubleSpinBox() - field.setAccessibleName(name) - field.setDecimals(6) - field.setRange(minimum, maximum) - field.setSingleStep(step) - field.setSuffix(suffix) - field.setValue(value) - field.setToolTip( - f"{name}. Edit this SI draft value, then validate the surface plan." - ) - return field - - -class MaterialEditor(QGroupBox): - """Editable surface identity and full v1 material parameter collection.""" - - def __init__(self, title: str, value: SurfaceMaterialDraft) -> None: - super().__init__(title) - self.surface_id = QLineEdit(value.surface_id) - self.surface_id.setAccessibleName(f"{title} surface ID") - self.surface_id.setToolTip( - f"Stable identifier for {title.lower()}; included in validated readback." - ) - self.fields: dict[str, QDoubleSpinBox] = {} - layout = QFormLayout(self) - layout.addRow("Surface ID", self.surface_id) - for name, label, suffix, step, minimum, maximum in _MATERIAL_FIELDS: - field = _number_input( - f"{title} {label}", getattr(value, name), suffix, step, minimum, maximum - ) - self.fields[name] = field - layout.addRow(label, field) - - def draft(self) -> SurfaceMaterialDraft: - """Read the current widgets without applying separate UI validation.""" - values = {name: field.value() for name, field in self.fields.items()} - return SurfaceMaterialDraft(self.surface_id.text(), **values) - - -class RegionalOverlayRow(QGroupBox): - """One removable bounded regional overlay row.""" - - def __init__( - self, - ordinal: int, - value: RegionalOverlayDraft, - remove: Callable[[RegionalOverlayRow], None], - ) -> None: - super().__init__(f"Regional overlay {ordinal}") - self.region_id = QLineEdit(value.region_id) - self.region_id.setToolTip( - "Stable overlay identifier; it must be unique within the regional plan." - ) - self.precedence = QSpinBox() - self.precedence.setRange(0, 1_000_000) - self.precedence.setValue(value.precedence) - self.precedence.setToolTip( - "Overlay selection precedence. Higher values win when intervals overlap." - ) - self.lower_coordinate = _number_input( - f"Overlay {ordinal} lower coordinate", value.lower_coordinate_m, " m" - ) - self.upper_coordinate = _number_input( - f"Overlay {ordinal} upper coordinate", value.upper_coordinate_m, " m" - ) - self.material = MaterialEditor(f"Overlay {ordinal} material", value.surface) - self.remove_button = QPushButton(f"Remove overlay {ordinal}") - self.remove_button.setToolTip( - "Remove this overlay from the unvalidated draft; one overlay is required." - ) - self.remove_button.clicked.connect(lambda: remove(self)) - form = QFormLayout() - form.addRow("Region ID", self.region_id) - form.addRow("Precedence", self.precedence) - form.addRow("Lower coordinate", self.lower_coordinate) - form.addRow("Upper coordinate", self.upper_coordinate) - layout = QVBoxLayout(self) - layout.addLayout(form) - layout.addWidget(self.material) - layout.addWidget(self.remove_button, alignment=Qt.AlignmentFlag.AlignRight) - - def draft(self) -> RegionalOverlayDraft: - """Read one overlay for authoritative contract validation.""" - return RegionalOverlayDraft( - self.region_id.text(), - self.precedence.value(), - self.lower_coordinate.value(), - self.upper_coordinate.value(), - self.material.draft(), - ) class RegionalSurfacePlanTab(QWidget): @@ -161,6 +46,8 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._rows: list[RegionalOverlayRow] = [] self._initial = illustrative_regional_surface_plan_draft() + self._imported_request: GroundRegionalMaterialPlanRequest | None = None + self.file_actions = RegionalSurfacePlanFileActions(self, self) self._build_ui() self._connect_static_changes() self._append_row(self._initial.regions[0]) @@ -252,8 +139,8 @@ def _identity_box(self) -> QGroupBox: ) geometry.setWordWrap(True) session = QLabel( - "Session-only draft: current workspace file commands do not persist " - "model inputs." + "Open/Save As persists this canonical request only. Workspace " + "persistence remains a separate contract." ) session.setWordWrap(True) layout = QFormLayout(box) @@ -267,10 +154,10 @@ def _identity_box(self) -> QGroupBox: def _base_box(self) -> QGroupBox: """Create the base interval and complete base material editor.""" box = QGroupBox("Base surface and domain") - self.domain_lower = _number_input( + self.domain_lower = number_input( "Base domain lower coordinate", self._initial.lower_coordinate_m, " m" ) - self.domain_upper = _number_input( + self.domain_upper = number_input( "Base domain upper coordinate", self._initial.upper_coordinate_m, " m" ) self.base_material = MaterialEditor("Base material", self._initial.base_surface) @@ -297,7 +184,21 @@ def _action_row(self) -> QHBoxLayout: "Validate the complete draft and display its canonical SI request." ) self.validate_button.clicked.connect(self.validate_plan) + self.open_button = QPushButton("Open JSON") + self.open_button.setAccessibleName("Open regional surface plan JSON") + self.open_button.setToolTip( + "Open and fully validate an editor-qualified canonical request." + ) + self.open_button.clicked.connect(self.file_actions.open) + self.save_button = QPushButton("Save As JSON") + self.save_button.setAccessibleName("Save regional surface plan JSON as") + self.save_button.setToolTip( + "Atomically save the validated canonical request to a chosen file." + ) + self.save_button.clicked.connect(self.file_actions.save_as) layout.addWidget(self.add_button) + layout.addWidget(self.open_button) + layout.addWidget(self.save_button) layout.addStretch(1) layout.addWidget(self.validate_button) return layout @@ -368,10 +269,35 @@ def draft(self) -> RegionalSurfacePlanDraft: tuple(row.draft() for row in self._rows), ) + def current_request(self) -> GroundRegionalMaterialPlanRequest: + """Return exact imported evidence unless the visible draft has changed.""" + return regional_surface_plan_request_for_draft( + self.draft(), self._imported_request + ) + + def apply_imported_request( + self, request: GroundRegionalMaterialPlanRequest + ) -> None: + """Populate widgets only after complete strict editor qualification.""" + draft = editor_draft_from_regional_surface_plan_request(request) + self.request_id.setText(draft.request_id) + self.source_revision.setText(draft.source_revision) + self.domain_lower.setValue(draft.lower_coordinate_m) + self.domain_upper.setValue(draft.upper_coordinate_m) + self.base_material.set_draft(draft.base_surface) + for row in self._rows: + self.rows_layout.removeWidget(row) + row.deleteLater() + self._rows.clear() + for region in draft.regions: + self._append_row(region) + self._update_row_actions() + self._imported_request = request + def validate_plan(self) -> None: """Validate through the strict contract and render canonical readback.""" try: - request = validate_regional_surface_plan_draft(self.draft()) + request = self.current_request() except (TypeError, ValueError) as exc: self.status_label.setText(str(exc)) self.status_label.setAccessibleName( diff --git a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py new file mode 100644 index 000000000..aac724c1f --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py @@ -0,0 +1,152 @@ +"""Reusable PyQt6 inputs for the regional surface-plan editor.""" + +from __future__ import annotations + +from collections.abc import Callable + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLineEdit, + QPushButton, + QSpinBox, + QVBoxLayout, +) + +from rate_of_closure.application.regional_surface_plan import ( + RegionalOverlayDraft, + SurfaceMaterialDraft, +) + +_MATERIAL_FIELDS = ( + ("normal_restitution", "Normal restitution", "", 0.01, 0.0, 1.0), + ("static_friction", "Static friction", "", 0.01, 0.0, 5.0), + ("kinetic_friction", "Kinetic friction", "", 0.01, 0.0, 5.0), + ("rolling_resistance", "Rolling resistance", "", 0.01, 0.0, 1.0), + ("firmness_pa", "Firmness", " Pa", 1_000.0, 1e-11, 1e100), + ("hardness_fraction", "Hardness", " fraction", 0.01, 0.0, 1.0), + ("grass_height_m", "Grass height", " m", 0.001, 0.0, 1e100), + ("compressibility_fraction", "Compressibility", " fraction", 0.01, 0.0, 1.0), + ( + "compression_damping_fraction", + "Compression damping", + " fraction", + 0.01, + 0.0, + 1.0, + ), + ("turf_density_kg_m3", "Turf density", " kg/m³", 1.0, 0.0, 1e100), + ("moisture_fraction", "Moisture", " fraction", 0.01, 0.0, 1.0), +) + + +def number_input( + name: str, + value: float, + suffix: str = "", + step: float = 0.1, + minimum: float = -1e100, + maximum: float = 1e100, +) -> QDoubleSpinBox: + """Create one canonical-precision accessible SI number input.""" + field = QDoubleSpinBox() + field.setAccessibleName(name) + field.setDecimals(11) + field.setRange(minimum, maximum) + field.setSingleStep(step) + field.setSuffix(suffix) + field.setValue(value) + field.setToolTip( + f"{name}. Edit this SI draft value, then validate the surface plan." + ) + return field + + +class MaterialEditor(QGroupBox): + """Editable surface identity and full v1 material parameter collection.""" + + def __init__(self, title: str, value: SurfaceMaterialDraft) -> None: + super().__init__(title) + self.surface_id = QLineEdit(value.surface_id) + self.surface_id.setAccessibleName(f"{title} surface ID") + self.surface_id.setToolTip( + f"Stable identifier for {title.lower()}; included in validated readback." + ) + self.fields: dict[str, QDoubleSpinBox] = {} + layout = QFormLayout(self) + layout.addRow("Surface ID", self.surface_id) + for name, label, suffix, step, minimum, maximum in _MATERIAL_FIELDS: + field = number_input( + f"{title} {label}", getattr(value, name), suffix, step, minimum, maximum + ) + self.fields[name] = field + layout.addRow(label, field) + + def draft(self) -> SurfaceMaterialDraft: + """Read the current widgets without applying separate UI validation.""" + values = {name: field.value() for name, field in self.fields.items()} + return SurfaceMaterialDraft(self.surface_id.text(), **values) + + def set_draft(self, value: SurfaceMaterialDraft) -> None: + """Replace all visible material values from one validated draft.""" + self.surface_id.setText(value.surface_id) + for name, field in self.fields.items(): + field.setValue(getattr(value, name)) + + +class RegionalOverlayRow(QGroupBox): + """One removable bounded regional overlay row.""" + + def __init__( + self, + ordinal: int, + value: RegionalOverlayDraft, + remove: Callable[[RegionalOverlayRow], None], + ) -> None: + super().__init__(f"Regional overlay {ordinal}") + self.region_id = QLineEdit(value.region_id) + self.region_id.setToolTip( + "Stable overlay identifier; it must be unique within the regional plan." + ) + self.precedence = QSpinBox() + self.precedence.setRange(0, 1_000_000) + self.precedence.setValue(value.precedence) + self.precedence.setToolTip( + "Overlay selection precedence. Higher values win when intervals overlap." + ) + self.lower_coordinate = number_input( + f"Overlay {ordinal} lower coordinate", value.lower_coordinate_m, " m" + ) + self.upper_coordinate = number_input( + f"Overlay {ordinal} upper coordinate", value.upper_coordinate_m, " m" + ) + self.material = MaterialEditor(f"Overlay {ordinal} material", value.surface) + self.remove_button = QPushButton(f"Remove overlay {ordinal}") + self.remove_button.setToolTip( + "Remove this overlay from the unvalidated draft; one overlay is required." + ) + self.remove_button.clicked.connect(lambda: remove(self)) + form = QFormLayout() + form.addRow("Region ID", self.region_id) + form.addRow("Precedence", self.precedence) + form.addRow("Lower coordinate", self.lower_coordinate) + form.addRow("Upper coordinate", self.upper_coordinate) + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(self.material) + layout.addWidget(self.remove_button, alignment=Qt.AlignmentFlag.AlignRight) + + def draft(self) -> RegionalOverlayDraft: + """Read one overlay for authoritative contract validation.""" + return RegionalOverlayDraft( + self.region_id.text(), + self.precedence.value(), + self.lower_coordinate.value(), + self.upper_coordinate.value(), + self.material.draft(), + ) + + +__all__ = ["MaterialEditor", "RegionalOverlayRow", "number_input"] diff --git a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx index 1356cb121..88570fdd9 100644 --- a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx +++ b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx @@ -1,13 +1,22 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MAX_REGIONAL_SURFACE_EDITOR_ROWS, buildGroundRegionalSurfacePlanRequest, + editorDraftFromGroundRegionalSurfacePlanRequest, illustrativeRegionalSurfacePlanDraft, + regionalSurfacePlanRequestForDraft, } from "../model/regionalSurfacePlan"; +import { + MAX_GROUND_REGIONAL_PLAN_WIRE_BYTES, + parseGroundRegionalMaterialPlanRequest, + stableGroundRegionalMaterialPlanJson, +} from "../model/groundRegionalPlan"; import { RegionalSurfacePlanPanel } from "./RegionalSurfacePlanPanel"; +afterEach(() => vi.restoreAllMocks()); + describe("RegionalSurfacePlanPanel", () => { it("matches Python provenance for the disclosed illustrative draft", () => { const request = buildGroundRegionalSurfacePlanRequest( @@ -84,6 +93,123 @@ describe("RegionalSurfacePlanPanel", () => { .toHaveLength(MAX_REGIONAL_SURFACE_EDITOR_ROWS); expect(add).toBeDisabled(); expect(screen.queryByRole("button", { name: /run|play/i })).not.toBeInTheDocument(); - expect(screen.getByText(/session-only draft/i)).toBeInTheDocument(); + expect(screen.getByText(/workspace persistence remains separate/i)).toBeInTheDocument(); + }); + + it("preserves an unchanged imported editor request exactly", () => { + const request = buildGroundRegionalSurfacePlanRequest({ + ...illustrativeRegionalSurfacePlanDraft(), request_id: "imported-plan", + }); + + const imported = editorDraftFromGroundRegionalSurfacePlanRequest(request); + + expect(regionalSurfacePlanRequestForDraft(imported, request)).toBe(request); + expect(stableGroundRegionalMaterialPlanJson( + regionalSurfacePlanRequestForDraft(imported, request), + )).toBe(stableGroundRegionalMaterialPlanJson(request)); + + const payload = JSON.parse(stableGroundRegionalMaterialPlanJson(request)); + payload.provenance.input_sha256 = "0".repeat(64); + const mismatched = parseGroundRegionalMaterialPlanRequest(payload); + expect(() => editorDraftFromGroundRegionalSurfacePlanRequest(mismatched)) + .toThrow(/digest does not match/i); + }); + + it("imports only after complete validation and retains the prior draft on error", async () => { + render(); + const request = buildGroundRegionalSurfacePlanRequest({ + ...illustrativeRegionalSurfacePlanDraft(), request_id: "browser-opened-plan", + }); + const input = screen.getByLabelText("Import regional surface plan JSON file"); + const validText = stableGroundRegionalMaterialPlanJson(request); + const valid = { + name: "valid.json", + size: new TextEncoder().encode(validText).byteLength, + text: vi.fn().mockResolvedValue(validText), + }; + + fireEvent.change(input, { target: { files: [valid] } }); + await waitFor(() => expect(screen.getByLabelText("Regional plan request ID")) + .toHaveValue("browser-opened-plan")); + + const corrupt = { + name: "corrupt.json", size: 47, + text: vi.fn().mockResolvedValue('{"request_id":"one","request_id":"two"}'), + }; + fireEvent.change(input, { target: { files: [corrupt] } }); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/duplicate/i)); + expect(screen.getByLabelText("Regional plan request ID")) + .toHaveValue("browser-opened-plan"); + + const invalidSyntax = { + name: "syntax.json", size: 9, + text: vi.fn().mockResolvedValue("{not-json"), + }; + fireEvent.change(input, { target: { files: [invalidSyntax] } }); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/invalid profile json/i)); + expect(screen.getByLabelText("Regional plan request ID")) + .toHaveValue("browser-opened-plan"); + }); + + it("rejects non-editor qualification without populating the editor", async () => { + render(); + const request = buildGroundRegionalSurfacePlanRequest( + illustrativeRegionalSurfacePlanDraft(), + ); + const payload = JSON.parse(stableGroundRegionalMaterialPlanJson(request)); + payload.provenance.producer = "external.course.authority"; + const input = screen.getByLabelText("Import regional surface plan JSON file"); + const file = { + name: "external.json", size: 100, + text: vi.fn().mockResolvedValue(JSON.stringify(payload)), + }; + + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/editor producer/i)); + expect(screen.getByLabelText("Regional plan request ID")) + .toHaveValue("illustrative-regional-plan"); + }); + + it("rejects oversized browser files before allocating their text", async () => { + render(); + const input = screen.getByLabelText("Import regional surface plan JSON file"); + const text = vi.fn().mockResolvedValue("{}"); + const file = { + name: "oversize.json", size: MAX_GROUND_REGIONAL_PLAN_WIRE_BYTES + 1, text, + }; + + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/maximum wire size/i)); + expect(text).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Regional plan request ID")) + .toHaveValue("illustrative-regional-plan"); + }); + + it("downloads canonical bytes and revokes its object URL", () => { + const createUrl = vi.fn(() => "blob:regional-plan"); + const revokeUrl = vi.fn(); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createUrl }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeUrl }); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Download regional surface plan JSON" })); + + expect(createUrl).toHaveBeenCalledOnce(); + expect(click).toHaveBeenCalledOnce(); + expect(revokeUrl).toHaveBeenCalledWith("blob:regional-plan"); + }); + + it("treats an empty browser chooser as cancellation", () => { + render(); + const input = screen.getByLabelText("Import regional surface plan JSON file"); + + fireEvent.change(input, { target: { files: [] } }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Regional plan request ID")) + .toHaveValue("illustrative-regional-plan"); }); }); diff --git a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx index 068fff039..6e8fc544c 100644 --- a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx +++ b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx @@ -1,13 +1,19 @@ -import { useState } from "react"; +import { type ChangeEvent, useRef, useState } from "react"; import { MAX_REGIONAL_SURFACE_EDITOR_ROWS, - buildGroundRegionalSurfacePlanRequest, + editorDraftFromGroundRegionalSurfacePlanRequest, illustrativeRegionalSurfacePlanDraft, + regionalSurfacePlanRequestForDraft, type RegionalOverlayDraft, type RegionalSurfacePlanDraft, type SurfaceMaterialDraft, } from "../model/regionalSurfacePlan"; +import { + downloadRegionalSurfacePlanRequest, + readRegionalSurfacePlanFile, +} from "../model/regionalSurfacePlanFiles"; +import type { GroundRegionalMaterialPlanRequest } from "../model/groundRegionalPlan"; type NumericMaterialKey = Exclude; @@ -133,15 +139,20 @@ function nextOverlay(draft: RegionalSurfacePlanDraft): RegionalOverlayDraft { export function RegionalSurfacePlanPanel() { const [draft, setDraft] = useState(illustrativeRegionalSurfacePlanDraft); + const [importedRequest, setImportedRequest] = + useState(null); const [error, setError] = useState(null); const [readback, setReadback] = useState(null); + const [fileStatus, setFileStatus] = useState(null); const [validationAttempted, setValidationAttempted] = useState(false); + const fileInput = useRef(null); const updateDraft = ( transform: (current: RegionalSurfacePlanDraft) => RegionalSurfacePlanDraft, ) => { setDraft(transform); setError(null); setReadback(null); + setFileStatus(null); setValidationAttempted(false); }; const updateRegion = (index: number, value: RegionalOverlayDraft) => @@ -152,7 +163,7 @@ export function RegionalSurfacePlanPanel() { const validate = () => { setValidationAttempted(true); try { - const request = buildGroundRegionalSurfacePlanRequest(draft); + const request = regionalSurfacePlanRequestForDraft(draft, importedRequest); setError(null); setReadback( `${request.schema_version} · ${request.unit_system} · ` + @@ -165,6 +176,35 @@ export function RegionalSurfacePlanPanel() { setError(caught instanceof Error ? caught.message : "Surface plan is invalid"); } }; + const importFile = async (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (file === undefined) return; + try { + const request = await readRegionalSurfacePlanFile(file); + const importedDraft = editorDraftFromGroundRegionalSurfacePlanRequest(request); + setDraft(importedDraft); + setImportedRequest(request); + setValidationAttempted(false); + setError(null); + setReadback(`${request.schema_version} · exact imported provenance ${request.provenance.input_sha256}`); + setFileStatus(`Imported ${file.name}. No physics executed.`); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Regional plan import failed"); + setFileStatus("Import failed; the editor and prior validated readback were preserved."); + } + }; + const download = () => { + try { + const request = regionalSurfacePlanRequestForDraft(draft, importedRequest); + downloadRegionalSurfacePlanRequest(request); + setError(null); + setFileStatus("Download prepared. Your browser controls the destination and overwrite behavior."); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Regional plan download failed"); + setFileStatus("Download failed; no browser filesystem state was changed."); + } + }; return (
@@ -181,7 +221,8 @@ export function RegionalSurfacePlanPanel() { This slice validates a material plan only; it does not run physics or playback.

- Session-only draft: the current web workspace adapter does not persist model inputs. + Import/download persists this canonical request only; workspace persistence remains separate. + Browser downloads cannot promise a native path, atomic replacement, or recent-file access.

Plan identity and provenance @@ -242,6 +283,9 @@ export function RegionalSurfacePlanPanel() { }))} /> ))}
+ { void importFile(event); }} /> + +
); } diff --git a/src/rate_of_closure/web/src/model/regionalSurfacePlan.ts b/src/rate_of_closure/web/src/model/regionalSurfacePlan.ts index 33301feeb..a52cdc0d3 100644 --- a/src/rate_of_closure/web/src/model/regionalSurfacePlan.ts +++ b/src/rate_of_closure/web/src/model/regionalSurfacePlan.ts @@ -6,6 +6,7 @@ import { GROUND_REGIONAL_PLAN_REQUEST_VERSION, parseGroundRegionalMaterialPlanRequest, type GroundRegionalMaterialPlanRequest, + type GroundRegionalMaterialRegion, } from "./groundRegionalPlan"; import { GROUND_TARGET_FRAME, type GroundSurfaceProfile } from "./flightGroundTypes"; import { canonicalGroundJson } from "./flightGroundContract"; @@ -106,6 +107,70 @@ const surfacePayload = (surface: SurfaceMaterialDraft): GroundSurfaceProfile => surface_velocity_m_s: [0, 0, 0], }); +const surfaceDraft = (surface: GroundSurfaceProfile): SurfaceMaterialDraft => ({ + surface_id: surface.surface_id, + normal_restitution: surface.normal_restitution, + static_friction: surface.static_friction, + kinetic_friction: surface.kinetic_friction, + rolling_resistance: surface.rolling_resistance, + firmness_pa: surface.firmness_pa, + hardness_fraction: surface.hardness_fraction, + grass_height_m: surface.grass_height_m, + compressibility_fraction: surface.compressibility_fraction, + compression_damping_fraction: surface.compression_damping_fraction, + turf_density_kg_m3: surface.turf_density_kg_m3, + moisture_fraction: surface.moisture_fraction, +}); + +const assertEditorSurface = (surface: GroundSurfaceProfile): void => { + if (surface.provider_id !== REGIONAL_SURFACE_EDITOR_PROVIDER || + surface.provider_version !== REGIONAL_SURFACE_EDITOR_VERSION) { + throw new RangeError("surface is not qualified by the editor provider v1"); + } +}; + +const overlayDraft = (region: GroundRegionalMaterialRegion): RegionalOverlayDraft => ({ + region_id: region.region_id, + precedence: region.precedence, + lower_coordinate_m: region.lower_coordinate_m, + upper_coordinate_m: region.upper_coordinate_m, + surface: surfaceDraft(region.surface), +}); + +/** Project a fully validated editor-v1 request without relabelling evidence. */ +export const editorDraftFromGroundRegionalSurfacePlanRequest = ( + request: GroundRegionalMaterialPlanRequest, +): RegionalSurfacePlanDraft => { + if (request.provenance.producer !== REGIONAL_SURFACE_EDITOR_PROVIDER || + request.provenance.producer_version !== REGIONAL_SURFACE_EDITOR_VERSION) { + throw new RangeError("request is not qualified by the editor producer v1"); + } + if (canonicalGroundJson(request.axis_origin_m) !== "[0,0,0]" || + canonicalGroundJson(request.axis_unit) !== "[1,0,0]") { + throw new RangeError("request uses an unsupported editor axis qualification"); + } + if (request.regions.length > MAX_REGIONAL_SURFACE_EDITOR_ROWS) { + throw new RangeError( + `editor supports one to at most ${MAX_REGIONAL_SURFACE_EDITOR_ROWS} regions`, + ); + } + assertEditorSurface(request.base_surface); + request.regions.forEach((region) => assertEditorSurface(region.surface)); + const draft: RegionalSurfacePlanDraft = { + request_id: request.request_id, + lower_coordinate_m: request.lower_coordinate_m, + upper_coordinate_m: request.upper_coordinate_m, + source_revision: request.provenance.source_revision, + calibration_kind: "unvalidated", + base_surface: surfaceDraft(request.base_surface), + regions: request.regions.map(overlayDraft), + }; + if (request.provenance.input_sha256 !== sha256Text(canonicalGroundJson(draft))) { + throw new RangeError("editor provenance digest does not match the editable request"); + } + return draft; +}; + export const buildGroundRegionalSurfacePlanRequest = ( draft: RegionalSurfacePlanDraft, ): GroundRegionalMaterialPlanRequest => { @@ -145,3 +210,17 @@ export const buildGroundRegionalSurfacePlanRequest = ( schema_version: GROUND_REGIONAL_PLAN_REQUEST_VERSION, }); }; + +/** Keep untouched imported evidence exact; bind fresh provenance after edits. */ +export const regionalSurfacePlanRequestForDraft = ( + draft: RegionalSurfacePlanDraft, + importedRequest: GroundRegionalMaterialPlanRequest | null = null, +): GroundRegionalMaterialPlanRequest => { + if (importedRequest !== null) { + const importedDraft = editorDraftFromGroundRegionalSurfacePlanRequest(importedRequest); + if (canonicalGroundJson(draft) === canonicalGroundJson(importedDraft)) { + return importedRequest; + } + } + return buildGroundRegionalSurfacePlanRequest(draft); +}; diff --git a/src/rate_of_closure/web/src/model/regionalSurfacePlanFiles.ts b/src/rate_of_closure/web/src/model/regionalSurfacePlanFiles.ts new file mode 100644 index 000000000..2ed7359b7 --- /dev/null +++ b/src/rate_of_closure/web/src/model/regionalSurfacePlanFiles.ts @@ -0,0 +1,40 @@ +/** Browser-only strict import/download boundary for regional plan requests. */ + +import { + MAX_GROUND_REGIONAL_PLAN_WIRE_BYTES, + groundRegionalMaterialPlanRequestFromJson, + stableGroundRegionalMaterialPlanJson, + type GroundRegionalMaterialPlanRequest, +} from "./groundRegionalPlan"; + +export interface RegionalPlanFile { + readonly name: string; + readonly size: number; + text(): Promise; +} + +export const readRegionalSurfacePlanFile = async ( + file: RegionalPlanFile, +): Promise => { + if (file.size > MAX_GROUND_REGIONAL_PLAN_WIRE_BYTES) { + throw new RangeError("regional material plan exceeds maximum wire size"); + } + return groundRegionalMaterialPlanRequestFromJson(await file.text()); +}; + +export const downloadRegionalSurfacePlanRequest = ( + request: GroundRegionalMaterialPlanRequest, +): void => { + const text = stableGroundRegionalMaterialPlanJson(request); + const url = URL.createObjectURL( + new Blob([text], { type: "application/json;charset=utf-8" }), + ); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "regional-surface-plan.json"; + anchor.click(); + } finally { + URL.revokeObjectURL(url); + } +}; diff --git a/tests/rate_of_closure/test_regional_surface_plan.py b/tests/rate_of_closure/test_regional_surface_plan.py index 1b6bdcd85..77d1a54f1 100644 --- a/tests/rate_of_closure/test_regional_surface_plan.py +++ b/tests/rate_of_closure/test_regional_surface_plan.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import replace +from pathlib import Path import pytest @@ -121,3 +122,156 @@ def test_pyqt_editor_invalidates_validated_readback_after_draft_change(qtbot) -> assert tab.readback.toPlainText() == "" assert tab.status_label.text() == "Changes not validated" + + +def test_pyqt_open_applies_only_a_fully_valid_editor_request( + qtbot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: # type: ignore[no-untyped-def] + pytest.importorskip("PyQt6") + from rate_of_closure.ui.pyqt6 import regional_surface_plan_io + from rate_of_closure.ui.pyqt6.regional_surface_plan_tab import ( + RegionalSurfacePlanTab, + ) + + request = validate_regional_surface_plan_draft( + replace(illustrative_regional_surface_plan_draft(), request_id="opened-plan") + ) + target = tmp_path / "opened.json" + target.write_text(request.to_json(), encoding="utf-8") + monkeypatch.setattr( + regional_surface_plan_io.QFileDialog, + "getOpenFileName", + lambda *_args: (str(target), "JSON files (*.json)"), + ) + tab = RegionalSurfacePlanTab() + qtbot.addWidget(tab) + + tab.open_button.click() + + assert tab.request_id.text() == "opened-plan" + assert tab.file_actions.recent_path == target + assert "opened" in tab.status_label.text().lower() + assert tab.readback.toPlainText() == request.to_json() + + +def test_pyqt_failed_open_rolls_back_editor_and_recent_path( + qtbot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: # type: ignore[no-untyped-def] + pytest.importorskip("PyQt6") + from rate_of_closure.ui.pyqt6 import regional_surface_plan_io + from rate_of_closure.ui.pyqt6.regional_surface_plan_tab import ( + RegionalSurfacePlanTab, + ) + + target = tmp_path / "corrupt.json" + target.write_text('{"request_id":"one","request_id":"two"}', encoding="utf-8") + monkeypatch.setattr( + regional_surface_plan_io.QFileDialog, + "getOpenFileName", + lambda *_args: (str(target), "JSON files (*.json)"), + ) + tab = RegionalSurfacePlanTab() + qtbot.addWidget(tab) + before = tab.draft() + + tab.open_button.click() + + assert tab.draft() == before + assert tab.file_actions.recent_path is None + assert "open failed" in tab.status_label.text().lower() + assert tab.status_label.accessibleName() == "Regional surface plan file error" + + +def test_pyqt_save_as_preserves_imported_request_bytes( + qtbot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: # type: ignore[no-untyped-def] + pytest.importorskip("PyQt6") + from rate_of_closure.ui.pyqt6 import regional_surface_plan_io + from rate_of_closure.ui.pyqt6.regional_surface_plan_tab import ( + RegionalSurfacePlanTab, + ) + + request = validate_regional_surface_plan_draft( + replace(illustrative_regional_surface_plan_draft(), request_id="saved-plan") + ) + source = tmp_path / "source.json" + destination = tmp_path / "copy.json" + source.write_text(request.to_json(), encoding="utf-8") + choices = iter(((str(source), "JSON files (*.json)"),)) + monkeypatch.setattr( + regional_surface_plan_io.QFileDialog, + "getOpenFileName", + lambda *_args: next(choices), + ) + monkeypatch.setattr( + regional_surface_plan_io.QFileDialog, + "getSaveFileName", + lambda *_args: (str(destination), "JSON files (*.json)"), + ) + tab = RegionalSurfacePlanTab() + qtbot.addWidget(tab) + + tab.open_button.click() + tab.save_button.click() + + assert destination.read_bytes() == source.read_bytes() + assert tab.file_actions.recent_path == destination + + +def test_pyqt_import_preserves_canonical_precision_and_large_si_values( + qtbot, +) -> None: # type: ignore[no-untyped-def] + pytest.importorskip("PyQt6") + from rate_of_closure.ui.pyqt6.regional_surface_plan_tab import ( + RegionalSurfacePlanTab, + ) + + draft = illustrative_regional_surface_plan_draft() + request = validate_regional_surface_plan_draft( + replace( + draft, + upper_coordinate_m=1_000_000_000_000.0, + base_surface=replace( + draft.base_surface, + firmness_pa=0.00000000001, + grass_height_m=123.12345678901, + turf_density_kg_m3=20_000.12345678901, + ), + ) + ) + tab = RegionalSurfacePlanTab() + qtbot.addWidget(tab) + + tab.apply_imported_request(request) + + assert tab.current_request() is request + + +def test_pyqt_cancelled_open_and_save_leave_status_and_recent_unchanged( + qtbot, monkeypatch: pytest.MonkeyPatch +) -> None: # type: ignore[no-untyped-def] + pytest.importorskip("PyQt6") + from rate_of_closure.ui.pyqt6 import regional_surface_plan_io + from rate_of_closure.ui.pyqt6.regional_surface_plan_tab import ( + RegionalSurfacePlanTab, + ) + + monkeypatch.setattr( + regional_surface_plan_io.QFileDialog, + "getOpenFileName", + lambda *_args: ("", ""), + ) + monkeypatch.setattr( + regional_surface_plan_io.QFileDialog, + "getSaveFileName", + lambda *_args: ("", ""), + ) + tab = RegionalSurfacePlanTab() + qtbot.addWidget(tab) + status = tab.status_label.text() + + tab.open_button.click() + tab.save_button.click() + + assert tab.status_label.text() == status + assert tab.file_actions.recent_path is None diff --git a/tests/rate_of_closure/test_regional_surface_plan_io.py b/tests/rate_of_closure/test_regional_surface_plan_io.py new file mode 100644 index 000000000..822a69fa7 --- /dev/null +++ b/tests/rate_of_closure/test_regional_surface_plan_io.py @@ -0,0 +1,140 @@ +"""Canonical regional surface-plan import/export boundary tests.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from rate_of_closure.application.regional_surface_plan import ( + EDITOR_PROVIDER_ID, + editor_draft_from_regional_surface_plan_request, + illustrative_regional_surface_plan_draft, + regional_surface_plan_request_for_draft, + validate_regional_surface_plan_draft, +) +from rate_of_closure.application.regional_surface_plan_files import ( + read_regional_surface_plan_request, + write_regional_surface_plan_request_atomic, +) +from shared.python.swing_sim.ground.regional_plan_records import ( + MAX_REGIONAL_PLAN_WIRE_BYTES, +) +from shared.python.swing_sim.ground.regional_plan_wire import ( + regional_material_plan_request_from_dict, +) + +pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] + + +def test_editor_request_file_round_trip_is_exact_and_deterministic( + tmp_path: Path, +) -> None: + request = validate_regional_surface_plan_draft( + illustrative_regional_surface_plan_draft() + ) + target = tmp_path / "regional-plan.json" + + assert write_regional_surface_plan_request_atomic(request, target) + + assert target.read_bytes() == request.to_json().encode("utf-8") + loaded = read_regional_surface_plan_request(target) + assert loaded == request + imported = editor_draft_from_regional_surface_plan_request(loaded) + assert regional_surface_plan_request_for_draft(imported, loaded) == request + + +def test_cancelled_request_write_is_a_no_op(tmp_path: Path) -> None: + request = validate_regional_surface_plan_draft( + illustrative_regional_surface_plan_draft() + ) + + assert write_regional_surface_plan_request_atomic(request, None) is False + assert list(tmp_path.iterdir()) == [] + + +def test_request_replace_failure_preserves_last_known_good( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from rate_of_closure.application import atomic_text_files + + request = validate_regional_surface_plan_draft( + illustrative_regional_surface_plan_draft() + ) + target = tmp_path / "regional-plan.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_surface_plan_request_atomic(request, target) + + assert target.read_text(encoding="utf-8") == "last-known-good" + assert not list(tmp_path.glob(".*.tmp")) + + +@pytest.mark.parametrize( + "text, message", + [ + ("{not-json", "JSON"), + ('{"request_id":"one","request_id":"two"}', "[Dd]uplicate"), + (" " * (MAX_REGIONAL_PLAN_WIRE_BYTES + 1), "maximum wire size"), + ], + ids=("corrupt", "duplicate", "oversize"), +) +def test_request_read_rejects_corruption_duplicates_and_oversize( + tmp_path: Path, text: str, message: str +) -> None: + target = tmp_path / "invalid.json" + target.write_text(text, encoding="utf-8") + + with pytest.raises((TypeError, ValueError), match=message): + read_regional_surface_plan_request(target) + + +def test_import_rejects_non_editor_provenance_without_coercion() -> None: + request = validate_regional_surface_plan_draft( + illustrative_regional_surface_plan_draft() + ) + payload = request.to_dict() + payload["provenance"]["producer"] = "external.course.authority" + external = regional_material_plan_request_from_dict(payload) + + with pytest.raises(ValueError, match="editor producer"): + editor_draft_from_regional_surface_plan_request(external) + + payload = request.to_dict() + payload["provenance"]["input_sha256"] = "0" * 64 + mismatched = regional_material_plan_request_from_dict(payload) + with pytest.raises(ValueError, match="digest does not match"): + editor_draft_from_regional_surface_plan_request(mismatched) + + +def test_import_rejects_editor_provider_with_unsupported_axis() -> None: + request = validate_regional_surface_plan_draft( + illustrative_regional_surface_plan_draft() + ) + payload = request.to_dict() + payload["axis_unit"] = [0.0, 0.0, 1.0] + rotated = regional_material_plan_request_from_dict(payload) + + with pytest.raises(ValueError, match="axis qualification"): + editor_draft_from_regional_surface_plan_request(rotated) + + +def test_edited_import_rebinds_provenance_instead_of_reusing_stale_digest() -> None: + request = validate_regional_surface_plan_draft( + illustrative_regional_surface_plan_draft() + ) + imported = editor_draft_from_regional_surface_plan_request(request) + edited = replace(imported, request_id="edited-plan") + + rebound = regional_surface_plan_request_for_draft(edited, request) + + assert rebound.request_id == "edited-plan" + assert rebound.provenance.producer == EDITOR_PROVIDER_ID + assert rebound.provenance.input_sha256 != request.provenance.input_sha256 diff --git a/tests/rate_of_closure/test_workspace_files.py b/tests/rate_of_closure/test_workspace_files.py index a7dcd85d3..5e260af4a 100644 --- a/tests/rate_of_closure/test_workspace_files.py +++ b/tests/rate_of_closure/test_workspace_files.py @@ -70,13 +70,15 @@ def fail_serialization(_document: WorkspaceDocument) -> str: def test_replace_failure_preserves_existing_and_removes_temporary_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + from rate_of_closure.application import atomic_text_files + target = tmp_path / "showcase.json" target.write_text("last-known-good", encoding="utf-8") def fail_replace(_source: str | Path, _target: str | Path) -> None: raise OSError("replace failed") - monkeypatch.setattr(workspace_files.os, "replace", fail_replace) + monkeypatch.setattr(atomic_text_files.os, "replace", fail_replace) with pytest.raises(OSError, match="replace failed"): workspace_files.write_workspace_atomic(_document("New"), target) From 10c394f6b1fd2927e7f3b1f96cc097cae6bfd380 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 00:59:27 -0700 Subject: [PATCH 2/7] fix(rate): preserve regional safe-number parity --- AGENT_HANDOFF.md | 24 +++++- SPEC.md | 9 +- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 22 +++++ docs/release/rate_of_closure_campaign.v1.json | 2 +- docs/specs/GROUND_SKID_ROLL.md | 4 + src/rate_of_closure/AGENT_HANDOFF.md | 24 +++++- .../ui/pyqt6/regional_surface_plan_tab.py | 2 +- .../ui/pyqt6/regional_surface_plan_widgets.py | 84 ++++++++++++++++--- .../RegionalSurfacePlanPanel.test.tsx | 26 ++++++ .../swing_sim/canonical_numeric_json.py | 12 ++- .../python/swing_sim/ground/contract_types.py | 12 ++- .../ground/tests/test_contract_api.py | 4 + .../test_regional_surface_plan.py | 11 ++- .../test_regional_surface_plan_io.py | 11 +++ 14 files changed, 222 insertions(+), 25 deletions(-) diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index a1a721473..ee6e4b8c2 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,5 +1,27 @@ # AGENT_HANDOFF — Tools +## 2026-08-11 regional request I/O safe-number follow-up + +Independent review of local commit `e39edf4b50b1fb9811b0032bec4758c7a08c9b74` +found two cross-runtime representation gaps. The PyQt6 precedence spin box +silently narrowed otherwise valid v1 integers above 1,000,000, and Python +accepted integer-valued wire numbers above JavaScript's exact safe range. + +The native editor now uses an exact decimal integer entry for precedence and +preserves every v1 value from zero through 9,007,199,254,740,991 without a +floating-point conversion. Shared Python canonical-number and ground-record +validation now reject magnitudes outside that same range, matching React while +retaining all finite fractional values inside it. PyQt6 floating-point editors +publish the same bounds. Regression coverage pins exact maximum-precedence +open/save round-trip and matched native/browser refusal of unsafe material +numbers. This changes no schema version, provenance algorithm, physics, or +browser filesystem limitation. Final local evidence is 132 shared ground tests, +21 regional editor/I/O tests, five atomic-workspace tests, 23 focused React +tests, the whole-shell tooltip sweep, and eight manifest tests passing. Ruff, +Ruff format, Black, focused MyPy, TypeScript, zero-warning ESLint, the 199-module +production build, module and changed-file size budgets, documentation +governance, and diff checks pass. The child remains local and unprotected. + ## 2026-08-10 PR #4339 stale-validation invalidation follow-up Rendered exact-head browser QA found that a validated one-overlay readback @@ -17,7 +39,7 @@ is unrelated. This changes no wire schema, provenance digest, physics, or persistence boundary; fresh protected CI is still required before merge. > Update this file in every implementation commit and every push to `main`. -> Last updated: 2026-08-10. +> Last updated: 2026-08-11. ## 2026-08-10 issue #4274 canonical regional request I/O local child diff --git a/SPEC.md b/SPEC.md index cf6fbab4e..082c55d83 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,9 +26,9 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.14.29 | -| **Spec Version** | 1.14.29 | -| **Last Spec Update** | 2026-08-10 | +| **Current Version** | 1.14.30 | +| **Spec Version** | 1.14.30 | +| **Last Spec Update** | 2026-08-11 | ## 2. Purpose & Mission @@ -69,6 +69,9 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific qualification, and row capacity. Unchanged imports preserve the exact request and provenance; edits create a fresh draft digest. External or differently qualified requests are rejected without coercion. +- Both runtimes accept wire numbers only inside JavaScript's exact safe range. + Native precedence entry remains integer-exact through the inclusive v1 + maximum of 9,007,199,254,740,991 and cannot silently narrow imported data. - Ground execution/playback, result interchange, measured calibration workflows, model-input workspace persistence, compiled regional physics, and UpstreamDrift consumers remain explicit non-goals. diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 111b65b14..bf5169d5d 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,27 @@ # Rate of Closure Ball-Flight Campaign Handoff +## 2026-08-11 regional request I/O safe-number follow-up + +Independent review of local commit `e39edf4b50b1fb9811b0032bec4758c7a08c9b74` +found two cross-runtime representation gaps. The PyQt6 precedence spin box +silently narrowed otherwise valid v1 integers above 1,000,000, and Python +accepted integer-valued wire numbers above JavaScript's exact safe range. + +The native editor now uses an exact decimal integer entry for precedence and +preserves every v1 value from zero through 9,007,199,254,740,991 without a +floating-point conversion. Shared Python canonical-number and ground-record +validation now reject magnitudes outside that same range, matching React while +retaining all finite fractional values inside it. PyQt6 floating-point editors +publish the same bounds. Regression coverage pins exact maximum-precedence +open/save round-trip and matched native/browser refusal of unsafe material +numbers. This changes no schema version, provenance algorithm, physics, or +browser filesystem limitation. Final local evidence is 132 shared ground tests, +21 regional editor/I/O tests, five atomic-workspace tests, 23 focused React +tests, the whole-shell tooltip sweep, and eight manifest tests passing. Ruff, +Ruff format, Black, focused MyPy, TypeScript, zero-warning ESLint, the 199-module +production build, module and changed-file size budgets, documentation +governance, and diff checks pass. The child remains local and unprotected. + ## 2026-08-10 PR #4339 stale-validation invalidation follow-up Rendered exact-head browser QA found that a validated one-overlay readback diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json index f5265d35b..d2a4208bc 100644 --- a/docs/release/rate_of_closure_campaign.v1.json +++ b/docs/release/rate_of_closure_campaign.v1.json @@ -1123,7 +1123,7 @@ "ground-impact-local-63a6", "ground-skid-roll-local-f475" ], - "evidence_gap": "The contract, flight-transfer, impact/bounce, and static-plane skid/roll carriers are open. Issues #4270 and #4271 are published on draft PRs 4302 and 4304 with exact local evidence but no protected evidence. Local unprotected children add coplanar regional-material reference physics, a strict Python/TypeScript plan wire boundary, matched PyQt6/React request editors, and strict editor-qualified canonical request import/export with native atomic semantics or explicit browser limitations. Protected editor/I/O evidence, execution/playback, result interchange, workspace model-input persistence, measured calibration, changing geometry, compiled-runtime physics, and downstream parity remain absent.", + "evidence_gap": "The contract, flight-transfer, impact/bounce, and static-plane skid/roll carriers are open. Issues #4270 and #4271 are published on draft PRs 4302 and 4304 with exact local evidence but no protected evidence. Local unprotected children add coplanar regional-material reference physics, a strict Python/TypeScript plan wire boundary, matched PyQt6/React request editors, and strict editor-qualified canonical request import/export with native atomic semantics or explicit browser limitations. A local review follow-up preserves the full exact safe-integer precedence range and aligns Python numeric acceptance with React. Protected editor/I/O evidence, execution/playback, result interchange, workspace model-input persistence, measured calibration, changing geometry, 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.", diff --git a/docs/specs/GROUND_SKID_ROLL.md b/docs/specs/GROUND_SKID_ROLL.md index d74a26c6b..dee8991ad 100644 --- a/docs/specs/GROUND_SKID_ROLL.md +++ b/docs/specs/GROUND_SKID_ROLL.md @@ -210,6 +210,10 @@ transactional and accepts only the editor producer/provider v1, fixed qualified axis/geometry, and editor row capacity. An unchanged import retains the exact request and provenance; editing rebinds the draft digest. Browser downloads cannot promise a native path, atomic replacement, or recent-file access. +Wire numbers are bounded to the shared cross-runtime safe range. Native +precedence entry preserves every nonnegative integer through +9,007,199,254,740,991 exactly, so a qualified import cannot be silently narrowed +before validation or Save As. Neither client claims execution, result playback, or measured-course calibration. Those capabilities require separate contracts and acceptance evidence. diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index b362bccd7..b04434802 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,5 +1,27 @@ # AGENT_HANDOFF — rate_of_closure +## 2026-08-11 regional request I/O safe-number follow-up + +Independent review of local commit `e39edf4b50b1fb9811b0032bec4758c7a08c9b74` +found two cross-runtime representation gaps. The PyQt6 precedence spin box +silently narrowed otherwise valid v1 integers above 1,000,000, and Python +accepted integer-valued wire numbers above JavaScript's exact safe range. + +The native editor now uses an exact decimal integer entry for precedence and +preserves every v1 value from zero through 9,007,199,254,740,991 without a +floating-point conversion. Shared Python canonical-number and ground-record +validation now reject magnitudes outside that same range, matching React while +retaining all finite fractional values inside it. PyQt6 floating-point editors +publish the same bounds. Regression coverage pins exact maximum-precedence +open/save round-trip and matched native/browser refusal of unsafe material +numbers. This changes no schema version, provenance algorithm, physics, or +browser filesystem limitation. Final local evidence is 132 shared ground tests, +21 regional editor/I/O tests, five atomic-workspace tests, 23 focused React +tests, the whole-shell tooltip sweep, and eight manifest tests passing. Ruff, +Ruff format, Black, focused MyPy, TypeScript, zero-warning ESLint, the 199-module +production build, module and changed-file size budgets, documentation +governance, and diff checks pass. The child remains local and unprotected. + ## 2026-08-10 PR #4339 stale-validation invalidation follow-up Rendered exact-head browser QA found that a validated one-overlay readback @@ -17,7 +39,7 @@ is unrelated. This changes no wire schema, provenance digest, physics, or persistence boundary; fresh protected CI is still required before merge. > **Update this file with every PR and every push to main.** -> Last updated: 2026-08-10 +> Last updated: 2026-08-11 ## 2026-08-10 issue #4274 canonical regional request I/O local child diff --git a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py index 807e3f5f0..9f39d2219 100644 --- a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py +++ b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_tab.py @@ -70,7 +70,7 @@ def _connect_static_changes(self) -> None: def _connect_row_changes(self, row: RegionalOverlayRow) -> None: """Connect one dynamic overlay to the shared invalidation boundary.""" row.region_id.textChanged.connect(self._mark_dirty) - row.precedence.valueChanged.connect(self._mark_dirty) + row.precedence.textChanged.connect(self._mark_dirty) row.lower_coordinate.valueChanged.connect(self._mark_dirty) row.upper_coordinate.valueChanged.connect(self._mark_dirty) self._connect_material_changes(row.material) diff --git a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py index aac724c1f..19b2cc9b2 100644 --- a/src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py +++ b/src/rate_of_closure/ui/pyqt6/regional_surface_plan_widgets.py @@ -5,13 +5,13 @@ from collections.abc import Callable from PyQt6.QtCore import Qt +from PyQt6.QtGui import QValidator from PyQt6.QtWidgets import ( QDoubleSpinBox, QFormLayout, QGroupBox, QLineEdit, QPushButton, - QSpinBox, QVBoxLayout, ) @@ -19,15 +19,21 @@ RegionalOverlayDraft, SurfaceMaterialDraft, ) +from shared.python.swing_sim.canonical_numeric_json import ( + MAX_CANONICAL_SAFE_INTEGER, +) + +_MAX_SAFE_FLOAT = float(MAX_CANONICAL_SAFE_INTEGER) +_MAX_SAFE_INTEGER_TEXT = str(MAX_CANONICAL_SAFE_INTEGER) _MATERIAL_FIELDS = ( ("normal_restitution", "Normal restitution", "", 0.01, 0.0, 1.0), ("static_friction", "Static friction", "", 0.01, 0.0, 5.0), ("kinetic_friction", "Kinetic friction", "", 0.01, 0.0, 5.0), ("rolling_resistance", "Rolling resistance", "", 0.01, 0.0, 1.0), - ("firmness_pa", "Firmness", " Pa", 1_000.0, 1e-11, 1e100), + ("firmness_pa", "Firmness", " Pa", 1_000.0, 1e-11, _MAX_SAFE_FLOAT), ("hardness_fraction", "Hardness", " fraction", 0.01, 0.0, 1.0), - ("grass_height_m", "Grass height", " m", 0.001, 0.0, 1e100), + ("grass_height_m", "Grass height", " m", 0.001, 0.0, _MAX_SAFE_FLOAT), ("compressibility_fraction", "Compressibility", " fraction", 0.01, 0.0, 1.0), ( "compression_damping_fraction", @@ -37,7 +43,14 @@ 0.0, 1.0, ), - ("turf_density_kg_m3", "Turf density", " kg/m³", 1.0, 0.0, 1e100), + ( + "turf_density_kg_m3", + "Turf density", + " kg/m³", + 1.0, + 0.0, + _MAX_SAFE_FLOAT, + ), ("moisture_fraction", "Moisture", " fraction", 0.01, 0.0, 1.0), ) @@ -47,8 +60,8 @@ def number_input( value: float, suffix: str = "", step: float = 0.1, - minimum: float = -1e100, - maximum: float = 1e100, + minimum: float = -_MAX_SAFE_FLOAT, + maximum: float = _MAX_SAFE_FLOAT, ) -> QDoubleSpinBox: """Create one canonical-precision accessible SI number input.""" field = QDoubleSpinBox() @@ -64,6 +77,52 @@ def number_input( return field +class _SafeIntegerValidator(QValidator): + """Accept one decimal integer in the shared JSON-safe nonnegative range.""" + + def validate( + self, input_text: str | None, position: int + ) -> tuple[QValidator.State, str, int]: + input_text = input_text or "" + if input_text == "": + state = QValidator.State.Intermediate + elif not input_text.isascii() or not input_text.isdecimal(): + state = QValidator.State.Invalid + elif len(input_text) < len(_MAX_SAFE_INTEGER_TEXT): + state = QValidator.State.Acceptable + elif input_text <= _MAX_SAFE_INTEGER_TEXT: + state = QValidator.State.Acceptable + else: + state = QValidator.State.Invalid + return state, input_text, position + + +class SafeIntegerEdit(QLineEdit): + """Exact integer editor that does not narrow the v1 safe-integer contract.""" + + def __init__(self, name: str, value: int) -> None: + super().__init__() + self._name = name + self.setAccessibleName(name) + self.setMaxLength(len(_MAX_SAFE_INTEGER_TEXT)) + self.setValidator(_SafeIntegerValidator(self)) + self.set_value(value) + + def set_value(self, value: int) -> None: + """Set one already validated exact safe integer without float conversion.""" + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{self._name} must be an integer") + if not 0 <= value <= MAX_CANONICAL_SAFE_INTEGER: + raise ValueError(f"{self._name} must lie within cross-runtime safe range") + self.setText(str(value)) + + def value(self) -> int: + """Return the exact integer or reject an incomplete editor value.""" + if not self.hasAcceptableInput(): + raise ValueError(f"{self._name} must lie within cross-runtime safe range") + return int(self.text()) + + class MaterialEditor(QGroupBox): """Editable surface identity and full v1 material parameter collection.""" @@ -110,9 +169,9 @@ def __init__( self.region_id.setToolTip( "Stable overlay identifier; it must be unique within the regional plan." ) - self.precedence = QSpinBox() - self.precedence.setRange(0, 1_000_000) - self.precedence.setValue(value.precedence) + self.precedence = SafeIntegerEdit( + f"Overlay {ordinal} precedence", value.precedence + ) self.precedence.setToolTip( "Overlay selection precedence. Higher values win when intervals overlap." ) @@ -149,4 +208,9 @@ def draft(self) -> RegionalOverlayDraft: ) -__all__ = ["MaterialEditor", "RegionalOverlayRow", "number_input"] +__all__ = [ + "MaterialEditor", + "RegionalOverlayRow", + "SafeIntegerEdit", + "number_input", +] diff --git a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx index 88570fdd9..72d77dc51 100644 --- a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx +++ b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx @@ -58,6 +58,32 @@ describe("RegionalSurfacePlanPanel", () => { .not.toBeInTheDocument(); }); + it("accepts the exact cross-runtime maximum precedence", () => { + render(); + fireEvent.change(screen.getByLabelText("Overlay 1 precedence"), { + target: { value: String(Number.MAX_SAFE_INTEGER) }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Validate surface plan" })); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Regional surface plan readback" })) + .toHaveTextContent("1 overlay(s)"); + }); + + it("rejects integer-valued material numbers beyond the cross-runtime safe range", () => { + render(); + fireEvent.change(screen.getByLabelText("Base Firmness (Pa)"), { + target: { value: "10000000000000000" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Validate surface plan" })); + + expect(screen.getByRole("alert")).toHaveTextContent(/cross-runtime safe range/i); + expect(screen.queryByRole("status", { name: "Regional surface plan readback" })) + .not.toBeInTheDocument(); + }); + it("links invalid interval fields to an accessible error without clearing input", () => { render(); const lower = screen.getByLabelText("Overlay 1 lower coordinate (m)"); diff --git a/src/shared/python/swing_sim/canonical_numeric_json.py b/src/shared/python/swing_sim/canonical_numeric_json.py index 9934a77c1..2e3c978a2 100644 --- a/src/shared/python/swing_sim/canonical_numeric_json.py +++ b/src/shared/python/swing_sim/canonical_numeric_json.py @@ -9,7 +9,7 @@ from typing import Any _FLOAT_QUANTUM = Decimal("0.00000000001") -_MAX_SAFE_INTEGER = 9_007_199_254_740_991 +MAX_CANONICAL_SAFE_INTEGER = 9_007_199_254_740_991 def _string_token(value: str) -> str: @@ -21,6 +21,8 @@ def _string_token(value: str) -> str: def _canonical_float_token(value: float) -> str: if not math.isfinite(value): raise ValueError("canonical JSON requires finite floats") + if abs(value) > MAX_CANONICAL_SAFE_INTEGER: + raise ValueError("canonical JSON number exceeds cross-runtime safe range") if value == 0 or value.is_integer(): return "0" if value == 0 else str(int(value)) rounded = Decimal.from_float(value).quantize(_FLOAT_QUANTUM, rounding=ROUND_HALF_UP) @@ -43,7 +45,7 @@ def canonical_numeric_json(value: Any) -> str: if isinstance(value, float): return _canonical_float_token(value) if isinstance(value, int): - if abs(value) > _MAX_SAFE_INTEGER: + if abs(value) > MAX_CANONICAL_SAFE_INTEGER: raise ValueError("canonical JSON integer exceeds cross-runtime safe range") return str(value) if isinstance(value, str): @@ -61,4 +63,8 @@ def canonical_numeric_json(value: Any) -> str: raise TypeError(f"unsupported canonical JSON value: {type(value).__name__}") -__all__ = ["canonical_numeric_float", "canonical_numeric_json"] +__all__ = [ + "MAX_CANONICAL_SAFE_INTEGER", + "canonical_numeric_float", + "canonical_numeric_json", +] diff --git a/src/shared/python/swing_sim/ground/contract_types.py b/src/shared/python/swing_sim/ground/contract_types.py index 1844ddb68..deb2979e1 100644 --- a/src/shared/python/swing_sim/ground/contract_types.py +++ b/src/shared/python/swing_sim/ground/contract_types.py @@ -6,7 +6,10 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, TypeAlias -from shared.python.swing_sim.canonical_numeric_json import canonical_numeric_float +from shared.python.swing_sim.canonical_numeric_json import ( + MAX_CANONICAL_SAFE_INTEGER, + canonical_numeric_float, +) if TYPE_CHECKING: from enum import StrEnum @@ -18,7 +21,6 @@ UNIT_SYSTEM_SI = "SI" Vector3: TypeAlias = tuple[float, float, float] _UNIT_TOLERANCE = 1e-9 -_MAX_SAFE_INTEGER = 9_007_199_254_740_991 _MIN_CANONICAL_POSITIVE = 0.00000000001 _TEXT_EDGE_WHITESPACE = " \t\r\n\f\v" @@ -29,6 +31,8 @@ def _raw_finite(value: float, name: str) -> float: number = float(value) if not math.isfinite(number): raise ValueError(f"{name} must be finite") + if abs(number) > MAX_CANONICAL_SAFE_INTEGER: + raise ValueError(f"{name} must lie within the cross-runtime safe range") return number @@ -67,10 +71,10 @@ def _integer(value: int | float, name: str, minimum: int = 0) -> int: normalized = int(value) else: raise ValueError(f"{name} must be an integer") - if not minimum <= normalized <= _MAX_SAFE_INTEGER: + if not minimum <= normalized <= MAX_CANONICAL_SAFE_INTEGER: raise ValueError( f"{name} must lie within cross-runtime safe range " - f"[{minimum}, {_MAX_SAFE_INTEGER}]" + f"[{minimum}, {MAX_CANONICAL_SAFE_INTEGER}]" ) return normalized diff --git a/src/shared/python/swing_sim/ground/tests/test_contract_api.py b/src/shared/python/swing_sim/ground/tests/test_contract_api.py index 8c9815c96..fee25b250 100644 --- a/src/shared/python/swing_sim/ground/tests/test_contract_api.py +++ b/src/shared/python/swing_sim/ground/tests/test_contract_api.py @@ -178,6 +178,10 @@ def test_cross_runtime_integer_and_text_edges_fail_closed() -> None: replace(_request(), max_events=10**1000) with pytest.raises(ValueError, match="safe range"): canonical_numeric_json(unsafe_integer) + with pytest.raises(ValueError, match="safe range"): + canonical_numeric_json(float(unsafe_integer)) + with pytest.raises(ValueError, match="safe range"): + replace(_surface(), firmness_pa=float(unsafe_integer)) with pytest.raises(ValueError, match="surrogate"): replace(_request(), request_id="\ud800") with pytest.raises(ValueError, match="surrogate"): diff --git a/tests/rate_of_closure/test_regional_surface_plan.py b/tests/rate_of_closure/test_regional_surface_plan.py index 77d1a54f1..4939ccdbb 100644 --- a/tests/rate_of_closure/test_regional_surface_plan.py +++ b/tests/rate_of_closure/test_regional_surface_plan.py @@ -12,6 +12,9 @@ illustrative_regional_surface_plan_draft, validate_regional_surface_plan_draft, ) +from shared.python.swing_sim.canonical_numeric_json import ( + MAX_CANONICAL_SAFE_INTEGER, +) pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] @@ -191,8 +194,13 @@ def test_pyqt_save_as_preserves_imported_request_bytes( RegionalSurfacePlanTab, ) + draft = illustrative_regional_surface_plan_draft() request = validate_regional_surface_plan_draft( - replace(illustrative_regional_surface_plan_draft(), request_id="saved-plan") + replace( + draft, + request_id="saved-plan", + regions=(replace(draft.regions[0], precedence=MAX_CANONICAL_SAFE_INTEGER),), + ) ) source = tmp_path / "source.json" destination = tmp_path / "copy.json" @@ -216,6 +224,7 @@ def test_pyqt_save_as_preserves_imported_request_bytes( assert destination.read_bytes() == source.read_bytes() assert tab.file_actions.recent_path == destination + assert tab.current_request() == request def test_pyqt_import_preserves_canonical_precision_and_large_si_values( diff --git a/tests/rate_of_closure/test_regional_surface_plan_io.py b/tests/rate_of_closure/test_regional_surface_plan_io.py index 822a69fa7..8de7d7319 100644 --- a/tests/rate_of_closure/test_regional_surface_plan_io.py +++ b/tests/rate_of_closure/test_regional_surface_plan_io.py @@ -138,3 +138,14 @@ def test_edited_import_rebinds_provenance_instead_of_reusing_stale_digest() -> N assert rebound.request_id == "edited-plan" assert rebound.provenance.producer == EDITOR_PROVIDER_ID assert rebound.provenance.input_sha256 != request.provenance.input_sha256 + + +def test_editor_rejects_unsafe_integer_valued_number() -> None: + draft = illustrative_regional_surface_plan_draft() + unsafe = replace( + draft, + base_surface=replace(draft.base_surface, firmness_pa=10_000_000_000_000_000.0), + ) + + with pytest.raises(ValueError, match="cross-runtime safe range"): + validate_regional_surface_plan_draft(unsafe) From d748e7a5ef3da5e6ce7737ff6829e0f14665fe97 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 01:16:05 -0700 Subject: [PATCH 3/7] fix(rate): bound regional plan file reads --- AGENT_HANDOFF.md | 18 +++++++++ SPEC.md | 7 +++- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 18 +++++++++ docs/release/rate_of_closure_campaign.v1.json | 2 +- docs/specs/GROUND_SKID_ROLL.md | 3 ++ src/rate_of_closure/AGENT_HANDOFF.md | 18 +++++++++ .../regional_surface_plan_files.py | 24 +++++++++--- .../test_regional_surface_plan_io.py | 39 +++++++++++++++++++ 8 files changed, 121 insertions(+), 8 deletions(-) diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index ee6e4b8c2..43e2fc806 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,5 +1,23 @@ # AGENT_HANDOFF — Tools +## 2026-08-11 bounded regional request read follow-up + +Independent review of local safe-number commit +`10c394f6b1fd2927e7f3b1f96cc097cae6bfd380` found that native request import +checked file size with `stat()` and then performed a separate unbounded text +read. A concurrently grown or replaced file could therefore allocate beyond +the 1 MiB wire cap before the strict parser rejected it. + +Native import now opens one binary handle, reads at most the wire cap plus one +sentinel byte, rejects overflow, strictly decodes UTF-8, and only then delegates +to the unchanged canonical v1 parser. Tests simulate content growth after the +metadata check and reject invalid UTF-8 explicitly. The complete regional and +atomic persistence set is 28 tests passing. This changes no schema, digest, +physics, browser behavior, or write semantics. Static and governance evidence +is recorded in this commit: Ruff, Ruff format, Black, focused MyPy, manifest, +module-size, documentation-governance, and diff checks pass. The child remains +local and unprotected. + ## 2026-08-11 regional request I/O safe-number follow-up Independent review of local commit `e39edf4b50b1fb9811b0032bec4758c7a08c9b74` diff --git a/SPEC.md b/SPEC.md index 082c55d83..4029a108d 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.30 | -| **Spec Version** | 1.14.30 | +| **Current Version** | 1.14.31 | +| **Spec Version** | 1.14.31 | | **Last Spec Update** | 2026-08-11 | ## 2. Purpose & Mission @@ -72,6 +72,9 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific - Both runtimes accept wire numbers only inside JavaScript's exact safe range. Native precedence entry remains integer-exact through the inclusive v1 maximum of 9,007,199,254,740,991 and cannot silently narrow imported data. +- Native request import reads a single binary-handle snapshot bounded to the + 1 MiB wire limit plus one sentinel byte, then performs strict UTF-8 decoding. + Concurrent file growth cannot trigger an unbounded allocation. - Ground execution/playback, result interchange, measured calibration workflows, model-input workspace persistence, compiled regional physics, and UpstreamDrift consumers remain explicit non-goals. diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index bf5169d5d..52d2b3abf 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,23 @@ # Rate of Closure Ball-Flight Campaign Handoff +## 2026-08-11 bounded regional request read follow-up + +Independent review of local safe-number commit +`10c394f6b1fd2927e7f3b1f96cc097cae6bfd380` found that native request import +checked file size with `stat()` and then performed a separate unbounded text +read. A concurrently grown or replaced file could therefore allocate beyond +the 1 MiB wire cap before the strict parser rejected it. + +Native import now opens one binary handle, reads at most the wire cap plus one +sentinel byte, rejects overflow, strictly decodes UTF-8, and only then delegates +to the unchanged canonical v1 parser. Tests simulate content growth after the +metadata check and reject invalid UTF-8 explicitly. The complete regional and +atomic persistence set is 28 tests passing. This changes no schema, digest, +physics, browser behavior, or write semantics. Static and governance evidence +is recorded in this commit: Ruff, Ruff format, Black, focused MyPy, manifest, +module-size, documentation-governance, and diff checks pass. The child remains +local and unprotected. + ## 2026-08-11 regional request I/O safe-number follow-up Independent review of local commit `e39edf4b50b1fb9811b0032bec4758c7a08c9b74` diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json index d2a4208bc..c0e5f6c7f 100644 --- a/docs/release/rate_of_closure_campaign.v1.json +++ b/docs/release/rate_of_closure_campaign.v1.json @@ -1123,7 +1123,7 @@ "ground-impact-local-63a6", "ground-skid-roll-local-f475" ], - "evidence_gap": "The contract, flight-transfer, impact/bounce, and static-plane skid/roll carriers are open. Issues #4270 and #4271 are published on draft PRs 4302 and 4304 with exact local evidence but no protected evidence. Local unprotected children add coplanar regional-material reference physics, a strict Python/TypeScript plan wire boundary, matched PyQt6/React request editors, and strict editor-qualified canonical request import/export with native atomic semantics or explicit browser limitations. A local review follow-up preserves the full exact safe-integer precedence range and aligns Python numeric acceptance with React. Protected editor/I/O evidence, execution/playback, result interchange, workspace model-input persistence, measured calibration, changing geometry, compiled-runtime physics, and downstream parity remain absent.", + "evidence_gap": "The contract, flight-transfer, impact/bounce, and static-plane skid/roll carriers are open. Issues #4270 and #4271 are published on draft PRs 4302 and 4304 with exact local evidence but no protected evidence. Local unprotected children add coplanar regional-material reference physics, a strict Python/TypeScript plan wire boundary, matched PyQt6/React request editors, and strict editor-qualified canonical request import/export with native atomic semantics or explicit browser limitations. Local review follow-ups preserve the full exact safe-integer precedence range, align Python numeric acceptance with React, and bound native import allocation through a single binary-handle read. Protected editor/I/O evidence, execution/playback, result interchange, workspace model-input persistence, measured calibration, changing geometry, 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.", diff --git a/docs/specs/GROUND_SKID_ROLL.md b/docs/specs/GROUND_SKID_ROLL.md index dee8991ad..1e39b2750 100644 --- a/docs/specs/GROUND_SKID_ROLL.md +++ b/docs/specs/GROUND_SKID_ROLL.md @@ -214,6 +214,9 @@ Wire numbers are bounded to the shared cross-runtime safe range. Native precedence entry preserves every nonnegative integer through 9,007,199,254,740,991 exactly, so a qualified import cannot be silently narrowed before validation or Save As. +Native import reads one binary handle with a one-byte overflow sentinel before +strict UTF-8 decoding, so mutable files cannot bypass the 1 MiB allocation cap +between a metadata check and content parsing. Neither client claims execution, result playback, or measured-course calibration. Those capabilities require separate contracts and acceptance evidence. diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index b04434802..e3abb51ee 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,5 +1,23 @@ # AGENT_HANDOFF — rate_of_closure +## 2026-08-11 bounded regional request read follow-up + +Independent review of local safe-number commit +`10c394f6b1fd2927e7f3b1f96cc097cae6bfd380` found that native request import +checked file size with `stat()` and then performed a separate unbounded text +read. A concurrently grown or replaced file could therefore allocate beyond +the 1 MiB wire cap before the strict parser rejected it. + +Native import now opens one binary handle, reads at most the wire cap plus one +sentinel byte, rejects overflow, strictly decodes UTF-8, and only then delegates +to the unchanged canonical v1 parser. Tests simulate content growth after the +metadata check and reject invalid UTF-8 explicitly. The complete regional and +atomic persistence set is 28 tests passing. This changes no schema, digest, +physics, browser behavior, or write semantics. Static and governance evidence +is recorded in this commit: Ruff, Ruff format, Black, focused MyPy, manifest, +module-size, documentation-governance, and diff checks pass. The child remains +local and unprotected. + ## 2026-08-11 regional request I/O safe-number follow-up Independent review of local commit `e39edf4b50b1fb9811b0032bec4758c7a08c9b74` diff --git a/src/rate_of_closure/application/regional_surface_plan_files.py b/src/rate_of_closure/application/regional_surface_plan_files.py index 861160f0e..353db4cf6 100644 --- a/src/rate_of_closure/application/regional_surface_plan_files.py +++ b/src/rate_of_closure/application/regional_surface_plan_files.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import cast from shared.python.swing_sim.ground.regional_plan_records import ( MAX_REGIONAL_PLAN_WIRE_BYTES, @@ -15,6 +16,18 @@ from .atomic_text_files import write_utf8_text_atomic +def _read_bounded_utf8(path: Path) -> str: + """Read one immutable handle snapshot without allocating beyond the cap.""" + with path.open("rb") as handle: + raw = handle.read(MAX_REGIONAL_PLAN_WIRE_BYTES + 1) + if len(raw) > MAX_REGIONAL_PLAN_WIRE_BYTES: + raise ValueError("regional material plan exceeds maximum wire size") + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("regional material plan must be valid UTF-8") from exc + + def read_regional_surface_plan_request( source: str | Path, ) -> GroundRegionalMaterialPlanRequest: @@ -22,9 +35,7 @@ def read_regional_surface_plan_request( path = Path(source) if not path.is_file(): raise FileNotFoundError(f"regional surface plan does not exist: {path}") - if path.stat().st_size > MAX_REGIONAL_PLAN_WIRE_BYTES: - raise ValueError("regional material plan exceeds maximum wire size") - return regional_material_plan_request_from_json(path.read_text(encoding="utf-8")) + return regional_material_plan_request_from_json(_read_bounded_utf8(path)) def write_regional_surface_plan_request_atomic( @@ -36,8 +47,11 @@ def write_regional_surface_plan_request_atomic( return False if type(request) is not GroundRegionalMaterialPlanRequest: raise TypeError("request must be an exact GroundRegionalMaterialPlanRequest") - return write_utf8_text_atomic( - request.to_json(), destination, document_name="regional surface plan" + return cast( + bool, + write_utf8_text_atomic( + request.to_json(), destination, document_name="regional surface plan" + ), ) diff --git a/tests/rate_of_closure/test_regional_surface_plan_io.py b/tests/rate_of_closure/test_regional_surface_plan_io.py index 8de7d7319..0beb0c557 100644 --- a/tests/rate_of_closure/test_regional_surface_plan_io.py +++ b/tests/rate_of_closure/test_regional_surface_plan_io.py @@ -96,6 +96,45 @@ def test_request_read_rejects_corruption_duplicates_and_oversize( read_regional_surface_plan_request(target) +def test_request_read_bounds_a_file_that_grows_after_metadata_check( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "growing.json" + target.write_bytes(b"{}") + requested_sizes: list[int] = [] + + class GrowingBinaryFile: + def __enter__(self) -> GrowingBinaryFile: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self, size: int) -> bytes: + requested_sizes.append(size) + return b" " * size + + monkeypatch.setattr( + Path, + "read_text", + lambda *_args, **_kwargs: pytest.fail("unbounded read_text used"), + ) + monkeypatch.setattr(Path, "open", lambda *_args, **_kwargs: GrowingBinaryFile()) + + with pytest.raises(ValueError, match="maximum wire size"): + read_regional_surface_plan_request(target) + + assert requested_sizes == [MAX_REGIONAL_PLAN_WIRE_BYTES + 1] + + +def test_request_read_rejects_invalid_utf8(tmp_path: Path) -> None: + target = tmp_path / "invalid-utf8.json" + target.write_bytes(b"\xff") + + with pytest.raises(ValueError, match="UTF-8"): + read_regional_surface_plan_request(target) + + def test_import_rejects_non_editor_provenance_without_coercion() -> None: request = validate_regional_surface_plan_draft( illustrative_regional_surface_plan_draft() From 9ea00edc6db3058fea839a88e11030f4d0afe659 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 01:21:08 -0700 Subject: [PATCH 4/7] docs(rate): record regional I/O publication --- AGENT_HANDOFF.md | 9 +++++++++ docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 11 +++++++++++ src/rate_of_closure/AGENT_HANDOFF.md | 9 +++++++++ 3 files changed, 29 insertions(+) diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 43e2fc806..40bbbc815 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,5 +1,14 @@ # AGENT_HANDOFF — Tools +## 2026-08-11 regional request I/O protected publication + +Branch `feat/4274-regional-plan-io` is published normally as a draft child of +exact PR #4339 head `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. Its reviewed +implementation head is `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97`; this +documentation-only publication commit changes no runtime behavior. A follow-up +records the assigned PR URL. Protected CI, independent review, issue #4274, +parent ordering, integration, and release remain open. + ## 2026-08-11 bounded regional request read follow-up Independent review of local safe-number commit diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 52d2b3abf..1d157a7e4 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,16 @@ # Rate of Closure Ball-Flight Campaign Handoff +## 2026-08-11 regional request I/O protected publication + +- Branch `feat/4274-regional-plan-io` is published normally as a draft child of + exact PR #4339 head `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. +- Reviewed implementation head + `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97` includes canonical PyQt6/React + request import/export, safe-number parity, and bounded strict UTF-8 native + reads. This documentation-only publication commit changes no runtime code. +- The assigned PR URL is recorded in the normal follow-up. Protected CI, + review, #4274, dependency ordering, integration, and release remain open. + ## 2026-08-11 bounded regional request read follow-up Independent review of local safe-number commit diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index e3abb51ee..8b2f5d46e 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,5 +1,14 @@ # AGENT_HANDOFF — rate_of_closure +## 2026-08-11 regional request I/O protected publication + +Branch `feat/4274-regional-plan-io` is published normally as a draft child of +exact PR #4339 head `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. Its reviewed +implementation head is `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97`; this +documentation-only publication commit changes no runtime behavior. A follow-up +records the assigned PR URL. Protected CI, independent review, issue #4274, +parent ordering, integration, and release remain open. + ## 2026-08-11 bounded regional request read follow-up Independent review of local safe-number commit From cffe349ac0a8054f1d168cb36684fd00bc5f8a49 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 01:22:54 -0700 Subject: [PATCH 5/7] docs(rate): link regional I/O draft PR --- AGENT_HANDOFF.md | 13 +++++++------ .../development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 12 +++++++----- src/rate_of_closure/AGENT_HANDOFF.md | 13 +++++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 40bbbc815..ad2b9e671 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -2,12 +2,13 @@ ## 2026-08-11 regional request I/O protected publication -Branch `feat/4274-regional-plan-io` is published normally as a draft child of -exact PR #4339 head `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. Its reviewed -implementation head is `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97`; this -documentation-only publication commit changes no runtime behavior. A follow-up -records the assigned PR URL. Protected CI, independent review, issue #4274, -parent ordering, integration, and release remain open. +Branch `feat/4274-regional-plan-io` is published normally as draft PR +[#4342](https://github.com/D-sorganization/Tools/pull/4342), targeting exact +PR #4339 branch `feat/4274-regional-surface-ui` at parent head +`cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. Its reviewed implementation +head is `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97`; publication commits change +no runtime behavior. Protected CI, independent review, issue #4274, parent +ordering, integration, and release remain open. ## 2026-08-11 bounded regional request read follow-up diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 1d157a7e4..5ce739131 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -2,14 +2,16 @@ ## 2026-08-11 regional request I/O protected publication -- Branch `feat/4274-regional-plan-io` is published normally as a draft child of - exact PR #4339 head `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. +- Branch `feat/4274-regional-plan-io` is published normally as draft PR + [#4342](https://github.com/D-sorganization/Tools/pull/4342), targeting exact + PR #4339 branch `feat/4274-regional-surface-ui` at parent head + `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. - Reviewed implementation head `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97` includes canonical PyQt6/React request import/export, safe-number parity, and bounded strict UTF-8 native - reads. This documentation-only publication commit changes no runtime code. -- The assigned PR URL is recorded in the normal follow-up. Protected CI, - review, #4274, dependency ordering, integration, and release remain open. + reads. Publication documentation commits change no runtime code. +- Protected CI, review, #4274, dependency ordering, integration, and release + remain open. ## 2026-08-11 bounded regional request read follow-up diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 8b2f5d46e..e5b12fe14 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -2,12 +2,13 @@ ## 2026-08-11 regional request I/O protected publication -Branch `feat/4274-regional-plan-io` is published normally as a draft child of -exact PR #4339 head `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. Its reviewed -implementation head is `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97`; this -documentation-only publication commit changes no runtime behavior. A follow-up -records the assigned PR URL. Protected CI, independent review, issue #4274, -parent ordering, integration, and release remain open. +Branch `feat/4274-regional-plan-io` is published normally as draft PR +[#4342](https://github.com/D-sorganization/Tools/pull/4342), targeting exact +PR #4339 branch `feat/4274-regional-surface-ui` at parent head +`cbb9c0a6bdc6a50f59f7a661139b9d53e1892980`. Its reviewed implementation +head is `d748e7a5ef3da5e6ce7737ff6829e0f14665fe97`; publication commits change +no runtime behavior. Protected CI, independent review, issue #4274, parent +ordering, integration, and release remain open. ## 2026-08-11 bounded regional request read follow-up From 8e1c7ccd99a7c4886c5fb9ccc7e4d94a6d7e3833 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 01:36:47 -0700 Subject: [PATCH 6/7] fix(rate): remove redundant regional write cast --- AGENT_HANDOFF.md | 10 ++++++++++ docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 10 ++++++++++ src/rate_of_closure/AGENT_HANDOFF.md | 10 ++++++++++ .../application/regional_surface_plan_files.py | 8 ++------ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index ad2b9e671..0f0581ef1 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,5 +1,15 @@ # AGENT_HANDOFF — Tools +## 2026-08-11 PR #4342 delta-MyPy follow-up + +Protected CI on exact PR #4342 head +`cffe349ac0a8054f1d168cb36684fd00bc5f8a49` identified one redundant `bool` +cast in the regional atomic-write adapter under the Linux delta-MyPy gate. The +cast is removed; the typed helper's direct Boolean return is preserved. This +changes no wire bytes, validation, file semantics, UI behavior, or physics. +Focused persistence tests and the CI-equivalent pinned MyPy command pass +locally; fresh protected CI/review and dependency ordering remain required. + ## 2026-08-11 regional request I/O protected publication Branch `feat/4274-regional-plan-io` is published normally as draft PR diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 5ce739131..32b3a0377 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,15 @@ # Rate of Closure Ball-Flight Campaign Handoff +## 2026-08-11 PR #4342 delta-MyPy follow-up + +- Protected CI on exact head + `cffe349ac0a8054f1d168cb36684fd00bc5f8a49` rejected one redundant Boolean + cast in the regional atomic-write adapter. +- The cast is removed and the typed helper's direct return is unchanged. + Focused persistence tests and the CI-equivalent pinned MyPy command pass. +- No wire, validation, file, UI, or physics behavior changes. Fresh protected + CI/review and dependency ordering remain open. + ## 2026-08-11 regional request I/O protected publication - Branch `feat/4274-regional-plan-io` is published normally as draft PR diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index e5b12fe14..346fcd5f5 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,5 +1,15 @@ # AGENT_HANDOFF — rate_of_closure +## 2026-08-11 PR #4342 delta-MyPy follow-up + +Protected CI on exact PR #4342 head +`cffe349ac0a8054f1d168cb36684fd00bc5f8a49` identified one redundant `bool` +cast in the regional atomic-write adapter under the Linux delta-MyPy gate. The +cast is removed; the typed helper's direct Boolean return is preserved. This +changes no wire bytes, validation, file semantics, UI behavior, or physics. +Focused persistence tests and the CI-equivalent pinned MyPy command pass +locally; fresh protected CI/review and dependency ordering remain required. + ## 2026-08-11 regional request I/O protected publication Branch `feat/4274-regional-plan-io` is published normally as draft PR diff --git a/src/rate_of_closure/application/regional_surface_plan_files.py b/src/rate_of_closure/application/regional_surface_plan_files.py index 353db4cf6..14ff29643 100644 --- a/src/rate_of_closure/application/regional_surface_plan_files.py +++ b/src/rate_of_closure/application/regional_surface_plan_files.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -from typing import cast from shared.python.swing_sim.ground.regional_plan_records import ( MAX_REGIONAL_PLAN_WIRE_BYTES, @@ -47,11 +46,8 @@ def write_regional_surface_plan_request_atomic( return False if type(request) is not GroundRegionalMaterialPlanRequest: raise TypeError("request must be an exact GroundRegionalMaterialPlanRequest") - return cast( - bool, - write_utf8_text_atomic( - request.to_json(), destination, document_name="regional surface plan" - ), + return write_utf8_text_atomic( + request.to_json(), destination, document_name="regional surface plan" ) From c1f47f2ef68b3db102da5416aaac17a40f675207 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 07:01:04 -0700 Subject: [PATCH 7/7] docs(rate): restore parent reconciliation history --- AGENT_HANDOFF.md | 106 ++++++++++++++++++ SPEC.md | 7 +- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 106 ++++++++++++++++++ src/rate_of_closure/AGENT_HANDOFF.md | 106 ++++++++++++++++++ 4 files changed, 323 insertions(+), 2 deletions(-) diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 8a18b561e..3c0df7972 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,5 +1,111 @@ # AGENT_HANDOFF — Tools + +## 2026-08-11 PR #4342 append-only preservation repair + +Independent audit of local merge `e7fedfb18de1550eed3484ed2fc99d0baaecdca1` +found that three older parent ancestry sections had been summarized instead of +retained verbatim. This docs-only follow-up restores the full parent text below +without changing production code, tests, schemas, manifest state, PR base, or +release claims. The exact parent sections and SPEC rows are now governed by +byte-for-byte comparison against +`d21741e312b849a63f73cabf351a15d9de80fb94`. + +## 2026-08-11 PR #4335 current-parent ancestry candidate + +The clean dedicated `feat/4271-regional-wire-contract` worktree starts from +exact live PR #4335 child `74a053d2d544da9f44a88007660ad28c0127f285` +and normally merges exact newly published PR #4332 parent +`04ccf08dd990de1cd056a3420e67772773a4be2e` as its second parent. PR #4335 +keeps base `feat/4271-regional-surface-transitions`; neither branch is rebased, +retargeted, rewritten, force-pushed, or published by this reconciliation. +Production physics, wire contracts, golden fixtures, numerical ordering, and +public APIs merge byte-exactly; only SPEC, manifest, and handoff records require +truthful reconciliation. + +The child retains the strict cross-runtime regional-plan request/result wire +contract, canonical JSON/SHA-256 evidence, fail-closed parsing, and Python +resolver adapter. The parent retains bounded coplanar regional transitions and +the complete reconciled impact/bounce/skid/roll ancestry. Changing normals, +height or surface-velocity discontinuities, terrain deformation, torsional-spin +damping, roll-to-skid transitions, internal transition-ledger export, regional +UI, compiled or TypeScript regional physics, downstream parity, protected CI, +review, normal stack integration, and main release remain open. This local +merge is not release evidence and requires independent review before an +ordinary fast-forward publication. + +Merged-tree qualification is `132` focused Python ground tests and `9` +focused React regional/ground-contract tests passing. Pinned Ruff 0.14.10 check +and format pass all `45` ground Python files. Pinned MyPy 1.13 passes the exact +two-file isolated CI boundary and all `31` production modules under the coherent +whole-package profile that disables only redundant-cast warnings; those casts +remain required by the isolated protected profile. Bandit reports no +medium/high finding. Documentation governance, manifest validation/layout and +all `8` manifest contracts, changed-production and minimum-test policies, the +official 500-LOC PR-delta gate (`6` files, zero violations), diff checks, and +mandatory maxima of `392` module lines, `46` function lines, and `4` parameters +all pass. + +## 2026-08-11 PR #4332 current-parent ancestry candidate + +The clean dedicated `feat/4271-regional-surface-transitions` worktree starts +from exact live PR #4332 child `1a48d749af508843fac2a5102f4dd56294429bda` +and normally merges exact newly published `feat/4271-ground-skid-roll` parent +`0ea6740965068542e9d8c7449e06ec07d88969e0` as its second parent. PR #4332 +keeps base `feat/4271-ground-skid-roll`; neither branch is rebased, retargeted, +rewritten, force-pushed, or published by this reconciliation. The only textual +merge conflict is the independent SPEC-version collision. SPEC 1.14.27 retains +the regional child record after the parent's 1.14.26 entry; production physics, +contracts, schemas, numerical ordering, and public APIs merge without conflict. + +The child retains bounded coplanar material overlays, explicit precedence, +exact quadratic boundary splitting, base-edge precedence, state and energy +continuity, strict `surface_transition` evidence, request-bound transition +limits, and randomized piecewise-analytic coverage. The current parent retains +its reviewed impact, bounce, skid, roll, resistance, qualified-rest, edge, +composition, and passive-ledger behavior. Changing normals, height or +surface-velocity discontinuities, terrain deformation, torsional-spin damping, +roll-to-skid transitions, regional UI, compiled regional physics, downstream +parity, protected CI, review, normal stack integration, and main release remain +open. This local merge is not release evidence and requires independent review +before an ordinary fast-forward publication. + +Merged-tree qualification is `121` focused Python ground/regional tests and +`5` focused React ground-contract tests passing. Pinned Ruff 0.14.10 check and +format pass all `41` ground Python files; pinned MyPy 1.13 passes all `28` +ground production modules; and Bandit reports no medium/high finding. The +campaign manifest validator and all `8` manifest contracts, documentation +governance, changed-production policy, minimum test contract, both parent and +child diff checks, and the official 500-LOC changed-file gate (`14` files, +zero violations) pass. Production maxima are `392` lines per module, `46` +lines per function, and `4` parameters excluding `self`/`cls`. The manifest +now records open PR #4332 at its still-published child head and open PR #4304 +at its exact newly published parent head; neither record claims protected or +main-release evidence. + +## 2026-08-11 PR #4304 current-parent ancestry candidate + +The clean dedicated `feat/4271-ground-skid-roll` worktree first fast-forwarded +to exact live PR #4304 child `52d9a6a978d8e6b8b19ef92f02f265c9058b00ad`, +then normally merged exact live `feat/4270-ground-impact-bounce` parent +`cf6e72bad98e5f36f782254942e6895b8b71e670`. The PR base remains unchanged; +neither branch was rebased, retargeted, rewritten, force-pushed, or published. +The automatic merge was conflict-free. Its only child-tree change before this +documentation reconciliation is deterministic formatting in the existing +skid/roll regression test; production physics, contracts, schemas, numerical +ordering, and public APIs remain unchanged. + +Local qualification on the merge candidate is `115` focused ground +tests passing on CPython 3.13, pinned Ruff 0.14.10 clean across the ground +package and tests, and pinned MyPy 1.13 clean across all `25` ground production +modules. The manifest validator and all `8` manifest tests, documentation +governance, exact-parent and exact-child diff checks, changed-production policy, +the official changed-file 500-LOC budget, and mandatory production limits of +400 lines per module, 50 lines per function, and four parameters all pass. The +local merge is a PUBLISH candidate for independent review only; protected +exact-head CI, approval, normal child propagation, and publication remain open. + + ## 2026-08-11 PR #4342 current-parent reconciliation candidate The clean `feat/4274-regional-plan-io` worktree normally merges exact diff --git a/SPEC.md b/SPEC.md index 0dc8a0d92..9c461a11e 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.32 | -| **Spec Version** | 1.14.32 | +| **Current Version** | 1.14.33 | +| **Spec Version** | 1.14.33 | | **Last Spec Update** | 2026-08-11 | ## 2. Purpose & Mission @@ -2935,9 +2935,12 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-11 | 1.14.33 | docs(rate-of-closure, #4342): restore verbatim the omitted PR #4335, #4332, and #4304 current-parent ancestry sections in all three canonical handoffs and the parent's exact parallel-branch SPEC 1.14.29 and 1.14.28 reconciliation rows; record the historical version collision without rewriting either branch's append-only record; change no production code, tests, schemas, manifest state, PR base, protected evidence, or release status. | | 2026-08-11 | 1.14.32 | chore(rate-of-closure, #4274 #4342): normally reconcile exact published regional request-I/O child `8e1c7ccd99a7c4886c5fb9ccc7e4d94a6d7e3833` with exact newly published regional-editor parent `d21741e312b849a63f73cabf351a15d9de80fb94` without changing PR #4342's base; preserve native atomic and browser-qualified canonical request I/O, safe-number and bounded UTF-8 behavior, and the parent's frozen validated engineering-number specification by delegating child controls to its three-parameter helper while retaining eleven-decimal presentation; keep protected CI, approval, integration, execution, calibration, downstream parity, and release open. | | 2026-08-11 | 1.14.31 | fix(rate-of-closure, #4274 #4342): remove one redundant Boolean cast rejected by protected delta-MyPy without changing wire bytes, validation, file semantics, UI behavior, or physics. | | 2026-08-11 | 1.14.30 | fix(rate-of-closure, #4274 #4339): replace the six-parameter PyQt engineering-number helper with a three-parameter helper and frozen validated specification while preserving names, values, units, ranges, increments, tooltips, and field order; add RED-first helper/configuration and structural-bound tests, retain byte-stable wire/digest/physics behavior, and keep protected CI, approval, integration, and release open. | +| 2026-08-11 | 1.14.29 | chore(rate-of-closure, #4274 #4339): normally reconcile exact published regional-surface editor child `cbb9c0a6bdc6a50f59f7a661139b9d53e1892980` with exact newly published regional-plan wire parent `9e01ccc3e891cc45907293751a192624195a77a5` without changing the PR base or rewriting either branch; retain matched PyQt6/React request editors, invalidation and engineering hints together with the strict Python/TypeScript plan contract, resolver binding, bounded coplanar regional physics, and complete ground ancestry, while preserving execution/playback, result interchange, measured calibration, workspace persistence, changing geometry, compiled-runtime physics, downstream parity, protected evidence, and release as open work. | +| 2026-08-11 | 1.14.28 | chore(ground-regions, #4271 #4335): normally reconcile exact live regional-wire child `74a053d2d544da9f44a88007660ad28c0127f285` with exact newly published regional-surface parent `04ccf08dd990de1cd056a3420e67772773a4be2e` without changing the PR base or rewriting either branch; preserve the strict Python/TypeScript plan wire boundary and Python resolver binding together with the parent's reconciled coplanar regional physics and complete skid/roll ancestry, while retaining protected CI/review, UI, compiled or TypeScript regional physics, changing geometry/velocity, internal transition-ledger export, downstream parity, and release as open work. | | 2026-08-10 | 1.14.29 | feat(rate-of-closure, #4274): add strict matched PyQt6 native atomic Open/Save As and React bounded import/canonical download for editor-qualified regional requests; preserve exact unchanged provenance, rebind edited drafts, reject unsupported qualifications without coercion, keep workspace persistence separate, and retain execution, calibration, protected evidence, and release as open work. | | 2026-08-10 | 1.14.28 | feat(rate-of-closure, #4274): add matched PyQt6 and React regional surface-plan editors with explicit SI material inputs, one-to-eight bounded overlays, illustrative/unvalidated and persistence/execution qualifications, accessible strict validation/readback, provenance bound to the actual draft, and delegation to the separate regional-plan v1 wire contract; retain playback, execution, result interchange, measured calibration, workspace model-input persistence, other runtimes, protected evidence, and release as open work. | | 2026-08-10 | 1.14.27 | feat(ground-roll, #4271): add separate strict Python/TypeScript `ground-regional-material-plan-request/v1` and result/v1 wire contracts with exact keys, SI units, explicit coplanar/static limitations, finite bounded base/overlay intervals, bounded region/document counts, unique region/precedence/surface identities, canonical cross-runtime JSON/SHA-256 evidence, fail-closed no-fabrication result binding, and a Python-only adapter into the existing regional resolver; preserve the frozen flight-to-ground v1 request/result and retain TypeScript/compiled regional physics, UI, changing geometry, internal transition-ledger export, downstream parity, protected CI, review, and release as open work. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 4a118edf1..5cb61944f 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,111 @@ # Rate of Closure Ball-Flight Campaign Handoff + +## 2026-08-11 PR #4342 append-only preservation repair + +Independent audit of local merge `e7fedfb18de1550eed3484ed2fc99d0baaecdca1` +found that three older parent ancestry sections had been summarized instead of +retained verbatim. This docs-only follow-up restores the full parent text below +without changing production code, tests, schemas, manifest state, PR base, or +release claims. The exact parent sections and SPEC rows are now governed by +byte-for-byte comparison against +`d21741e312b849a63f73cabf351a15d9de80fb94`. + +## 2026-08-11 PR #4335 current-parent ancestry candidate + +The clean dedicated `feat/4271-regional-wire-contract` worktree starts from +exact live PR #4335 child `74a053d2d544da9f44a88007660ad28c0127f285` +and normally merges exact newly published PR #4332 parent +`04ccf08dd990de1cd056a3420e67772773a4be2e` as its second parent. PR #4335 +keeps base `feat/4271-regional-surface-transitions`; neither branch is rebased, +retargeted, rewritten, force-pushed, or published by this reconciliation. +Production physics, wire contracts, golden fixtures, numerical ordering, and +public APIs merge byte-exactly; only SPEC, manifest, and handoff records require +truthful reconciliation. + +The child retains the strict cross-runtime regional-plan request/result wire +contract, canonical JSON/SHA-256 evidence, fail-closed parsing, and Python +resolver adapter. The parent retains bounded coplanar regional transitions and +the complete reconciled impact/bounce/skid/roll ancestry. Changing normals, +height or surface-velocity discontinuities, terrain deformation, torsional-spin +damping, roll-to-skid transitions, internal transition-ledger export, regional +UI, compiled or TypeScript regional physics, downstream parity, protected CI, +review, normal stack integration, and main release remain open. This local +merge is not release evidence and requires independent review before an +ordinary fast-forward publication. + +Merged-tree qualification is `132` focused Python ground tests and `9` +focused React regional/ground-contract tests passing. Pinned Ruff 0.14.10 check +and format pass all `45` ground Python files. Pinned MyPy 1.13 passes the exact +two-file isolated CI boundary and all `31` production modules under the coherent +whole-package profile that disables only redundant-cast warnings; those casts +remain required by the isolated protected profile. Bandit reports no +medium/high finding. Documentation governance, manifest validation/layout and +all `8` manifest contracts, changed-production and minimum-test policies, the +official 500-LOC PR-delta gate (`6` files, zero violations), diff checks, and +mandatory maxima of `392` module lines, `46` function lines, and `4` parameters +all pass. + +## 2026-08-11 PR #4332 current-parent ancestry candidate + +The clean dedicated `feat/4271-regional-surface-transitions` worktree starts +from exact live PR #4332 child `1a48d749af508843fac2a5102f4dd56294429bda` +and normally merges exact newly published `feat/4271-ground-skid-roll` parent +`0ea6740965068542e9d8c7449e06ec07d88969e0` as its second parent. PR #4332 +keeps base `feat/4271-ground-skid-roll`; neither branch is rebased, retargeted, +rewritten, force-pushed, or published by this reconciliation. The only textual +merge conflict is the independent SPEC-version collision. SPEC 1.14.27 retains +the regional child record after the parent's 1.14.26 entry; production physics, +contracts, schemas, numerical ordering, and public APIs merge without conflict. + +The child retains bounded coplanar material overlays, explicit precedence, +exact quadratic boundary splitting, base-edge precedence, state and energy +continuity, strict `surface_transition` evidence, request-bound transition +limits, and randomized piecewise-analytic coverage. The current parent retains +its reviewed impact, bounce, skid, roll, resistance, qualified-rest, edge, +composition, and passive-ledger behavior. Changing normals, height or +surface-velocity discontinuities, terrain deformation, torsional-spin damping, +roll-to-skid transitions, regional UI, compiled regional physics, downstream +parity, protected CI, review, normal stack integration, and main release remain +open. This local merge is not release evidence and requires independent review +before an ordinary fast-forward publication. + +Merged-tree qualification is `121` focused Python ground/regional tests and +`5` focused React ground-contract tests passing. Pinned Ruff 0.14.10 check and +format pass all `41` ground Python files; pinned MyPy 1.13 passes all `28` +ground production modules; and Bandit reports no medium/high finding. The +campaign manifest validator and all `8` manifest contracts, documentation +governance, changed-production policy, minimum test contract, both parent and +child diff checks, and the official 500-LOC changed-file gate (`14` files, +zero violations) pass. Production maxima are `392` lines per module, `46` +lines per function, and `4` parameters excluding `self`/`cls`. The manifest +now records open PR #4332 at its still-published child head and open PR #4304 +at its exact newly published parent head; neither record claims protected or +main-release evidence. + +## 2026-08-11 PR #4304 current-parent ancestry candidate + +The clean dedicated `feat/4271-ground-skid-roll` worktree first fast-forwarded +to exact live PR #4304 child `52d9a6a978d8e6b8b19ef92f02f265c9058b00ad`, +then normally merged exact live `feat/4270-ground-impact-bounce` parent +`cf6e72bad98e5f36f782254942e6895b8b71e670`. The PR base remains unchanged; +neither branch was rebased, retargeted, rewritten, force-pushed, or published. +The automatic merge was conflict-free. Its only child-tree change before this +documentation reconciliation is deterministic formatting in the existing +skid/roll regression test; production physics, contracts, schemas, numerical +ordering, and public APIs remain unchanged. + +Local qualification on the merge candidate is `115` focused ground +tests passing on CPython 3.13, pinned Ruff 0.14.10 clean across the ground +package and tests, and pinned MyPy 1.13 clean across all `25` ground production +modules. The manifest validator and all `8` manifest tests, documentation +governance, exact-parent and exact-child diff checks, changed-production policy, +the official changed-file 500-LOC budget, and mandatory production limits of +400 lines per module, 50 lines per function, and four parameters all pass. The +local merge is a PUBLISH candidate for independent review only; protected +exact-head CI, approval, normal child propagation, and publication remain open. + + ## 2026-08-11 PR #4342 current-parent reconciliation candidate - The clean `feat/4274-regional-plan-io` worktree normally merges exact diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 91700a527..0ee8eb40e 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,5 +1,111 @@ # AGENT_HANDOFF — rate_of_closure + +## 2026-08-11 PR #4342 append-only preservation repair + +Independent audit of local merge `e7fedfb18de1550eed3484ed2fc99d0baaecdca1` +found that three older parent ancestry sections had been summarized instead of +retained verbatim. This docs-only follow-up restores the full parent text below +without changing production code, tests, schemas, manifest state, PR base, or +release claims. The exact parent sections and SPEC rows are now governed by +byte-for-byte comparison against +`d21741e312b849a63f73cabf351a15d9de80fb94`. + +## 2026-08-11 PR #4335 current-parent ancestry candidate + +The clean dedicated `feat/4271-regional-wire-contract` worktree starts from +exact live PR #4335 child `74a053d2d544da9f44a88007660ad28c0127f285` +and normally merges exact newly published PR #4332 parent +`04ccf08dd990de1cd056a3420e67772773a4be2e` as its second parent. PR #4335 +keeps base `feat/4271-regional-surface-transitions`; neither branch is rebased, +retargeted, rewritten, force-pushed, or published by this reconciliation. +Production physics, wire contracts, golden fixtures, numerical ordering, and +public APIs merge byte-exactly; only SPEC, manifest, and handoff records require +truthful reconciliation. + +The child retains the strict cross-runtime regional-plan request/result wire +contract, canonical JSON/SHA-256 evidence, fail-closed parsing, and Python +resolver adapter. The parent retains bounded coplanar regional transitions and +the complete reconciled impact/bounce/skid/roll ancestry. Changing normals, +height or surface-velocity discontinuities, terrain deformation, torsional-spin +damping, roll-to-skid transitions, internal transition-ledger export, regional +UI, compiled or TypeScript regional physics, downstream parity, protected CI, +review, normal stack integration, and main release remain open. This local +merge is not release evidence and requires independent review before an +ordinary fast-forward publication. + +Merged-tree qualification is `132` focused Python ground tests and `9` +focused React regional/ground-contract tests passing. Pinned Ruff 0.14.10 check +and format pass all `45` ground Python files. Pinned MyPy 1.13 passes the exact +two-file isolated CI boundary and all `31` production modules under the coherent +whole-package profile that disables only redundant-cast warnings; those casts +remain required by the isolated protected profile. Bandit reports no +medium/high finding. Documentation governance, manifest validation/layout and +all `8` manifest contracts, changed-production and minimum-test policies, the +official 500-LOC PR-delta gate (`6` files, zero violations), diff checks, and +mandatory maxima of `392` module lines, `46` function lines, and `4` parameters +all pass. + +## 2026-08-11 PR #4332 current-parent ancestry candidate + +The clean dedicated `feat/4271-regional-surface-transitions` worktree starts +from exact live PR #4332 child `1a48d749af508843fac2a5102f4dd56294429bda` +and normally merges exact newly published `feat/4271-ground-skid-roll` parent +`0ea6740965068542e9d8c7449e06ec07d88969e0` as its second parent. PR #4332 +keeps base `feat/4271-ground-skid-roll`; neither branch is rebased, retargeted, +rewritten, force-pushed, or published by this reconciliation. The only textual +merge conflict is the independent SPEC-version collision. SPEC 1.14.27 retains +the regional child record after the parent's 1.14.26 entry; production physics, +contracts, schemas, numerical ordering, and public APIs merge without conflict. + +The child retains bounded coplanar material overlays, explicit precedence, +exact quadratic boundary splitting, base-edge precedence, state and energy +continuity, strict `surface_transition` evidence, request-bound transition +limits, and randomized piecewise-analytic coverage. The current parent retains +its reviewed impact, bounce, skid, roll, resistance, qualified-rest, edge, +composition, and passive-ledger behavior. Changing normals, height or +surface-velocity discontinuities, terrain deformation, torsional-spin damping, +roll-to-skid transitions, regional UI, compiled regional physics, downstream +parity, protected CI, review, normal stack integration, and main release remain +open. This local merge is not release evidence and requires independent review +before an ordinary fast-forward publication. + +Merged-tree qualification is `121` focused Python ground/regional tests and +`5` focused React ground-contract tests passing. Pinned Ruff 0.14.10 check and +format pass all `41` ground Python files; pinned MyPy 1.13 passes all `28` +ground production modules; and Bandit reports no medium/high finding. The +campaign manifest validator and all `8` manifest contracts, documentation +governance, changed-production policy, minimum test contract, both parent and +child diff checks, and the official 500-LOC changed-file gate (`14` files, +zero violations) pass. Production maxima are `392` lines per module, `46` +lines per function, and `4` parameters excluding `self`/`cls`. The manifest +now records open PR #4332 at its still-published child head and open PR #4304 +at its exact newly published parent head; neither record claims protected or +main-release evidence. + +## 2026-08-11 PR #4304 current-parent ancestry candidate + +The clean dedicated `feat/4271-ground-skid-roll` worktree first fast-forwarded +to exact live PR #4304 child `52d9a6a978d8e6b8b19ef92f02f265c9058b00ad`, +then normally merged exact live `feat/4270-ground-impact-bounce` parent +`cf6e72bad98e5f36f782254942e6895b8b71e670`. The PR base remains unchanged; +neither branch was rebased, retargeted, rewritten, force-pushed, or published. +The automatic merge was conflict-free. Its only child-tree change before this +documentation reconciliation is deterministic formatting in the existing +skid/roll regression test; production physics, contracts, schemas, numerical +ordering, and public APIs remain unchanged. + +Local qualification on the merge candidate is `115` focused ground +tests passing on CPython 3.13, pinned Ruff 0.14.10 clean across the ground +package and tests, and pinned MyPy 1.13 clean across all `25` ground production +modules. The manifest validator and all `8` manifest tests, documentation +governance, exact-parent and exact-child diff checks, changed-production policy, +the official changed-file 500-LOC budget, and mandatory production limits of +400 lines per module, 50 lines per function, and four parameters all pass. The +local merge is a PUBLISH candidate for independent review only; protected +exact-head CI, approval, normal child propagation, and publication remain open. + + ## 2026-08-11 PR #4342 current-parent reconciliation candidate The clean `feat/4274-regional-plan-io` worktree normally merges exact