diff --git a/.github/workflows/maturin-swing-core.yml b/.github/workflows/maturin-swing-core.yml index e59100b387..d6505d7414 100644 --- a/.github/workflows/maturin-swing-core.yml +++ b/.github/workflows/maturin-swing-core.yml @@ -110,6 +110,9 @@ jobs: - name: Run Rust<->Python parity suite (gate, non-skipped) env: PYTHONPATH: src + # This Rust-only lane must not import unrelated plugins cached on a + # self-hosted runner (for example pytest-qt without PyQt6 installed). + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" run: | python -m pytest \ src/shared/python/swing_sim/tests/test_parity_rust.py \ diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 765de36ad9..01b7cd9635 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,8 +1,60 @@ -# AGENT_HANDOFF — Tools (monorepo root) +# AGENT_HANDOFF — Tools -> **Update this file with every PR and every push to main.** +> Update this append-only handoff in every implementation commit and every push +> to `main`. > Last updated: 2026-08-11 +## 2026-08-11 current scalar-adapter propagation into wind workflow PR #4282 + +- PR `#4282` remains on `feat/4199-wind-workflow`, based on + `feat/4199-wind-scalar-adapter`. Exact published child + `b2baf6ef615b0d756a86ac4ca7eef2fc583210ee` is merged first with exact newly + published parent `9321c1d2e091b8c7e5a4a83aa9ad726290e7fb5a` second by a + normal merge; no rebase, retarget, force-push, or parent rewrite is used. +- All implementation and test paths merge automatically. The child responsive + PyQt6/React wind workflow, execution/progress/cancellation, persistence, + export, consolidated capability stack, and strict campaign manifest remain + authoritative while inheriting the parent's current wind scalar-adapter, + variation, workspace, launch-monitor/D-plane, split-kinetics, and Qt repairs. + Only append-only handoffs and SPEC conflict textually; both histories remain. +- Focused pre-commit regression is green: all 94 focused Python/PyQt wind, + responsive-layout, navigation, playback, and manifest tests pass, and all 44 + tests in eight focused React wind/responsive suites pass. Docs governance and + strict campaign-manifest generation/validation also pass. +- Exact post-commit PR-delta gates are also green: pinned Ruff 0.14.10 checks + and formats all 57 changed Python files, pinned MyPy 1.13.0 accepts all 38 + changed production files, and pinned Bandit 1.7.7 finds no medium/high issues + in 37 scanned source files. The 500-LOC budget, changed-Python policy, + minimum-test contract, changed-test assertions, diff hygiene, React + type-check, React lint, and production web build all pass. +- This candidate remains local pending independent review, ordinary publication, + protected exact-head CI, unresolved-thread checks, and dependency order. + +## 2026-08-11 Exact #4281 parent propagation into PR #4282 + +- Draft PR `#4282` remains on `feat/4199-wind-workflow`, based on + `feat/4199-wind-scalar-adapter`. Exact remote child + `29e15d6ff631f7f30afcc745be783f2e716d7dcf` normally merges exact published + parent `a7dce5f89b483303938f518b74b4028a1c68ba81` in child-first order without + rebase, reset, retarget, force-push, or parent rewrite. +- The responsive PyQt6/React wind workflow and consolidated capability stack + remain intact while inheriting the parent's split kinetics facade, Ground/Tee + parity, scalar-adapter module/parameter repair, and complete prior stack. +- The sole source conflict is the child's obsolete formatting-only edit to the + pre-split kinetics monolith; the validated parent facade is authoritative. + Four documentation conflicts retain both append-only histories. Seven + child-owned files require the pinned Ruff formatter before publication. +- The exact skipped-import MyPy profile makes the facade cache suppression + redundant; an explicit `KineticsSeries | None` cast preserves the runtime + object while satisfying the static boundary without an ignore. +- Local evidence is 74 focused Python/PyQt wind, kinetics, and Ground/Tee + tests; 29 focused React tests; and 8 campaign-manifest tests. TypeScript, + focused zero-warning ESLint, Ruff check/format, pinned MyPy 1.13, Bandit's + medium/high threshold, strict wind module/signature/function budgets, + manifest, documentation, policy, SPEC, and diff gates pass. +- This is a local reconciliation candidate. Independent review, fresh + exact-head protected CI, unresolved-thread checks, dependency order, and + ordinary publication remain required. ## 2026-08-11 current variation propagation into wind PR #4281 - PR `#4281` remains on `feat/4199-wind-scalar-adapter`, based on @@ -30,7 +82,6 @@ - This candidate remains local pending independent review, ordinary publication, protected exact-head CI, unresolved-thread checks, and dependency order. - ## 2026-08-11 PR #4281 wind adapter module-budget repair - The 403-line `wind_strategy_plot_adapter.py` blocker is repaired by moving @@ -307,19 +358,28 @@ remain open. `main`. The strict campaign release manifest is not present on #4203's exact history (it was introduced later on a divergent campaign branch), so this bounded child records that limitation instead of reconstructing it. - -## 2026-08-10 Second variation-parent propagation into PR #4281 - -- Draft PR `#4281` retains branch `feat/4199-wind-scalar-adapter` and base - `feat/4144-variation-export-continuation`. Exact repaired parent head - `b90e5021a59e2081415b51ef29fbed06377bc201` is incorporated through a - normal merge commit without rebase, retarget, force-push, or history rewrite. -- There is no scalar-adapter production/test conflict. Both append-only - handoff/SPEC histories are retained and the adapter contract is unchanged. -- Parent quality success is not child release evidence. Fresh exact-head CI, - review, and all earlier dependencies remain open. -- Reconciled-child evidence is 25 focused D-plane/impact tests plus docs - governance, changed-file size budget, and whitespace checks. +## 2026-08-10 Repaired wind-scalar parent propagation + +Draft PR `#4282` retains branch `feat/4199-wind-workflow` and base +`feat/4199-wind-scalar-adapter`. Exact repaired parent head +`d6fb04e07c2a625412e9208b07103acdc42c621b` is incorporated through a normal +merge commit after its quality gate passed. No wind-workflow production or test +code conflicted, and no rebase, retarget, force-push, or history rewrite was +used. The current-state handoff remains authoritative. Verification covers 25 +focused tests plus documentation governance, changed-file size, and whitespace +checks; fresh protected CI, review, and downstream propagation remain required. + +## 2026-08-10 PR #4282 exact-head mypy repair + +Protected quality-gate run `31395741841`, job `93478091420`, checked merge ref +`ae75c4b006072b20a713e45b177defcc16a380fe` for exact head +`aa6eeffb0395f7ed7954f2315b1c625cada552d8`. Checkout, dependency install, +Ruff, and formatting passed; pinned mypy 1.13 then failed only +`scripts/rate_campaign_manifest.py:335:5 [no-any-return]`. Under the exact CI +`--follow-imports=skip` profile, Pydantic's `model_validate_json` return is +`Any`. The validated local now carries an explicit `CampaignManifest` type, +preserving runtime behavior without a redundant cast or suppression. Do not +retry or cite the obsolete failed head as passing. ## 2026-08-10 Exact workspace-parent propagation into PR #4280 @@ -359,246 +419,177 @@ guarded publication. ## 2026-08-10 Second workspace-parent propagation into PR #4280 -- Draft PR `#4280` retains branch `feat/4144-variation-export-continuation` - and base `feat/4218-toolstrip-workspace`. Exact repaired parent head - `61b7f48b5aeb7d57246b4963da3df086e79cbe15` is incorporated through a - normal merge commit without rebase, retarget, force-push, or history rewrite. -- There is no feature-code conflict. The variation/export implementation stays - intact while both branches' append-only handoff/SPEC evidence is retained. -- Parent quality-gate success is not child release evidence. Fresh exact-head - protected CI, review, and all earlier dependency gates remain open. -- Reconciled-child evidence is 25 focused D-plane/impact tests plus docs - governance, changed-file size budget, and whitespace checks. +## 2026-08-10 Exact wind-adapter propagation into PR #4282 + +Draft PR `#4282` remains on `feat/4199-wind-workflow` with base +`feat/4199-wind-scalar-adapter`. The normal merge starts from original child +`5f77af4add23547a21cc3fabce98ae9ad4260427` and incorporates exact published +parent `4a793c4c3f19aad43a3c215800b266be487ace49` second. Do not rebase, +retarget, force-push, rewrite either parent, change draft state, or merge the PR. + +The consolidated responsive wind/capability workflow and campaign release +authority remain additive with the parent's variation visualizations, +result-local selection, workspace/toolstrip, Python 3.10 compatibility, and +matching `scalar-ensemble/v1` wind adapter. The canonical navigation-state +module now owns the child capability-tab ID, preserving the parent's safe +legacy visibility migration without duplicating navigation constants. The +parent's strict deterministic wind boundary also remains intact: exact +per-trial provenance, zero shear/turbulence, seed zero, and no gusts. +Static review replaces child-owned optimized-away runtime assertions with +explicit invariant failures and identifies the fixed-argument executable +builder subprocess boundary with Bandit's native annotations. The exact-head +CI repair above handles the separate skipped-import Pydantic return boundary. + +SPEC 1.14.14 is the unique combined child entry above parent 1.14.13. +Verification is green across 73 focused Python navigation/wind/compatibility +tests, the complete Rate/shared-swing/golf-club matrix (`1,657 passed`, two +explicit optional build123d skips), and all 643 React tests across 105 files. +TypeScript, zero-warning ESLint, Vite production build, 12 Rust tests, 31 real +CPython 3.10 compatibility tests, and 43 post-review invariant/GUI/manifest +tests pass. Ruff/format, pinned MyPy 1.13 across 37 changed production files, +Bandit, the exact-parent 500-line budget, campaign-manifest contracts, +docs/minimum-test/assertion governance, CI-policy secrets scan, compilation, +and diff gates pass. Independent re-review found no actionable findings after +94 Python workflow/navigation tests, 20 manifest/GUI tests, and 61 React +workflow tests plus independent static checks. Protected current-head CI, +unresolved threads, and required repository review remain release gates. + +## Active Rate of Closure campaign + +The active checkout is +`C:\Users\diete\Repositories\Tools-worktrees\toolstrip-workspace` on the +existing PR #4282 carrier `feat/4199-wind-workflow`. The local continuation +starts from original published head +`5f77af4add23547a21cc3fabce98ae9ad4260427` and normally incorporates exact +published #4281 parent `4a793c4c3f19aad43a3c215800b266be487ace49` +without changing the branch base `feat/4199-wind-scalar-adapter`. It composes +four reviewed slices: +strict capability parsing and signed decimal entry; complete capability +diagnostics/result exports and quantitative React scatter scales; package-safe +static-web release entrypoints; and the strict `rate-of-closure-campaign/v1` +release-evidence authority. The normal merge also carries the parent's +Python 3.10 compatibility, variation-export, and scalar-ensemble corrections. +No branch was rebased, retargeted, rewritten, or force-pushed. Protected CI +and review remain due after normal publication. + +Canonical files: + +- `docs/release/rate_of_closure_campaign.v1.json` — normalized campaign state; +- `scripts/rate_campaign_manifest.py` — schema and contradiction validator; +- `docs/release/RATE_OF_CLOSURE_CAMPAIGN_MANIFEST.md` — maintenance procedure; +- `src/rate_of_closure/AGENT_HANDOFF.md` — current Rate-specific handoff; +- `docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md` — historical detail. + +The manifest deliberately reports the campaign as **not released**. It +distinguishes specified scope, feature-stack implementation, protected parent +merge, and exact `main` release. A local pass, a feature-branch merge, and a +default-branch release are never interchangeable. + +## Current carrier and release state + +Capability PRs #4294, #4289, and #4283 were merged top-down into +`feat/4199-wind-workflow`; their feature parents were not protected. PR #4282 +is the current open carrier on base `feat/4199-wind-scalar-adapter`. Exact +published parent `4a793c4c3f19aad43a3c215800b266be487ace49` is incorporated +through the normal local merge containing this handoff. + +The previous exact #4282 head `3186a265b1` built and loaded `swing_core`, but +its Python 3.11 parity job failed before collection because a cached +`pytest-qt` plugin loaded without PyQt6. Commit `18fe89201` disables third-party +pytest plugin auto-loading only in that Rust parity step. Its focused workflow +contract test passes; a fresh hosted parity run is still required. + +Combined local evidence on the composed continuation is 1,657 Python tests +with two explicit optional skips and 105 React files / 643 tests, plus the +TypeScript, zero-warning ESLint, 188-module Vite build, Rust, Python 3.10, +manifest, Ruff, pinned mypy, Bandit, governance, secrets, compilation, and +diff evidence recorded above. + +Hosted quality-gate run `31340032608` reached mypy 1.13 and exposed +CI-context-only `no-any-return` findings in the Pydantic manifest loader and +Qt elapsed-timer adapter because the delta lane uses `--follow-imports=skip`. +Both boundaries now narrow their return values explicitly. The exact Python +3.12/mypy 1.13 delta is clean across 54 files; Ruff passes and 62 focused +regression tests plus eight campaign-manifest tests pass (only pre-existing +optional-plugin config warnings). + +Corrected-parent propagation evidence is 62 focused wind/scalar/variation and +compatibility tests on both Python 3.11 and real CPython 3.10.20, plus 8 React +files / 35 tests, TypeScript, and focused zero-warning ESLint. The Python 3.10 +run exposed one child-owned direct `enum.StrEnum` import in capability +observations; it now uses the shared runtime compatibility contract and is +included in the source-level regression. Ruff check/format passes 15 focused +files, pinned mypy 1.13 passes 10 production modules, and all nine campaign +manifest/parity contracts pass. + +The direct web launcher dynamically loads the root bootstrap through +`importlib` instead of mutating `sys.path` in the changed entrypoint. Its real +child-process delegation test and the changed-Python policy guard cover that +release path. + +Only `main` is the release boundary. It requires `quality-gate` and +`tests (3.11)`. Outer PR #4119 remains the main-targeting platform carrier and +requires current-main reconciliation. Impact-interval PR #4133 merged after +its parent stack had already propagated and is not proven in #4119. + +## Critical open programs + +- #4142/#4144: variation graphics are substantial, but global sensitivity, + bounded performance, downstream pinning, and release remain. +- #4146: club-builder core exists; manufacturing export, image fitting, full + client workflow, and downstream qualification remain. +- #4158: wedge kinematics, clearance, turf, visualization, and forgiveness are + feature-stack implementations without a protected release. +- #4191/#4201: wind, inverse flight, playback, and capability work exist on the + stack; scientific, installed-package, and cross-surface release gates remain. +- #4218/#4234: toolstrip and selected layout fixes exist; camera issue #4284, + complete persistence, high-DPI, keyboard, and visual baselines remain. +- #4260/#4267: four-surface parity is specified; ground contracts/transfer are + partial and bounce, skid, roll, total distance, UI, and parity remain open. + +## Required validation + +```powershell +python scripts/rate_campaign_manifest.py +python -m pytest tests/rate_of_closure/test_campaign_release_manifest.py -q +python -m pytest tests/ops/test_maturin_swing_core_workflow.py -q +python -m ruff check scripts/rate_campaign_manifest.py ` + tests/rate_of_closure/test_campaign_release_manifest.py +python -m ruff format --check scripts/rate_campaign_manifest.py ` + tests/rate_of_closure/test_campaign_release_manifest.py +python -m mypy --ignore-missing-imports scripts/rate_campaign_manifest.py +``` -## 2026-08-10 Exact variation-parent propagation into PR #4281 +For a feature change, also run the complete affected Rate Python/PyQt/shared +swing and React suites recorded in the per-tool handoff. -Draft PR `#4281` remains on `feat/4199-wind-scalar-adapter` with base -`feat/4144-variation-export-continuation`. The normal merge starts from -original child `cf52529b1e68479321bb93b1be3d59c77f782008` and incorporates exact -published parent `8bcc49fc4e16e5e43be0b7f0f03c3017d5b79d0c` as its second parent. Do -not rebase, retarget, force-push, rewrite either parent, or change draft state. +## Do not -The child's matching Python/TypeScript `scalar-ensemble/v1` contract and wind -strategy plot adapter remain additive with the parent's selected-scatter -exports, accessible raw tables, result-local linked selection, complete -workspace/toolstrip, playback, navigation, compatibility, and module-budget -repairs. Both adapters now enforce the same deterministic wind-scenario -boundary: exact per-trial provenance, zero shear and turbulence, seed zero, and -no gusts. SPEC 1.14.13 is the unique combined child entry above parent 1.14.12. - -Verification is green across 27 focused Python contract/adapter/selection -tests, the complete Rate/shared-swing/golf-club matrix (`1,549 passed`, two -explicit optional build123d skips), and all 556 React tests across 92 files. -TypeScript, zero-warning ESLint, Vite production build, Ruff, Ruff-format, -pinned MyPy 1.13, Bandit, the exact-parent two-file 500-line budget, docs, -minimum-test, assertions, detect-secrets, diff, and CPython 3.10 compilation -pass. Independent re-review found no actionable findings after checking the -exact provenance derivation and all five deterministic-scenario regressions; -protected current-head CI, unresolved threads, and required repository review -remain release gates. +- Do not mark a program released without a 40-character `main` merge SHA. +- Do not convert queued, cancelled, skipped, or failed checks into passing + evidence. +- Do not retarget or rewrite the protected stack, force-push, or admin-merge. +- Do not duplicate carrier or test metadata in individual program records. +- Do not treat an UpstreamDrift launcher tile as four-surface parity. -## 2026-08-10 PR #4280 workspace timestamp propagation +Fleet handoff policy is tracked by Repository_Management #1393/#1397. Any +`src/**` handoff change also requires the same-commit `SPEC.md` update. -Draft PR #4280 remains on `feat/4144-variation-export-continuation` with base -`feat/4218-toolstrip-workspace`. Exact corrected parent -`05383d333b6fd87eaf5e37305476f50b505c2c2e` is incorporated through the normal -merge containing this handoff, without rebasing, retargeting, force-pushing, -or rewriting either branch. The child retains its selected-scatter CSV export, -typed unavailable values, accessible raw tables, and focused visualization -modules while inheriting the deterministic Python 3.10-3.12 UTC parser. +## 2026-08-11 parent handoff sections preserved during #4282 reconciliation -The monotonic specification assigns the parent repair version 1.14.10 and the -variation child version 1.14.11. The reconciled tree passes all `778` Rate -tests, the real-CPython-3.10.20 compatibility suite (`27 passed`), the focused -React variation suite (`1 file / 8 tests`), TypeScript, zero-warning focused -ESLint, Ruff, format, and pinned mypy 1.13. Documentation, size, and diff gates -must remain clean in the merge commit. Protected CI, review, and propagation -to #4281 and later descendants remain separate release gates. - -## 2026-08-10 Second parent propagation into PR #4279 +## 2026-08-10 Second variation-parent propagation into PR #4281 -- Draft PR `#4279` retains branch `feat/4218-toolstrip-workspace` and base - `feat/4181-launch-monitor-registry`; exact repaired parent head - `12dd76a8dbcc106c4683f2f2e53076f8dc6f1b76` is incorporated through a - normal merge commit with no rebase, retarget, or history rewrite. -- The trees have no feature-code conflict. Reconciliation retains both - branches' append-only handoff/SPEC evidence, while the inherited D-plane - ndarray typing repair remains numerically and behaviorally neutral. -- Parent quality-gate success does not authorize this child. Fresh exact-head - protected CI, review, and all dependency gates remain open. +- Draft PR `#4281` retains branch `feat/4199-wind-scalar-adapter` and base + `feat/4144-variation-export-continuation`. Exact repaired parent head + `b90e5021a59e2081415b51ef29fbed06377bc201` is incorporated through a + normal merge commit without rebase, retarget, force-push, or history rewrite. +- There is no scalar-adapter production/test conflict. Both append-only + handoff/SPEC histories are retained and the adapter contract is unchanged. +- Parent quality success is not child release evidence. Fresh exact-head CI, + review, and all earlier dependencies remain open. - Reconciled-child evidence is 25 focused D-plane/impact tests plus docs governance, changed-file size budget, and whitespace checks. -## 2026-08-10 Exact parent propagation into PR #4279 - -Draft PR `#4279` remains on `feat/4218-toolstrip-workspace` with base -`feat/4181-launch-monitor-registry`. This merge starts from original child -head `05383d333b6fd87eaf5e37305476f50b505c2c2e` and incorporates exact -published parent head `31cbc007d4c85b5479b7cd0fb0969124eab2af67` through a -normal two-parent merge; no rebase, retarget, force-push, or draft-state change -is permitted. - -The semantic reconciliation preserves the child's File/View/Tools workspace, -module visibility, granular playback, trail, plot, and launcher integration. -It also keeps the parent's focused `ImpactLayerControls`, plotting catalog, -triple-pendulum, and primary-navigation modules. Workspace navigation now -aliases the parent's canonical stable-ID/settings constants, while retaining -the child's visibility and required-module state. Impact-layer automation and -the rendered controls share the parent's single checkbox mapping. - -Combined verification is green: 1,339 Python tests pass with six explicit -optional build123d/Rust-wheel skips; 545 React tests pass across 89 files, and -TypeScript, ESLint, and the Vite production build pass. The post-format semantic -rerun is 40 passing navigation/simulation GUI tests. Ruff, Ruff-format, Black, -pinned MyPy 1.13 across all 18 changed production modules, Bandit, the exact -18-file 500-line gate, docs, minimum-test, changed-test assertions, -detect-secrets fingerprints, and staged/unstaged diff checks pass. CPython -3.10.20 compiles every changed Python file and its dependency-free navigation -state round-trip passes; 30 compatibility/date-boundary regressions also pass. -There is no Rust delta from the exact parent, whose 12-test `swing-core` -evidence remains applicable. Independent staged review found no actionable -findings after 76 additional focused PyQt/navigation/workspace tests; protected -exact-head CI and required repository review remain release gates after an -ordinary guarded push. - -## 2026-08-10 PR #4279 fractional-timestamp Python 3.10 repair - -Exact-head CI on descendant PR #4281 exposed one remaining workspace parser -difference: CPython 3.10 accepts only three or six fractional-second digits in -`datetime.fromisoformat`, while newer supported interpreters accept the -one-digit value already used by the workspace ordering contract. The earliest -owner is PR #4279. Its shared workspace validator now enforces one anchored UTC -grammar and parses zero through six fractional digits consistently, rejecting -greater precision instead of silently truncating it on newer Python versions. -UTC-only validation, serialized values, schema fields, and chronological -comparison are unchanged. - -Evidence is `778 passed` for the full Rate suite and `45 passed` for the -compatibility plus complete workspace document suites on the local supported -interpreter, plus `27 passed` for the source-level compatibility suite on real -CPython 3.10.20. The latter has only expected warnings for plugins absent from -that intentionally minimal runtime. Ruff, formatting, pinned mypy 1.13, -documentation governance, and the 400-line budget pass. Exact remote-head -identity and normal publication/descendant propagation remain release gates. - -## 2026-08-09 PR #4281 scalar-ensemble parent propagation - -Draft PR #4281 remains on `feat/4199-wind-scalar-adapter` with unchanged base -`feat/4144-variation-export-continuation`. Exact corrected #4280 parent -`38ed58cab96842a3007e76a855db83ee2452b8fd` is incorporated by the normal -merge containing this handoff; no branch was rebased, retargeted, force-pushed, -or published by this continuation. The result preserves the complete parent -workspace, Python 3.10 compatibility, and variation-export history together -with #4281's independently owned scalar-ensemble and wind-strategy adapters. - -The child supplies matching UI-neutral Python/TypeScript -`scalar-ensemble/v1` contracts with stable RFC3986 composite row identity, -immutable nullable rows, and overall/per-cohort availability. Its wind adapter -retains every actual and perfect-information status and exposes true/estimated -wind, launch/aim, target, landing, miss, cost, and information delta. Impact -variables remain explicitly unavailable because the analysis starts at -prescribed launch. SPEC 1.14.12 records this child source delta separately -from parent versions 1.14.11 and earlier. - -Current focused evidence is `49 passed` on Python 3.11 and `49 passed` on real -CPython 3.10.20 with PyQt6/scientific dependencies present. React passes `2 -files / 10 tests`, TypeScript, and focused zero-warning ESLint. Ruff -check/format passes the four child Python files; pinned mypy 1.13 passes both -production modules and the corrected wind-adapter test. Documentation -governance, ancestry/SPEC assertions, and final diff checks are required in -this same local merge. Protected CI, review, publication, and descendant -propagation remain separate release gates. The background runner, progress and -cancellation, PyQt6/React strategy/scatter workflow, persistence, and export -wiring remain incomplete and must not be represented as delivered. - -## 2026-08-09 PR #4280 parent propagation and SPEC restoration - -Draft PR #4280 remains on `feat/4144-variation-export-continuation` with base -`feat/4218-toolstrip-workspace`. Exact corrected parent -`3f67ed466fefc8991db9c4409f921f25e1c37142` is incorporated by the normal -merge containing this handoff; no branch was rebased, retargeted, force-pushed, -or published by this continuation. The result retains the complete workspace -and Python 3.10 compatibility history together with #4280's independently -owned variation scatter/export changes. - -The child exports every selected scatter axis to CSV with stable trial index, -typed outcome, and explicit unavailable values in both PyQt6 and React. PyQt6 -also exposes the selected raw rows in bounded accessible tables, with shared -table population and focused scatter/matrix modules. SPEC 1.14.11 now records -this child source delta separately from parent versions 1.14.10 and earlier. - -Current evidence is `46 passed` on Python 3.11 and `19 passed` on real CPython -3.10.20 with PyQt6 present. React passes `1 file / 8 tests`, TypeScript, and -focused zero-warning ESLint. Ruff check/format passes five child Python files; -pinned mypy 1.13 passes the four child production modules. Documentation -governance, ancestry/SPEC assertions, and final diff checks pass locally. -Protected CI, review, publication, and descendant propagation remain separate -release gates. - -## 2026-08-09 PR #4279 Python 3.10 compatibility completion - -Draft PR #4279 remains on `feat/4218-toolstrip-workspace` with unchanged base -`feat/4181-launch-monitor-registry`. Exact corrected parent -`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` was incorporated by the normal -local merge `a340fabefa443d47325c5538f342683b38c01ade`; no branch was rebased, -retargeted, force-pushed, or published by this continuation. - -The child-owned command registry and view-workspace enums now obtain -`StrEnum` from `shared.python.compatibility` at runtime while retaining the -native type behind `TYPE_CHECKING`. Workspace timestamp validation likewise -uses the shared `UTC` value. This removes all three Python 3.11-only imports -introduced by #4279 without changing command IDs, enum values, timestamp -serialization, workspace schemas, or UI behavior. The merged regression -guards nine string-enum modules and both UTC modules by inspecting the actual -runtime import branch, then executes the three child workspace modules. - -Evidence is `126 passed` on Python 3.11, plus `14 passed` and a successful -10-module dotted-import probe on real CPython 3.10.20. Ruff check/format passes -the 11 production modules and compatibility test; pinned mypy 1.13 passes all -11 production modules with the changed-file CI settings. Documentation -governance and final diff checks must pass in this same local commit. Protected -CI, review, publication, and descendant propagation remain separate gates. - -## 2026-08-09 PR #4279 launch-registry propagation - -Draft PR #4279 remains on `feat/4218-toolstrip-workspace` with unchanged base -`feat/4181-launch-monitor-registry`. Exact parent head -`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` is incorporated through the normal -merge commit containing this handoff; neither branch was rebased, retargeted, -force-pushed, or pushed by this continuation. Source changes applied cleanly; -the overlapping handoff and specification histories were reconciled -monotonically. The result preserves the parent's package-relative facade and -Python 3.10 enum repairs plus the child's workspace, toolstrip, playback, plot, -and module-navigation implementation. - -Focused validation is `126 passed` across both facade contract modules and all -Python/PyQt test files changed by #4279. React workspace validation is `8 files -/ 32 tests passed`. Ruff check and format pass for all 28 relevant Python -files. CI-pinned mypy 1.13 passes the 18 changed production files plus the two -facade contract tests. The type gate exposed one real child-boundary defect: -`legend_visible()` returned an untyped Qt value; it now converts that value at -the widget boundary with `bool(...)`. The affected simulation GUI rerun is `29 -passed`; documentation governance and staged/unstaged diff checks also pass. - -## 2026-08-10 Second Parent Repair Propagation (#4202 -> #4203) - -- Draft child PR `#4203` retains base `feat/4189-dplane` and receives exact - repaired parent head `7d8d2f06dc797021d01939691e58f8425b652b33` - through a normal merge commit; no base, parent history, or draft state is - rewritten. -- The propagated parent repair adds explicit NumPy ndarray result boundaries - to the two D-plane helpers that failed the hosted pinned MyPy gate. Numerical - behavior, frames, schemas, and UI behavior remain unchanged. -- The exact parent quality gate is green. Protected child checks, review, and - all earlier dependency gates remain open; this propagation does not - authorize a merge or close #4189. -- Child-tree verification after conflict reconciliation is 25 focused D-plane, - impact-contract, impact-kinematics, and impact-scene tests; docs governance, - changed-file size budget, and whitespace checks also pass. The Windows - unpinned MyPy 1.15 environment cannot parse the installed Python-3.12-only - NumPy stub syntax under this branch's Python 3.11 target, and WSL currently - fails to start with `E_FAIL`; neither attempt is reported as a passing gate. - ## 2026-08-10 Parent Repair Propagation (#4202 → #4203) - Draft child PR `#4203` remains on `feat/4181-launch-monitor-registry`, based @@ -917,3 +908,234 @@ any PR touching `src/**`, `tests/**`, `config/**`, `pyproject.toml`, `rate_of_closure/web`. 6. #4125 H5: stand up the public release-management repo (cross-repo, not started). + +## 2026-08-11 additional parent sections preserved during #4282 reconciliation + +## 2026-08-10 Exact variation-parent propagation into PR #4281 + +Draft PR `#4281` remains on `feat/4199-wind-scalar-adapter` with base +`feat/4144-variation-export-continuation`. The normal merge starts from +original child `cf52529b1e68479321bb93b1be3d59c77f782008` and incorporates exact +published parent `8bcc49fc4e16e5e43be0b7f0f03c3017d5b79d0c` as its second parent. Do +not rebase, retarget, force-push, rewrite either parent, or change draft state. + +The child's matching Python/TypeScript `scalar-ensemble/v1` contract and wind +strategy plot adapter remain additive with the parent's selected-scatter +exports, accessible raw tables, result-local linked selection, complete +workspace/toolstrip, playback, navigation, compatibility, and module-budget +repairs. Both adapters now enforce the same deterministic wind-scenario +boundary: exact per-trial provenance, zero shear and turbulence, seed zero, and +no gusts. SPEC 1.14.13 is the unique combined child entry above parent 1.14.12. + +Verification is green across 27 focused Python contract/adapter/selection +tests, the complete Rate/shared-swing/golf-club matrix (`1,549 passed`, two +explicit optional build123d skips), and all 556 React tests across 92 files. +TypeScript, zero-warning ESLint, Vite production build, Ruff, Ruff-format, +pinned MyPy 1.13, Bandit, the exact-parent two-file 500-line budget, docs, +minimum-test, assertions, detect-secrets, diff, and CPython 3.10 compilation +pass. Independent re-review found no actionable findings after checking the +exact provenance derivation and all five deterministic-scenario regressions; +protected current-head CI, unresolved threads, and required repository review +remain release gates. + +## 2026-08-10 PR #4280 workspace timestamp propagation + +Draft PR #4280 remains on `feat/4144-variation-export-continuation` with base +`feat/4218-toolstrip-workspace`. Exact corrected parent +`05383d333b6fd87eaf5e37305476f50b505c2c2e` is incorporated through the normal +merge containing this handoff, without rebasing, retargeting, force-pushing, +or rewriting either branch. The child retains its selected-scatter CSV export, +typed unavailable values, accessible raw tables, and focused visualization +modules while inheriting the deterministic Python 3.10-3.12 UTC parser. + +The monotonic specification assigns the parent repair version 1.14.10 and the +variation child version 1.14.11. The reconciled tree passes all `778` Rate +tests, the real-CPython-3.10.20 compatibility suite (`27 passed`), the focused +React variation suite (`1 file / 8 tests`), TypeScript, zero-warning focused +ESLint, Ruff, format, and pinned mypy 1.13. Documentation, size, and diff gates +must remain clean in the merge commit. Protected CI, review, and propagation +to #4281 and later descendants remain separate release gates. + +## 2026-08-10 Second parent propagation into PR #4279 + +- Draft PR `#4279` retains branch `feat/4218-toolstrip-workspace` and base + `feat/4181-launch-monitor-registry`; exact repaired parent head + `12dd76a8dbcc106c4683f2f2e53076f8dc6f1b76` is incorporated through a + normal merge commit with no rebase, retarget, or history rewrite. +- The trees have no feature-code conflict. Reconciliation retains both + branches' append-only handoff/SPEC evidence, while the inherited D-plane + ndarray typing repair remains numerically and behaviorally neutral. +- Parent quality-gate success does not authorize this child. Fresh exact-head + protected CI, review, and all dependency gates remain open. +- Reconciled-child evidence is 25 focused D-plane/impact tests plus docs + governance, changed-file size budget, and whitespace checks. + +## 2026-08-10 Exact parent propagation into PR #4279 + +Draft PR `#4279` remains on `feat/4218-toolstrip-workspace` with base +`feat/4181-launch-monitor-registry`. This merge starts from original child +head `05383d333b6fd87eaf5e37305476f50b505c2c2e` and incorporates exact +published parent head `31cbc007d4c85b5479b7cd0fb0969124eab2af67` through a +normal two-parent merge; no rebase, retarget, force-push, or draft-state change +is permitted. + +The semantic reconciliation preserves the child's File/View/Tools workspace, +module visibility, granular playback, trail, plot, and launcher integration. +It also keeps the parent's focused `ImpactLayerControls`, plotting catalog, +triple-pendulum, and primary-navigation modules. Workspace navigation now +aliases the parent's canonical stable-ID/settings constants, while retaining +the child's visibility and required-module state. Impact-layer automation and +the rendered controls share the parent's single checkbox mapping. + +Combined verification is green: 1,339 Python tests pass with six explicit +optional build123d/Rust-wheel skips; 545 React tests pass across 89 files, and +TypeScript, ESLint, and the Vite production build pass. The post-format semantic +rerun is 40 passing navigation/simulation GUI tests. Ruff, Ruff-format, Black, +pinned MyPy 1.13 across all 18 changed production modules, Bandit, the exact +18-file 500-line gate, docs, minimum-test, changed-test assertions, +detect-secrets fingerprints, and staged/unstaged diff checks pass. CPython +3.10.20 compiles every changed Python file and its dependency-free navigation +state round-trip passes; 30 compatibility/date-boundary regressions also pass. +There is no Rust delta from the exact parent, whose 12-test `swing-core` +evidence remains applicable. Independent staged review found no actionable +findings after 76 additional focused PyQt/navigation/workspace tests; protected +exact-head CI and required repository review remain release gates after an +ordinary guarded push. + +## 2026-08-10 PR #4279 fractional-timestamp Python 3.10 repair + +Exact-head CI on descendant PR #4281 exposed one remaining workspace parser +difference: CPython 3.10 accepts only three or six fractional-second digits in +`datetime.fromisoformat`, while newer supported interpreters accept the +one-digit value already used by the workspace ordering contract. The earliest +owner is PR #4279. Its shared workspace validator now enforces one anchored UTC +grammar and parses zero through six fractional digits consistently, rejecting +greater precision instead of silently truncating it on newer Python versions. +UTC-only validation, serialized values, schema fields, and chronological +comparison are unchanged. + +Evidence is `778 passed` for the full Rate suite and `45 passed` for the +compatibility plus complete workspace document suites on the local supported +interpreter, plus `27 passed` for the source-level compatibility suite on real +CPython 3.10.20. The latter has only expected warnings for plugins absent from +that intentionally minimal runtime. Ruff, formatting, pinned mypy 1.13, +documentation governance, and the 400-line budget pass. Exact remote-head +identity and normal publication/descendant propagation remain release gates. + +## 2026-08-09 PR #4281 scalar-ensemble parent propagation + +Draft PR #4281 remains on `feat/4199-wind-scalar-adapter` with unchanged base +`feat/4144-variation-export-continuation`. Exact corrected #4280 parent +`38ed58cab96842a3007e76a855db83ee2452b8fd` is incorporated by the normal +merge containing this handoff; no branch was rebased, retargeted, force-pushed, +or published by this continuation. The result preserves the complete parent +workspace, Python 3.10 compatibility, and variation-export history together +with #4281's independently owned scalar-ensemble and wind-strategy adapters. + +The child supplies matching UI-neutral Python/TypeScript +`scalar-ensemble/v1` contracts with stable RFC3986 composite row identity, +immutable nullable rows, and overall/per-cohort availability. Its wind adapter +retains every actual and perfect-information status and exposes true/estimated +wind, launch/aim, target, landing, miss, cost, and information delta. Impact +variables remain explicitly unavailable because the analysis starts at +prescribed launch. SPEC 1.14.12 records this child source delta separately +from parent versions 1.14.11 and earlier. + +Current focused evidence is `49 passed` on Python 3.11 and `49 passed` on real +CPython 3.10.20 with PyQt6/scientific dependencies present. React passes `2 +files / 10 tests`, TypeScript, and focused zero-warning ESLint. Ruff +check/format passes the four child Python files; pinned mypy 1.13 passes both +production modules and the corrected wind-adapter test. Documentation +governance, ancestry/SPEC assertions, and final diff checks are required in +this same local merge. Protected CI, review, publication, and descendant +propagation remain separate release gates. The background runner, progress and +cancellation, PyQt6/React strategy/scatter workflow, persistence, and export +wiring remain incomplete and must not be represented as delivered. + +## 2026-08-09 PR #4280 parent propagation and SPEC restoration + +Draft PR #4280 remains on `feat/4144-variation-export-continuation` with base +`feat/4218-toolstrip-workspace`. Exact corrected parent +`3f67ed466fefc8991db9c4409f921f25e1c37142` is incorporated by the normal +merge containing this handoff; no branch was rebased, retargeted, force-pushed, +or published by this continuation. The result retains the complete workspace +and Python 3.10 compatibility history together with #4280's independently +owned variation scatter/export changes. + +The child exports every selected scatter axis to CSV with stable trial index, +typed outcome, and explicit unavailable values in both PyQt6 and React. PyQt6 +also exposes the selected raw rows in bounded accessible tables, with shared +table population and focused scatter/matrix modules. SPEC 1.14.11 now records +this child source delta separately from parent versions 1.14.10 and earlier. + +Current evidence is `46 passed` on Python 3.11 and `19 passed` on real CPython +3.10.20 with PyQt6 present. React passes `1 file / 8 tests`, TypeScript, and +focused zero-warning ESLint. Ruff check/format passes five child Python files; +pinned mypy 1.13 passes the four child production modules. Documentation +governance, ancestry/SPEC assertions, and final diff checks pass locally. +Protected CI, review, publication, and descendant propagation remain separate +release gates. + +## 2026-08-09 PR #4279 Python 3.10 compatibility completion + +Draft PR #4279 remains on `feat/4218-toolstrip-workspace` with unchanged base +`feat/4181-launch-monitor-registry`. Exact corrected parent +`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` was incorporated by the normal +local merge `a340fabefa443d47325c5538f342683b38c01ade`; no branch was rebased, +retargeted, force-pushed, or published by this continuation. + +The child-owned command registry and view-workspace enums now obtain +`StrEnum` from `shared.python.compatibility` at runtime while retaining the +native type behind `TYPE_CHECKING`. Workspace timestamp validation likewise +uses the shared `UTC` value. This removes all three Python 3.11-only imports +introduced by #4279 without changing command IDs, enum values, timestamp +serialization, workspace schemas, or UI behavior. The merged regression +guards nine string-enum modules and both UTC modules by inspecting the actual +runtime import branch, then executes the three child workspace modules. + +Evidence is `126 passed` on Python 3.11, plus `14 passed` and a successful +10-module dotted-import probe on real CPython 3.10.20. Ruff check/format passes +the 11 production modules and compatibility test; pinned mypy 1.13 passes all +11 production modules with the changed-file CI settings. Documentation +governance and final diff checks must pass in this same local commit. Protected +CI, review, publication, and descendant propagation remain separate gates. + +## 2026-08-09 PR #4279 launch-registry propagation + +Draft PR #4279 remains on `feat/4218-toolstrip-workspace` with unchanged base +`feat/4181-launch-monitor-registry`. Exact parent head +`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` is incorporated through the normal +merge commit containing this handoff; neither branch was rebased, retargeted, +force-pushed, or pushed by this continuation. Source changes applied cleanly; +the overlapping handoff and specification histories were reconciled +monotonically. The result preserves the parent's package-relative facade and +Python 3.10 enum repairs plus the child's workspace, toolstrip, playback, plot, +and module-navigation implementation. + +Focused validation is `126 passed` across both facade contract modules and all +Python/PyQt test files changed by #4279. React workspace validation is `8 files +/ 32 tests passed`. Ruff check and format pass for all 28 relevant Python +files. CI-pinned mypy 1.13 passes the 18 changed production files plus the two +facade contract tests. The type gate exposed one real child-boundary defect: +`legend_visible()` returned an untyped Qt value; it now converts that value at +the widget boundary with `bool(...)`. The affected simulation GUI rerun is `29 +passed`; documentation governance and staged/unstaged diff checks also pass. + +## 2026-08-10 Second Parent Repair Propagation (#4202 -> #4203) + +- Draft child PR `#4203` retains base `feat/4189-dplane` and receives exact + repaired parent head `7d8d2f06dc797021d01939691e58f8425b652b33` + through a normal merge commit; no base, parent history, or draft state is + rewritten. +- The propagated parent repair adds explicit NumPy ndarray result boundaries + to the two D-plane helpers that failed the hosted pinned MyPy gate. Numerical + behavior, frames, schemas, and UI behavior remain unchanged. +- The exact parent quality gate is green. Protected child checks, review, and + all earlier dependency gates remain open; this propagation does not + authorize a merge or close #4189. +- Child-tree verification after conflict reconciliation is 25 focused D-plane, + impact-contract, impact-kinematics, and impact-scene tests; docs governance, + changed-file size budget, and whitespace checks also pass. The Windows + unpinned MyPy 1.15 environment cannot parse the installed Python-3.12-only + NumPy stub syntax under this branch's Python 3.11 target, and WSL currently + fails to start with `E_FAIL`; neither attempt is reported as a passing gate. diff --git a/SPEC.md b/SPEC.md index 34770e45ad..ec7e7c70f4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,14 +26,27 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.14.21 | -| **Spec Version** | 1.14.21 | +| **Current Version** | 1.14.22 | +| **Spec Version** | 1.14.22 | | **Last Spec Update** | 2026-08-11 | ## 2. Purpose & Mission Comprehensive monorepo housing 45+ utility tools for data processing, scientific computing, process engineering, and automation. This is the central tooling hub for the D-sorganization fleet, providing modular engineering calculation tools with PyQt6 GUIs, FastAPI web services, Rust numerical kernels, and a unified launcher with plugin architecture for extensibility. +### 2026-08-11 Wind workflow receives current scalar-adapter parent + +- The responsive PyQt6/React wind-strategy workflow and consolidated + capability stack inherit exact scalar-adapter parent + `a7dce5f89b483303938f518b74b4028a1c68ba81` through a normal child-first + merge, preserving branch topology and both histories. +- The validated split kinetics facade replaces one obsolete child formatting + hunk. Seven remaining child-owned paths receive pinned Ruff formatting only; + physics, public APIs, schemas, frames, units, and UI behavior are unchanged. +- The exact skipped-import MyPy profile replaces one unused cache-return ignore + with an explicit `KineticsSeries | None` cast. The cached object and runtime + behavior are unchanged. + ### 2026-08-11 Wind strategy plot-adapter module-budget repair - `rate_of_closure.variation.wind_strategy_plot_adapter` remains the public @@ -62,6 +75,70 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific `kinetics.py` at 646 LOC, above the ungrandfathered 500-LOC ceiling. The façade and its focused implementation modules are now 222, 205, and 131 LOC. +### 2026-08-09 Capability workflow contract hardening + +- Python and TypeScript capability workflow readers reject JSON primitive type + mismatches throughout the nested v1 document instead of coercing numeric + strings, booleans, or numeric text fields. +- A shared golden parser fixture pins identical acceptance and rejection, + including portable acceptance of mathematically integral JSON numbers for + integer fields. +- React Shot Optimizer numeric controls retain decimal draft text, allowing + ordinary leading-minus keyboard entry before validated numeric commit. + +### 2026-08-09 Capability result diagnostics + +- PyQt6 and React ranked-alternative views expose the full v1 optimization + diagnostics, explicit parameter units, evidence-envelope state, and Pareto + state rather than a reduced carry/dispersion summary. +- Ranked results export through spreadsheet-safe CSV and a deterministic + `capability-result-export/v1` JSON envelope. Lossless raw observation exports + remain separate so aggregate recommendations cannot obscure failed or + no-impact trials. +- React scalar-ensemble scatter plots render numeric grid/tick scales, visible + axis ranges, and cohort legends with paired-finite counts. + +### 2026-08-09 Rate campaign release-evidence authority + +- `docs/release/rate_of_closure_campaign.v1.json` is the normalized authority + for primary campaign issues, specifications, carriers, four-surface support, + commit-bound tests, limitations, dependencies, and release status. +- `scripts/rate_campaign_manifest.py` validates the strict version-1 contract + and emits its deterministic JSON Schema. Feature-stack implementation, + protected parent integration, and exact `main` release are mutually + exclusive states with fail-closed cross-field requirements. +- The current manifest truthfully records no complete campaign release SHA; + local tests and unprotected feature-parent merges cannot satisfy that claim. +- Composed local implementation evidence is commit-bound in the manifest; + maintainers must still refresh live mutable PR heads before any write, + merge, release, or issue-closure decision. +- The manifest loader explicitly narrows Pydantic's validated value for the + CI mypy 1.13 `--follow-imports=skip` context without weakening runtime + validation or the generated schema. +- The PyQt playback adapter similarly converts Qt's elapsed-time result to a + concrete `float`, preserving the typed seconds contract when Qt imports are + deliberately skipped by the changed-file CI lane. + +### 2026-08-09 Swing-core parity CI isolation + +- The dedicated `swing_core` Rust-to-Python parity lane disables third-party + pytest plugin auto-loading so cached GUI plugins on self-hosted runners cannot + introduce undeclared PyQt dependencies into the Rust-only qualification job. +- The lane continues to install the built wheel and run the same non-skippable + parity suite; numerical behavior and production dependencies are unchanged. + +### 2026-08-09 Rate web-release stability + +- The documented direct Rate web launcher imports its registration through the + package-qualified path and dynamically loads the repository bootstrap without + direct `sys.path` mutation, so `python src/rate_of_closure/launch_web.py` + works outside package-module execution and satisfies changed-file policy. +- Rate supports a static Vite web release and a PyQt6/PyInstaller desktop + release. It does not claim Tauri support without a `src-tauri` project or an + artifact qualification lane. +- The Flight Explorer parent-panel test explicitly settles its lazy wind-panel + import inside React `act`, removing suite-load timing from the assertion. + ### 2026-08-10 D-plane ndarray typing boundary repair - The private D-plane vector conversion and horizontal-projection helpers bind @@ -287,15 +364,6 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific stable point and variable identifiers, disclose unavailable downstream values, and keep reproducible sampling seeds and exact cohort counts. -### 2026-08-10 Rate of Closure Python 3.10 datetime boundary - -- Rate of Closure modules must import `UTC` from - `shared.python.compatibility`, never directly from `datetime`, because - `datetime.UTC` is unavailable on the supported Python 3.10 runtime. -- An AST-based regression guard scans the complete Rate source tree so future - user-interface or persistence work cannot silently restore the incompatible - import. - ### 2026-08-05 Rate of Closure Python 3.10 CI compatibility - Rate of Closure and shared swing simulation string enums use Python 3.10-safe @@ -1122,7 +1190,7 @@ high_mm)` exposes the face-curvature normal (gradient of the difference between a launch monitor's reported geometric-center path and the impact point's actual delivery for a rotating clubhead (twist model, v(P) = v(ref) + omega x r). PyQt6 desktop app (animated 3D clubhead + - closure sweep, ThemedWindowMixin) plus a React/Vite/Tauri web clone in + closure sweep, ThemedWindowMixin) plus a React/Vite static web clone in `src/rate_of_closure/web/` whose TypeScript model is pinned test-for-test against the Python implementation. Conventions and rate data follow the AffineDrift launch-monitor research: the standard launch-monitor frame @@ -1135,7 +1203,7 @@ high_mm)` exposes the face-curvature normal (gradient of the explanations, and a Derivation & Traceability tab typesetting the full calculation with live numeric substitution (matplotlib mathtext on desktop, bundled KaTeX on web). `build_executable.py` packages the - desktop app with PyInstaller; the web app packages via Tauri. + desktop app with PyInstaller; the web app releases as a static Vite bundle. Registered in `tool_manifest.yaml` (web port 5193); tests in `tests/rate_of_closure/`. ### 2026-08-05 Wedge impact-point kinematics and AoA attribution @@ -2927,6 +2995,23 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-11 | 1.14.22 | merge(rate-of-closure, #4281 #4282): normally merge exact published responsive wind-workflow child `b2baf6ef615b0d756a86ac4ca7eef2fc583210ee` first with exact newly published wind scalar-adapter parent `9321c1d2e091b8c7e5a4a83aa9ad726290e7fb5a` second while preserving PR #4282's `feat/4199-wind-scalar-adapter` base and both histories; retain responsive PyQt6/React wind execution, progress/cancellation, controls, scatter, persistence/export, consolidated capability workflow, and strict campaign release authority while inheriting the current scalar-adapter, variation, workspace/toolstrip, launch-monitor/D-plane, split-kinetics, and Qt repairs; keep review, ordinary publication, protected exact-head CI, unresolved-thread checks, dependency integration, and release open. | +| 2026-08-06 | 1.13.16 | feat(swing_sim, rate_of_closure, #4196): map desired flight to frame-explicit centered driver/iron delivery solution families with strict cross-runtime schemas, observed intervals/correlations, local sensitivities, complete residuals, model manifests, and rejected no-impact/miss diagnostics. | +| 2026-08-06 | 1.13.15 | feat(swing_sim, rate_of_closure, #4195): add strict desired-flight inverse-solver contracts, deterministic bounded multi-objective search, ranked residual-rich candidates, typed infeasible/no-impact/nonconverged outcomes, and Python/TypeScript parity fixtures. | +| 2026-08-06 | 1.13.14 | feat(swing_sim, rate_of_closure, #4194): add the canonical source-backed flight-result metric catalog, analytic landing/trajectory derivation, typed unavailable and qualified-ground boundaries, complete run manifests, deterministic Python/TypeScript exports, and cross-client parity fixtures. | +| 2026-08-11 | 1.14.21 | merge/fix(rate-of-closure, #4281 #4282): normally merge exact published scalar-adapter parent `a7dce5f89b483303938f518b74b4028a1c68ba81` into exact remote responsive-workflow child `29e15d6ff631f7f30afcc745be783f2e716d7dcf` in child-first order; preserve the complete PyQt6/React wind workflow and capability stack while inheriting the parent's split kinetics and scalar-adapter size/parameter repairs; resolve obsolete monolithic kinetics wholly to the parent facade, retain both append-only documentation histories, apply pinned Ruff formatting only to seven surviving child-owned paths, and replace one skipped-import cache-return ignore with an explicit typed cast without changing behavior, APIs, schemas, frames, or units. | +| 2026-08-10 | 1.14.16 | fix(rate-of-closure, #4281 #4282): normally propagate repaired wind scalar-adapter parent `d6fb04e07c2a625412e9208b07103acdc42c621b` into the responsive wind workflow without production/test conflict or history rewrite; retain current-state handoff policy and verify with 25 focused tests plus governance, size, and whitespace checks. | +| 2026-08-10 | 1.14.15 | fix(rate-of-closure, #4282): preserve the strict campaign-manifest return contract under the repository's exact pinned-mypy skipped-import profile by explicitly typing the Pydantic-validated local as `CampaignManifest`; this repairs hosted quality-gate run `31395741841` without changing JSON parsing, validation, failure behavior, PR base, or dependency topology, and requires the resulting exact head to propagate normally into #4285. | +| 2026-08-10 | 1.14.14 | feat/fix(rate-of-closure, #4199 #4281 #4282): normally propagate exact scalar-adapter parent `4a793c4c3f19aad43a3c215800b266be487ace49` into the responsive PyQt6/React wind-workflow carrier without changing its base; preserve the consolidated capability workflow and release authority while inheriting the complete variation, workspace, Python 3.10 compatibility, immutable scalar-row, stable identity, availability, and deterministic wind-scenario repairs; register the child capability tab in the parent's canonical navigation-state tuple so legacy saved orders reveal it without duplicating state; retain runtime invariant enforcement under optimized Python; and satisfy pinned typing/security analysis without weakening the fixed-argument builder boundary. | +| 2026-08-10 | 1.14.13 | feat/fix(wind variation, #4144 #4199 #4280 #4281): normally propagate exact variation parent `8bcc49fc4e16e5e43be0b7f0f03c3017d5b79d0c` into the wind scalar-adapter child while preserving matching UI-neutral Python/TypeScript `scalar-ensemble/v1` contracts with structured provenance, unit-bearing variables, stable RFC3986 row IDs, immutable nullable raw rows, and overall/per-cohort availability; adapt actual and perfect-information wind outcomes with explicit status, true/estimated wind, launch/aim, target, landing, miss, cost, and information-delta values; enforce the deterministic scenario boundary identically in both clients with exact per-trial provenance, zero shear/turbulence, seed zero, and empty gusts; and retain the parent's result-local linked selection and complete visualization/workspace repairs. | +| 2026-08-10 | 1.14.12 | feat/fix(variation, #4144 #4218 #4279 #4280): normally propagate exact workspace parent `6717e9e09d507dbc24bedb36177f1cdf0b4fd90b` into the variation-export child while preserving selected-scatter CSV parity with stable trial identity, typed unavailable outcomes, bounded accessible PyQt raw tables, focused PyQt/React visualization modules, linked selection, and all-trial arc analysis alongside the complete workspace/toolstrip, playback, plot, navigation, compatibility, and module-budget repairs; make linked trial selection result-local in both clients, clear it atomically when a rerun replaces the result, and reject or neutralize indices outside the current trial count so smaller reruns cannot crash or dim every new point. | +| 2026-08-10 | 1.14.11 | feat/fix(rate-of-closure, #4203 #4218 #4279): normally propagate exact launch-registry parent `31cbc007d4c85b5479b7cd0fb0969124eab2af67` into the workspace/toolstrip child while preserving granular playback, path trails, module visibility, and independent plot controls; reuse the parent's single persisted impact-layer mapping and canonical navigation constants without duplicating state; and retain its focused triple-pendulum, plotting-catalog, and primary-navigation repairs. | +| 2026-08-10 | 1.14.10 | fix(compatibility, #4279): make workspace UTC timestamp parsing deterministic across Python 3.10-3.12 with one anchored canonical grammar, consistent zero- through six-digit fractional-second parsing, and rejection of greater-than-microsecond precision instead of interpreter-dependent rejection or truncation. | +| 2026-08-09 | 1.14.9 | fix(compatibility, #4279): route the child command/view `StrEnum` runtime imports and workspace-validation `UTC` import through `shared.python.compatibility` while preserving native enum typing under `TYPE_CHECKING`, all wire values, schemas, UTC serialization, and UI behavior; merge the parent and child regression into one nine-enum/two-UTC runtime-import contract exercised with real CPython 3.10.20. | +| 2026-08-09 | 1.14.8 | feat(rate-of-closure, #4201): harden cross-runtime capability parsing and signed input; expose complete diagnostics, result exports, and quantitative scatter annotations; stabilize supported PyQt6/static-Vite entrypoints; and add a strict campaign manifest with generated schema, four-surface status, explicit gaps/dependencies, and fail-closed feature-stack/protected-parent/`main` release distinctions. | +| 2026-08-08 | 1.14.7 | docs(rate-of-closure, #4197): record the capability stack consolidation onto `feat/4199-wind-workflow`, superseding the per-slice stacked-branch cautions now that #4283, #4289, and #4294 are merged, and note that the unfinished CI matrix reflects an offline runner fleet rather than failures. | +| 2026-08-08 | 1.14.6 | feat(rate-of-closure, #4197): add matched PyQt6/React Shot Optimizer workspaces with strict cross-runtime workflow persistence, off-main-thread qualified flight execution, truthful progress/cancellation, complete scalar-ensemble cohort retention, ranked alternatives, stage-qualified selectable scatter axes, zoom/autofit, bounded raw-row paging, lossless CSV/stable JSON, substantive hover guidance, and saved-layout migration that reveals newly registered modules. | +| 2026-08-08 | 1.14.5 | feat(ball-flight, #4197): add profile/request-bound Python and TypeScript Waterloo/Penner capability evaluators with exact established parameter IDs, physical-domain and unit validation, sourced per-club spin defaults or paired variable spin inputs, shared gyro-projected positive-fade/right spin-axis convention across result/impact/variation producers, canonical target-frame conversion, target-aware complete scalar metrics, typed horizon nonconvergence, fail-fast invariants, and a shared 16-scalar tolerance fixture. Corrects the TypeScript ground-crossing interpolation so a descending launch from z=0 cannot extrapolate to a negative sample time, which previously raised an untyped `RangeError` that was absorbed as an `evaluator_exception`; such launches now report `nonconverged`, matching the Python runtime. | | 2026-08-11 | 1.14.21 | merge(rate-of-closure, #4280 #4281): normally merge exact published wind scalar-adapter child `a7dce5f89b483303938f518b74b4028a1c68ba81` first with exact newly published variation-export parent `e6c7460a01082631565fb9ed48aa32538bd7772c` second while preserving PR #4281's `feat/4144-variation-export-continuation` base and both histories; retain matching Python/TypeScript `scalar-ensemble/v1` provenance, availability, deterministic-scenario, wind-strategy adapter, and strict module-budget behavior while inheriting current variation visualization/export, workspace/toolstrip, launch-monitor/D-plane ancestry, split kinetics, and behavior-preserving Qt boundaries; keep review, ordinary publication, protected exact-head CI, unresolved-thread checks, dependency integration, and release open. | | 2026-08-11 | 1.14.20 | refactor(rate-of-closure, #4281): replace the private wind-scenario validator's five loose parameters with a scenario and one frozen, slotted expectation object; preserve validation order, messages, behavior, public API, and wire output while satisfying the four-parameter and 50-line production-function contracts. | | 2026-08-11 | 1.14.19 | refactor(rate-of-closure, #4281): split request/analysis validation from the 403-line wind-strategy plot adapter into a private collaborator, leaving 252-line and 186-line production modules below the mandatory 400-line ceiling while preserving the public API, scalar-ensemble wire order, row identity and values, availability, attributes, validation messages, and no-flight-rerun boundary. | @@ -2962,11 +3047,13 @@ Active development with stable core, continuous tool expansion, and web API in p | 2026-08-06 | 1.14.2 | feat(rate_of_closure, #4192 #4234): complete the shared spatial-target workflows in PyQt6 and React with canonical cross-tab state, versioned JSON/CSV/manifest persistence, no-run 2D/3D rendering, continuous aerial passage, surface-projected landing assessment, field-linked validation, stale-solver protection, high-DPI canvases, responsive wrapped PyQt forms, collapsible engineering detail/layer controls, and movable or hideable legends; keep aerial requests fail-closed where solver/variation objectives remain ground-only. | | 2026-08-06 | 1.14.1 | feat(ball-flight integration, #4192-#4200 #4205): integrate the canonical metric catalog, Launch Direction conventions, inverse and impact-family solvers, capability-aware objectives, spatial targets, reproducible wind and uncertainty analysis, responsive locked-aspect plots, and timestamp-accurate Launch/Apex/Landing 3D playback across the shared Python contracts and the PyQt6/React Rate of Closure clients. | | 2026-08-06 | 1.14.0 | feat(swing_sim, rate_of_closure, #4192): add the UI-neutral `swing_sim.spatial_target` version-1 contract with canonical app-frame downrange/elevation/right coordinates, source-frame provenance and flight-frame conversion, surface-circle/corridor and 3D sphere/box acceptance geometry, signed closest-point miss vectors, deterministic Python/TypeScript serialization, and explicit legacy green/fairway migration. | -| 2026-08-06 | 1.13.16 | feat(swing_sim, rate_of_closure, #4196): map desired flight to frame-explicit centered driver/iron delivery solution families with strict cross-runtime schemas, observed intervals/correlations, local sensitivities, complete residuals, model manifests, and rejected no-impact/miss diagnostics. | -| 2026-08-06 | 1.13.15 | feat(swing_sim, rate_of_closure, #4195): add strict desired-flight inverse-solver contracts, deterministic bounded multi-objective search, ranked residual-rich candidates, typed infeasible/no-impact/nonconverged outcomes, and Python/TypeScript parity fixtures. | -| 2026-08-06 | 1.13.14 | feat(swing_sim, rate_of_closure, #4194): add the canonical source-backed flight-result metric catalog, analytic landing/trajectory derivation, typed unavailable and qualified-ground boundaries, complete run manifests, deterministic Python/TypeScript exports, and cross-client parity fixtures. | -| 2026-08-06 | 1.13.13 | fix(ball-flight, #4199): migrate strategy output to v2; separate policy-fixed true-wind counterfactuals from preset-oracle regret and add failure-inclusive target-hold, miss-distance CVaR, and directional risk metrics with Python/TypeScript parity. | +| 2026-08-06 | 1.13.13 | feat(swing_sim, rate_of_closure, #4196): map desired flight to frame-explicit centered driver/iron delivery solution families with strict cross-runtime schemas, observed intervals/correlations, local sensitivities, complete residuals, model manifests, and rejected no-impact/miss diagnostics. | +| 2026-08-06 | 1.13.12 | feat(swing_sim, rate_of_closure, #4195): add strict desired-flight inverse-solver contracts, deterministic bounded multi-objective search, ranked residual-rich candidates, typed infeasible/no-impact/nonconverged outcomes, and Python/TypeScript parity fixtures. | +| 2026-08-06 | 1.13.11 | feat(swing_sim, rate_of_closure, #4194): add the canonical source-backed flight-result metric catalog, analytic landing/trajectory derivation, typed unavailable and qualified-ground boundaries, complete run manifests, deterministic Python/TypeScript exports, and cross-client parity fixtures. | | 2026-08-06 | 1.13.12 | feat(ball-flight, #4198 #4199): add Python/TypeScript deterministic true-versus-estimated wind ensembles, correlated under/overestimation, common-random-number club/aim strategy trials, landing scatter cohorts, and expected-cost/regret summaries. | +| 2026-08-06 | 1.13.13 | fix(ball-flight, #4199): migrate strategy output to v2; separate policy-fixed true-wind counterfactuals from preset-oracle regret and add failure-inclusive target-hold, miss-distance CVaR, and directional risk metrics with Python/TypeScript parity. | +| 2026-08-06 | 1.13.12 | feat(rate_of_closure, #4200): add deterministic timestamp interpolation and accessible play/pause/scrub/speed/restart/Launch/Apex/Landing controls to PyQt6 and React; preserve Matplotlib camera state with mutable markers; add a dependency-free rotatable/zoomable orthographic web canvas with a locked physical metre scale and one cancellable animation loop. | +| 2026-08-06 | 1.13.9 | feat(rate_of_closure, swing_sim, #4182 #4183 #4189): add typed reference-frame-explicit 3D D-plane geometry, face-center/contact/reference analyses, exact-versus-planar spin-loft residuals, persistent PyQt6/React engineering layers, and shaded sector exports. | | 2026-08-06 | 1.13.11 | feat(rate_of_closure, #4200): add deterministic timestamp interpolation and accessible play/pause/scrub/speed/restart/Launch/Apex/Landing controls to PyQt6 and React; preserve Matplotlib camera state with mutable markers; add a dependency-free rotatable/zoomable orthographic web canvas with a locked physical metre scale and one cancellable animation loop. | | 2026-08-10 | 1.13.11 | fix(swing-sim, d-plane, #4202): add explicit ndarray result boundaries to the private vector conversion and horizontal-projection helpers, closing the exact changed-file MyPy `no-any-return` failures without changing numerical semantics, DbC validation, frames, schemas, or UI behavior. | | 2026-08-10 | 1.13.10 | feat/fix(rate-of-closure, swing-sim, #4179 #4182 #4183 #4189 #4202): retain typed reference-frame-explicit 3D D-plane geometry, face-center/contact/reference analyses, exact-versus-planar spin-loft residuals, persistent PyQt6/React engineering layers, and shaded sector exports while propagating the Python 3.10 UTC repair and source-wide AST guard through the exact impact-visualization parent; extract persisted D-plane layer controls to restore the protected simulation-view module budget. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index c65f3ea94d..345ebb538a 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,4 +1,57 @@ -# Rate of Closure Campaign Handoff +# Rate of Closure Ball-Flight Campaign Handoff + +## 2026-08-11 current scalar-adapter propagation into wind workflow PR #4282 + +- PR `#4282` remains on `feat/4199-wind-workflow`, based on + `feat/4199-wind-scalar-adapter`. Exact published child + `b2baf6ef615b0d756a86ac4ca7eef2fc583210ee` is the first parent and exact + newly published scalar-adapter head + `9321c1d2e091b8c7e5a4a83aa9ad726290e7fb5a` is the second parent of this + normal local merge. +- Production and test paths merge automatically. Responsive PyQt6/React wind + execution, progress/cancellation, controls, scatter, persistence/export, + consolidated capability workflow, and strict campaign release authority + remain authoritative while inheriting the current scalar-adapter, variation, + workspace/toolstrip, launch-monitor/D-plane, split-kinetics, and Qt repairs. + Only append-only handoffs and SPEC conflict textually; both histories remain. +- Focused pre-commit regression is green: 94 Python/PyQt wind, responsive- + layout, navigation, playback, and manifest tests pass, as do all 44 tests in + eight focused React wind/responsive suites. Docs governance and strict + campaign-manifest generation/validation also pass. +- Exact post-commit PR-delta gates are also green: pinned Ruff 0.14.10 checks + and formats 57 changed Python files, pinned MyPy 1.13.0 accepts 38 production + files, and pinned Bandit 1.7.7 finds no medium/high issues in 37 scanned source + files. The 500-LOC budget, changed-Python policy, minimum-test contract, + changed-test assertions, diff hygiene, React type-check/lint, and production + web build all pass. +- No issue or epic is completed by this propagation. Independent review, + normal publication, + protected exact-head CI, unresolved-thread checks, dependency integration, + and release remain open. + +## 2026-08-11 Exact #4281 parent propagation into PR #4282 + +- Draft PR `#4282` retains branch `feat/4199-wind-workflow` and base + `feat/4199-wind-scalar-adapter`. Exact remote child + `29e15d6ff631f7f30afcc745be783f2e716d7dcf` normally merges exact published + parent `a7dce5f89b483303938f518b74b4028a1c68ba81` second without rewriting either + history or the dependency stack. +- Responsive PyQt6/React wind execution, progress/cancellation, controls, + scatter, persistence/export, and the consolidated capability stack remain + authoritative. The parent contributes its split kinetics facade, Ground/Tee + parity, and strict 252-line public/213-line private scalar-adapter design. +- The obsolete formatting-only child edit to monolithic kinetics resolves to + the parent facade. All documentation history remains present. Seven + child-owned Ruff-format failures remain the only known current-head code + formatting correction. The skipped-import MyPy profile additionally replaces + one now-unused cache-return ignore with an explicit typed cast that preserves + the cached object and runtime behavior. Independent review and protected CI + remain required. +- Local evidence is 74 focused Python/PyQt wind, kinetics, and Ground/Tee + tests; 29 focused React tests; and 8 campaign-manifest tests. TypeScript, + focused zero-warning ESLint, Ruff check/format, pinned MyPy 1.13, Bandit's + medium/high threshold, strict wind module/signature/function budgets, + manifest, documentation, policy, SPEC, and diff gates pass. ## 2026-08-11 current variation propagation into wind scalar PR #4281 @@ -66,40 +119,74 @@ any merge or release claim. ## 2026-08-10 Second #4280 propagation into wind scalar PR #4281 - -- PR `#4281` stays on `feat/4199-wind-scalar-adapter`, based on - `feat/4144-variation-export-continuation`. -- Exact repaired parent `b90e5021a59e2081415b51ef29fbed06377bc201` - is incorporated through a normal merge commit. There is no production/test - scalar-adapter conflict and no branch/PR topology or history rewrite. -- Fresh protected CI, review, and dependency ordering remain open. -- Post-reconciliation evidence is 25 focused D-plane/impact tests plus docs - governance, changed-file-size, and whitespace gates. - -## 2026-08-10 Exact #4280 propagation into wind scalar PR #4281 - -- PR `#4281` remains on `feat/4199-wind-scalar-adapter`, based on - `feat/4144-variation-export-continuation`. -- Original child `cf52529b1e68479321bb93b1be3d59c77f782008` normally merges - exact parent `8bcc49fc4e16e5e43be0b7f0f03c3017d5b79d0c`; preserve that - parent order and do not rebase, retarget, force-push, rewrite a parent, or - change draft state. -- The Python/TypeScript `scalar-ensemble/v1` contract and wind-strategy plot - adapter retain stable row identity, provenance, typed unavailable values, - and per-cohort availability while inheriting the complete result-local - selection, visualization, workspace, compatibility, and module-budget tree. - Both implementations enforce the same deterministic scenario boundary: - exact per-trial provenance, zero shear/turbulence, seed zero, and no gusts. -- SPEC 1.14.13 is the unique combined child entry. Verification passes 27 - focused Python contract/adapter/selection tests, 1,549 complete - Rate/shared-swing/golf-club tests with two explicit optional build123d skips, - and all 556 React tests across 92 files plus type-check/lint/build. Exact- - parent Ruff/format/pinned MyPy/Bandit, the two-file 500-line budget, docs, - minimum-test, assertions, detect-secrets, diff, and CPython 3.10 compilation - pass. Independent re-review found no actionable findings after checking the - exact provenance derivation and all five deterministic-scenario regressions. +## 2026-08-10 Repaired wind-scalar parent propagation into PR #4282 + +- Draft PR `#4282` keeps branch `feat/4199-wind-workflow` and base + `feat/4199-wind-scalar-adapter`. +- Exact repaired parent head `d6fb04e07c2a625412e9208b07103acdc42c621b` + is incorporated through a normal merge after its quality gate passed. +- No wind-workflow production or test code conflicted. No rebase, retarget, + force-push, parent rewrite, draft-state change, or merge was used. +- Twenty-five focused tests plus documentation governance, changed-file size, + and whitespace checks pass locally. Fresh protected CI and review must pass + before this exact head can propagate normally into PR `#4285`. + +## 2026-08-10 PR #4282 exact-head mypy repair + +- Hosted run `31395741841`, quality job `93478091420`, checked merge ref + `ae75c4b006072b20a713e45b177defcc16a380fe` for exact #4282 head + `aa6eeffb0395f7ed7954f2315b1c625cada552d8`. +- Checkout, installation, Ruff, and format passed. Pinned mypy 1.13 failed only + `scripts/rate_campaign_manifest.py:335:5 [no-any-return]` because the exact + skipped-import profile treats Pydantic's validator result as `Any`. +- The validated local now has an explicit `CampaignManifest` annotation. This + preserves the strict JSON contract and avoids both a suppression and the + redundant cast reported by the non-skipped profile. +- The failed run belongs to the obsolete head. Publish normally, preserve the + PR base/draft state, and propagate the resulting exact head into #4285. + +## 2026-08-10 Exact #4281 propagation into wind workflow PR #4282 + +- PR `#4282` remains on `feat/4199-wind-workflow`, based on + `feat/4199-wind-scalar-adapter`. +- Original child `5f77af4add23547a21cc3fabce98ae9ad4260427` normally merges exact + parent `4a793c4c3f19aad43a3c215800b266be487ace49`; preserve that parent order + and do not rebase, retarget, force-push, rewrite a parent, change draft state, + or merge the PR. +- The responsive PyQt6/React wind workflow, consolidated capability stack, and + campaign release authority remain additive with the parent's strict + scalar-ensemble wind adapter, variation visualization, result-local + selection, workspace/toolstrip, and Python 3.10 compatibility repairs. +- The canonical navigation-state tuple now includes the child-owned capability + tab while retaining the parent's legacy-order visibility migration; no + duplicate navigation constant remains. +- Static review replaces optimized-away UI/capability assertions with explicit + runtime invariant failures and records the fixed-argument executable builder + subprocess boundary using Bandit's native suppression syntax. The separate + exact-CI Pydantic return repair is recorded above. +- SPEC 1.14.14 is the unique combined child entry. Verification passes 73 + focused Python tests, the complete 1,657-test Rate/shared-swing/golf-club + matrix with two explicit optional build123d skips, 643 React tests across 105 + files plus type-check/lint/build, 12 Rust tests, 31 real-CPython-3.10 + compatibility tests, and 43 post-review invariant/GUI/manifest tests. + Ruff/format, pinned MyPy 1.13 across 37 changed production files, Bandit, + exact-parent size, manifest, docs, minimum-test, assertions, CI-policy + secrets, compilation, and diff gates pass. Independent re-review found no + actionable findings after 94 Python workflow/navigation tests, 20 + manifest/GUI tests, 61 React workflow tests, and independent static checks. Protected current-head CI, unresolved threads, and repository review remain release gates after publication. +## 2026-08-09 PR #4282 corrected-parent propagation + +Draft #4282 remains on `feat/4199-wind-workflow` with unchanged base +`feat/4199-wind-scalar-adapter`. Exact corrected #4281 parent +`958770049f0124dac0426a6dd62fd4edbf437e7a` is incorporated by the normal +local merge containing this handoff; no branch was rebased, retargeted, +force-pushed, or published. The result preserves the responsive PyQt6/React +wind-strategy workflow, the consolidated capability stack, release-manifest +authority, and launcher/type-gate repairs while inheriting the complete +Python 3.10 compatibility, variation-export, and scalar-ensemble corrections. + ## 2026-08-11 current workspace propagation into variation PR #4280 - PR `#4280` remains on `feat/4144-variation-export-continuation`, based on @@ -125,6 +212,7 @@ - No issue or epic is completed by this propagation. Independent review, normal publication, protected exact-head CI, unresolved-thread checks, dependency integration, and release remain open. + ## 2026-08-11 reviewed workspace parent propagation into variation PR #4280 - PR `#4280` remains on `feat/4144-variation-export-continuation`, based on @@ -479,7 +567,6 @@ Status verified 2026-08-07. This isolated integration is published as draft [PR #4217](https://github.com/D-sorganization/Tools/pull/4217). No source PR branch was rewritten. - ## 2026-08-10 PR #4281 workspace timestamp propagation Exact parent `f90836e342efc8be624739802375af2876d11e5f` is incorporated into @@ -507,17 +594,21 @@ and wind-strategy adapter while inheriting the full variation-export and Python 3.10 compatibility history. SPEC 1.14.12 records this child source delta independently and monotonically above parent 1.14.11. -Focused evidence is `49 passed` on Python 3.11 and `49 passed` on real CPython -3.10.20 with PyQt6/scientific dependencies installed. React passes `2 files / -10 tests`, TypeScript, and focused zero-warning ESLint. Ruff check/format is -clean across the four child Python files; pinned mypy 1.13 passes both -production modules and the wind-adapter test after replacing its stale ignore -with a `Literal` status annotation. Documentation, ancestry/SPEC assertions, -and diff checks are required in the same local merge. Protected CI, review, -publication, and normal propagation to #4282 and later children remain open. -The adapter is not the complete #4199 workflow: background execution, -progress/cancellation, client strategy/scatter UI, persistence, and export -wiring remain explicit downstream requirements. +SPEC 1.14.13 records the current #4282 propagation monotonically above the +corrected parent's 1.14.12, 1.14.11, and 1.14.10 entries. Protected CI, review, +publication, installed-package evidence, downstream UpstreamDrift parity, and +the remaining scientific/accessibility/performance release gates remain +separate. This local merge is not a protected release. + +Focused evidence is 62 tests on Python 3.11 and 62 tests on real CPython +3.10.20 with PyQt6 and the complete focused scientific dependency set. The +real 3.10 run exposed one child-owned direct `enum.StrEnum` import in +`capability_observation.py`; it now uses the established shared runtime +compatibility contract, retains native typing under `TYPE_CHECKING`, and is +pinned by the expanded regression. React passes 8 files / 35 tests, TypeScript, +and focused zero-warning ESLint. Ruff check/format passes 15 focused files; +pinned mypy 1.13 passes 10 production modules; and the nine campaign +manifest/parity contracts pass. ## 2026-08-10 PR #4280 workspace timestamp propagation @@ -545,12 +636,25 @@ scatter-axis CSV export parity, typed unavailable rows, PyQt accessible raw tables, and the focused table/scatter/matrix module split. SPEC 1.14.11 records that child source delta independently and monotonically above parent 1.14.10. -Focused evidence is `46 passed` on Python 3.11 and `19 passed` on real CPython -3.10.20 with PyQt6 present. React passes `1 file / 8 tests`, TypeScript, and -focused zero-warning ESLint. Ruff check/format passes the five child Python -files; pinned mypy 1.13 passes the four production modules. Documentation, -ancestry/SPEC assertions, and diff checks pass locally. Protected CI, review, -publication, and normal propagation to #4281 and later children remain open. +## 2026-08-09 Capability workflow hardening continuation + +Local branch `feat/4201-capability-contract-hardening` starts at exact carrier +`18fe89201d657116bbca99922297c14968356c44`. It makes the Python and TypeScript +`capability-optimization-workflow/v1` readers fail closed on JSON primitive +type mismatches and pins the identical accept/reject matrix in a shared golden +fixture. The Python reader validates the complete nested wire document before +calling model constructors, so numeric strings, booleans-as-numbers, numeric +text identifiers/provenance, and fractional integer fields cannot be silently +normalized. Integral JSON numbers remain portable between runtimes. + +The React Shot Optimizer also replaces browser-managed number inputs with +decimal text drafts. This makes leading-minus and decimal entry reliable while +preserving numeric commit and existing workflow validation. RED evidence was +Python `8 failed, 16 passed` and React `2 failed, 26 passed`; final focused +evidence is Python `43 passed`, React `9 files / 69 tests passed`, with Ruff, +format, mypy, TypeScript, ESLint, structural, and diff checks clean. This +continuation also builds 187 Vite modules without a size warning; it is local +only and has not changed GitHub state. ## 2026-08-10 PR #4279 fractional timestamp compatibility @@ -569,82 +673,146 @@ the 400-line budget pass. Publication is not completion: verify the exact remote head/base, push normally, then propagate in stack order through #4280, #4281, #4282, and every later descendant that carries the workspace. -## 2026-08-09 PR #4279 Python 3.10 compatibility completion - -Exact corrected #4203 parent -`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` is incorporated into #4279 by -normal local merge `a340fabefa443d47325c5538f342683b38c01ade`; the stack base is -unchanged and nothing was rebased, retargeted, force-pushed, or published. -The two child-owned string-enum modules and child workspace UTC validator now -use the existing shared Python 3.10 compatibility contract at runtime. Native -enum typing remains explicit under `TYPE_CHECKING`, and wire IDs, schemas, -timestamp serialization, physics, and UI behavior are unchanged. - -The reconciled test covers nine `StrEnum` runtime imports, both `UTC` runtime -imports, and direct execution of all three child workspace modules. Python -3.11 passes `126` focused tests. Real CPython 3.10.20 passes all `14` -compatibility cases and imports 10 dotted Rate/shared targets. Ruff -check/format and pinned mypy 1.13 pass all affected files; documentation -governance and diff checks are required in the same commit. Protected CI, -review, publication, and normal propagation to #4280 and later descendants -remain outstanding. - -## 2026-08-09 PR #4279 parent propagation - -The #4279 continuation remains based on `feat/4181-launch-monitor-registry` and -now contains exact parent head -`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` through a normal merge. Source -changes applied cleanly; the overlapping handoff/specification histories were -reconciled monotonically. The result retains the parent's Linux-safe facade -and Python 3.10 compatibility contracts plus the child's workspace, -top-toolstrip, module-navigation, playback, and independent plot controls. No -branch was rebased, retargeted, force-pushed, or published by this continuation. - -Current focused evidence is `126 passed` for both facade modules and all -Python/PyQt tests changed by #4279, plus `8 React files / 32 tests passed` for -the matching workspace surfaces. Ruff check/format is clean across all 28 -relevant Python files. CI-pinned mypy 1.13 is clean across the 18 changed -production files and both facade tests after making the child's -`legend_visible()` Qt boundary return an explicit `bool`. Documentation -governance, staged/unstaged diff checks, and the affected `29`-test simulation -GUI rerun pass. Protected CI, review, and publication remain separate release -requirements. - -## 2026-08-09 Python 3.10 compatibility follow-up - -The descendant Python 3.10 lane revealed seven unconditional -`enum.StrEnum` imports already present at PR #4203. The earliest owning parent -now routes runtime imports through `shared.python.compatibility.StrEnum` while -using the stdlib type only under `TYPE_CHECKING`. The change preserves every -enum value and schema while removing the Python 3.11-only import boundary. -Local evidence is 64 focused contract/physics/compatibility tests, Ruff and -format, pinned mypy 1.13 across eight changed files, and a real CPython 3.10.20 -probe of the shared fallback and seven module import declarations. Parent is -published at exact head `ab7de5a47977417e02926c3fbc7476002e82b690`; -propagate it through #4279, #4280, #4281, and #4282 before retrying descendants. - -The same scan found one parent-owned `datetime.UTC` import in the PyQt torque -profile controller. It now consumes the established compatibility constant, -preserving UTC serialization while allowing Python 3.10 collection. This -follow-up belongs on #4203 before the child stack is propagated. - -## 2026-08-09 Launch-registry parent CI repair - -The earliest campaign parent, draft PR #4203, remains based on -`feat/4189-dplane` at exact published head -`912ebc9d69b05763a76c2c8f198d943737e2d3fb`. CI run `31199764932` passed its -quality gate, then all Python versions failed before assertions because pytest -collected the flight and solver contract modules through `src.shared...` while -their absolute dotted facade aliases requested the editable `shared...` -namespace. The scoped repair converts only those two test imports to relative -package imports. Production APIs and physics are unchanged. The same run's -Rust failure is the known runner inability to link `-lpython3.11`. Validate -the two contract modules under importlib collection, publish a new exact head, -and propagate it through #4279, #4280, #4281, and #4282 in normal order. -Both modules now pass all `12` tests on Windows and WSL Python 3.11 with -importlib collection. Ruff check/format and pinned mypy 1.13 are clean. A -test-only `Any` cast keeps frozen-dataclass metadata introspection explicit -without weakening the runtime contract. +## 2026-08-09 Capability results stabilization + +The isolated `feat/4201-capability-results-diagnostics` continuation is based +on exact wind-workflow carrier head `18fe89201`. It closes two audit findings +without changing the optimizer or physics: both clients expose every ranked +diagnostic and parameter unit, both provide result CSV plus versioned result +JSON, and React scalar scatters now carry numeric scales and cohort legends. +Raw observation exports remain lossless and distinct. This is feature-stack +implementation evidence only; #4197/#4201 remain open through integration, +hosted CI, review, downstream parity, and release to `main`. + +Local evidence: 813 Rate Python/PyQt tests and 104 React files / 628 tests, +Ruff check/format, targeted mypy for the new export and tab modules, TypeScript, +zero-warning ESLint, and the 188-module Vite production build passed. The +pre-existing mypy 1.19.1/Python 3.13 internal serialization assertion on +`capability_results.py` reproduces unchanged at carrier `18fe89201`. + +## 2026-08-09 Web-release stability continuation + +Local branch `feat/4201-web-release-stability` is based exactly on campaign +carrier `18fe89201`. It fixes direct-path execution of +`src/rate_of_closure/launch_web.py`, adds a subprocess delegation smoke +contract, and reconciles the supported release boundary to the files that +exist: static Vite web output plus PyQt6/PyInstaller desktop output. Rate has +no `src-tauri` project, so its stale Tauri scripts, CLI dependency, lockfile +entries, and current documentation claims were removed instead of inventing an +unqualified wrapper. + +The load-sensitive `FlightExplorerPanel` assertion now explicitly settles the +real lazy Wind Strategy import inside React `act`; it does not extend a timeout +or replace the child component with a mock. This is test synchronization only, +not a UI or physics change. The branch must remain local until reviewed and +folded into the established campaign carrier; no PR, push, or merge belongs to +this handoff slice. + +Exact local evidence: 813 Rate Python/PyQt tests passed with 15 existing +warnings; 9 launcher/registration tests passed; 102 React files / 624 tests +passed; and five sequential focused Flight Explorer runs passed 25/25 tests. +TypeScript type-check, zero-warning ESLint, the 187-module static build, +focused Ruff check/format, targeted mypy, package installation/audit, and diff +checks pass. `tests/test_dry_compliance.py` retains two unrelated baseline +failures for the Movement Optimizer and Optimizer GUI PyQt launchers. + +## 2026-08-09 Machine-readable campaign authority + +`docs/release/rate_of_closure_campaign.v1.json` is now the canonical current +state for the primary Rate, impact, flight, variation, club-builder, wedge, +toolstrip, design-quality, parity, and ground programs. Its normalized carrier +and evidence tables replace status inference from the chronological entries +below. `scripts/rate_campaign_manifest.py` supplies strict Pydantic validation +and a generated JSON Schema; it rejects missing programs, unresolved evidence, +placeholders, malformed SHAs, and contradictory release claims. + +The authority records the current campaign as **not released**. Capability PRs +#4294, #4289, and #4283 are merged into feature parent #4282, but those were not +protected `main` merges. Current carrier head `18fe89201` contains the hermetic +`swing_core` parity-lane correction and its focused workflow regression; a +fresh hosted parity run, top-down stack propagation, #4133 reconciliation, +#4119 current-main conflict resolution, protected checks, installed-package +evidence, and UpstreamDrift parity are still required. + +Maintain the JSON authority and the current-state handoffs in the same commit +whenever a carrier SHA, test result, limitation, supported surface, or release +stage changes. Historical detail below remains useful provenance but must not +override a contradictory validated manifest record. + +The four reviewed stabilization slices are composed on implementation head +`2c1a77baa`: strict workflow parsing/signed editing, complete capability +diagnostics and result exports, static-web entrypoint/package truth, and this +manifest authority. Combined local evidence is 828 Rate Python/PyQt tests and +104 React files / 642 tests, TypeScript, zero-warning ESLint, the 188-module +Vite build, Ruff, targeted mypy, deterministic manifest/schema validation, and +nine manifest/parity contracts. This remains local evidence; #4282 still needs +a normal push, exact-head hosted CI, review, and dependency-ordered release. + +The first exact-head hosted quality gate (`31340032608`) passed checkout, +dependency installation, Ruff, and formatting, then failed mypy 1.13 because +`--follow-imports=skip` exposed Pydantic and Qt boundary returns as `Any`. The +loader now casts the validated model and the playback adapter converts elapsed +milliseconds to a concrete `float`; these are static typing fixes, not schema +or runtime behavior changes. The exact Python 3.12/mypy 1.13 command passes all +54 delta files; Ruff, 62 focused regression tests, and eight campaign-manifest +tests pass locally. + +## 2026-08-08 Capability workspace continuation + +The active stacked child is `feat/4197-capability-optimization-ui`, based +exactly on evaluator commit `c280407d432c153639bb266c9c721a014a129723` +(draft PR #4289). It adds matched PyQt6/React Shot Optimizer modules with the +strict cross-runtime `capability-optimization-workflow/v1` document, qualified +Waterloo/Penner worker execution, progress/cancellation, complete retained +observation cohorts, ranked alternatives, selectable stage-qualified scalar +axes, managed zoom/autofit, accessible 25-row paging, spreadsheet-safe CSV, +and stable JSON. The captured basis includes profile/club IDs, delivery +center/spread, sourced fixed spin, positive-right target frames, objective, +budgets, alternatives count, and deterministic seed. + +Live browser and standalone PyQt rendered review verified the workflows and +found three repaired integration defects: duplicated target-axis labels, old +saved layouts hiding newly registered modules, and a cramped PyQt results +split. All optimizer controls now have substantive hover guidance. Verified +local evidence is 808 Rate Python/PyQt tests plus 615 swing_sim tests and 102 +React files / 619 tests; Ruff, formatting, CI-equivalent mypy 1.13, +TypeScript, zero-warning ESLint, the 187-module production build with a +lazy-loaded Shot Optimizer chunk, structural limits, and diff checks pass. The model boundary is visible: still-air carry to +first ground crossing only, with wind, bounce, roll, and total distance outside +v1. Publish as a protected child of #4289 and keep #4197 open through CI, +review, ordered merge, and downstream parity. + +## 2026-08-08 Capability evaluator continuation + +The active child branch is `feat/4197-capability-flight-evaluator`, based +exactly on capability-observation PR #4283 head +`49612946138b1021f80c9f8d2a4d06f1610825db`. It adds the first qualified +full-flight evaluator for #4197 in shared Python and the React model layer. +The factory binds `player-capability-profile/v1` plus +`capability-optimization-request/v1`; validates requested clubs, exact sample +fields, units, finite values, declared safe bounds, and physical domains; runs +the real Waterloo/Penner model; converts trajectory and spin into the canonical +target frame; binds the request target; and emits every available scalar +canonical metric. Existing three-variable profiles require a sourced spin +default for every requested club, while profiles may opt into paired variable +`total_spin` and `spin_axis_tilt`. Positive tilt is fade/right, matching the +existing Flight Explorer, glossary, D-plane, variation, and solver convention. + +No-ground-crossing horizons are typed `nonconverged`; expected Python +floating-point overflow is typed `failed` without leaking exception text; +contract and programming errors surface; and this post-impact adapter cannot +report `no_impact`. Python uses SciPy RK45 and React uses fixed-step RK4, so +logical model/version and metric-set parity are exact while numeric parity is +banded through `capability_flight_evaluator_parity_v1.json` and integrator +provenance remains runtime-specific. Canonical result, impact-diagnostic, and +variation producers share one gyro-projected spin-axis tilt calculation. + +Post-review full-suite evidence is `138 passed, 4 skipped` in Python and +`97` files / `597` React tests. Ruff, formatting, targeted mypy, TypeScript, +zero-warning ESLint, and the 176-module Vite build pass. The next required +slice is the end-user PyQt6/React capability workspace with +off-main-thread execution, progress/cancel, profile/target/environment editing, +observation scatter/table/CSV, persistence, and rendered QA. Keep #4197 open. ## Integration checkout @@ -1241,6 +1409,438 @@ remains an explicit fail-closed boundary. 7. Rerun every recorded gate, inspect protected GitHub checks/reviews, and keep #4201 open until every acceptance criterion has current evidence. +## 2026-08-07 responsive wind workflow checkpoint + +Branch `feat/4199-wind-workflow` is published as +[draft PR #4282](https://github.com/D-sorganization/Tools/pull/4282) at exact +implementation head `fdcc25008`. It is stacked on exact draft PR #4281 head +`8b8690e8760d82ba814e8d95588d2540d28a6759`; do not extend, retarget, rewrite, +or merge ahead of #4281. + +The slice delivers matched PyQt6 and React current-launch wind-strategy +workflows on the shared `wind-strategy-analysis/v2` and +`scalar-ensemble/v1` authorities. It adds off-GUI-thread/off-main-thread +execution, exact progress, cancellation and teardown, canonical target reuse, +all-variable cohort-aware scatter, null-preserving generic CSV, explicit +availability, captured calculation basis, and stale-result invalidation. The +managed plot controls reset toolbar history and expose Auto Fit, zoom, and +legend placement. React data marks are clipped to the plot region and the +axes have numeric ticks/gridlines. Its workspace is genuinely code-split, +not hidden behind a raised bundle-warning threshold. + +Native-window QA at 1280 x 768 found and closed two late usability gaps. Ball +flight now has an accessible Loop control in both clients and wraps without +creating a second timer/animation frame. The PyQt wind panel now uses compact +two-column Setup and plot-first Results views, switches to Results after a +successful run, and leaves run/cancel/export and progress/status continuously +available. A live five-trial run completed 5/5 with the captured basis, +summary, scatter, native pan/zoom, Auto Fit, and legend placement visible. +The in-app browser connection refused localhost navigation under its URL +policy, so React visual evidence remains the full component suite and +production build rather than a claimed live-browser pass. + +Current primary validation is: + +- Python/PyQt/shared swing: `1350 passed, 5 skipped, 15 warnings`; +- React: `94` files / `566` tests, plus focused playback and wind passes; +- Rust swing core: `12 passed`; +- Ruff, Black, focused mypy, TypeScript, zero-warning ESLint, production Vite + build, structural line/function budgets, and `git diff --check`: passed. + +The five Python skips are the absent optional `swing_core` and `tools_core` +wheel fast paths, not failures. The two warning classes are established +Hypothesis collection configuration and the empty polynomial preview legend. +Hosted CI, required review, mergeability, and exact deployed/default-branch +state remain unproven until the new child PR is published and protected checks +finish. + +The independent rolling-ground audit refined epic #4267 at +. +It defines carry, final coordinates, launch-monitor total displacement, and +bounce/skid/roll/ground path lengths separately; requires full angular state +and arbitrary-normal physical contact; and restricts UpstreamDrift terrain +reuse to a one-way versioned adapter. The four-surface audit refined #4260 at +: +CI must prove the complete capability by `tools.pyqt6`, `tools.react`, +`upstreamdrift.pyqt6`, and `upstreamdrift.react` Cartesian product with +commit-fresh evidence. A launcher/native-window handoff is not parity. + +The next universal-ensemble slice is the capability optimizer. Its exact +streaming observation/cancellation/scalar-adapter contract is recorded at +. +Keep the ordinary optimization result compact, stream every attempted sample +in deterministic order, preserve evaluator metrics and reasons, and never +invent outputs for no-impact or failed rows. + +### 2026-08-07 protected-CI repair and ground/parity audit + +PR #4282 initially failed the hosted Python 3.12 delta mypy gate because the +wind lifecycle mixin and `QWidget` exposed incompatible `closeEvent` +signatures. Commit `424b4c395370aea26069386c070a65f7abe885bc` moves the Qt +override onto a concrete `WindStrategyGroupBox` and leaves the reusable mixin +responsible only for cancellation/join behavior. Fresh Python 3.12 mypy +passes for all 11 changed source files; Ruff, format, diff validation, and the +19 focused wind-panel/worker/playback tests also pass. This is a scoped CI +repair, not evidence that the still-queued protected stack is merge-ready. + +The current remote UpstreamDrift audit basis is `main` at +`0782853295e005af68818617e4725eb980890f43`. Reusable ground assets exist in +its Rust contact kernel, split terrain/material package, compressible-turf +helpers, and putting roll engine, but none is a qualified drop-in. Material +round trips lose seven physical fields, the elevation-grid boundary contract +has two failing cases, terminal flight spin is not exported as a full vector, +and the Rust contact result uses scalar spin and a per-unit-mass energy value +labelled as joules. Tools must own a strict, versioned target-frame +flight-to-ground request/result authority; UpstreamDrift may contribute only a +one-way explicit adapter. + +The parity matrix remains materially incomplete. Tools PyQt is the broadest +native surface; Tools React still has reduced impact/flight model authority; +UpstreamDrift PyQt is an external launcher; and UpstreamDrift React has no Rate +of Closure route. A separate generic simulator, copied TypeScript physics, +or launcher tile does not satisfy parity. Required next evidence is a +commit-fresh capability-by-surface manifest backed by shared golden fixtures, +one authoritative Tools physics contract, thin UI adapters, and an immutable +UpstreamDrift Tools pin. + +### 2026-08-07 capability-observation continuation + +Active branch `feat/4197-capability-observer` is based exactly on PR #4282 +head `6e3c1029f1f3a80ae09020ef7d0afacb3c0d5484`. It must remain a normal +stacked child of `feat/4199-wind-workflow`; do not retarget, rewrite, or merge +it ahead of that parent. + +The branch is published as +[draft PR #4283](https://github.com/D-sorganization/Tools/pull/4283). Its +validated implementation/hardening head is +`5c6073bd68ed4c8f23b343d4d11c2dc4277ea246`; this handoff-only continuation +will advance that head without changing the tested runtime behavior. + +The optimizer now accepts optional synchronous observation and cooperative +cancellation hooks without retaining traces in `OptimizationResult`. Every +attempt emits one immutable `capability-sample-observation/v1` record in exact +candidate/club/sample order. Python and TypeScript normalize evaluator +exceptions, malformed results, no-impact, nonconvergence, and missing landing +metrics identically, preserve all valid evaluator metrics and provenance, and +never expose raw exception text. Cancellation is checked before the next +evaluator call and reports exact attempted/total counts. + +The app-layer adapters convert streamed observations into the shared +`scalar-ensemble/v1` authority. They declare the complete scalar flight +catalog, preserve unavailable outputs as null, include nominal and perturbed +parameters plus target diagnostics, require a contiguous zero-based prefix, +and reject overflow before retaining a row. TypeScript deep-parses and +freezes caller input before storage. Stable JSON ordering is Unicode +code-point based in both runtimes; ASCII and Unicode parity fixtures hash to +`df36f765afdf508d00a3d264911ce5b6f07e25da3744b187596d67487ea3be5f` +and `18086b5e97d576598bbfa63407b6eda786a3a7ce20509654de282400bd32efd0`. + +Current local evidence on this branch is 120 Python flight/adapter tests +passed with four expected optional `tools_core` skips, and 96 React files / 580 +tests passed. Python 3.12 mypy, Ruff, Black, TypeScript, zero-warning ESLint, +the Vite production build, structural budgets, and `git diff --check` pass. +This completes the stream/adapter contract slice of #4197, not its remaining +end-user optimization workflow or the wider release epic. + +Independent pre-publication review then found four fail-closed contract gaps, +all corrected before opening a PR: native Python/JavaScript number formatting +was not byte-stable at IEEE rounding and exponent edges; Unicode title-casing +could derive different labels; public observations admitted impossible +status/metric combinations; and the TypeScript declaration signature could +collide when identifiers contained its delimiters. The replacement canonical +writer emits code-point-sorted JSON with raw numeric tokens, fixed 11-decimal +half-away rounding, decimal integer-valued magnitudes, and normalized negative +zero. ASCII-only initial-letter label casing, strict landing/incomplete metric +invariants, and structural declaration comparison now match in both runtimes. + +Adversarial regression coverage includes binary half boundaries, `1e-12`, +`1e-11`, large integer-valued magnitudes, negative zero, Unicode identifiers, +delimiter-bearing declarations, non-finite inputs, and every effective/source +status combination. Updated evidence is 135 Python flight/adapter tests passed +with four expected Rust-wheel skips and 96 React files / 584 tests passed, plus +Python 3.12 mypy, Ruff, Black, TypeScript, ESLint, Vite build, structural +budgets, and diff checks. The initial implementation commit was +`43ad5e35be299f2ab11260784ee707fc5721fd2e`; corrections are committed at +`5c6073bd68ed4c8f23b343d4d11c2dc4277ea246` and published in draft PR #4283. +Protected CI, reviews, and every parent PR remain required. + +The first hosted CI Standard run on PR #4283 reached delta mypy after checkout, +dependency installation, Ruff, and formatting passed. With unchanged imports +skipped, mypy treated the request fields used by the new private runtime as +`Any` and rejected `_OptimizationContext.total_count` for returning an implicit +`Any`. The request contract already guarantees positive integer operands; the +scoped fix makes the return boundary explicit with `int(...)`. The exact +seven-file Python 3.12 CI mypy command, Ruff/format, diff check, and the full +135-test flight/adapter suite now pass (four optional Rust-wheel skips). This +fix and handoff update are committed together as `SELF`; resolve the exact head +with `git rev-parse HEAD` and push normally. + +The next protected run exposed two `detect-secrets` false positives in each +runtime's cross-language SHA-256 parity assertions. They are deterministic test +digests, not credentials. Mark the four exact constants with the scanner's +`pragma: allowlist secret` annotation; do not add broad path exclusions or +rewrite the baseline. Re-run the scanner normalization gate, focused parity +tests, lint, and diff checks. Commit this CI repair with this handoff update and +push normally on `feat/4197-capability-observer` before propagating the parent +head through the protected stack. + +## 2026-08-11 parent sections preserved during #4282 reconciliation + +## 2026-08-10 Exact #4280 propagation into wind scalar PR #4281 + +- PR `#4281` remains on `feat/4199-wind-scalar-adapter`, based on + `feat/4144-variation-export-continuation`. +- Original child `cf52529b1e68479321bb93b1be3d59c77f782008` normally merges + exact parent `8bcc49fc4e16e5e43be0b7f0f03c3017d5b79d0c`; preserve that + parent order and do not rebase, retarget, force-push, rewrite a parent, or + change draft state. +- The Python/TypeScript `scalar-ensemble/v1` contract and wind-strategy plot + adapter retain stable row identity, provenance, typed unavailable values, + and per-cohort availability while inheriting the complete result-local + selection, visualization, workspace, compatibility, and module-budget tree. + Both implementations enforce the same deterministic scenario boundary: + exact per-trial provenance, zero shear/turbulence, seed zero, and no gusts. +- SPEC 1.14.13 is the unique combined child entry. Verification passes 27 + focused Python contract/adapter/selection tests, 1,549 complete + Rate/shared-swing/golf-club tests with two explicit optional build123d skips, + and all 556 React tests across 92 files plus type-check/lint/build. Exact- + parent Ruff/format/pinned MyPy/Bandit, the two-file 500-line budget, docs, + minimum-test, assertions, detect-secrets, diff, and CPython 3.10 compilation + pass. Independent re-review found no actionable findings after checking the + exact provenance derivation and all five deterministic-scenario regressions. + Protected current-head CI, unresolved threads, and repository review remain + release gates after publication. + +## 2026-08-11 latest #4203 propagation into workspace PR #4279 + +- PR `#4279` remains on `feat/4218-toolstrip-workspace`, based on + `feat/4181-launch-monitor-registry`; exact parent + `0216a547aa79727091a2939b96e779e8ddbd7304` is incorporated by a normal + two-parent merge without rebase, retarget, force-push, or history rewrite. +- There are no feature-code conflicts. The child retains its workspace, + toolstrip, module-visibility, navigation, granular playback, and independent + plot behavior while inheriting the parent's pinned formatting and + identity-preserving 222/205/131-line kinetics split. +- Preserve both append-only histories below. Fresh merged-tree protected CI, + required review, unresolved-thread checks, and dependency order remain + release gates; this local merge is not a completion or publication claim. + +## 2026-08-10 Second #4203 propagation into workspace PR #4279 + +- PR `#4279` remains the immediate child on + `feat/4218-toolstrip-workspace`, based on + `feat/4181-launch-monitor-registry`. +- Exact repaired parent head + `12dd76a8dbcc106c4683f2f2e53076f8dc6f1b76` is incorporated through a + normal merge commit; no feature code conflicts and no branch or PR topology + is rewritten. +- Preserve both append-only handoff histories. Fresh child protected CI, + review, and earlier dependency gates remain required. +- Post-reconciliation evidence is 25 focused D-plane/impact tests plus docs + governance, changed-file-size, and whitespace gates. + +## 2026-08-10 Exact #4203 propagation into workspace PR #4279 + +- PR `#4279` remains the immediate child on branch + `feat/4218-toolstrip-workspace`, based on + `feat/4181-launch-monitor-registry`. +- Original child `05383d333b6fd87eaf5e37305476f50b505c2c2e` normally merges + exact parent `31cbc007d4c85b5479b7cd0fb0969124eab2af67`; preserve that + two-parent order and do not rebase, retarget, force-push, or change draft + state. +- The child workspace/toolstrip, module-visibility, granular playback, path + trail, and independent plot behavior remain authoritative. The parent + `ImpactLayerControls`, navigation-state constants, plotting catalog, and + triple-pendulum extractions are integrated without duplicate state. +- Verification is green across 1,339 Python tests with six explicit skips, 545 + React tests plus type-check/lint/build, the 40-test post-format PyQt rerun, + exact-parent Ruff/Ruff-format/Black/pinned MyPy/Bandit, the 500-line budget, + docs/minimum-test/assertion/detect-secrets/diff gates, real CPython 3.10.20 + compile/import checks, and 30 compatibility regressions. There is no Rust + delta from the exact parent. Independent staged review found no actionable + findings after 76 additional focused PyQt/navigation/workspace tests. + Current-head protected CI, unresolved-thread checks, and required repository + review remain release gates after guarded publication. + +## 2026-08-11 #4203 append-only SPEC preservation repair + +- Independent review of local candidate `e20b4f630...` found four exact + current-parent history rows missing from SPEC: 2026-08-10 versions 1.13.11, + 1.13.9, 1.13.7, and 1.13.6. The current documentation-only follow-up restores + those rows verbatim and advances the declared/changelog version together. +- Runtime files, tests, ordered merge parents, PR base, and validation evidence + are byte-unchanged. Re-review, normal publication, exact-head protected CI, + and downstream propagation remain required. + +## 2026-08-11 #4203 current-parent reconciliation + +- The current D-plane base is exact head + `f3363aa88868f6a5c7e9ccfc682a9eca014e86c1`; #4203's exact published child + is `217e36dc93d30f79826847f958fbcd10805e58ed`. They are being composed in + that order by a normal merge while retaining base `feat/4189-dplane`. +- The parent's nine changed files merge without semantic conflict except for + formatting around the inherited kinetics origin offset. The split child + facade remains authoritative because the obsolete parent expression reaches + through `source.inner`; the equivalent explanatory comment is preserved. +- The exact CI-pinned MyPy 1.13 delta found four additional Qt-stub `Any` + boundaries after the merge. …11026 tokens truncated… PR #4217 +was squash-merged there. It adds one +UI-neutral registry for 17 File/View/Tools commands, a strict versioned +workspace document with atomic file persistence, matched PyQt/React top +toolstrips, persistent module visibility/order, theme and shortcut surfaces, +and direct Impact/Swing/Flight navigation. File actions that do not yet have a +complete client adapter remain visibly disabled with a reason rather than +pretending to save incomplete state. + +The same continuation corrects the interaction defects reported against the +live Swing and Plots views. Playback now has deterministic replay-from-end, +Restart, granular 0.05x through 4.00x speed, pause, and loop behavior. The +full swing path is opt-in so a persistent trail does not obscure the current +frame. Each managed plot now owns a distinct figure/canvas, zoom state, +Auto Fit action, wheel zoom, and independently movable or hideable legend; +the plot workspace presents all managed plots instead of reusing one selected +canvas. PyQt small-window testing caught the new playback editor compressing +below the 64 px readability floor; the explicit editor minimum fixes that case +and the three-case layout suite passes. + +Two read-only cross-repository audits are now tracked as separate programs: + +- [#4260](https://github.com/D-sorganization/Tools/issues/4260), with + #4261-#4266, establishes one impact/flight authority and a machine-readable + parity contract across Tools PyQt, Tools React, UpstreamDrift PyQt, and + UpstreamDrift React. +- [#4267](https://github.com/D-sorganization/Tools/issues/4267), with + #4268-#4276, defines qualified landing, bounce, skid, roll, and total-distance + modeling with editable ground profiles and exact UpstreamDrift adapters. + +The parity audit found that UpstreamDrift PyQt reuses Tools, while the +UpstreamDrift React launcher has no native Rate React route. UpstreamDrift's +Tools gitlink `ff4240217005e1415ca409fd124e50b64ee642d2` also predates the +current integration head by 184 commits, and its sibling/vendor resolution is +ambiguous. The ground audit found a useful existing fail-closed +`GroundModelResult` boundary plus reusable putting/terrain primitives, but no +qualified end-to-end ground solver. Before bounce can be correct, airborne +flight must terminate against physical terrain plus ball radius and preserve +the full terminal angular-velocity vector; the current relative launch-plane +event and spin-free trajectory state do neither. Those prerequisites are +explicit in #4269 and must not be hidden by UI-derived estimates. + +The final local verification pass is green. The complete Rate-of-Closure and +shared swing-model run passed 890 tests with one expected skip because the +optional `swing_core` Rust wheel is not installed; the remaining 15 warnings +are the existing Hypothesis collection warning. React passed 89 files / 545 +tests, zero-warning ESLint, TypeScript checking, and the production Vite build. +Ruff, Black, targeted mypy, `git diff --check`, and the repository structural +limits also pass: every changed production Python file is at most 400 lines and +every changed production Python function is at most 50 lines. Rendered PyQt +inspection confirmed independent plot canvases, responsive single-column +reflow at the tested desktop width, independent 125%/100% zoom state, working +Auto Fit, and the opt-in trail/playback controls. These are local validation +results only; they do not establish protected CI, review, merge, or release +status. + +### 2026-08-07 variation export and completion audit continuation + +The post-toolstrip branch `feat/4144-variation-export-continuation` is published +as [draft PR #4280](https://github.com/D-sorganization/Tools/pull/4280), based +on exact parent head `c36ca36e91f34fa849d2508708bf9dd6c0cdc392`. It keeps #4279 unchanged +while closing one remaining #4144 parity gap: selected scalar scatter data can +now be exported as CSV from both clients, retaining every raw trial, typed +outcome, and unavailable cell rather than only the finite points drawn on the +canvas. PyQt also has a bounded read-only raw-trial table matching the web +workflow, and the table population is shared with the matrix view. + +The complete post-change local gates passed: + +- Python/PyQt/shared swing suite: `890 passed, 1 skipped, 15 warnings`; the + skip is the optional `swing_core` wheel and the warnings are the existing + Hypothesis collection and empty polynomial-preview legend warnings. +- React: `89` files / `545` tests passed. +- Ruff check/format, Black, targeted mypy, TypeScript, zero-warning ESLint, + the `166`-module Vite production build, and `git diff --check` passed. +- Every changed production file is below 400 lines and every changed + production function is at most 50 lines. + +A live GitHub/source reconciliation covered every requested epic in this +campaign. No epic yet satisfies its own definition of done: most implementation +is still on feature branches, #4119 is the only Rate platform PR targeting +`main` and is currently dirty, #4203 and #4279 remain draft/unstable, and only +formal club-builder child #4147 is closed. The variation request is +substantively implemented, but #4142/#4144 remain open because bounded +large-ensemble execution, nonlinear global sensitivity, localized execution, +the immutable UpstreamDrift consumer pin, protected CI, and default-branch +release are incomplete. + +The literal universal-runner audit also found two uncovered many-evaluation +paths. Wind strategy analysis retains all paired outcomes but has no user +workflow or universal plot adapter; capability optimization retains aggregates +but not individual sample rows. The next safe model slice is a UI-neutral +scalar-ensemble contract with unique composite row IDs, unit-bearing variable +metadata, caller-defined cohorts, paired-finite scatter extraction, and exact +availability accounting. Wind integration must accept both its immutable +request and analysis so launch definitions and provenance are not inferred. +Issue #4199 already owns the required controls, scatter, strategy table, +progress/cancellation, and export workflow. + +The first narrow #4199 implementation slice is published as +[draft PR #4281](https://github.com/D-sorganization/Tools/pull/4281) from branch +`feat/4199-wind-scalar-adapter`, stacked on exact PR #4280 head +`d71b0ea01b5659d3049ff05627c41f06481207e4`. Implementation commit +`4a28114aa` introduces an exact +cross-runtime `scalar-ensemble/v1` wire contract and pure wind-strategy +adapters. The contract preserves structured provenance, unit-bearing variable +definitions, caller-defined cohorts, RFC3986 composite identities, nullable +raw rows, and exact scatter availability. The adapters validate the immutable +request against the stored paired analysis, preserve completed, +nonconverged, and invalid outcomes, and never invoke a flight model. React has +an explicit mocked-integrator regression test for that boundary. + +Current exact local evidence is 906 Python/PyQt/shared-swing tests passed with +one expected optional-Rust skip and 15 existing warnings, plus 91 React test +files / 555 tests passed. Ruff, formatting, Black, focused mypy, TypeScript, +zero-warning ESLint, the 166-module production build, and `git diff --check` +passed. The function budget passed, but the 403-line wind adapter did not meet +the mandatory below-400 module ceiling at that historical head; the +2026-08-11 repair above corrects that blocker. The adapter is plot-ready model +infrastructure, not an end-user workflow; #4199 remains open for worker, +progress/cancellation, client controls, strategy/scatter displays, +persistence, and exports. + +### 2026-08-07 ground and four-surface audit refinement + +The rolling-ground and cross-application parity requests remain tracked by the +existing [ground epic #4267](https://github.com/D-sorganization/Tools/issues/4267) +and [parity epic #4260](https://github.com/D-sorganization/Tools/issues/4260); +no duplicate epic or child issue is required. The latest exact-path audit and +acceptance refinements are attached to +[the ground epic](https://github.com/D-sorganization/Tools/issues/4267#issuecomment-5222725556) +and [the parity epic](https://github.com/D-sorganization/Tools/issues/4260#issuecomment-5222726010). + +The scientific implementation order is contractual: #4268 defines the +surface/contact/trajectory/result transfer state, then #4269 corrects physical +terrain contact and preserves terminal full angular velocity. Only then may +#4270/#4271 qualify the 3D impulse, repeated bounce, skid, and pure-roll +phases. Carry remains first physical contact. Final downrange, final lateral, +horizontal displacement, surface path length, and launch-monitor-style total +distance are distinct quantities; no implementation may silently assume +`total distance = carry + roll distance`. + +Reusable UpstreamDrift scope is deliberately narrow: its split terrain +material/elevation/normal/region package can feed a one-way versioned DTO +adapter. Current scalar landing, heuristic putting-roll, duplicate legacy +terrain, and Rust tangential-loss implementations are reference material, not +the qualified physics authority. Upstream surface defaults remain illustrative +until citations, calibration, uncertainty, and applicability are recorded. + +The parity matrix must distinguish seven product identities: standalone Rate +PyQt6 and React, the Upstream Rate PyQt provider and React route, Upstream Shot +Tracer PyQt6 and React, and the legacy Upstream ball-flight GUI. Current +Upstream `main` (`0782853295e005af68818617e4725eb980890f43`) pins Tools at +`ff4240217005e1415ca409fd124e50b64ee642d2`, exposes no native Rate React route, +and contains contradictory vendor-first and sibling-first Tools resolvers. +These facts are current audit evidence, not completion; #4260, #4267, and all +children remain open. + ## 2026-08-10 PR #4202 D-plane ndarray typing repair - The repair is based on exact published PR `#4202` head @@ -1379,3 +1979,82 @@ as infrastructure and are never counted as green evidence. Every implementation commit must update this file, both other canonical handoffs, and `SPEC.md`, or explicitly record no material handoff change and the reason. + +## 2026-08-11 additional parent sections preserved during #4282 reconciliation + +## 2026-08-09 PR #4279 Python 3.10 compatibility completion + +Exact corrected #4203 parent +`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` is incorporated into #4279 by +normal local merge `a340fabefa443d47325c5538f342683b38c01ade`; the stack base is +unchanged and nothing was rebased, retargeted, force-pushed, or published. +The two child-owned string-enum modules and child workspace UTC validator now +use the existing shared Python 3.10 compatibility contract at runtime. Native +enum typing remains explicit under `TYPE_CHECKING`, and wire IDs, schemas, +timestamp serialization, physics, and UI behavior are unchanged. + +The reconciled test covers nine `StrEnum` runtime imports, both `UTC` runtime +imports, and direct execution of all three child workspace modules. Python +3.11 passes `126` focused tests. Real CPython 3.10.20 passes all `14` +compatibility cases and imports 10 dotted Rate/shared targets. Ruff +check/format and pinned mypy 1.13 pass all affected files; documentation +governance and diff checks are required in the same commit. Protected CI, +review, publication, and normal propagation to #4280 and later descendants +remain outstanding. + +## 2026-08-09 PR #4279 parent propagation + +The #4279 continuation remains based on `feat/4181-launch-monitor-registry` and +now contains exact parent head +`08a2fdd8ce6bbc8fbb8f121927a677d4addb6b11` through a normal merge. Source +changes applied cleanly; the overlapping handoff/specification histories were +reconciled monotonically. The result retains the parent's Linux-safe facade +and Python 3.10 compatibility contracts plus the child's workspace, +top-toolstrip, module-navigation, playback, and independent plot controls. No +branch was rebased, retargeted, force-pushed, or published by this continuation. + +Current focused evidence is `126 passed` for both facade modules and all +Python/PyQt tests changed by #4279, plus `8 React files / 32 tests passed` for +the matching workspace surfaces. Ruff check/format is clean across all 28 +relevant Python files. CI-pinned mypy 1.13 is clean across the 18 changed +production files and both facade tests after making the child's +`legend_visible()` Qt boundary return an explicit `bool`. Documentation +governance, staged/unstaged diff checks, and the affected `29`-test simulation +GUI rerun pass. Protected CI, review, and publication remain separate release +requirements. + +## 2026-08-09 Python 3.10 compatibility follow-up + +The descendant Python 3.10 lane revealed seven unconditional +`enum.StrEnum` imports already present at PR #4203. The earliest owning parent +now routes runtime imports through `shared.python.compatibility.StrEnum` while +using the stdlib type only under `TYPE_CHECKING`. The change preserves every +enum value and schema while removing the Python 3.11-only import boundary. +Local evidence is 64 focused contract/physics/compatibility tests, Ruff and +format, pinned mypy 1.13 across eight changed files, and a real CPython 3.10.20 +probe of the shared fallback and seven module import declarations. Parent is +published at exact head `ab7de5a47977417e02926c3fbc7476002e82b690`; +propagate it through #4279, #4280, #4281, and #4282 before retrying descendants. + +The same scan found one parent-owned `datetime.UTC` import in the PyQt torque +profile controller. It now consumes the established compatibility constant, +preserving UTC serialization while allowing Python 3.10 collection. This +follow-up belongs on #4203 before the child stack is propagated. + +## 2026-08-09 Launch-registry parent CI repair + +The earliest campaign parent, draft PR #4203, remains based on +`feat/4189-dplane` at exact published head +`912ebc9d69b05763a76c2c8f198d943737e2d3fb`. CI run `31199764932` passed its +quality gate, then all Python versions failed before assertions because pytest +collected the flight and solver contract modules through `src.shared...` while +their absolute dotted facade aliases requested the editable `shared...` +namespace. The scoped repair converts only those two test imports to relative +package imports. Production APIs and physics are unchanged. The same run's +Rust failure is the known runner inability to link `-lpython3.11`. Validate +the two contract modules under importlib collection, publish a new exact head, +and propagate it through #4279, #4280, #4281, and #4282 in normal order. +Both modules now pass all `12` tests on Windows and WSL Python 3.11 with +importlib collection. Ruff check/format and pinned mypy 1.13 are clean. A +test-only `Any` cast keeps frozen-dataclass metadata introspection explicit +without weakening the runtime contract. diff --git a/docs/release/RATE_OF_CLOSURE_CAMPAIGN_MANIFEST.md b/docs/release/RATE_OF_CLOSURE_CAMPAIGN_MANIFEST.md new file mode 100644 index 0000000000..a2c06b3aac --- /dev/null +++ b/docs/release/RATE_OF_CLOSURE_CAMPAIGN_MANIFEST.md @@ -0,0 +1,70 @@ +# Rate of Closure Campaign Manifest + +`rate_of_closure_campaign.v1.json` is the machine-readable authority for +implementation and release claims in the Rate of Closure, impact, and flight +campaign. It complements the issue tracker and handoff; it does not replace +either one. + +## What It Records + +The manifest normalizes metadata that was previously repeated in narrative +handoffs: + +- one record for every primary campaign epic, program, or release gate; +- canonical repository specifications and GitHub issue authorities; +- shared pull-request carriers and exact observed branch heads; +- all four supported product surfaces; +- commit-bound local or hosted test evidence; +- explicit evidence gaps, limitations, dependencies, and child issue IDs; +- release status and the exact `main` SHA when a release is eventually proven. + +Program delivery uses four mutually exclusive stages: + +1. `specified_only` — acceptance scope exists, but no implementation carrier is + claimed; +2. `implemented_on_feature_stack` — implementation exists outside a qualified + protected release; +3. `protected_merged_to_parent` — a parent merge and passing protected checks + are both recorded; +4. `released_to_main` — the program release SHA matches a merged carrier into + `main`. + +A feature-branch merge is not automatically stage 3. A local test pass is not +a protected merge, and a protected parent merge is not automatically a +default-branch release. + +## Validation + +Run the deterministic validator from the repository root: + +```powershell +python scripts/rate_campaign_manifest.py +``` + +Emit the versioned JSON Schema generated from the same Pydantic models: + +```powershell +python scripts/rate_campaign_manifest.py --schema +``` + +The validator fails closed when it finds undeclared fields, missing programs, +duplicate IDs, malformed SHAs, unresolved carrier or evidence references, +missing repository paths, placeholders, or contradictory delivery/release +states. + +## Maintenance Procedure + +Update the manifest in the same commit as a material carrier, test-evidence, or +release-state change: + +1. Refresh only the affected carrier head, state, and merge evidence. +2. Add commit-bound test evidence; do not replace failed or queued evidence + with narrative optimism. +3. Advance a program stage only when its validator preconditions are true. +4. Record limitations and missing evidence even when implementation exists. +5. Run the focused manifest tests and validator. +6. Update the root and Rate campaign handoffs in the same commit. + +The checked-in snapshot does not query GitHub at validation time. This keeps CI +deterministic and avoids API-rate coupling, but means a maintainer must refresh +carrier observations deliberately after a merge, push, or protected run. diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json new file mode 100644 index 0000000000..53bd49067a --- /dev/null +++ b/docs/release/rate_of_closure_campaign.v1.json @@ -0,0 +1,1070 @@ +{ + "schema_version": "rate-of-closure-campaign/v1", + "repository": "D-sorganization/Tools", + "as_of": "2026-08-09", + "default_branch": "main", + "required_main_checks": [ + "quality-gate", + "tests (3.11)" + ], + "release_stage_definitions": [ + "specified_only", + "implemented_on_feature_stack", + "protected_merged_to_parent", + "released_to_main" + ], + "campaign_release": { + "status": "not_released", + "branch": "main", + "sha": null + }, + "carriers": [ + { + "id": "pr-4119", + "pr": 4119, + "branch": "feat/impact-simulation-platform", + "base_branch": "main", + "head_sha": "d4c843d6531ad31e22b3775b8a286da16d9a3d3c", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4133", + "pr": 4133, + "branch": "feat/impact-interval-dynamics", + "base_branch": "feat/course-showcase", + "head_sha": "b8209dd866e308058c443cb81c3182d2437b46a9", + "state": "merged", + "merge_commit_sha": "bc8ce9b8b98f8cefc9458b903f5bf2f98f1fd9ba", + "protection_evidence": "not_protected" + }, + { + "id": "pr-4167", + "pr": 4167, + "branch": "feat/4144-variation-visualizations", + "base_branch": "feat/investigation-suite", + "head_sha": "edaa56358a9ccf47809533fcab28e6415b336771", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4173", + "pr": 4173, + "branch": "feat/4163-impact-inspector", + "base_branch": "feat/4144-variation-visualizations", + "head_sha": "3c43955aaeb3964ff8c3ef2748d626baae518b76", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4179", + "pr": 4179, + "branch": "feat/4162-wedge-impact-visualization", + "base_branch": "feat/4166-wedge-turf-physics", + "head_sha": "0eb804e70887c788421332369e42792411aff55a", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4202", + "pr": 4202, + "branch": "feat/4189-dplane", + "base_branch": "feat/4162-wedge-impact-visualization", + "head_sha": "b4abec03bccfbdd87ddf91427159c5c2332c21dd", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4203", + "pr": 4203, + "branch": "feat/4181-launch-monitor-registry", + "base_branch": "feat/4189-dplane", + "head_sha": "912ebc9d69b05763a76c2c8f198d943737e2d3fb", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4217", + "pr": 4217, + "branch": "codex/ballflight-campaign-integration", + "base_branch": "feat/4181-launch-monitor-registry", + "head_sha": "655fea08f62bb48c6ca3498a8893936ef9370e9e", + "state": "merged", + "merge_commit_sha": "912ebc9d69b05763a76c2c8f198d943737e2d3fb", + "protection_evidence": "not_protected" + }, + { + "id": "pr-4279", + "pr": 4279, + "branch": "feat/4218-toolstrip-workspace", + "base_branch": "feat/4181-launch-monitor-registry", + "head_sha": "c36ca36e91f34fa849d2508708bf9dd6c0cdc392", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4280", + "pr": 4280, + "branch": "feat/4144-variation-export-continuation", + "base_branch": "feat/4218-toolstrip-workspace", + "head_sha": "d71b0ea01b5659d3049ff05627c41f06481207e4", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4281", + "pr": 4281, + "branch": "feat/4199-wind-scalar-adapter", + "base_branch": "feat/4144-variation-export-continuation", + "head_sha": "8b8690e8760d82ba814e8d95588d2540d28a6759", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4282", + "pr": 4282, + "branch": "feat/4199-wind-workflow", + "base_branch": "feat/4199-wind-scalar-adapter", + "head_sha": "18fe89201d657116bbca99922297c14968356c44", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4283", + "pr": 4283, + "branch": "feat/4197-capability-observer", + "base_branch": "feat/4199-wind-workflow", + "head_sha": "9bbb98e16e435a0d4c74153b909f2ebfefbbce7a", + "state": "merged", + "merge_commit_sha": "c1827bbdc50a6e11cc475db2636b4e47a4c15416", + "protection_evidence": "not_protected" + }, + { + "id": "pr-4285", + "pr": 4285, + "branch": "feat/4268-ground-contract", + "base_branch": "feat/4197-capability-observer", + "head_sha": "3235af71150a774954e7673fc81d7179330fbe76", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4288", + "pr": 4288, + "branch": "feat/4269-flight-ground-transfer", + "base_branch": "feat/4268-ground-contract", + "head_sha": "d2d3d0f53a78aa863574afe43290a29c48318d94", + "state": "open", + "merge_commit_sha": null, + "protection_evidence": "not_recorded" + }, + { + "id": "pr-4289", + "pr": 4289, + "branch": "feat/4197-capability-flight-evaluator", + "base_branch": "feat/4197-capability-observer", + "head_sha": "43e13bb30a9a1e80bac910990661a69b05d6adcf", + "state": "merged", + "merge_commit_sha": "9bbb98e16e435a0d4c74153b909f2ebfefbbce7a", + "protection_evidence": "not_protected" + }, + { + "id": "pr-4294", + "pr": 4294, + "branch": "feat/4197-capability-optimization-ui", + "base_branch": "feat/4197-capability-flight-evaluator", + "head_sha": "35db7d8210ff1365726dbf45c7a40e74194ae647", + "state": "merged", + "merge_commit_sha": "43e13bb30a9a1e80bac910990661a69b05d6adcf", + "protection_evidence": "not_protected" + } + ], + "test_evidence": [ + { + "id": "rate-stack-local-c182", + "kind": "local", + "commit_sha": "c1827bbdc50a6e11cc475db2636b4e47a4c15416", + "outcome": "passed", + "commands": [ + "python -m pytest tests/rate_of_closure src/shared/python/swing_sim -q", + "npm test -- --run" + ], + "summary": "1426 Python Rate and shared-swing tests and 624 React tests passed on the consolidated capability head; this is local, not protected-release evidence.", + "source_paths": [ + "src/rate_of_closure/AGENT_HANDOFF.md" + ] + }, + { + "id": "variation-local-d71", + "kind": "local", + "commit_sha": "d71b0ea01b5659d3049ff05627c41f06481207e4", + "outcome": "passed", + "commands": [ + "python -m pytest tests/rate_of_closure src/shared/python/swing_sim -q", + "npm test -- --run" + ], + "summary": "890 Python/PyQt/shared-swing tests and 545 React tests passed for the variation export continuation, with one optional Rust skip.", + "source_paths": [ + "docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md" + ] + }, + { + "id": "toolstrip-local-c36", + "kind": "local", + "commit_sha": "c36ca36e91f34fa849d2508708bf9dd6c0cdc392", + "outcome": "passed", + "commands": [ + "python -m pytest tests/rate_of_closure src/shared/python/swing_sim -q", + "npm test -- --run" + ], + "summary": "890 Python/PyQt/shared-swing tests and 545 React tests passed for the toolstrip and independent plot workspace carrier.", + "source_paths": [ + "docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md" + ] + }, + { + "id": "swing-parity-hosted-3186", + "kind": "hosted", + "commit_sha": "3186a265b10a1648391bb7f6e92042aab0caa35c", + "outcome": "failed", + "commands": [ + "Run Rust<->Python parity suite (gate, non-skipped)" + ], + "summary": "The wheel built and loaded, but cached pytest-qt plugin auto-loading failed before parity test collection because PyQt6 was absent.", + "source_paths": [ + "AGENT_HANDOFF.md" + ] + }, + { + "id": "swing-parity-isolation-18fe", + "kind": "local", + "commit_sha": "18fe89201d657116bbca99922297c14968356c44", + "outcome": "passed", + "commands": [ + "python -m pytest tests/ops/test_maturin_swing_core_workflow.py -q" + ], + "summary": "One workflow contract test passed and proves the Rust-only parity step disables unrelated third-party pytest plugin auto-loading.", + "source_paths": [ + "tests/ops/test_maturin_swing_core_workflow.py" + ] + }, + { + "id": "campaign-composition-local-2c1", + "kind": "local", + "commit_sha": "2c1a77baa115c4521f8995a4476d6a530b16b47d", + "outcome": "passed", + "commands": [ + "python -m pytest -q tests/rate_of_closure", + "npm run type-check && npm run lint && npm test && npm run build", + "python scripts/rate_campaign_manifest.py", + "python -m pytest -q tests/rate_of_closure/test_campaign_release_manifest.py tests/ops/test_maturin_swing_core_workflow.py" + ], + "summary": "The composed capability, diagnostics, web-release, and campaign-manifest continuation passed 828 Rate Python/PyQt tests, 104 React files and 642 tests, TypeScript, zero-warning ESLint, the 188-module production build, and nine manifest/parity contracts.", + "source_paths": [ + "AGENT_HANDOFF.md", + "src/rate_of_closure/AGENT_HANDOFF.md" + ] + } + ], + "programs": [ + { + "issue": 4103, + "title": "Swing-Impact-Ball-Flight Simulation Platform", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4103" + }, + { + "kind": "repository_path", + "value": "SPEC.md" + } + ], + "tracked_issue_ids": [], + "carrier_ids": [ + "pr-4119" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "rate-stack-local-c182" + ], + "evidence_gap": "The outer main-targeting carrier is conflicting, and conflict resolution requires fresh protected checks.", + "limitations": [ + "The platform is not released to main and downstream application parity is incomplete.", + "Impact-interval PR 4133 merged after its parent stack propagated and requires explicit reconciliation." + ], + "depends_on_issues": [], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4120, + "title": "Investigation and Variation Suite", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4120" + }, + { + "kind": "repository_path", + "value": "src/shared/python/swing_sim/variation/spec.py" + }, + { + "kind": "repository_path", + "value": "docs/rate_of_closure/variation_visualization_performance.md" + } + ], + "tracked_issue_ids": [ + 4142, + 4144 + ], + "carrier_ids": [ + "pr-4167", + "pr-4280" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "variation-local-d71" + ], + "evidence_gap": "Protected CI, nonlinear global sensitivity, bounded large-ensemble evidence, and downstream release remain open.", + "limitations": [ + "Feature-stack plots and exports do not establish default-branch availability.", + "Not every many-evaluation runner has released cross-surface visualization parity." + ], + "depends_on_issues": [ + 4103 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4125, + "title": "Realistic Clubs, Swing Kinetics, Putting, and Showcase", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4125" + }, + { + "kind": "repository_path", + "value": "SPEC.md" + } + ], + "tracked_issue_ids": [ + 4146, + 4158 + ], + "carrier_ids": [ + "pr-4119", + "pr-4179" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "implemented_unverified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "rate-stack-local-c182" + ], + "evidence_gap": "The complete showcase scope lacks one protected release run and installed-package evidence.", + "limitations": [ + "Representative geometry is not equivalent to qualified manufacturing CAD.", + "The current feature stack has not been released through the outer platform PR." + ], + "depends_on_issues": [ + 4120 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4130, + "title": "Impact-Interval Club Dynamics", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4130" + }, + { + "kind": "repository_path", + "value": "docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md" + } + ], + "tracked_issue_ids": [], + "carrier_ids": [ + "pr-4133" + ], + "supported_surfaces": { + "tools.pyqt6": "implemented_unverified", + "tools.react": "contract_only", + "upstreamdrift.pyqt6": "not_started", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [], + "evidence_gap": "PR 4133 is absent from the current outer platform head and has no current-carrier protected evidence.", + "limitations": [ + "A merge into a historical feature parent did not propagate this implementation toward main.", + "Downstream and user-interface integration must be requalified after reconciliation." + ], + "depends_on_issues": [ + 4125 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4142, + "title": "Ensemble Variation, Quiet Zones, and Sensitivity Attribution", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4142" + }, + { + "kind": "repository_path", + "value": "src/shared/python/swing_sim/variation/spec.py" + }, + { + "kind": "repository_path", + "value": "docs/rate_of_closure/variation_visualization_performance.md" + } + ], + "tracked_issue_ids": [ + 4144 + ], + "carrier_ids": [ + "pr-4167", + "pr-4280" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "variation-local-d71" + ], + "evidence_gap": "Global nonlinear sensitivity, localized execution, performance budgets, and downstream parity remain open.", + "limitations": [ + "Existing local visualization evidence is not protected release evidence.", + "UpstreamDrift does not yet consume an immutable released variation authority." + ], + "depends_on_issues": [ + 4120 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4146, + "title": "Shared Golf Club Builder", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4146" + }, + { + "kind": "repository_path", + "value": "docs/specs/GOLF_CLUB_SHAFT_PROFILES.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/GOLF_CLUB_WEDGE_CAD.md" + } + ], + "tracked_issue_ids": [ + 4147, + 4148, + 4149, + 4150, + 4151, + 4152 + ], + "carrier_ids": [ + "pr-4173" + ], + "supported_surfaces": { + "tools.pyqt6": "implemented_unverified", + "tools.react": "not_started", + "upstreamdrift.pyqt6": "not_started", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [], + "evidence_gap": "Only the assembly-contract child is closed; complete CAD export, image fitting, standalone workflow, and downstream qualification lack program-level evidence.", + "limitations": [ + "Shaft and wedge CAD slices are carried through a later integration branch but their epic remains incomplete.", + "STEP and manufacturing-mesh qualification and calibrated three-view image fitting remain open." + ], + "depends_on_issues": [ + 4125 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4158, + "title": "Wedge Delivery Kinematics, Bounce, and Chip-Shot Explorer", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4158" + }, + { + "kind": "repository_path", + "value": "docs/specs/GOLF_CLUB_WEDGE_KINEMATICS.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/GOLF_CLUB_WEDGE_GROUND_CLEARANCE.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/GOLF_CLUB_TURF_CONTACT.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/WEDGE_IMPACT_VISUALIZATION.md" + } + ], + "tracked_issue_ids": [ + 4159, + 4160, + 4161, + 4162, + 4163, + 4164, + 4165, + 4166 + ], + "carrier_ids": [ + "pr-4173", + "pr-4179" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "rate-stack-local-c182" + ], + "evidence_gap": "The complete wedge stack lacks protected release, downstream parity, and final scientific qualification.", + "limitations": [ + "The worked shaft-rotation example is a declared sensitivity case rather than a measured wedge population norm.", + "Current turf and forgiveness outputs remain model-conditional." + ], + "depends_on_issues": [ + 4142, + 4146 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4181, + "title": "Launch Monitor Convention Registry and Comparability", + "kind": "program", + "delivery_stage": "implemented_on_feature_stack", + "completion": "implemented", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4181" + }, + { + "kind": "repository_path", + "value": "docs/specs/LAUNCH_MONITOR_CONVENTIONS.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/LAUNCH_MONITOR_ANALYTICS.md" + } + ], + "tracked_issue_ids": [ + 4193, + 4194 + ], + "carrier_ids": [ + "pr-4203", + "pr-4217" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "rate-stack-local-c182" + ], + "evidence_gap": "The implemented registry has not reached a protected main release or immutable downstream pin.", + "limitations": [ + "Unavailable vendor conventions remain visibly unavailable rather than inferred.", + "Cross-application convention parity is not released." + ], + "depends_on_issues": [ + 4158 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4189, + "title": "Comprehensive 3D D-Plane Calculation and Visualization", + "kind": "program", + "delivery_stage": "implemented_on_feature_stack", + "completion": "implemented", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4189" + }, + { + "kind": "repository_path", + "value": "docs/specs/D_PLANE_GEOMETRY.md" + } + ], + "tracked_issue_ids": [], + "carrier_ids": [ + "pr-4202", + "pr-4203" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "rate-stack-local-c182" + ], + "evidence_gap": "D-plane parity has no protected main or downstream release evidence.", + "limitations": [ + "The feature remains nested below the launch-monitor and wedge carrier chain.", + "Vendor comparison depends on explicitly available convention definitions." + ], + "depends_on_issues": [ + 4158, + 4181 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4191, + "title": "Shot Design, Wind Strategy, Inverse Flight, and 3D Playback", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4191" + }, + { + "kind": "repository_path", + "value": "docs/specs/BALL_FLIGHT_RESULT_CONTRACT.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/INVERSE_FLIGHT_SOLVER.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/IMPACT_SOLUTION_FAMILIES.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/WIND_PHYSICS.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/WIND_UNCERTAINTY_STRATEGY.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/BALL_FLIGHT_PLAYBACK.md" + }, + { + "kind": "repository_path", + "value": "docs/specs/CAPABILITY_OPTIMIZATION.md" + } + ], + "tracked_issue_ids": [ + 4192, + 4193, + 4194, + 4195, + 4196, + 4197, + 4198, + 4199, + 4200, + 4201 + ], + "carrier_ids": [ + "pr-4217", + "pr-4281", + "pr-4282", + "pr-4283", + "pr-4289", + "pr-4294" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "contract_only", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "campaign-composition-local-2c1", + "rate-stack-local-c182", + "swing-parity-hosted-3186", + "swing-parity-isolation-18fe" + ], + "evidence_gap": "The current carrier requires a fresh hosted parity run, protected main integration, scientific qualification, and downstream parity.", + "limitations": [ + "Capability optimization is still-air carry to first ground crossing and excludes bounce, roll, and total distance.", + "Feature-parent merges for capability slices were not protected main releases." + ], + "depends_on_issues": [ + 4181, + 4189 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4201, + "title": "Cross-Interface Validation, Documentation, and Release Evidence", + "kind": "release_gate", + "delivery_stage": "specified_only", + "completion": "specified", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4201" + }, + { + "kind": "repository_path", + "value": "docs/release/rate_of_closure_campaign.v1.json" + }, + { + "kind": "repository_path", + "value": "docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md" + } + ], + "tracked_issue_ids": [], + "carrier_ids": [], + "supported_surfaces": { + "tools.pyqt6": "contract_only", + "tools.react": "contract_only", + "upstreamdrift.pyqt6": "not_started", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "campaign-composition-local-2c1", + "swing-parity-hosted-3186", + "swing-parity-isolation-18fe" + ], + "evidence_gap": "No exact protected main merge SHA, installed-package matrix, immutable UpstreamDrift pin, or complete release evidence exists.", + "limitations": [ + "Local test totals cannot satisfy this release gate.", + "A successful workflow configuration regression does not replace the queued hosted parity execution." + ], + "depends_on_issues": [ + 4191, + 4218, + 4234, + 4260, + 4267 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4218, + "title": "Modern Toolstrip, Persistence, and Workspace Modules", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4218" + }, + { + "kind": "repository_path", + "value": "src/rate_of_closure/application/workspace_document.py" + }, + { + "kind": "repository_path", + "value": "src/rate_of_closure/view_workspace.py" + } + ], + "tracked_issue_ids": [ + 4219, + 4220, + 4221, + 4222, + 4223, + 4224, + 4225, + 4284 + ], + "carrier_ids": [ + "pr-4279" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "locally_verified", + "upstreamdrift.pyqt6": "not_started", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "toolstrip-local-c36" + ], + "evidence_gap": "Complete persistence adapters, synchronized multi-view state, camera snap controls, accessibility, and protected release remain open.", + "limitations": [ + "Unavailable persistence actions remain disabled rather than simulating success.", + "The camera tracking and canonical snap-view child has no implementation carrier." + ], + "depends_on_issues": [ + 4191 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4234, + "title": "Production-Grade Visual Design, Layout, Context, and Error UX", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4234" + }, + { + "kind": "repository_path", + "value": "docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md" + } + ], + "tracked_issue_ids": [ + 4235, + 4236, + 4237, + 4238, + 4239 + ], + "carrier_ids": [ + "pr-4279", + "pr-4282" + ], + "supported_surfaces": { + "tools.pyqt6": "locally_verified", + "tools.react": "implemented_unverified", + "upstreamdrift.pyqt6": "not_started", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [ + "campaign-composition-local-2c1", + "toolstrip-local-c36", + "rate-stack-local-c182" + ], + "evidence_gap": "High-DPI, keyboard, reduced-motion, stable pixel baselines, and full responsive React evidence remain open.", + "limitations": [ + "Rendered fixes cover selected desktop sizes rather than the complete product matrix.", + "Current Calculation and error-recovery acceptance criteria are not complete across four surfaces." + ], + "depends_on_issues": [ + 4218 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4260, + "title": "Golf Impact and Flight Authority with Four-Surface Parity", + "kind": "epic", + "delivery_stage": "specified_only", + "completion": "specified", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4260" + }, + { + "kind": "repository_path", + "value": "docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md" + } + ], + "tracked_issue_ids": [ + 4261, + 4262, + 4263, + 4264, + 4265, + 4266 + ], + "carrier_ids": [], + "supported_surfaces": { + "tools.pyqt6": "contract_only", + "tools.react": "contract_only", + "upstreamdrift.pyqt6": "not_started", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [], + "evidence_gap": "No machine-readable four-surface capability matrix, immutable Tools pin, or installed conformance run exists.", + "limitations": [ + "A launcher tile does not establish an independent surface implementation.", + "Current UpstreamDrift source resolution and React routing do not satisfy parity." + ], + "depends_on_issues": [ + 4191 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + }, + { + "issue": 4267, + "title": "Qualified Landing, Bounce, Roll, and Ground-Surface Modeling", + "kind": "epic", + "delivery_stage": "implemented_on_feature_stack", + "completion": "partial_implementation", + "authorities": [ + { + "kind": "github_issue", + "value": "https://github.com/D-sorganization/Tools/issues/4267" + }, + { + "kind": "repository_path", + "value": "docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md" + } + ], + "tracked_issue_ids": [ + 4268, + 4269, + 4270, + 4271, + 4272, + 4273, + 4274, + 4275, + 4276 + ], + "carrier_ids": [ + "pr-4285", + "pr-4288" + ], + "supported_surfaces": { + "tools.pyqt6": "contract_only", + "tools.react": "contract_only", + "upstreamdrift.pyqt6": "not_started", + "upstreamdrift.react": "not_started" + }, + "evidence_ids": [], + "evidence_gap": "The contract and flight-transfer carriers are open; qualified bounce, skid, roll, total-distance, UI, and parity evidence do not exist.", + "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." + ], + "depends_on_issues": [ + 4191 + ], + "release": { + "status": "not_released", + "branch": "main", + "sha": null + } + } + ] +} diff --git a/docs/specs/BALL_FLIGHT_RESULT_CONTRACT.md b/docs/specs/BALL_FLIGHT_RESULT_CONTRACT.md index ddfcd14126..dd3eb548f4 100644 --- a/docs/specs/BALL_FLIGHT_RESULT_CONTRACT.md +++ b/docs/specs/BALL_FLIGHT_RESULT_CONTRACT.md @@ -21,6 +21,10 @@ All vectors use the right-handed target frame - `y` points up; - `z` points right when looking downrange; - Launch Direction and lateral/offline values are positive right; +- Spin Axis Tilt is `atan2(-omega_y, omega_z)`; positive means fade/right + curvature and negative means draw/left curvature for a rightward backspin + axis. The forward `omega_x` gyro component is projected out rather than + reducing the reported tilt; - vertical launch angle is positive up; and - landing angle is reported as a positive downward angle below horizontal. diff --git a/docs/specs/CAPABILITY_OPTIMIZATION.md b/docs/specs/CAPABILITY_OPTIMIZATION.md index e60f8e7d88..bfde3ba83d 100644 --- a/docs/specs/CAPABILITY_OPTIMIZATION.md +++ b/docs/specs/CAPABILITY_OPTIMIZATION.md @@ -16,6 +16,50 @@ Each club supplies either a correlation matrix, combined with the parameter stan The bounded search alternates across the requested discrete clubs. Within each club it evaluates the declared baseline and deterministic low-discrepancy continuous candidates over all delivery parameters. Every candidate receives the same seeded low-discrepancy ensemble transformed by the declared covariance. Bias is applied before clipping to the hard safety envelope. +## Qualified Waterloo/Penner Evaluator + +`make_capability_flight_evaluator` and `makeCapabilityFlightEvaluator` bind a +validated player profile and optimization request to the actual Waterloo/Penner +forward model. The established variable IDs are `ball_speed` (`m/s`), +`launch_angle` (`deg`), and `launch_direction` (`deg`, target-frame positive +right). Existing profiles may use explicit configuration defaults for total +spin and canonical target-frame spin-axis tilt, but each default is keyed by +club and requires a nonempty evidence/provenance string. There is no global +driver-spin fallback. A club may instead declare `total_spin` (`rpm`) and +`spin_axis_tilt` (`deg`) together as variable capability parameters. +Positive tilt means fade/right curvature in the target frame. Metric +provenance records whether spin was sampled or supplied by a named fixed +club default. + +Unknown variables, wrong units, non-finite values, undeclared clubs, and +samples outside a club's hard bounds fail before integration. The full safe +interval must also fit the physical flight domain: ball speed is strictly +positive, total spin is nonnegative, launch angle and spin-axis tilt lie in +`[-90, 90]` degrees, and launch direction lies in `[-180, 180]` degrees. +The shared trajectory interval contract is `[0.001, 0.1]` seconds in exact +0.001-second increments; unsupported settings fail identically before either +runtime runs. + +Every trajectory position, velocity, and spin vector is transformed into the +canonical target frame without adding a tee-height display offset. The request +target is supplied to `ball-flight-result/v1` derivation, so a completed run +returns all available scalar launch, landing, and target-residual metrics—not +only the carry/offline pair required by the optimizer. A completed result +requires a physical descending ground crossing with available carry and +offline values. Reaching the time horizon first is `nonconverged` with no +partial metrics. Expected Python floating-point overflow failures are +`failed` with a stable non-leaking reason. Contract and programming errors +surface instead of being silently counted as ordinary failed trials. This +post-impact launch evaluator cannot fabricate `no_impact`; contact-aware +evaluators own that status. + +The logical coefficient model is versioned as +`waterloo-penner-coefficients/v1`. Python uses adaptive SciPy RK45 and React +uses fixed-step RK4; each runtime records its actual integrator in provenance. +Metric sets, frames, signs, and typed statuses are parity contracts, while +numerical comparisons use the same published tolerance bands as the flight +explorer rather than claiming bitwise integrator equivalence. + ## Objectives And Diagnostics The v1 request supports: @@ -29,6 +73,32 @@ The v1 request supports: Every returned alternative includes mean carry, expected miss, RMS landing dispersion, target-hold probability, miss-distance CVaR, downside carry, ensemble counts, no-impact and failure fractions, confidence, limiting constraints, extrapolation, and Pareto membership. Downside carry is the positive shortfall between the target-center distance and the mean of the lowest `(1 - cvar_alpha)` carry tail; miss CVaR is the mean of the corresponding highest miss-distance tail. Ranking is deterministic for a deterministic evaluator. Candidates below the configured minimum success fraction receive a dominating penalty but remain visible when the alternatives budget permits, preserving failure evidence. +## End-User Workflow + +PyQt6 and React expose the optimizer through a primary `Shot Optimizer` module. +Both clients author and strictly validate +`capability-optimization-workflow/v1`, containing the complete profile, +request, sourced per-club fixed-spin configuration, integrator settings, and +deterministic search basis. The default document is explicitly representative +and user-authored; it is not presented as measured player data. + +The persisted v1 wire contract is strict at every nested primitive. Numeric +fields accept only finite JSON numbers (and integer fields require an integral +number); text fields accept only nonempty JSON strings. Numeric strings, +booleans used as numbers, fractional integer values, and numeric identifiers or +provenance values are rejected in both runtimes. Python and TypeScript execute +one shared versioned accept/reject fixture to prevent parser drift. + +Optimization runs outside the UI thread. Progress is based on attempted model +evaluations, cancellation publishes no partial optimization result, and input +changes invalidate captured output. Every attempted sample is retained in +`scalar-ensemble/v1` with complete, no-impact, or failed cohort identity. The +clients present ranked alternatives, selectable scalar axes, paired-finite and +unavailable counts, managed zoom/autofit, a bounded paged raw table, +spreadsheet-safe lossless CSV, and stable JSON. Duplicate evaluator and target +diagnostic labels are stage-qualified in selectors without changing their +contract keys. + ## Interpretation And Limitations - Results are conditional model recommendations, not measured launch-monitor results or guarantees. @@ -36,8 +106,18 @@ Every returned alternative includes mean carry, expected miss, RMS landing dispe - The deterministic low-discrepancy ensemble is reproducible and correlation-aware; it is not a replacement for convergence studies or posterior sampling. - Clipping enforces hard delivery bounds but can distort tail covariance near a bound. The result reports the limiting safety or evidence boundary so callers can identify that condition. - Target hold is landing containment only. Roll, turf, hazards, weather uncertainty, and strategic utility require evaluators or higher-level policies that explicitly model them. -- V1 searches continuous delivery parameters uniformly over declared safety bounds. Adaptive optimization, player-specific priors, UI authoring, persistence, and live launch-monitor fitting are deferred extensions. +- V1 searches continuous delivery parameters uniformly over declared safety bounds. Adaptive optimization and player-specific priors remain deferred. +- The matched PyQt6/React workflow is implemented. Issue #4197 still requires protected CI, ordered review/merge, and downstream application parity before closure. ## Cross-Runtime Parity -Python and TypeScript share `capability_optimizer_golden_v1.json`. It pins the profile, request, selected club, mean carry, target-hold probability, variability score, and downside-tail score for an analytic evaluator. Runtime tests exercise all six objectives and strict contract validation. +Python and TypeScript share `capability_optimizer_golden_v1.json`. It pins the +profile, request, selected club, mean carry, target-hold probability, +variability score, and downside-tail score for an analytic evaluator. They +also share `capability_flight_evaluator_parity_v1.json`, which bands all 16 +available scalar metrics for one pinned Waterloo/Penner launch. Runtime tests +cover both spin-tilt signs, per-club default provenance, all physical domains, +coarse-but-supported sampling, typed edge states, all six objectives, and +strict contract validation. Cross-producer regressions also pin the same +gyro-projected tilt calculation in canonical result derivation, impact +diagnostics, and variation output. diff --git a/scripts/rate_campaign_manifest.py b/scripts/rate_campaign_manifest.py new file mode 100644 index 0000000000..80add1455d --- /dev/null +++ b/scripts/rate_campaign_manifest.py @@ -0,0 +1,403 @@ +"""Validate the versioned Rate of Closure campaign release authority.""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import sys +from enum import StrEnum +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST = REPO_ROOT / "docs" / "release" / "rate_of_closure_campaign.v1.json" +SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") +ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +PLACEHOLDER_PATTERN = re.compile(r"\b(?:FIXME|PLACEHOLDER|TBD|TODO|UNKNOWN)\b", re.I) +ISSUE_URL_PATTERN = re.compile( + r"^https://github\.com/D-sorganization/Tools/issues/[1-9][0-9]*$" +) +PRIMARY_ISSUES = frozenset( + (4103, 4120, 4125, 4130, 4142, 4146, 4158, 4181, 4189, 4191) + + (4201, 4218, 4234, 4260, 4267) +) + + +class StrictModel(BaseModel): + """Base contract that rejects undeclared fields and mutation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ReleaseStage(StrEnum): + """Mutually exclusive campaign delivery stages.""" + + SPECIFIED_ONLY = "specified_only" + FEATURE_STACK = "implemented_on_feature_stack" + PROTECTED_PARENT = "protected_merged_to_parent" + MAIN = "released_to_main" + + +class CompletionState(StrEnum): + """Scope completion independent of repository integration.""" + + SPECIFIED = "specified" + PARTIAL = "partial_implementation" + IMPLEMENTED = "implemented" + + +class ReleaseStatus(StrEnum): + """Default-branch release state.""" + + NOT_RELEASED = "not_released" + RELEASED = "released" + + +class CarrierState(StrEnum): + """Observed pull-request integration state.""" + + OPEN = "open" + MERGED = "merged" + CLOSED = "closed" + + +class ProtectionEvidence(StrEnum): + """Evidence attached to a carrier merge.""" + + NOT_RECORDED = "not_recorded" + NOT_PROTECTED = "not_protected" + CHECKS_PASSED = "protected_checks_passed" + + +class SurfaceState(StrEnum): + """Truthful implementation state for one product surface.""" + + NOT_STARTED = "not_started" + CONTRACT_ONLY = "contract_only" + IMPLEMENTED = "implemented_unverified" + LOCALLY_VERIFIED = "locally_verified" + RELEASED = "released" + + +class EvidenceOutcome(StrEnum): + """Terminal or nonterminal evidence result.""" + + PASSED = "passed" + FAILED = "failed" + QUEUED = "queued" + CANCELLED = "cancelled" + + +class AuthorityRef(StrictModel): + """Repository or issue authority for a program contract.""" + + kind: Literal["repository_path", "github_issue"] + value: str = Field(min_length=1) + + +class ReleaseRecord(StrictModel): + """Default-branch release evidence for a complete program.""" + + status: ReleaseStatus + branch: Literal["main"] = "main" + sha: str | None + + @model_validator(mode="after") + def validate_release_sha(self) -> ReleaseRecord: + """Require an exact SHA only for a claimed main release.""" + if self.status is ReleaseStatus.RELEASED: + if self.sha is None or SHA_PATTERN.fullmatch(self.sha) is None: + raise ValueError("released state requires a 40-character release SHA") + elif self.sha is not None: + raise ValueError("not_released state cannot carry a release SHA") + return self + + +class CarrierRecord(StrictModel): + """One nonduplicated pull-request carrier record.""" + + id: str = Field(pattern=ID_PATTERN.pattern) + pr: int = Field(gt=0) + branch: str = Field(min_length=1) + base_branch: str = Field(min_length=1) + head_sha: str = Field(pattern=SHA_PATTERN.pattern) + state: CarrierState + merge_commit_sha: str | None + protection_evidence: ProtectionEvidence + + @model_validator(mode="after") + def validate_merge_fields(self) -> CarrierRecord: + """Keep open and merged carrier evidence mutually consistent.""" + if self.state is CarrierState.MERGED: + if self.merge_commit_sha is None: + raise ValueError("merged carrier requires merge_commit_sha") + if SHA_PATTERN.fullmatch(self.merge_commit_sha) is None: + raise ValueError( + "merge_commit_sha must contain 40 lowercase hex characters" + ) + elif self.merge_commit_sha is not None: + raise ValueError("unmerged carrier cannot carry merge_commit_sha") + return self + + +class TestEvidence(StrictModel): + """Commit-bound test or hosted-check evidence.""" + + id: str = Field(pattern=ID_PATTERN.pattern) + kind: Literal["local", "hosted"] + commit_sha: str = Field(pattern=SHA_PATTERN.pattern) + outcome: EvidenceOutcome + commands: list[str] = Field(min_length=1) + summary: str = Field(min_length=1) + source_paths: list[str] = Field(min_length=1) + + +class ProgramRecord(StrictModel): + """Release evidence for one primary campaign issue or epic.""" + + issue: int = Field(gt=0) + title: str = Field(min_length=1) + kind: Literal["epic", "program", "release_gate"] + delivery_stage: ReleaseStage + completion: CompletionState + authorities: list[AuthorityRef] = Field(min_length=1) + tracked_issue_ids: list[int] + carrier_ids: list[str] + supported_surfaces: dict[str, SurfaceState] + evidence_ids: list[str] + evidence_gap: str | None + limitations: list[str] = Field(min_length=1) + depends_on_issues: list[int] + release: ReleaseRecord + + @model_validator(mode="after") + def validate_program_state(self) -> ProgramRecord: + """Reject delivery-stage and completion contradictions.""" + if self.delivery_stage is ReleaseStage.SPECIFIED_ONLY: + if self.completion is not CompletionState.SPECIFIED or self.carrier_ids: + raise ValueError( + "specified_only requires specified scope and no carriers" + ) + elif self.completion is CompletionState.SPECIFIED or not self.carrier_ids: + raise ValueError( + "implemented delivery stage requires implementation and carriers" + ) + if self.delivery_stage is ReleaseStage.MAIN: + if self.release.status is not ReleaseStatus.RELEASED: + raise ValueError("released_to_main requires released status") + elif self.release.status is not ReleaseStatus.NOT_RELEASED: + raise ValueError("non-main delivery stage cannot claim a release") + if not self.evidence_ids and not self.evidence_gap: + raise ValueError( + "program requires evidence_ids or an explicit evidence_gap" + ) + expected = { + "tools.pyqt6", + "tools.react", + "upstreamdrift.pyqt6", + "upstreamdrift.react", + } + if set(self.supported_surfaces) != expected: + raise ValueError( + "supported_surfaces must declare all four product surfaces" + ) + if self.issue in self.depends_on_issues: + raise ValueError("program cannot depend on its own issue") + return self + + +class CampaignManifest(StrictModel): + """Canonical Rate of Closure campaign release-evidence document.""" + + schema_version: Literal["rate-of-closure-campaign/v1"] + repository: Literal["D-sorganization/Tools"] + as_of: str = Field(pattern=r"^20[0-9]{2}-[0-9]{2}-[0-9]{2}$") + default_branch: Literal["main"] + required_main_checks: list[str] = Field(min_length=1) + release_stage_definitions: list[ReleaseStage] + campaign_release: ReleaseRecord + carriers: list[CarrierRecord] + test_evidence: list[TestEvidence] + programs: list[ProgramRecord] + + @model_validator(mode="after") + def validate_authority(self) -> CampaignManifest: + """Validate scope, references, ordering, and release claims.""" + _require_unique(self.carriers, "id") + _require_unique(self.carriers, "pr") + _require_unique(self.test_evidence, "id") + _require_unique(self.programs, "issue") + if {program.issue for program in self.programs} != PRIMARY_ISSUES: + raise ValueError( + "programs must cover the canonical primary campaign issues" + ) + if self.release_stage_definitions != list(ReleaseStage): + raise ValueError( + "release_stage_definitions must list every stage in canonical order" + ) + if self.required_main_checks != ["quality-gate", "tests (3.11)"]: + raise ValueError("required_main_checks must match protected main") + release_shas = {program.release.sha for program in self.programs} + release_shas.discard(None) + if self.campaign_release.status is ReleaseStatus.RELEASED: + all_released = all( + program.delivery_stage is ReleaseStage.MAIN for program in self.programs + ) + if release_shas != {self.campaign_release.sha} or not all_released: + raise ValueError( + "campaign release requires every program at the same main SHA" + ) + elif release_shas: + raise ValueError("program release contradicts unreleased campaign") + pending = { + program.issue: set(program.depends_on_issues) & PRIMARY_ISSUES + for program in self.programs + } + while pending and ( + ready := { + issue for issue, dependencies in pending.items() if not dependencies + } + ): + for issue in ready: + pending.pop(issue) + for dependencies in pending.values(): + dependencies.difference_update(ready) + if pending: + raise ValueError("campaign program dependency cycle is forbidden") + _validate_references(self) + _reject_placeholders(self.model_dump(mode="json"), "manifest") + return self + + +def _require_unique(records: list[Any], field_name: str) -> None: + values = [getattr(record, field_name) for record in records] + if len(values) != len(set(values)): + raise ValueError(f"duplicate {field_name} values are not allowed") + + +def _validate_references(manifest: CampaignManifest) -> None: + carriers = {carrier.id: carrier for carrier in manifest.carriers} + evidence_ids = {evidence.id for evidence in manifest.test_evidence} + for program in manifest.programs: + if not set(program.carrier_ids).issubset(carriers): + raise ValueError(f"issue {program.issue} references an undeclared carrier") + if not set(program.evidence_ids).issubset(evidence_ids): + raise ValueError( + f"issue {program.issue} references undeclared test evidence" + ) + _validate_integrated_stage(program, carriers) + + +def _validate_integrated_stage( + program: ProgramRecord, carriers: dict[str, CarrierRecord] +) -> None: + referenced = [carriers[carrier_id] for carrier_id in program.carrier_ids] + if program.delivery_stage is ReleaseStage.PROTECTED_PARENT: + qualified = any( + carrier.state is CarrierState.MERGED + and carrier.protection_evidence is ProtectionEvidence.CHECKS_PASSED + for carrier in referenced + ) + if not qualified: + raise ValueError( + "protected_merged_to_parent requires protected merge evidence" + ) + if program.delivery_stage is ReleaseStage.MAIN: + released = [ + carrier + for carrier in referenced + if carrier.state is CarrierState.MERGED and carrier.base_branch == "main" + ] + if not released or program.release.sha not in { + carrier.merge_commit_sha for carrier in released + }: + raise ValueError("released_to_main SHA must match a main carrier merge") + + +def _reject_placeholders(value: Any, path: str) -> None: + if isinstance(value, str) and PLACEHOLDER_PATTERN.search(value): + raise ValueError(f"placeholder text is forbidden at {path}") + if isinstance(value, dict): + for key, child in value.items(): + _reject_placeholders(child, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_placeholders(child, f"{path}[{index}]") + + +def load_campaign_manifest(path: Path = DEFAULT_MANIFEST) -> CampaignManifest: + """Read and strictly validate a UTF-8 JSON campaign manifest.""" + validated: CampaignManifest = CampaignManifest.model_validate_json( + path.read_text(encoding="utf-8") + ) + return validated + + +def validate_repository_evidence( + manifest: CampaignManifest, repo_root: Path = REPO_ROOT +) -> None: + """Validate repository paths and issue references against ``repo_root``.""" + paths = _repository_paths(manifest) + missing = [path for path in paths if not (repo_root / path).is_file()] + if missing: + raise ValueError(f"campaign evidence paths do not exist: {sorted(missing)}") + issue_urls = _issue_urls(manifest) + invalid_urls = [ + url for url in issue_urls if ISSUE_URL_PATTERN.fullmatch(url) is None + ] + if invalid_urls: + raise ValueError(f"invalid campaign issue references: {sorted(invalid_urls)}") + + +def _repository_paths(manifest: CampaignManifest) -> set[str]: + paths = { + authority.value + for program in manifest.programs + for authority in program.authorities + if authority.kind == "repository_path" + } + return paths | { + path for evidence in manifest.test_evidence for path in evidence.source_paths + } + + +def _issue_urls(manifest: CampaignManifest) -> set[str]: + return { + authority.value + for program in manifest.programs + for authority in program.authorities + if authority.kind == "github_issue" + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", nargs="?", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--schema", action="store_true", help="emit JSON Schema") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Validate the manifest or emit its deterministic JSON Schema.""" + args = _parser().parse_args(argv) + if args.schema: + schema = CampaignManifest.model_json_schema() + json.dump(schema, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 + try: + manifest = load_campaign_manifest(args.manifest) + validate_repository_evidence(manifest) + except (OSError, ValueError, ValidationError) as error: + logging.error("campaign manifest validation failed: %s", error) + return 1 + return 0 + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + raise SystemExit(main()) diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 5f7601afed..c79d9d1819 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,7 +1,230 @@ # AGENT_HANDOFF — rate_of_closure -> **Update this file with every PR and every push to main.** -> Last updated: 2026-08-11 +> Update this append-only handoff with every implementation commit and every +> push to `main`. Last updated: 2026-08-11. + +## 2026-08-11 Wind workflow receives newly published scalar-adapter parent + +PR `#4282` keeps branch `feat/4199-wind-workflow` and base +`feat/4199-wind-scalar-adapter`. Exact published child +`b2baf6ef615b0d756a86ac4ca7eef2fc583210ee` is merged first and exact current +parent `9321c1d2e091b8c7e5a4a83aa9ad726290e7fb5a` second. Implementation and test +paths merge automatically: responsive PyQt6/React wind execution, +progress/cancellation, controls, scatter, persistence/export, consolidated +capability workflow, and strict campaign release authority remain intact while +the parent contributes current scalar-adapter, variation, workspace, +launch-monitor/D-plane, split-kinetics, and Qt repairs. Only append-only +handoffs and SPEC conflict textually; both histories are preserved. All 94 +focused Python/PyQt wind, responsive-layout, navigation, playback, and manifest +tests pass, as do all 44 tests in eight focused React wind/responsive suites. +Docs governance and strict campaign-manifest generation/validation pass. +Pinned Ruff checks/formats all 57 changed Python files; pinned MyPy accepts 38 +production files; pinned Bandit finds no medium/high issues in 37 scanned source +files; the 500-LOC, changed-Python policy, test-contract/assertion, diff, React +type-check/lint, and production-build gates pass. Review, ordinary publication, +protected exact-head CI, unresolved threads, dependency order, and release +remain open. + +## 2026-08-11 Wind workflow receives current scalar-adapter parent + +PR `#4282` remains on `feat/4199-wind-workflow`, based on +`feat/4199-wind-scalar-adapter`. Exact remote child +`29e15d6ff631f7f30afcc745be783f2e716d7dcf` normally merges exact published +parent `a7dce5f89b483303938f518b74b4028a1c68ba81` second, preserving child-first +history and the existing base. The responsive PyQt6/React workflow and +consolidated capability stack inherit the parent's split kinetics facade, +Ground/Tee parity, and 252-line public/213-line private wind-adapter repair. + +The only source conflict is an obsolete formatting-only edit to the pre-split +kinetics monolith and resolves wholly to the validated parent facade. Both +append-only documentation histories remain present. Seven surviving +child-owned paths receive only repository-pinned Ruff formatting. The exact +skipped-import MyPy profile also replaces one now-unused cache-return ignore +with an explicit `KineticsSeries | None` cast; the cached object and runtime +behavior are unchanged. Protected exact-head CI, independent review, +unresolved-thread checks, publication, and dependency order remain release +gates. + +Local evidence is 74 focused Python/PyQt wind, kinetics, and Ground/Tee tests; +29 focused React tests; and 8 campaign-manifest tests. TypeScript, focused +zero-warning ESLint, Ruff check/format, pinned MyPy 1.13, Bandit's medium/high +threshold, strict wind module/signature/function budgets, manifest, +documentation, policy, SPEC, and diff gates pass. + +## 2026-08-10 Repaired scalar-adapter propagation into wind workflow + +Draft PR `#4282` remains on `feat/4199-wind-workflow`, based on +`feat/4199-wind-scalar-adapter`. It now normally incorporates repaired parent +head `d6fb04e07c2a625412e9208b07103acdc42c621b` after that head's quality gate +passed. The merge had no wind-workflow production/test conflict and used no +rebase, retarget, force-push, or history rewrite. Twenty-five focused tests and +the documentation, size, and whitespace gates pass locally; protected CI, +review, and later-stack propagation remain outstanding. + +## 2026-08-10 Exact-head manifest typing repair + +PR #4282 exact head `aa6eeffb0395f7ed7954f2315b1c625cada552d8` +failed hosted quality-gate run `31395741841` only at pinned-mypy +`scripts/rate_campaign_manifest.py:335:5 [no-any-return]`. The CI profile skips +import analysis, so the Pydantic validator result becomes `Any`; an explicit +local `CampaignManifest` annotation now retains the validated return contract +without changing parsing, validation, or error behavior. Ruff and format had +already passed on the failed head. Publish this repair normally, then propagate +its new exact head into #4285; do not retry the obsolete run. + +## 2026-08-10 Wind workflow receives exact scalar-adapter parent + +PR `#4282` keeps branch `feat/4199-wind-workflow`, base +`feat/4199-wind-scalar-adapter`, and original child first parent +`5f77af4add23547a21cc3fabce98ae9ad4260427`. Exact parent +`4a793c4c3f19aad43a3c215800b266be487ace49` merges normally second. The +responsive PyQt6/React wind strategy workflow, consolidated capability stack, +and campaign release authority remain additive with the parent's complete +variation, workspace, compatibility, and strict scalar-adapter history. + +The parent's extracted navigation-state tuple is the single authority and now +includes the child-owned `capability_optimization` ID. This preserves safe +legacy visibility migration and prevents a persisted pre-capability workspace +from hiding the new tab. Runtime-only UI and capability invariants now raise +explicit errors under optimized Python, and the safe fixed-argument builder +subprocess is annotated for Bandit. The skipped-import manifest return repair +is recorded above. SPEC 1.14.15 is the current exact-head repair entry. +Verification passes 73 focused Python tests, the complete 1,657-test +Rate/shared-swing/golf-club matrix with two explicit optional build123d skips, +all 643 React tests across 105 files plus type-check/lint/build, 12 Rust tests, +31 real-CPython-3.10 compatibility tests, and 43 post-review invariant/GUI/ +manifest tests. Ruff/format, pinned MyPy 1.13 across 37 changed production +files, Bandit, exact-parent size, manifest, docs, minimum-test, assertions, +CI-policy secrets, compilation, and diff gates pass. Independent re-review +found no actionable findings after 94 Python workflow/navigation tests, 20 +manifest/GUI tests, 61 React workflow tests, and independent static checks. +Protected CI/review and dependency-ordered propagation remain release gates. + +## Current continuation + +The active local continuation is integrated directly on the existing PR #4282 +carrier `feat/4199-wind-workflow`, starting from original published head +`5f77af4add23547a21cc3fabce98ae9ad4260427` and normally incorporating exact +published #4281 parent `4a793c4c3f19aad43a3c215800b266be487ace49`. +The base remains `feat/4199-wind-scalar-adapter`; no branch was rebased, +retargeted, force-pushed, or published by this continuation. It adds strict +cross-runtime +capability parsing, reliable signed decimal entry, complete ranked diagnostics +and result exports, quantitative React scatter annotations, package-safe +static-web release entrypoints, and the strict `rate-of-closure-campaign/v1` +release authority. It also carries the corrected parent's Python 3.10 +compatibility, variation-export, scalar-ensemble, and wind-adapter history. +Normal publication does not establish a protected release; CI, review, and +downstream evidence remain required. + +The direct web launcher dynamically loads the root bootstrap module without a +launcher-local `sys.path` mutation; its subprocess delegation test and the +changed-production-Python policy guard cover this release entrypoint. + +Use these authorities together: + +- `docs/release/rate_of_closure_campaign.v1.json` — machine state; +- `scripts/rate_campaign_manifest.py` — validation and generated JSON Schema; +- `docs/release/RATE_OF_CLOSURE_CAMPAIGN_MANIFEST.md` — update procedure; +- `docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md` — detailed history; +- `docs/specs/*.md` — scientific and interoperability contracts. + +The manifest is intentionally normalized: programs reference shared carrier +and test-evidence records. It fails on missing primary issues, undeclared +references, duplicate IDs, malformed SHAs, absent repository evidence, +placeholders, and contradictory release claims. + +## Carrier state + +The capability stack was collapsed top-down on 2026-08-09: + +1. #4294 Shot Optimizer UI merged into `feat/4197-capability-flight-evaluator`; +2. #4289 evaluator merged into `feat/4197-capability-observer`; +3. #4283 observer merged into `feat/4199-wind-workflow`. + +These were feature-parent merges, not protected releases. Current carrier +#4282 descends from #4281 → #4280 → #4279 → #4203 → #4202 → wedge and +variation parents. Exact published #4281 parent +`4a793c4c3f19aad43a3c215800b266be487ace49` is incorporated locally. Preserve +that dependency order and use normal merges only. + +Outer platform PR #4119 targets `main` and still needs reconciliation. PR #4133 +impact-interval dynamics merged into a historical feature parent after that +parent had already propagated, so its source and tests must be explicitly +reconciled before #4130 can close. + +## Implemented on the feature stack + +- PyQt6 and React variation workspaces: selectable input/contact/impact/shot + scatter, matrix, landing dispersion, every-trial 3D swing arcs, quiet zones, + linked selection, bounded raw rows, and lossless exports. +- Matched toolstrip/workspace controls, module visibility, independent plots, + legend placement, zoom/autofit, replay, loop, and granular playback rate. +- Launch-monitor convention, D-plane, spatial target, inverse flight, wind, + interactive flight playback, and capability-optimization contracts/workflows. +- Wedge kinematics, clearance, turf proxy, impact visualization, and + forgiveness-analysis slices. + +## Current limitations + +- No complete campaign program has a qualified `main` release SHA. +- Capability v1 is still-air carry to first ground crossing; it excludes wind, + bounce, roll, and total distance in that evaluator. +- Ground contracts and flight transfer are open carriers; qualified bounce, + skid, roll, profiles, total-distance optimization, UI, and Rust/WASM parity + remain. +- UpstreamDrift React has no native Rate route, and the Tools source pin and + resolver behavior are not a qualified immutable parity boundary. +- Camera tracking/snap views (#4284), complete persistence adapters, high-DPI, + keyboard, reduced-motion, and visual-regression matrices remain open. +- Local/rendered evidence is not protected CI or installed-package evidence. + +## Most recent evidence + +- Corrected-parent propagation: 62 focused wind/scalar/variation and + compatibility tests pass on Python 3.11 and real CPython 3.10.20; 8 React + files / 35 tests, TypeScript, and focused zero-warning ESLint pass. The real + 3.10 run found and now guards #4282's child-owned capability-observation + `StrEnum` boundary. Ruff check/format passes 15 focused files, pinned mypy + 1.13 passes 10 production modules, and nine campaign manifest/parity + contracts pass. +- Composed local continuation: 828 Rate Python/PyQt tests and 104 React files / + 642 tests passed; TypeScript, zero-warning ESLint, and the 188-module Vite + production build passed. The manifest, schema JSON, Ruff, targeted mypy, and + nine manifest/parity contracts pass on implementation head `2c1a77baa`. + Hosted CI has not run on this composition. +- Hosted quality-gate run `31340032608` subsequently found mypy 1.13 + `no-any-return` findings at the Pydantic loader and Qt elapsed-timer adapter + under `--follow-imports=skip`; both boundaries now narrow their return + values explicitly. The exact Python 3.12/mypy 1.13 delta is clean across 54 + files; Ruff, 62 focused regression tests, and eight campaign-manifest tests + also pass locally. +- Consolidated capability head `c1827bbdc`: 1,426 Python Rate/shared-swing + tests and 624 React tests passed locally. +- Variation continuation `d71b0ea01`: 890 Python/PyQt/shared-swing tests and + 545 React tests passed locally, with one optional Rust skip. +- Toolstrip head `c36ca36e9`: the same 890/545 local suite totals passed. +- Hosted #4282 head `3186a265b1`: `swing_core` built, but cached plugin loading + failed before parity collection; this is failed evidence, not a model pass. +- CI-isolation head `18fe89201`: focused workflow regression passes locally; + the hosted non-skipped parity run is still required. + +## Validation + +```powershell +python scripts/rate_campaign_manifest.py +python -m pytest tests/rate_of_closure/test_campaign_release_manifest.py -q +$env:QT_QPA_PLATFORM='offscreen' +$env:PYTHONPATH=(Resolve-Path 'src').Path +python -m pytest tests/rate_of_closure src/shared/python/swing_sim -q +cd src/rate_of_closure/web +npm test -- --run +npm run type-check +npm run lint +npm run build +``` + +## Inherited #4281 handoff history ## 2026-08-11 Wind scalar adapter receives current variation parent @@ -942,6 +1165,219 @@ python3 -m ruff check src/rate_of_closure src/shared/python/swing_sim python3 -m mypy src/rate_of_closure src/shared/python/swing_sim ``` +Run Ruff format/check and pinned delta mypy on every changed Python file. Do not +rewrite parents, force-push, bypass checks, infer unavailable values, or close +#4201 until exact protected merge, installed package, downstream pin, science, +performance, accessibility, documentation, and rollback evidence all exist. + +## 2026-08-11 parent sections preserved during #4282 reconciliation + +## 2026-08-11 Workspace child receives hosted MyPy repair + +PR `#4279` retains branch `feat/4218-toolstrip-workspace` and base +`feat/4181-launch-monitor-registry`. Exact child +`7806a16f58e1c6999d32f0127a187fbb21f839a1` normally merges exact published +parent `3796b49e40b677fbac4e05739f8be49f905df2cb`. No feature-code conflict +exists: the inherited production delta is four static `numpy.ndarray` casts, +while workspace/toolstrip behavior and runtime arrays, physics, frames, units, +imports, and UI behavior remain unchanged. This local merge does not satisfy +fresh protected CI, required review, unresolved-thread, dependency, or release +gates. + +## 2026-08-11 workspace child receives latest repaired parent + +PR `#4279` retains branch `feat/4218-toolstrip-workspace` and base +`feat/4181-launch-monitor-registry`. A normal merge incorporates exact parent +`0216a547aa79727091a2939b96e779e8ddbd7304` into exact child +`61b7f48b5aeb7d57246b4963da3df086e79cbe15`. There are no feature-code +conflicts: workspace/toolstrip, visibility, navigation, playback, and plot +behavior remain unchanged while the parent kinetics façade/dynamics/series +split and pinned formatting are inherited. This local merge does not satisfy +fresh protected CI, required review, unresolved-thread, dependency, or release +gates. + +## 2026-08-11 Append-only SPEC preservation repair + +Independent review found that the local #4203 reconciliation omitted four +exact D-plane parent SPEC rows dated 2026-08-10 (versions 1.13.11, 1.13.9, +1.13.7, and 1.13.6). They are restored verbatim in a documentation-only +follow-up. Production behavior, tests, ordered parents, base, and local quality +evidence are unchanged; re-review, normal publication, and protected CI remain +open. + +## 2026-08-11 Current D-plane parent reconciliation + +PR #4203's published child `217e36dc93d30f79826847f958fbcd10805e58ed` +is being normally merged with exact current D-plane base +`f3363aa88868f6a5c7e9ccfc682a9eca014e86c1`. The base and source branch stay +unchanged. The only conflict is the parent's formatting of an older monolithic +kinetics expression: the split facade keeps its typed pendulum accessor and +inherits the parent's geometry explanation. No launch-monitor, D-plane, or +kinetics behavior is intentionally changed. Four Qt-stub `Any` returns exposed +by the exact MyPy 1.13 delta are also narrowed to their declared `bool`/`str` +contracts without changing values. Focused/full validation, independent +review, normal publication, and protected CI remain open. + +## 2026-08-11 Exact-head format completion + +Protected CI on #4203 head `7d69a545ae555679f0318940e67c1786626d6794` +failed only Ruff formatting. The pinned 0.14.10 reproduction found eleven +noncompliant changed Python files: four inherited files plus seven altered by +the automated pre-commit repair. The pending repair intentionally changes no +launch-monitor, kinetics, simulation, or UI behavior and requires independent +review plus fresh protected CI before integration. + +## 2026-08-11 Hosted MyPy NumPy-return repair + +CI Standard run `31477542889`, job `93734652129`, found four +`no-any-return` errors after the kinetics façade extraction at exact PR #4203 +head `0216a547aa79727091a2939b96e779e8ddbd7304`. The NumPy stubs widen +`linalg.norm`, `concatenate`, and matrix projection results to `Any` even +though the runtime operations return arrays. The private series/dynamics +modules and public façade now use explicit `cast(np.ndarray, ...)` boundaries. +These casts are static only: array identity, values, dtype, shape, physics, +frames, units, imports, and UI behavior are unchanged. Verification must cover +the complete PR changed-source MyPy set and both focused and full Rate tests. +Current local evidence is 102 changed-source MyPy files, 28 focused tests, and +all 701 Rate tests passing; complete-delta Ruff/format and Bandit are also +green. + +## 2026-08-11 Kinetics façade extraction + +The strict current-head delta reproduces a protected size failure that the +PR-base comparison hides: `kinetics.py` is changed versus `HEAD~1` and is 646 +LOC, above the ungrandfathered 500-LOC maximum. The public module is now a +222-LOC run adapter/facade; its pure double-pendulum dynamics are in the +205-LOC `_kinetics_dynamics.py`, and the immutable `KineticsSeries`/DbC +contract is in the 131-LOC `_kinetics_series.py`. Existing public imports and +the `_reaction_forces` seam are the same implementation objects. A RED-first +identity test plus the existing physics, energy, force, parity-fixture, +presentation, and PyQt tests protect behavior. No physics, frame, SI-unit, +schema, UI, or stack-order change is intended; the commit remains local only. +Focused kinetics/presentation/PyQt verification is 28 passing tests; the +complete Rate-of-Closure Python regression suite is 701 passing tests. + +## 2026-08-11 #4203 pinned-Ruff formatting repair + +No material handoff change: current-head CI Standard run `31468208320`, job +`93705508050`, identified eight files that differ from repository-pinned Ruff +0.14.10 output. This commit changes formatting only; Rate physics, behavior, +frames, persistence schemas, PyQt/React contracts, stack bases, and dependency +order remain unchanged. Fresh protected CI and review are still required. + +## 2026-08-10 Ground/Tee parity child receives repaired parent + +Ready PR `#4325` keeps branch `feat/4143-tee-parity-fixture` and base +`feat/4181-launch-monitor-registry`. It normally merges exact repaired parent +head `12dd76a8dbcc106c4683f2f2e53076f8dc6f1b76` without any production/test +code conflict or history rewrite. Shared parity and rendered-evidence +contracts remain intact; fresh protected CI, review, dependency order, and +#4143 release remain open. + +## 2026-08-10 #4143 Python/React Golden Ball-Setup Parity + +The isolated `feat/4143-tee-parity-fixture` child begins at exact draft PR +#4203 head `31cbc007d4c85b5479b7cd0fb0969124eab2af67`. One versioned JSON fixture now +drives both Python and React ball-support tests with explicit SI units and the +ground-plane-to-ball-bottom height reference. The shared cases cover Driver +and non-Driver defaults, user overrides, Ground zero effective height, +derived ball-center geometry and serialization, negative/NaN/infinite height +rejection, and backward-compatible migration of a legacy run without +`ball_setup`. + +Evidence is 18 passing Python tee/parity tests and 24 passing React +tee/persistence/parity tests, plus green TypeScript, ESLint, Vite production +build, Ruff check, and Ruff format. Production model and UI code are unchanged. +Recorded visual evidence now adds 1600 x 1200 Playwright Driver/Tee and +rerun-Ground captures plus 1400 x 900 hidden-window PyQt captures. The browser +run asserts control state, diagram geometry, and zero console/page errors; the +desktop run asserts canonical center/artist state, nonblank output, and +different Ground/Tee digests without pixel absolutes. The exact artifacts and +versioned manifests are in +`C:\Users\diete\AppData\Local\Temp\rate-4143-visual-evidence-8050eeba`. +Do not close #4143: protected current-head CI/review and release to `main` +remain. This exact parent predates the strict campaign manifest on a divergent +branch, so no downstream manifest was copied into this bounded child. + +## 2026-08-10 Wind scalar child receives second repaired parent + +PR `#4281` retains branch `feat/4199-wind-scalar-adapter` and base +`feat/4144-variation-export-continuation`. It normally merges exact repaired +parent `b90e5021a59e2081415b51ef29fbed06377bc201` without scalar-adapter code +conflict or history rewrite. Fresh child CI, review, and dependency order +remain required. +Post-reconciliation evidence is 25 focused D-plane/impact tests plus docs, +changed-file-size, and whitespace gates. + +## 2026-08-10 Variation export child receives exact workspace parent + +PR `#4280` retains branch `feat/4144-variation-export-continuation`, base +`feat/4218-toolstrip-workspace`, and original child first parent +`f90836e342efc8be624739802375af2876d11e5f`. Exact parent +`6717e9e09d507dbc24bedb36177f1cdf0b4fd90b` merges normally as the second +parent. All variation visualization/export source merged without conflict: +selected scatter CSV, typed unavailable rows, accessible bounded raw tables, +linked trials, focused PyQt widgets, and React parity remain additive with the +parent workspace/toolstrip/playback/plot/navigation repairs. SPEC 1.14.12 is +the unique combined child entry. Staged review exposed a stale linked-trial +selection when a smaller rerun replaced the active result. Corrected PyQt +views clear result-local selection before repopulating and validate all public +setter indices; React clears on result identity change and shares only bounded +selections with linked views. Thirty-seven focused variation tests and the +complete Rate/shared-swing/golf-club matrix pass 1,528 tests with two explicit +optional build123d skips; all 546 React tests across 90 files pass; +TypeScript, ESLint, and production build are green. Exact-parent +Ruff/Ruff-format/Black, pinned MyPy 1.13 across four Python production modules, +Bandit, file-size, docs, minimum-test, assertion, detect-secrets, diff, real +CPython 3.10.20 compilation, and 30 compatibility regressions pass. There is +no Rust delta from the exact parent. Independent staged re-review found no +actionable findings, including the first replacement-render React boundary. +Protected CI/review remain release gates after an ordinary guarded push. + +## 2026-08-10 Variation child receives second repaired workspace parent + +PR `#4280` retains branch `feat/4144-variation-export-continuation` and base +`feat/4218-toolstrip-workspace`. It normally merges exact repaired parent head +`61b7f48b5aeb7d57246b4963da3df086e79cbe15` without feature-code conflict or +history rewrite. Variation/export behavior remains unchanged; fresh child CI, +review, and dependency order remain required. +Post-reconciliation evidence is 25 focused D-plane/impact tests plus docs, +changed-file-size, and whitespace gates. + +## 2026-08-10 Wind scalar child receives exact variation parent + +PR `#4281` keeps branch `feat/4199-wind-scalar-adapter`, base +`feat/4144-variation-export-continuation`, and original child first parent +`cf52529b1e68479321bb93b1be3d59c77f782008`. Exact parent +`8bcc49fc4e16e5e43be0b7f0f03c3017d5b79d0c` merges normally second. The +child's scalar-ensemble wire contract, provenance-preserving wind rows, +availability accounting, and plot adapter remain additive with the parent's +variation visualization/export and result-local selection repairs. Python and +TypeScript reject wind scenarios unless their per-trial provenance is exact, +shear/turbulence are zero, seed is zero, and gusts are empty. SPEC 1.14.13 is +the unique combined child entry. Twenty-seven focused Python tests, the +1,549-test complete Rate/shared-swing/golf-club matrix with two explicit +optional build123d skips, and all 556 React tests across 92 files pass. +TypeScript, ESLint, production build, Ruff/format, pinned MyPy, Bandit, size, +docs, minimum-test, assertions, detect-secrets, diff, and CPython 3.10 compile +gates pass. Independent re-review found no actionable findings after checking +the exact provenance derivation and all five deterministic-scenario +regressions; protected CI/review remain release gates. + +## 2026-08-10 Variation child timestamp propagation + +The #4280 continuation now carries exact parent +`05383d333b6fd87eaf5e37305476f50b505c2c2e` through a normal merge while +preserving its original base and complete variation export/accessibility +implementation. The inherit…4250 tokens truncated…7417e02926c3fbc7476002e82b690`; propagate it through the existing +stack without changing bases. + +A follow-up scan found the torque-profile controller importing +`datetime.UTC` directly. That parent-owned Python 3.11 boundary now uses +`shared.python.compatibility.UTC`; persisted timestamps and torque-profile +behavior are unchanged. Re-run the focused torque-profile UI tests and the +real Python 3.10 compatibility probe before propagation. + ## Do-Not List - Do not duplicate the Kelvin-Voigt contact-force law — #4130 requires diff --git a/src/rate_of_closure/README.md b/src/rate_of_closure/README.md index 9a07cfea19..7e287ba1df 100644 --- a/src/rate_of_closure/README.md +++ b/src/rate_of_closure/README.md @@ -66,13 +66,16 @@ outcome bias, r = −.14), not a bias you can simply calibrate away. # PyQt6 desktop (animated 3D clubhead, closure sweep) python src/rate_of_closure/launch_pyqt6.py -# Shareable web version (same math, pinned test-for-test to the Python) -cd src/rate_of_closure/web && npm install && npm run dev +# Shareable web version through the repository launcher +python src/rate_of_closure/launch_web.py + +# Equivalent direct Vite development workflow +cd src/rate_of_closure/web && npm ci && npm run dev ``` The web app builds to a static bundle (`npm run build`) that can be hosted -anywhere as a link, and carries the same Tauri scripts as the other web -tools for desktop packaging. +anywhere as a link. A Tauri desktop wrapper is not currently part of this +tool; the supported desktop release is the PyQt6/PyInstaller application. Both interfaces open with a generated driver head and its engineering CG target visible. The Simulation view runs immediately and supports manual, @@ -92,11 +95,13 @@ environment: python src/rate_of_closure/build_executable.py # one-folder app python src/rate_of_closure/build_executable.py --onefile # single file -# Web shell (Tauri) — from src/rate_of_closure/web -npm run tauri build +# Static web release — from src/rate_of_closure/web +npm ci +npm run build ``` -The PyInstaller output lands in `dist/RateOfClosureExplorer`. +The PyInstaller output lands in `dist/RateOfClosureExplorer`; the static web +bundle lands in `src/rate_of_closure/web/dist`. ## Structure diff --git a/src/rate_of_closure/application/capability_result_export.py b/src/rate_of_closure/application/capability_result_export.py new file mode 100644 index 0000000000..ee22adaf7e --- /dev/null +++ b/src/rate_of_closure/application/capability_result_export.py @@ -0,0 +1,125 @@ +"""Stable, versioned exports for ranked capability alternatives.""" + +from __future__ import annotations + +import csv +import io + +from rate_of_closure.variation.canonical_numeric_json import canonical_numeric_json +from rate_of_closure.variation.scalar_ensemble_contract import ScalarEnsembleDataset +from shared.python.contracts import require +from shared.python.swing_sim.flight.capability_result import ( + OptimizationAlternative, + OptimizationResult, +) + +CAPABILITY_RESULT_EXPORT_SCHEMA = "capability-result-export/v1" +_HEADERS = ( + "rank", + "club_id", + "parameters", + "score", + "mean_carry_m", + "expected_miss_m", + "dispersion_rms_m", + "target_hold_probability", + "cvar_miss_m", + "downside_carry_m", + "sample_count", + "successful_count", + "no_impact_count", + "failed_count", + "failure_fraction", + "confidence", + "extrapolated", + "pareto_efficient", + "limiting_constraints", +) + + +def _parameter_units(dataset: ScalarEnsembleDataset) -> dict[str, str]: + units = { + variable.key.removeprefix("nominal."): variable.unit + for variable in dataset.variables + if variable.key.startswith("nominal.") + } + require(bool(units), "capability result export requires parameter units") + return units + + +def _parameter_text(item: OptimizationAlternative, units: dict[str, str]) -> str: + missing = [ + parameter_id + for parameter_id, _value in item.parameters + if parameter_id not in units + ] + require(not missing, "result parameters require declared units", tuple(missing)) + return "; ".join( + f"{parameter_id}={value:.12g} {units[parameter_id]}" + for parameter_id, value in item.parameters + ) + + +def _alternative_row( + item: OptimizationAlternative, units: dict[str, str] +) -> tuple[object, ...]: + return ( + item.rank, + item.club_id, + _parameter_text(item, units), + item.score, + item.mean_carry_m, + item.expected_miss_m, + item.dispersion_rms_m, + item.target_hold_probability, + item.cvar_miss_m, + item.downside_carry_m, + item.sample_count, + item.successful_count, + item.no_impact_count, + item.failed_count, + item.failure_fraction, + item.confidence, + item.extrapolated, + item.pareto_efficient, + "; ".join(item.limiting_constraints), + ) + + +def capability_alternatives_csv( + result: OptimizationResult, dataset: ScalarEnsembleDataset +) -> str: + """Export every ranked diagnostic with unambiguous parameter units.""" + require(dataset.result_id == result.problem_id, "result and dataset IDs must match") + units = _parameter_units(dataset) + output = io.StringIO() + writer = csv.writer(output, lineterminator="\n") + writer.writerow(_HEADERS) + writer.writerows(_alternative_row(item, units) for item in result.alternatives) + return output.getvalue().removesuffix("\n") + + +def capability_result_export_json( + result: OptimizationResult, dataset: ScalarEnsembleDataset +) -> str: + """Export the strict result and its external unit declarations.""" + require(dataset.result_id == result.problem_id, "result and dataset IDs must match") + units = _parameter_units(dataset) + source = canonical_numeric_json( + { + "parameter_units": [ + {"parameter_id": parameter_id, "unit": unit} + for parameter_id, unit in units.items() + ], + "result": result.to_dict(), + "schema_version": CAPABILITY_RESULT_EXPORT_SCHEMA, + } + ) + return str(source) + + +__all__ = [ + "CAPABILITY_RESULT_EXPORT_SCHEMA", + "capability_alternatives_csv", + "capability_result_export_json", +] diff --git a/src/rate_of_closure/application/capability_workflow.py b/src/rate_of_closure/application/capability_workflow.py new file mode 100644 index 0000000000..08d3e2ad33 --- /dev/null +++ b/src/rate_of_closure/application/capability_workflow.py @@ -0,0 +1,373 @@ +"""Versioned authoring and persistence facade for capability optimization.""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from typing import Any + +from rate_of_closure.application.capability_workflow_wire import ( + validate_capability_workflow_wire, +) +from shared.python.swing_sim.flight.capability_contract import ( + CapabilityObjective, + CapabilityParameter, + ClubCapability, + OptimizationRequest, + PlayerCapabilityProfile, + TargetDefinition, +) +from shared.python.swing_sim.flight.capability_flight_evaluator import ( + CapabilityFlightEvaluatorConfig, + CapabilitySpinDefault, +) + +CAPABILITY_WORKFLOW_SCHEMA_VERSION = "capability-optimization-workflow/v1" +MAX_WORKFLOW_OBSERVATIONS = 100_000 +_PROVENANCE = "rate-of-closure/capability-workflow/user-authored/v1" +CAPABILITY_WORKFLOW_NUMERIC_BOUNDS = { + "ball_speed_mps": (1.0, 100.0), + "ball_speed_std_mps": (0.0, 30.0), + "launch_angle_deg": (-10.0, 45.0), + "launch_angle_std_deg": (0.0, 30.0), + "launch_direction_deg": (-30.0, 30.0), + "launch_direction_std_deg": (0.0, 30.0), + "total_spin_rpm": (0.0, 20_000.0), + "spin_axis_tilt_deg": (-90.0, 90.0), + "target_distance_m": (0.1, 1_000.0), + "target_lateral_m": (-500.0, 500.0), + "target_radius_m": (0.1, 500.0), + "max_time_s": (0.001, 120.0), + "trajectory_sample_interval_s": (0.001, 0.1), +} +CAPABILITY_WORKFLOW_INTEGER_BOUNDS = { + "candidate_budget": (1, MAX_WORKFLOW_OBSERVATIONS), + "ensemble_size": (1, MAX_WORKFLOW_OBSERVATIONS), + "alternatives_count": (1, MAX_WORKFLOW_OBSERVATIONS), + "seed": (0, 2**31 - 1), +} + + +@dataclass(frozen=True) +class CapabilityWorkflowInputs: + """Editable single-club workflow inputs expressed in canonical units.""" + + profile_id: str = "representative-driver-profile" + club_id: str = "driver" + ball_speed_mps: float = 67.0 + ball_speed_std_mps: float = 1.5 + launch_angle_deg: float = 12.5 + launch_angle_std_deg: float = 1.0 + launch_direction_deg: float = 0.0 + launch_direction_std_deg: float = 1.5 + total_spin_rpm: float = 2600.0 + spin_axis_tilt_deg: float = 0.0 + target_distance_m: float = 230.0 + target_lateral_m: float = 0.0 + target_radius_m: float = 12.0 + objective: CapabilityObjective = CapabilityObjective.MAXIMIZE_TARGET_HOLD + candidate_budget: int = 8 + ensemble_size: int = 12 + alternatives_count: int = 3 + seed: int = 4197 + max_time_s: float = 10.0 + trajectory_sample_interval_s: float = 0.01 + + +@dataclass(frozen=True) +class CapabilityWorkflowDocument: + """Strict persisted profile, request, and evaluator configuration.""" + + profile: PlayerCapabilityProfile + request: OptimizationRequest + evaluator_config: CapabilityFlightEvaluatorConfig + schema_version: str = CAPABILITY_WORKFLOW_SCHEMA_VERSION + + def __post_init__(self) -> None: + if self.schema_version != CAPABILITY_WORKFLOW_SCHEMA_VERSION: + raise ValueError(f"unsupported schema_version: {self.schema_version}") + requested = set(self.request.club_ids) + available = {club.club_id for club in self.profile.clubs} + if not requested <= available: + raise ValueError("request club_ids must exist in the profile") + spin_clubs = {item.club_id for item in self.evaluator_config.spin_defaults} + if spin_clubs != requested: + raise ValueError( + "spin default club_ids must exactly match request club_ids" + ) + attempts = self.request.candidate_budget * self.request.ensemble_size + if attempts > MAX_WORKFLOW_OBSERVATIONS: + raise ValueError( + f"workflow may not exceed {MAX_WORKFLOW_OBSERVATIONS} observations" + ) + _validate_authoring_inputs(capability_workflow_inputs(self)) + + def to_wire(self) -> dict[str, object]: + """Return the exact versioned persistence representation.""" + return { + "evaluator_config": _config_to_wire(self.evaluator_config), + "profile": self.profile.to_dict(), + "request": self.request.to_dict(), + "schema_version": self.schema_version, + } + + +def _finite(value: object, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be finite") + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError(f"{name} must be finite") + return parsed + + +def _text(value: object, name: str) -> str: + if not isinstance(value, str): + raise ValueError(f"{name} must be nonempty text") + parsed = value.strip() + if not parsed: + raise ValueError(f"{name} must be nonempty") + return parsed + + +def _validate_authoring_inputs(inputs: CapabilityWorkflowInputs) -> None: + for key, (minimum, maximum) in CAPABILITY_WORKFLOW_NUMERIC_BOUNDS.items(): + value = _finite(getattr(inputs, key), key) + if not minimum <= value <= maximum: + raise ValueError(f"{key} must lie within [{minimum:g}, {maximum:g}]") + for key, (minimum, maximum) in CAPABILITY_WORKFLOW_INTEGER_BOUNDS.items(): + value = getattr(inputs, key) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{key} must be an integer") + if not minimum <= value <= maximum: + raise ValueError(f"{key} must lie within [{minimum}, {maximum}]") + + +def _parameter( + parameter_id: str, unit: str, baseline: float, standard_deviation: float +) -> CapabilityParameter: + domains = { + "ball_speed": (1.0, 100.0, 20.0, 90.0), + "launch_angle": (-10.0, 45.0, 0.0, 35.0), + "launch_direction": (-30.0, 30.0, -15.0, 15.0), + } + lower, upper, evidence_lower, evidence_upper = domains[parameter_id] + return CapabilityParameter( + parameter_id, + unit, + lower, + upper, + evidence_lower, + evidence_upper, + _finite(baseline, f"{parameter_id} baseline"), + 0.0, + _finite(standard_deviation, f"{parameter_id} standard deviation"), + ) + + +def _profile(inputs: CapabilityWorkflowInputs) -> PlayerCapabilityProfile: + if inputs.ball_speed_mps <= 0.0: + raise ValueError("ball_speed_mps must be greater than zero") + parameters = ( + _parameter( + "ball_speed", "m/s", inputs.ball_speed_mps, inputs.ball_speed_std_mps + ), + _parameter( + "launch_angle", + "deg", + inputs.launch_angle_deg, + inputs.launch_angle_std_deg, + ), + _parameter( + "launch_direction", + "deg", + inputs.launch_direction_deg, + inputs.launch_direction_std_deg, + ), + ) + club_id = _text(inputs.club_id, "club_id") + club = ClubCapability( + club_id, + parameters, + "correlation", + ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + _PROVENANCE, + 0.8, + ) + return PlayerCapabilityProfile( + _text(inputs.profile_id, "profile_id"), (club,), _PROVENANCE, 0.8 + ) + + +def _request(inputs: CapabilityWorkflowInputs, club_id: str) -> OptimizationRequest: + target = TargetDefinition( + "green", + _finite(inputs.target_distance_m, "target_distance_m"), + _finite(inputs.target_lateral_m, "target_lateral_m"), + _finite(inputs.target_radius_m, "target_radius_m"), + _finite(inputs.target_radius_m, "target_radius_m"), + _finite(inputs.target_radius_m, "target_radius_m"), + ) + return OptimizationRequest( + f"capability-{_text(inputs.profile_id, 'profile_id')}", + CapabilityObjective(inputs.objective), + (club_id,), + target, + inputs.candidate_budget, + inputs.ensemble_size, + inputs.alternatives_count, + inputs.seed, + 0.9, + 0.8, + ) + + +def _config( + inputs: CapabilityWorkflowInputs, club_id: str +) -> CapabilityFlightEvaluatorConfig: + if not -90.0 <= inputs.spin_axis_tilt_deg <= 90.0: + raise ValueError("spin_axis_tilt_deg must lie within [-90, 90]") + spin = CapabilitySpinDefault( + club_id, + _finite(inputs.total_spin_rpm, "total_spin_rpm"), + _finite(inputs.spin_axis_tilt_deg, "spin_axis_tilt_deg"), + _PROVENANCE, + ) + return CapabilityFlightEvaluatorConfig( + _finite(inputs.max_time_s, "max_time_s"), + _finite(inputs.trajectory_sample_interval_s, "trajectory_sample_interval_s"), + (spin,), + ) + + +def build_capability_workflow( + inputs: CapabilityWorkflowInputs, +) -> CapabilityWorkflowDocument: + """Build a validated model-ready document from editable canonical inputs.""" + if not isinstance(inputs, CapabilityWorkflowInputs): + raise TypeError("inputs must be CapabilityWorkflowInputs") + profile = _profile(inputs) + club_id = profile.clubs[0].club_id + return CapabilityWorkflowDocument( + profile, _request(inputs, club_id), _config(inputs, club_id) + ) + + +def _config_to_wire(config: CapabilityFlightEvaluatorConfig) -> dict[str, object]: + return { + "max_time_s": config.max_time_s, + "spin_defaults": [ + { + "club_id": item.club_id, + "provenance": item.provenance, + "spin_axis_tilt_deg": item.spin_axis_tilt_deg, + "total_spin_rpm": item.total_spin_rpm, + } + for item in config.spin_defaults + ], + "trajectory_sample_interval_s": config.trajectory_sample_interval_s, + } + + +def _config_from_wire(payload: dict[str, Any]) -> CapabilityFlightEvaluatorConfig: + expected = {"max_time_s", "spin_defaults", "trajectory_sample_interval_s"} + if set(payload) != expected or not isinstance(payload["spin_defaults"], list): + raise ValueError("evaluator_config fields do not match v1 schema") + defaults = [] + for source in payload["spin_defaults"]: + if not isinstance(source, dict) or set(source) != { + "club_id", + "provenance", + "spin_axis_tilt_deg", + "total_spin_rpm", + }: + raise ValueError("spin default fields do not match v1 schema") + defaults.append( + CapabilitySpinDefault( + _text(source["club_id"], "spin default club_id"), + _finite(source["total_spin_rpm"], "total_spin_rpm"), + _finite(source["spin_axis_tilt_deg"], "spin_axis_tilt_deg"), + _text(source["provenance"], "spin default provenance"), + ) + ) + return CapabilityFlightEvaluatorConfig( + _finite(payload["max_time_s"], "max_time_s"), + _finite( + payload["trajectory_sample_interval_s"], + "trajectory_sample_interval_s", + ), + tuple(defaults), + ) + + +def capability_workflow_json(document: CapabilityWorkflowDocument) -> str: + """Serialize a workflow deterministically for local persistence.""" + if not isinstance(document, CapabilityWorkflowDocument): + raise TypeError("document must be CapabilityWorkflowDocument") + return json.dumps(document.to_wire(), sort_keys=True, separators=(",", ":")) + + +def capability_workflow_from_json(source: str) -> CapabilityWorkflowDocument: + """Parse one exact workflow document and reject unknown fields.""" + payload = validate_capability_workflow_wire(json.loads(source)) + return CapabilityWorkflowDocument( + PlayerCapabilityProfile.from_dict(payload["profile"]), + OptimizationRequest.from_dict(payload["request"]), + _config_from_wire(payload["evaluator_config"]), + _text(payload["schema_version"], "schema_version"), + ) + + +def capability_workflow_inputs( + document: CapabilityWorkflowDocument, +) -> CapabilityWorkflowInputs: + """Project an editable single-club document back into form inputs.""" + if ( + len(document.profile.clubs) != 1 + or len(document.evaluator_config.spin_defaults) != 1 + ): + raise ValueError( + "interactive workflow supports exactly one club and spin default" + ) + club = document.profile.clubs[0] + parameters = {item.parameter_id: item for item in club.parameters} + if set(parameters) != {"ball_speed", "launch_angle", "launch_direction"}: + raise ValueError("interactive workflow requires the three launch parameters") + spin = document.evaluator_config.spin_defaults[0] + target = document.request.target + return CapabilityWorkflowInputs( + profile_id=document.profile.profile_id, + club_id=club.club_id, + ball_speed_mps=parameters["ball_speed"].baseline, + ball_speed_std_mps=parameters["ball_speed"].standard_deviation, + launch_angle_deg=parameters["launch_angle"].baseline, + launch_angle_std_deg=parameters["launch_angle"].standard_deviation, + launch_direction_deg=parameters["launch_direction"].baseline, + launch_direction_std_deg=parameters["launch_direction"].standard_deviation, + total_spin_rpm=spin.total_spin_rpm, + spin_axis_tilt_deg=spin.spin_axis_tilt_deg, + target_distance_m=target.distance_m, + target_lateral_m=target.lateral_m, + target_radius_m=target.radius_m, + objective=document.request.objective, + candidate_budget=document.request.candidate_budget, + ensemble_size=document.request.ensemble_size, + alternatives_count=document.request.alternatives_count, + seed=document.request.seed, + max_time_s=document.evaluator_config.max_time_s, + trajectory_sample_interval_s=document.evaluator_config.trajectory_sample_interval_s, + ) + + +__all__ = [ + "CAPABILITY_WORKFLOW_SCHEMA_VERSION", + "CAPABILITY_WORKFLOW_INTEGER_BOUNDS", + "CAPABILITY_WORKFLOW_NUMERIC_BOUNDS", + "CapabilityWorkflowDocument", + "CapabilityWorkflowInputs", + "build_capability_workflow", + "capability_workflow_from_json", + "capability_workflow_inputs", + "capability_workflow_json", +] diff --git a/src/rate_of_closure/application/capability_workflow_wire.py b/src/rate_of_closure/application/capability_workflow_wire.py new file mode 100644 index 0000000000..308b36c9cd --- /dev/null +++ b/src/rate_of_closure/application/capability_workflow_wire.py @@ -0,0 +1,160 @@ +"""Strict JSON-wire type validation for capability workflow documents.""" + +from __future__ import annotations + +import math +from typing import Any + +_PARAMETER_FIELDS = { + "baseline", + "bias", + "evidence_lower_bound", + "evidence_upper_bound", + "lower_bound", + "parameter_id", + "standard_deviation", + "unit", + "upper_bound", +} +_PARAMETER_NUMBERS = _PARAMETER_FIELDS - {"parameter_id", "unit"} +_TARGET_FIELDS = { + "band_half_length_m", + "distance_m", + "half_width_m", + "kind", + "lateral_m", + "radius_m", +} +_REQUEST_FIELDS = { + "alternatives_count", + "candidate_budget", + "club_ids", + "cvar_alpha", + "ensemble_size", + "minimum_success_fraction", + "objective", + "problem_id", + "schema_version", + "seed", + "target", +} + + +def _record(value: object, fields: set[str], name: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise ValueError(f"{name} fields do not match v1 schema") + return value + + +def _array(value: object, name: str) -> list[Any]: + if not isinstance(value, list): + raise ValueError(f"{name} must be an array") + return value + + +def _text(value: object, name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} must be nonempty text") + return value + + +def _number(value: object, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a finite number") + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError(f"{name} must be a finite number") + return parsed + + +def _integer(value: object, name: str) -> int: + parsed = _number(value, name) + if not parsed.is_integer(): + raise ValueError(f"{name} must be an integer") + return int(parsed) + + +def _validate_parameter(value: object) -> None: + parameter = _record(value, _PARAMETER_FIELDS, "capability parameter") + _text(parameter["parameter_id"], "parameter_id") + _text(parameter["unit"], "parameter unit") + for field in _PARAMETER_NUMBERS: + _number(parameter[field], field) + + +def _validate_club(value: object) -> None: + fields = { + "club_id", + "confidence", + "matrix", + "matrix_kind", + "parameters", + "provenance", + } + club = _record(value, fields, "club capability") + for field in ("club_id", "matrix_kind", "provenance"): + _text(club[field], field) + _number(club["confidence"], "club confidence") + for parameter in _array(club["parameters"], "club parameters"): + _validate_parameter(parameter) + for row in _array(club["matrix"], "capability matrix"): + for entry in _array(row, "capability matrix row"): + _number(entry, "capability matrix entry") + + +def _validate_profile(value: object) -> None: + fields = {"clubs", "confidence", "profile_id", "provenance", "schema_version"} + profile = _record(value, fields, "player capability profile") + for field in ("profile_id", "provenance", "schema_version"): + _text(profile[field], field) + _number(profile["confidence"], "profile confidence") + for club in _array(profile["clubs"], "profile clubs"): + _validate_club(club) + + +def _validate_target(value: object) -> None: + target = _record(value, _TARGET_FIELDS, "target definition") + _text(target["kind"], "target kind") + for field in _TARGET_FIELDS - {"kind"}: + _number(target[field], field) + + +def _validate_request(value: object) -> None: + request = _record(value, _REQUEST_FIELDS, "optimization request") + for field in ("problem_id", "objective", "schema_version"): + _text(request[field], field) + for club_id in _array(request["club_ids"], "request club_ids"): + _text(club_id, "request club_id") + for field in ("candidate_budget", "ensemble_size", "alternatives_count", "seed"): + request[field] = _integer(request[field], field) + for field in ("cvar_alpha", "minimum_success_fraction"): + _number(request[field], field) + _validate_target(request["target"]) + + +def _validate_config(value: object) -> None: + fields = {"max_time_s", "spin_defaults", "trajectory_sample_interval_s"} + config = _record(value, fields, "evaluator_config") + _number(config["max_time_s"], "max_time_s") + _number(config["trajectory_sample_interval_s"], "trajectory_sample_interval_s") + spin_fields = {"club_id", "provenance", "spin_axis_tilt_deg", "total_spin_rpm"} + for value in _array(config["spin_defaults"], "spin_defaults"): + spin = _record(value, spin_fields, "spin default") + _text(spin["club_id"], "spin default club_id") + _text(spin["provenance"], "spin default provenance") + _number(spin["total_spin_rpm"], "total_spin_rpm") + _number(spin["spin_axis_tilt_deg"], "spin_axis_tilt_deg") + + +def validate_capability_workflow_wire(payload: object) -> dict[str, Any]: + """Validate exact structure and primitive JSON types before model parsing.""" + fields = {"evaluator_config", "profile", "request", "schema_version"} + document = _record(payload, fields, "capability workflow") + _text(document["schema_version"], "schema_version") + _validate_profile(document["profile"]) + _validate_request(document["request"]) + _validate_config(document["evaluator_config"]) + return document + + +__all__ = ["validate_capability_workflow_wire"] diff --git a/src/rate_of_closure/build_executable.py b/src/rate_of_closure/build_executable.py index 8fb70f4cb8..6a2e1d472d 100644 --- a/src/rate_of_closure/build_executable.py +++ b/src/rate_of_closure/build_executable.py @@ -9,15 +9,15 @@ The result lands in ``dist/RateOfClosureExplorer`` (one-folder mode: faster startup and easier antivirus review than one-file). Requires ``pip install pyinstaller`` in the environment that already runs the -tool. The shareable web equivalent is ``npm run tauri build`` inside -``src/rate_of_closure/web``. +tool. The supported shareable web release is the static Vite bundle built +with ``npm ci`` and ``npm run build`` inside ``src/rate_of_closure/web``. """ from __future__ import annotations import importlib.util import shutil -import subprocess # noqa: S404 - fixed argument list, no user input +import subprocess # nosec B404 # Fixed argument list; no user input. import sys from pathlib import Path @@ -56,7 +56,7 @@ def build(one_file: bool = False) -> Path: str(dist), str(_HERE / "launch_pyqt6.py"), ] - completed = subprocess.run(command, check=False) # noqa: S603 + completed = subprocess.run(command, check=False) # nosec B603 # Fixed argv. if completed.returncode != 0: raise RuntimeError(f"PyInstaller failed with code {completed.returncode}") diff --git a/src/rate_of_closure/helptext.py b/src/rate_of_closure/helptext.py index 0132ca2482..40b0ec123d 100644 --- a/src/rate_of_closure/helptext.py +++ b/src/rate_of_closure/helptext.py @@ -30,6 +30,31 @@ def _entry(title: str, html: str) -> HelpEntry: #: Tab key -> help entry. Keys match the main-window tab order. HELP_TEXTS: dict[str, HelpEntry] = { + "capability_optimization": _entry( + "Shot Optimizer", + "

What this tab does

" + "

Builds an auditable player-and-club capability profile and searches " + "for robust launch conditions with the full Waterloo/Penner flight model. " + "Ball speed, launch angle, launch direction, their measured variability, " + "fixed spin, target geometry, objective, budgets, and seed are all visible.

" + "

Workflow

  1. Enter the three launch centers and standard " + "deviations, then review the explicit total-spin and spin-axis settings. " + "Positive launch direction and spin tilt mean right/fade in the target " + "frame.
  2. Set the landing target, objective, candidate budget, trials " + "per candidate, " + "retained alternatives, and deterministic seed.
  3. Run the optimization " + "in the background; progress and cancellation occur at sample boundaries.
  4. " + "
  5. Compare ranked alternatives, choose any parameter, flight metric, or " + "target diagnostic for the managed scatter axes, and page through raw rows. " + "Save/load the strict workflow or export every observation as " + "CSV/JSON.
" + "

Tips and limits

The current evaluator models still-air carry " + "to the first " + "ground crossing. Wind, bounce, roll, and total distance are not silently " + "included. Python uses adaptive RK45 while the web uses fixed-step RK4; both " + "record their integrator and use tolerance-based parity rather than claiming " + "bit-identical rankings near a tie.

", + ), "clubhead": _entry( "3D Clubhead", "

What this tab does

" diff --git a/src/rate_of_closure/launch_web.py b/src/rate_of_closure/launch_web.py index 7dd11af692..c299819032 100644 --- a/src/rate_of_closure/launch_web.py +++ b/src/rate_of_closure/launch_web.py @@ -3,21 +3,34 @@ from __future__ import annotations +import importlib.util import sys from pathlib import Path +from typing import Protocol, cast -# Add repository root to sys.path to allow importing _bootstrap -_repo_root = Path(__file__).resolve().parents[2] -if str(_repo_root) not in sys.path: - sys.path.insert(0, str(_repo_root)) -from _bootstrap import bootstrap # noqa: E402 +class _BootstrapModule(Protocol): + """Minimum repository-bootstrap contract needed by this launcher.""" -_REPO_ROOT = bootstrap(__file__) + def bootstrap(self, caller_file: str) -> Path: + """Install repository-local import paths and return the root.""" + + +def _load_bootstrap() -> _BootstrapModule: + """Load the root bootstrap without mutating import paths in this script.""" + bootstrap_path = Path(__file__).resolve().parents[2] / "_bootstrap.py" + spec = importlib.util.spec_from_file_location("_tools_bootstrap", bootstrap_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot load bootstrap module from {bootstrap_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return cast(_BootstrapModule, module) -from shared.python.gui_launcher import launch_web_from_gui_info # noqa: E402 -from .gui_registration import GUI_INFO # noqa: E402 +_REPO_ROOT = _load_bootstrap().bootstrap(__file__) + +from rate_of_closure.gui_registration import GUI_INFO # noqa: E402 +from shared.python.gui_launcher import launch_web_from_gui_info # noqa: E402 if __name__ == "__main__": sys.exit(launch_web_from_gui_info(GUI_INFO, __file__)) diff --git a/src/rate_of_closure/simulation/kinetics.py b/src/rate_of_closure/simulation/kinetics.py index 622c537510..a4401bd0ca 100644 --- a/src/rate_of_closure/simulation/kinetics.py +++ b/src/rate_of_closure/simulation/kinetics.py @@ -218,7 +218,7 @@ def kinetics_for_run(run: SimulationRun) -> KineticsSeries | None: """Return cached :func:`compute_kinetics`, keyed by run identity.""" require(isinstance(run, SimulationRun), "run must be a SimulationRun", run) if _CACHE["id"] == id(run): - return _CACHE["series"] # type: ignore[return-value] + return cast(KineticsSeries | None, _CACHE["series"]) series = compute_kinetics(run) _CACHE["id"] = id(run) _CACHE["series"] = series diff --git a/src/rate_of_closure/ui/pyqt6/capability_controls.py b/src/rate_of_closure/ui/pyqt6/capability_controls.py new file mode 100644 index 0000000000..f894d0e866 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/capability_controls.py @@ -0,0 +1,186 @@ +"""Editable canonical inputs for the PyQt capability workflow.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QLineEdit, + QSpinBox, +) + +from rate_of_closure.application.capability_workflow import ( + CAPABILITY_WORKFLOW_INTEGER_BOUNDS, + CAPABILITY_WORKFLOW_NUMERIC_BOUNDS, + CapabilityWorkflowInputs, +) +from shared.python.swing_sim.flight.capability_contract import CapabilityObjective + + +@dataclass(frozen=True) +class _NumericSpec: + key: str + label: str + unit: str + minimum: float + maximum: float + step: float + + +@dataclass(frozen=True) +class _IntegerSpec: + key: str + label: str + minimum: int + maximum: int + + +def _numeric_spec(key: str, label: str, unit: str, step: float) -> _NumericSpec: + minimum, maximum = CAPABILITY_WORKFLOW_NUMERIC_BOUNDS[key] + return _NumericSpec(key, label, unit, minimum, maximum, step) + + +def _integer_spec(key: str, label: str) -> _IntegerSpec: + minimum, maximum = CAPABILITY_WORKFLOW_INTEGER_BOUNDS[key] + return _IntegerSpec(key, label, minimum, maximum) + + +_NUMERIC_SPECS = ( + _numeric_spec("ball_speed_mps", "Ball speed center", "m/s", 0.1), + _numeric_spec("ball_speed_std_mps", "Ball speed std. dev.", "m/s", 0.1), + _numeric_spec("launch_angle_deg", "Launch angle center", "deg", 0.1), + _numeric_spec("launch_angle_std_deg", "Launch angle std. dev.", "deg", 0.1), + _numeric_spec("launch_direction_deg", "Launch direction (+ right)", "deg", 0.1), + _numeric_spec("launch_direction_std_deg", "Direction std. dev.", "deg", 0.1), + _numeric_spec("total_spin_rpm", "Fixed total spin", "rpm", 10), + _numeric_spec("spin_axis_tilt_deg", "Spin tilt (+ fade/right)", "deg", 0.1), + _numeric_spec("target_distance_m", "Target distance", "m", 1), + _numeric_spec("target_lateral_m", "Target lateral (+ right)", "m", 1), + _numeric_spec("target_radius_m", "Target radius", "m", 1), + _numeric_spec("max_time_s", "Maximum flight time", "s", 0.1), + _numeric_spec( + "trajectory_sample_interval_s", "Trajectory sample interval", "s", 0.001 + ), +) +_INTEGER_SPECS = ( + _integer_spec("candidate_budget", "Candidate budget"), + _integer_spec("ensemble_size", "Trials per candidate"), + _integer_spec("alternatives_count", "Alternatives retained"), + _integer_spec("seed", "Deterministic seed"), +) + + +class CapabilityControls(QGroupBox): + """Form that exposes every factor consumed by the interactive workflow.""" + + changed = pyqtSignal() # noqa: N815 + + def __init__(self) -> None: + super().__init__("Player, Club, Target, and Search Basis") + self._numeric: dict[str, QDoubleSpinBox] = {} + self._integers: dict[str, QSpinBox] = {} + self._build_ui() + self.set_inputs(CapabilityWorkflowInputs()) + + def _build_ui(self) -> None: + layout = QFormLayout(self) + self.profile_id = QLineEdit() + self.club_id = QLineEdit() + self.objective = QComboBox() + self.profile_id.setToolTip( + "Stable identifier saved with this user-authored capability profile." + ) + self.club_id.setToolTip( + "Club key optimized by this workflow; it must match the profile club." + ) + self.objective.setToolTip( + "Ranking criterion applied to the complete retained shot ensemble." + ) + for objective in CapabilityObjective: + self.objective.addItem(objective.value.replace("_", " ").title(), objective) + layout.addRow("Profile ID", self.profile_id) + layout.addRow("Club ID", self.club_id) + layout.addRow("Objective", self.objective) + for numeric_spec in _NUMERIC_SPECS: + self._add_numeric(layout, numeric_spec) + for integer_spec in _INTEGER_SPECS: + self._add_integer(layout, integer_spec) + self.profile_id.textChanged.connect(self.changed) + self.club_id.textChanged.connect(self.changed) + self.objective.currentIndexChanged.connect(self.changed) + + def _add_numeric(self, layout: QFormLayout, spec: _NumericSpec) -> None: + spin = QDoubleSpinBox() + spin.setRange(spec.minimum, spec.maximum) + spin.setDecimals(3) + spin.setSingleStep(spec.step) + spin.setSuffix(f" {spec.unit}") + spin.setAccessibleName(spec.label) + spin.setToolTip( + f"{spec.label} in {spec.unit}; allowed range {spec.minimum:g} to " + f"{spec.maximum:g}. Positive lateral and direction values are right " + "of target." + ) + spin.valueChanged.connect(self.changed) + layout.addRow(spec.label, spin) + self._numeric[spec.key] = spin + + def _add_integer(self, layout: QFormLayout, spec: _IntegerSpec) -> None: + spin = QSpinBox() + spin.setRange(spec.minimum, spec.maximum) + spin.setAccessibleName(spec.label) + spin.setToolTip( + f"{spec.label}; allowed range {spec.minimum} to {spec.maximum}. " + "Candidate budget times trials is the total model-evaluation count." + ) + spin.valueChanged.connect(self.changed) + layout.addRow(spec.label, spin) + self._integers[spec.key] = spin + + def inputs(self) -> CapabilityWorkflowInputs: + """Return an immutable canonical snapshot of every visible control.""" + numeric = {key: float(spin.value()) for key, spin in self._numeric.items()} + integer = {key: int(spin.value()) for key, spin in self._integers.items()} + return CapabilityWorkflowInputs( + profile_id=self.profile_id.text(), + club_id=self.club_id.text(), + objective=CapabilityObjective(self.objective.currentData()), + ball_speed_mps=numeric["ball_speed_mps"], + ball_speed_std_mps=numeric["ball_speed_std_mps"], + launch_angle_deg=numeric["launch_angle_deg"], + launch_angle_std_deg=numeric["launch_angle_std_deg"], + launch_direction_deg=numeric["launch_direction_deg"], + launch_direction_std_deg=numeric["launch_direction_std_deg"], + total_spin_rpm=numeric["total_spin_rpm"], + spin_axis_tilt_deg=numeric["spin_axis_tilt_deg"], + target_distance_m=numeric["target_distance_m"], + target_lateral_m=numeric["target_lateral_m"], + target_radius_m=numeric["target_radius_m"], + max_time_s=numeric["max_time_s"], + trajectory_sample_interval_s=numeric["trajectory_sample_interval_s"], + candidate_budget=integer["candidate_budget"], + ensemble_size=integer["ensemble_size"], + alternatives_count=integer["alternatives_count"], + seed=integer["seed"], + ) + + def set_inputs(self, inputs: CapabilityWorkflowInputs) -> None: + """Replace the complete form while emitting only one invalidation.""" + self.blockSignals(True) + self.profile_id.setText(inputs.profile_id) + self.club_id.setText(inputs.club_id) + self.objective.setCurrentIndex(self.objective.findData(inputs.objective)) + for key, numeric_spin in self._numeric.items(): + numeric_spin.setValue(float(getattr(inputs, key))) + for key, integer_spin in self._integers.items(): + integer_spin.setValue(int(getattr(inputs, key))) + self.blockSignals(False) + self.changed.emit() + + +__all__ = ["CapabilityControls"] diff --git a/src/rate_of_closure/ui/pyqt6/capability_results.py b/src/rate_of_closure/ui/pyqt6/capability_results.py new file mode 100644 index 0000000000..fe011d6769 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/capability_results.py @@ -0,0 +1,274 @@ +"""Ranked alternatives and observation diagnostics for capability runs.""" + +from __future__ import annotations + +from functools import partial + +from matplotlib.figure import Figure +from PyQt6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from rate_of_closure.ui.pyqt6.plot_canvas_pane import PlotCanvasPane +from rate_of_closure.variation.scalar_ensemble_contract import ( + ScalarEnsembleDataset, + ScalarScatterData, +) +from rate_of_closure.variation.scalar_ensemble_io import non_complete_reason_summary +from shared.python.swing_sim.flight.capability_result import ( + OptimizationAlternative, + OptimizationResult, +) + +_PAGE_SIZE = 25 +_COLORS = {"complete": "#2f8bd6", "no_impact": "#eb9f3c", "failed": "#d35f5f"} + + +def _parameter_units(dataset: ScalarEnsembleDataset) -> dict[str, str]: + return { + variable.key.removeprefix("nominal."): variable.unit + for variable in dataset.variables + if variable.key.startswith("nominal.") + } + + +def _alternative_values( + item: OptimizationAlternative, units: dict[str, str] +) -> tuple[str, ...]: + recommendation = " · ".join( + f"{key}={value:.5g} {units[key]}" for key, value in item.parameters + ) + outcomes = ( + f"{item.successful_count}/{item.sample_count} complete · " + f"{item.no_impact_count} no impact · {item.failed_count} failed" + ) + evidence = ( + f"{100 * item.confidence:.1f}% · " + f"{'extrapolated' if item.extrapolated else 'within envelope'} · " + f"{', '.join(item.limiting_constraints) or 'no limits'}" + ) + return ( + f"{item.rank}. {item.club_id}", + recommendation, + f"{item.score:.5g}", + f"{item.mean_carry_m:.2f} m", + f"{item.expected_miss_m:.2f} m", + f"{item.dispersion_rms_m:.2f} m", + f"{100 * item.target_hold_probability:.1f}%", + f"{item.cvar_miss_m:.2f} m", + f"{item.downside_carry_m:.2f} m", + outcomes, + f"{100 * item.failure_fraction:.1f}%", + evidence, + "efficient" if item.pareto_efficient else "dominated", + ) + + +def _draw_scatter(figure: Figure, scatter: ScalarScatterData) -> None: + figure.clear() + axes = figure.add_subplot(111) + for cohort, color in _COLORS.items(): + points = tuple(point for point in scatter.points if point.cohort == cohort) + if points: + axes.scatter( + [point.x for point in points], + [point.y for point in points], + s=18, + alpha=0.7, + label=cohort.replace("_", " ").title(), + color=color, + ) + axes.set_xlabel(f"{scatter.x_variable.label} [{scatter.x_variable.unit}]") + axes.set_ylabel(f"{scatter.y_variable.label} [{scatter.y_variable.unit}]") + axes.set_title("Capability Observation Scatter") + axes.grid(alpha=0.25) + if axes.collections: + axes.legend(loc="best", fontsize=8) + + +class CapabilityResults(QWidget): + """Bounded presentation of ranked alternatives and every raw observation.""" + + def __init__(self) -> None: + super().__init__() + self._dataset: ScalarEnsembleDataset | None = None + self._page = 0 + self._build_ui() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + self.summary = QLabel("No current optimization result.") + self.summary.setWordWrap(True) + layout.addWidget(self.summary) + self.alternatives = QTableWidget(0, 13) + self.alternatives.setHorizontalHeaderLabels( + [ + "Rank / Club", + "Recommendation", + "Score", + "Carry", + "Mean miss", + "Dispersion", + "Target hold", + "Miss CVaR", + "Downside carry", + "Outcomes", + "Failure rate", + "Evidence", + "Pareto", + ] + ) + layout.addWidget(self.alternatives) + layout.addLayout(self._build_axis_controls()) + self.availability = QLabel("No current ensemble result.") + layout.addWidget(self.availability) + self.plot = PlotCanvasPane("Capability Observation Scatter") + layout.addWidget(self.plot, stretch=1) + self.raw_rows = QTableWidget(0, 4) + self.raw_rows.setHorizontalHeaderLabels( + ["Row", "Cohort", "Series", "Available scalars"] + ) + layout.addWidget(self.raw_rows) + layout.addLayout(self._build_paging_controls()) + self.x_axis.currentIndexChanged.connect(self._redraw) + self.y_axis.currentIndexChanged.connect(self._redraw) + + def _build_axis_controls(self) -> QHBoxLayout: + axes = QHBoxLayout() + self.x_axis = QComboBox() + self.y_axis = QComboBox() + self.x_axis.setAccessibleName("Capability scatter horizontal axis") + self.y_axis.setAccessibleName("Capability scatter vertical axis") + self.x_axis.setToolTip( + "Choose any retained scalar for the scatter plot horizontal axis." + ) + self.y_axis.setToolTip( + "Choose any retained scalar for the scatter plot vertical axis." + ) + axes.addWidget(QLabel("Horizontal axis")) + axes.addWidget(self.x_axis) + axes.addWidget(QLabel("Vertical axis")) + axes.addWidget(self.y_axis) + return axes + + def _build_paging_controls(self) -> QHBoxLayout: + paging = QHBoxLayout() + self.previous = QPushButton("Previous rows") + self.next = QPushButton("Next rows") + self.previous.setToolTip("Show the previous 25 retained observation rows.") + self.next.setToolTip("Show the next 25 retained observation rows.") + self.page_label = QLabel("Page 0 of 0") + paging.addWidget(self.previous) + paging.addWidget(self.page_label) + paging.addWidget(self.next) + self.previous.clicked.connect(lambda: self._change_page(-1)) + self.next.clicked.connect(lambda: self._change_page(1)) + return paging + + def set_output( + self, result: OptimizationResult, dataset: ScalarEnsembleDataset + ) -> None: + """Replace the complete immutable result and reset derived views.""" + self._dataset = dataset + self._page = 0 + self.summary.setText( + f"Attempted {result.evaluations_attempted}; complete " + f"{result.evaluations_completed}; failed {result.failed_count}; " + f"no impact {result.no_impact_count}. Status: {result.status}." + f"{non_complete_reason_summary(dataset)}" + ) + self._populate_alternatives(result, dataset) + self._populate_axes(dataset) + self._populate_page() + + def _populate_alternatives( + self, result: OptimizationResult, dataset: ScalarEnsembleDataset + ) -> None: + self.alternatives.setRowCount(len(result.alternatives)) + units = _parameter_units(dataset) + for row, item in enumerate(result.alternatives): + for column, value in enumerate(_alternative_values(item, units)): + self.alternatives.setItem(row, column, QTableWidgetItem(value)) + self.alternatives.resizeColumnsToContents() + + def _populate_axes(self, dataset: ScalarEnsembleDataset) -> None: + duplicates = { + (variable.label, variable.unit) + for variable in dataset.variables + if sum( + item.label == variable.label and item.unit == variable.unit + for item in dataset.variables + ) + > 1 + } + for combo in (self.x_axis, self.y_axis): + combo.blockSignals(True) + combo.clear() + for variable in dataset.variables: + prefix = ( + f"{variable.stage_key.title()} · " + if (variable.label, variable.unit) in duplicates + else "" + ) + combo.addItem( + f"{prefix}{variable.label} [{variable.unit}]", variable.key + ) + combo.blockSignals(False) + self.x_axis.setCurrentIndex( + max(0, self.x_axis.findData("perturbed.ball_speed")) + ) + self.y_axis.setCurrentIndex( + max(0, self.y_axis.findData("metric.carry_distance")) + ) + self._redraw() + + def _redraw(self, *_args: object) -> None: + if self._dataset is None or self.x_axis.currentIndex() < 0: + return + scatter = self._dataset.scatter( + str(self.x_axis.currentData()), str(self.y_axis.currentData()) + ) + self.plot.render_custom(partial(_draw_scatter, scatter=scatter)) + count = scatter.availability.overall + self.availability.setText( + f"Paired finite {count.paired_finite}/{count.total_rows}; " + f"unavailable {count.unavailable}." + ) + + def _change_page(self, offset: int) -> None: + if self._dataset is None: + return + pages = max(1, (len(self._dataset.rows) + _PAGE_SIZE - 1) // _PAGE_SIZE) + self._page = max(0, min(pages - 1, self._page + offset)) + self._populate_page() + + def _populate_page(self) -> None: + if self._dataset is None: + return + rows = self._dataset.rows[ + self._page * _PAGE_SIZE : (self._page + 1) * _PAGE_SIZE + ] + pages = max(1, (len(self._dataset.rows) + _PAGE_SIZE - 1) // _PAGE_SIZE) + self.raw_rows.setRowCount(len(rows)) + for index, row in enumerate(rows): + values = ( + row.row_id, + row.cohort, + row.series_id or "", + str(sum(value is not None for value in row.values.values())), + ) + for column, value in enumerate(values): + self.raw_rows.setItem(index, column, QTableWidgetItem(value)) + self.page_label.setText(f"Page {self._page + 1} of {pages}") + self.previous.setEnabled(self._page > 0) + self.next.setEnabled(self._page + 1 < pages) + + +__all__ = ["CapabilityResults"] diff --git a/src/rate_of_closure/ui/pyqt6/capability_tab.py b/src/rate_of_closure/ui/pyqt6/capability_tab.py new file mode 100644 index 0000000000..6a035ec765 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/capability_tab.py @@ -0,0 +1,291 @@ +"""User-facing PyQt capability optimization workflow.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from PyQt6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QScrollArea, + QSplitter, + QVBoxLayout, + QWidget, +) + +from rate_of_closure.application.capability_result_export import ( + capability_alternatives_csv, + capability_result_export_json, +) +from rate_of_closure.application.capability_workflow import ( + CapabilityWorkflowDocument, + build_capability_workflow, + capability_workflow_from_json, + capability_workflow_inputs, + capability_workflow_json, +) +from rate_of_closure.ui.pyqt6.capability_controls import CapabilityControls +from rate_of_closure.ui.pyqt6.capability_results import CapabilityResults +from rate_of_closure.ui.pyqt6.capability_worker import CapabilityOptimizationWorker +from rate_of_closure.variation.capability_observation_adapter import ( + capability_observation_ensemble_json, +) +from rate_of_closure.variation.scalar_ensemble_contract import ScalarEnsembleDataset +from rate_of_closure.variation.scalar_ensemble_io import scalar_ensemble_csv +from shared.python.swing_sim.flight.capability_result import OptimizationResult + +logger = logging.getLogger(__name__) +_SHUTDOWN_WAIT_MS = 10_000 +_ACTION_TOOLTIPS = ( + "Validate the captured basis and run optimization in a background thread.", + "Request cooperative cancellation; partial results are never published.", + "Save the complete versioned profile, target, model, and search basis.", + "Load and strictly validate a saved capability workflow.", + "Export every retained observation as spreadsheet-safe lossless CSV.", + "Export every retained observation using the stable versioned JSON schema.", + "Export every ranked alternative and engineering diagnostic with units.", + "Export the strict ranked result and parameter-unit declarations.", +) + + +class CapabilityOptimizationTab(QWidget): + """Author, run, inspect, persist, and export one robust shot search.""" + + def __init__(self) -> None: + super().__init__() + self._worker: CapabilityOptimizationWorker | None = None + self._document: CapabilityWorkflowDocument | None = None + self._dataset: ScalarEnsembleDataset | None = None + self._result: OptimizationResult | None = None + self._build_ui() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + note = QLabel( + "Still-air carry to first ground crossing. Fixed spin is explicit and " + "sourced; wind, bounce, roll, and total distance are not included." + ) + note.setWordWrap(True) + layout.addWidget(note) + splitter = QSplitter() + self.controls = CapabilityControls() + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self.controls) + scroll.setMinimumWidth(340) + self.results = CapabilityResults() + self.results.setMinimumWidth(520) + self.results.setVisible(False) + splitter.addWidget(scroll) + splitter.addWidget(self.results) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([360, 640]) + layout.addWidget(splitter, stretch=1) + layout.addLayout(self._build_actions()) + self.progress = QProgressBar() + self.progress.setRange(0, 1) + self.status = QLabel("Ready") + layout.addWidget(self.progress) + layout.addWidget(self.status) + self.cancel_button.setEnabled(False) + self.csv_button.setEnabled(False) + self.json_button.setEnabled(False) + self.result_csv_button.setEnabled(False) + self.result_json_button.setEnabled(False) + self._connect_signals() + + def _build_actions(self) -> QHBoxLayout: + actions = QHBoxLayout() + self.run_button = QPushButton("Run Optimization") + self.cancel_button = QPushButton("Cancel") + self.save_button = QPushButton("Save Workflow") + self.load_button = QPushButton("Load Workflow") + self.csv_button = QPushButton("Export Raw CSV") + self.json_button = QPushButton("Export Stable JSON") + self.result_csv_button = QPushButton("Export Results CSV") + self.result_json_button = QPushButton("Export Results JSON") + buttons = ( + self.run_button, + self.cancel_button, + self.save_button, + self.load_button, + self.csv_button, + self.json_button, + self.result_csv_button, + self.result_json_button, + ) + for button, tooltip in zip(buttons, _ACTION_TOOLTIPS, strict=True): + actions.addWidget(button) + button.setToolTip(tooltip) + return actions + + def _connect_signals(self) -> None: + self.controls.changed.connect(self._invalidate) + self.run_button.clicked.connect(self.run) + self.cancel_button.clicked.connect(self.cancel) + self.save_button.clicked.connect(self._save_workflow) + self.load_button.clicked.connect(self._load_workflow) + self.csv_button.clicked.connect(self._export_csv) + self.json_button.clicked.connect(self._export_json) + self.result_csv_button.clicked.connect(self._export_result_csv) + self.result_json_button.clicked.connect(self._export_result_json) + + def _invalidate(self) -> None: + if self._worker is not None and self._worker.isRunning(): + self._worker.cancel() + self._document = None + self._dataset = None + self._result = None + self.results.setVisible(False) + self.csv_button.setEnabled(False) + self.json_button.setEnabled(False) + self.result_csv_button.setEnabled(False) + self.result_json_button.setEnabled(False) + self.status.setText("Inputs changed — run again.") + + def run(self) -> None: + """Validate the full basis before starting a background calculation.""" + self.stop() + try: + document = build_capability_workflow(self.controls.inputs()) + except (TypeError, ValueError) as exc: + self.status.setText(f"Invalid inputs: {exc}") + return + self._document = document + total = document.request.candidate_budget * document.request.ensemble_size + self.progress.setRange(0, total) + self.progress.setValue(0) + self.status.setText(f"Running 0/{total} model evaluations…") + self._worker = CapabilityOptimizationWorker(document) + self._worker.progressed.connect(self._on_progress) + self._worker.succeeded.connect(self._on_success) + self._worker.cancelled.connect(self._on_cancelled) + self._worker.failed.connect(self._on_failed) + self._set_running(True) + self._worker.start() + + def _set_running(self, running: bool) -> None: + self.run_button.setEnabled(not running) + self.cancel_button.setEnabled(running) + + def _on_progress(self, completed: int, total: int) -> None: + self.progress.setRange(0, total) + self.progress.setValue(completed) + self.status.setText(f"Running {completed}/{total} model evaluations…") + + def _on_success( + self, result: OptimizationResult, dataset: ScalarEnsembleDataset + ) -> None: + self._dataset = dataset + self._result = result + self.results.set_output(result, dataset) + self.results.setVisible(True) + self.csv_button.setEnabled(True) + self.json_button.setEnabled(True) + self.result_csv_button.setEnabled(True) + self.result_json_button.setEnabled(True) + self.status.setText(f"Completed {len(dataset.rows)} observations.") + self._set_running(False) + + def _on_cancelled(self, completed: int, total: int) -> None: + self.status.setText(f"Cancelled after {completed}/{total} evaluations.") + self._set_running(False) + + def _on_failed(self, message: str) -> None: + self.status.setText(f"Optimization failed: {message}") + self._set_running(False) + + def cancel(self) -> None: + """Request typed cancellation without publishing partial results.""" + if self._worker is not None: + self._worker.cancel() + self.status.setText("Cancellation requested…") + + def stop(self) -> None: + """Cancel and join the worker so it cannot outlive the application.""" + worker = self._worker + if worker is None: + return + worker.cancel() + if worker.isRunning() and not worker.wait(_SHUTDOWN_WAIT_MS): + logger.warning("capability worker exceeded shutdown grace period; joining") + worker.wait() + self._worker = None + self._set_running(False) + + def _save_workflow(self) -> None: + try: + source = capability_workflow_json( + build_capability_workflow(self.controls.inputs()) + ) + except (TypeError, ValueError) as exc: + self.status.setText(f"Cannot save invalid workflow: {exc}") + return + self._save_text("Save Capability Workflow", "capability-workflow.json", source) + + def _load_workflow(self) -> None: + selected, _filter = QFileDialog.getOpenFileName( + self, "Load Capability Workflow", "", "JSON files (*.json)" + ) + if not selected: + return + try: + document = capability_workflow_from_json( + Path(selected).read_text(encoding="utf-8") + ) + self.controls.set_inputs(capability_workflow_inputs(document)) + except (OSError, TypeError, ValueError) as exc: + self.status.setText(f"Workflow load failed: {exc}") + else: + self.status.setText("Workflow loaded — run when ready.") + + def _export_csv(self) -> None: + if self._dataset is not None: + self._save_text( + "Export Capability Rows", + "capability-observations.csv", + scalar_ensemble_csv(self._dataset), + ) + + def _export_json(self) -> None: + if self._dataset is not None: + self._save_text( + "Export Capability Rows", + "capability-observations.json", + capability_observation_ensemble_json(self._dataset), + ) + + def _export_result_csv(self) -> None: + if self._result is not None and self._dataset is not None: + self._save_text( + "Export Capability Results", + "capability-results.csv", + capability_alternatives_csv(self._result, self._dataset), + ) + + def _export_result_json(self) -> None: + if self._result is not None and self._dataset is not None: + self._save_text( + "Export Capability Results", + "capability-results.json", + capability_result_export_json(self._result, self._dataset), + ) + + def _save_text(self, title: str, default_name: str, source: str) -> None: + selected, _filter = QFileDialog.getSaveFileName(self, title, default_name) + if not selected: + return + try: + Path(selected).write_text(source + "\n", encoding="utf-8") + except OSError as exc: + self.status.setText(f"Export failed: {exc}") + else: + self.status.setText(f"Saved {selected}.") + + +__all__ = ["CapabilityOptimizationTab"] diff --git a/src/rate_of_closure/ui/pyqt6/capability_worker.py b/src/rate_of_closure/ui/pyqt6/capability_worker.py new file mode 100644 index 0000000000..afe5e9e2a4 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/capability_worker.py @@ -0,0 +1,91 @@ +"""Responsive QThread wrapper for capability optimization.""" + +from __future__ import annotations + +import logging +import threading + +from PyQt6.QtCore import QThread, pyqtSignal + +from rate_of_closure.application.capability_workflow import CapabilityWorkflowDocument +from rate_of_closure.variation.capability_observation_adapter import ( + CapabilityObservationEnsembleBuilder, +) +from shared.python.swing_sim.flight.capability_flight_evaluator import ( + make_capability_flight_evaluator, +) +from shared.python.swing_sim.flight.capability_observation import ( + CapabilityOptimizationCancelled, + CapabilityOptimizationHooks, + CapabilitySampleObservation, +) +from shared.python.swing_sim.flight.capability_optimizer import optimize_capability + +logger = logging.getLogger(__name__) + + +class CapabilityOptimizationWorker(QThread): + """Run an immutable capability workflow off the GUI thread.""" + + progressed = pyqtSignal(int, int) # noqa: N815 + succeeded = pyqtSignal(object, object) # noqa: N815 + cancelled = pyqtSignal(int, int) # noqa: N815 + failed = pyqtSignal(str) # noqa: N815 + + def __init__(self, document: CapabilityWorkflowDocument) -> None: + super().__init__() + if not isinstance(document, CapabilityWorkflowDocument): + raise TypeError("document must be a CapabilityWorkflowDocument") + self._document = document + self._cancel_event = threading.Event() + self._last_progress = 0 + + def cancel(self) -> None: + """Request cooperative cancellation at the next sample boundary.""" + self._cancel_event.set() + + def _accept( + self, + builder: CapabilityObservationEnsembleBuilder, + observation: CapabilitySampleObservation, + ) -> None: + builder.accept(observation) + completed, total = observation.attempted_count, observation.total_count + interval = max(1, total // 100) + if ( + completed == total + or completed == 1 + or completed - self._last_progress >= interval + ): + self._last_progress = completed + self.progressed.emit(completed, total) + + def run(self) -> None: # pragma: no cover - Qt signal behavior is tested + """Execute the optimizer and publish only a complete immutable result.""" + document = self._document + total = document.request.candidate_budget * document.request.ensemble_size + builder = CapabilityObservationEnsembleBuilder( + document.request.target, total, document.profile.provenance + ) + try: + evaluator = make_capability_flight_evaluator( + document.profile, document.request, document.evaluator_config + ) + hooks = CapabilityOptimizationHooks( + observation_sink=lambda item: self._accept(builder, item), + should_cancel=self._cancel_event.is_set, + ) + result = optimize_capability( + document.profile, document.request, evaluator, hooks=hooks + ) + dataset = builder.build() + except CapabilityOptimizationCancelled as exc: + self.cancelled.emit(exc.attempted_count, exc.total_count) + except Exception as exc: # noqa: BLE001 - surface worker failures in UI + logger.warning("capability optimization failed: %s", exc) + self.failed.emit(str(exc)) + else: + self.succeeded.emit(result, dataset) + + +__all__ = ["CapabilityOptimizationWorker"] diff --git a/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py b/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py index 16f8e45ad7..87f273a27e 100644 --- a/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py +++ b/src/rate_of_closure/ui/pyqt6/flight_explorer_tab.py @@ -27,19 +27,17 @@ QScrollArea, QSplitter, QStackedWidget, + QTabWidget, QTextBrowser, QVBoxLayout, QWidget, ) from rate_of_closure.derivation import LAUNCH_EXPLANATIONS -from rate_of_closure.model import MPH_PER_MPS from rate_of_closure.simulation import ( FlightExploration, WindComparison, explore_with_optional_wind, - launch_from_delivery, - launch_from_direct, ) from rate_of_closure.ui.pyqt6.flight_explorer_controls import ( DELIVERY_FIELDS, @@ -58,14 +56,20 @@ from rate_of_closure.ui.pyqt6.spatial_target_workflow import ( build_spatial_target_workflow, ) +from rate_of_closure.ui.pyqt6.wind_strategy_launch import ( + FlightExplorerLaunchValues, + WindStrategyLaunchContext, + build_flight_explorer_launch, +) +from rate_of_closure.ui.pyqt6.wind_strategy_panel import WindStrategyPanel from rate_of_closure.units import FIELD_GUIDANCE, format_distance_m from shared.python.swing_sim.flight import ( LAUNCH_DIRECTION_DEFINITIONS, + LaunchConditions, LaunchDirectionConvention, launch_direction_sign_labels, ) from shared.python.swing_sim.flight.registry import FlightModelType -from shared.python.swing_sim.impact import DeliveryParameters logger = logging.getLogger(__name__) @@ -97,6 +101,7 @@ def __init__(self, parent: QWidget | None = None) -> None: build_spatial_target_workflow(self._flight_view) ) left_layout.addWidget(self._spatial_target_panel) + self._wind_strategy_panel = WindStrategyPanel(self._wind_strategy_context) left_layout.addWidget(self._build_results_box()) left_layout.addWidget(self._build_explanation_box()) left_layout.addStretch(1) @@ -106,9 +111,16 @@ def __init__(self, parent: QWidget | None = None) -> None: left.setWidget(left_content) left.setMinimumWidth(300) + right_tabs = QTabWidget() + right_tabs.setAccessibleName("Flight Explorer Workspaces") + right_tabs.addTab(self._flight_panel, "Flight Playback") + right_tabs.addTab(self._wind_strategy_panel, "Wind Strategy") + right_tabs.setTabToolTip( + 1, "Analyze current-launch dispersion under uncertain wind." + ) splitter = QSplitter() splitter.addWidget(left) - splitter.addWidget(self._flight_panel) + splitter.addWidget(right_tabs) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) layout = QHBoxLayout(self) @@ -163,7 +175,8 @@ def _build_entry_box(self) -> QGroupBox: foresight_item = cast( QStandardItemModel, self._direction_convention_combo.model() ).item(2) - assert foresight_item is not None + if foresight_item is None: + raise RuntimeError("launch-direction convention item was not created") foresight_item.setEnabled(False) foresight_item.setToolTip( "Unavailable: the general public sign convention is not established " @@ -267,40 +280,33 @@ def last_exploration(self) -> FlightExploration | None: """The most recent successful exploration, if any.""" return self._exploration + def current_launch(self) -> LaunchConditions: + """Build launch conditions from the current visible controls.""" + values = FlightExplorerLaunchValues( + self.mode() == ENTRY_MODES[0], + self.speed_mps(), + {key: spin.value() for key, spin in self._direct_spins.items()}, + {key: spin.value() for key, spin in self._delivery_spins.items()}, + self._direction_convention_combo.currentData(), + ) + return build_flight_explorer_launch(values) + + def _wind_strategy_context(self) -> WindStrategyLaunchContext: + """Return the current launch/target/model context for wind analysis.""" + return WindStrategyLaunchContext( + self.current_launch(), + self._spatial_target_panel.target(), + self._model_combo.currentText(), + ) + + def stop(self) -> None: + """Cancel and join background wind analysis during shutdown.""" + self._wind_strategy_panel.stop() + def run_now(self) -> FlightExploration | None: """Build launch conditions, run the flight, populate the views.""" try: - if self.mode() == ENTRY_MODES[0]: - launch = launch_from_direct( - ball_speed_mph=self.speed_mps() * MPH_PER_MPS, - launch_angle_deg=self._direct_spins["launch_angle_deg"].value(), - launch_direction_deg=self._direct_spins[ - "launch_direction_deg" - ].value(), - spin_rpm=self._direct_spins["spin_rpm"].value(), - spin_axis_tilt_deg=self._direct_spins["spin_axis_tilt_deg"].value(), - direction_convention=self._direction_convention_combo.currentData(), - ) - else: - launch = launch_from_delivery( - DeliveryParameters( - clubhead_speed_mps=self.speed_mps(), - club_path_deg=self._delivery_spins["club_path_deg"].value(), - face_angle_deg=self._delivery_spins["face_angle_deg"].value(), - attack_angle_deg=self._delivery_spins[ - "attack_angle_deg" - ].value(), - dynamic_loft_deg=self._delivery_spins[ - "dynamic_loft_deg" - ].value(), - impact_offset_toe_mm=self._delivery_spins[ - "impact_offset_toe_mm" - ].value(), - impact_offset_high_mm=self._delivery_spins[ - "impact_offset_high_mm" - ].value(), - ) - ) + launch = self.current_launch() model_name = self._model_combo.currentText() exploration, comparison = explore_with_optional_wind( launch, self.wind_controls.optional_scenario(), model_name diff --git a/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py b/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py index 30697feb54..7012c2f068 100644 --- a/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py +++ b/src/rate_of_closure/ui/pyqt6/flight_playback_controls.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import QElapsedTimer, QSignalBlocker, Qt, QTimer, pyqtSignal from PyQt6.QtGui import QCloseEvent from PyQt6.QtWidgets import ( + QCheckBox, QComboBox, QHBoxLayout, QLabel, @@ -84,6 +85,10 @@ def _build_ui(self) -> None: ) self.speed_combo.currentIndexChanged.connect(self._change_speed) row.addWidget(self.speed_combo) + self.loop_check = QCheckBox("Loop") + self.loop_check.setAccessibleName("Loop Ball Flight Playback") + self.loop_check.setToolTip("Restart at launch when the flight reaches landing.") + row.addWidget(self.loop_check) self.time_label = QLabel("0.00 / 0.00 s") self.time_label.setMinimumWidth(94) self.time_label.setAccessibleName("Ball Flight Playback Time") @@ -178,14 +183,27 @@ def timer(self) -> QTimer: """The sole animation timer (test and lifecycle inspection seam).""" return self._timer + def set_looping(self, looping: bool) -> None: + """Set whether playback wraps from landing back to launch.""" + self.loop_check.setChecked(looping) + def _toggle(self) -> None: self.pause() if self._timer.isActive() else self.play() def _advance(self) -> None: - elapsed_s = self._elapsed.restart() / 1000.0 - self._set_time(self._current_time_s + elapsed_s * self._speed) - if self._current_time_s >= self._duration_s: - self.pause() + next_time_s = self._current_time_s + self._elapsed_seconds() * self._speed + if next_time_s < self._duration_s: + self._set_time(next_time_s) + return + if self.loop_check.isChecked() and self._duration_s > 0.0: + self._set_time(next_time_s % self._duration_s) + return + self._set_time(self._duration_s) + self.pause() + + def _elapsed_seconds(self) -> float: + """Return elapsed wall time through a deterministic test seam.""" + return float(self._elapsed.restart()) / 1000.0 def _scrub(self, value: int) -> None: self.pause() diff --git a/src/rate_of_closure/ui/pyqt6/main_window.py b/src/rate_of_closure/ui/pyqt6/main_window.py index 6b1eb30d61..4ead61e0bd 100644 --- a/src/rate_of_closure/ui/pyqt6/main_window.py +++ b/src/rate_of_closure/ui/pyqt6/main_window.py @@ -33,6 +33,7 @@ ApplicationToolstrip, ModuleManagerDialog, ) +from rate_of_closure.ui.pyqt6.capability_tab import CapabilityOptimizationTab from rate_of_closure.ui.pyqt6.club_view import Club3DView from rate_of_closure.ui.pyqt6.controls_panel import ControlsPanel from rate_of_closure.ui.pyqt6.derivation_view import DerivationView @@ -72,10 +73,8 @@ from rate_of_closure.units import convert_from_canonical from shared.python.swing_sim.variation import VariationDataset -__all__ = ["RateOfClosureMainWindow"] - -# Compatibility exports retained for existing shell and help-contract tests. -__all__ += [ +__all__ = [ + "RateOfClosureMainWindow", "_DEFAULT_TAB_IDS", "_NAVIGATION_SETTINGS_APP", "_NAVIGATION_SETTINGS_ORG", @@ -85,13 +84,9 @@ "_TAB_HELP_KEYS", ] -# ── Theme integration (optional — graceful fallback) ─────────────── try: from shared.python.theme.integration import ThemedWindowMixin - - _THEME_AVAILABLE = True except ImportError: # standalone / vendored use - _THEME_AVAILABLE = False class ThemedWindowMixin: # type: ignore[no-redef] """No-op stand-in when the shared theme package is unavailable.""" @@ -130,6 +125,7 @@ def _create_views(self) -> None: self._simulation_tab.runCompleted.connect(self._plots_tab.set_run) self._flight_explorer_tab = FlightExplorerTab() self._launch_monitor_analytics_tab = LaunchMonitorAnalyticsTab() + self._capability_optimization_tab = CapabilityOptimizationTab() self._variation_tab = VariationTab() self._variation_tab.studyCompleted.connect(self._on_variation_study) self._putting_tab = PuttingTab() @@ -181,6 +177,11 @@ def _primary_tab_specs(self) -> tuple[PrimaryTabSpec, ...]: self._launch_monitor_analytics_tab, "Launch Monitor Analytics", ), + ( + "capability_optimization", + self._capability_optimization_tab, + "Shot Optimizer", + ), ("variation", self._variation_tab, "Variation"), ("putting", self._putting_tab, "Putting"), ("glossary", self._glossary_tab, "Glossary"), @@ -394,4 +395,6 @@ def closeEvent(self, event) -> None: # type: ignore[no-untyped-def] # noqa: N8 self._club_view.stop() self._simulation_tab.stop() self._variation_tab.stop() + self._flight_explorer_tab.stop() + self._capability_optimization_tab.stop() super().closeEvent(event) diff --git a/src/rate_of_closure/ui/pyqt6/navigation_state.py b/src/rate_of_closure/ui/pyqt6/navigation_state.py index c9ba95c0d3..d1dc24d301 100644 --- a/src/rate_of_closure/ui/pyqt6/navigation_state.py +++ b/src/rate_of_closure/ui/pyqt6/navigation_state.py @@ -27,6 +27,7 @@ "simulation", "flight_explorer", "launch_monitor_analytics", + "capability_optimization", "variation", "putting", "glossary", diff --git a/src/rate_of_closure/ui/pyqt6/plot_canvas_pane.py b/src/rate_of_closure/ui/pyqt6/plot_canvas_pane.py index 3e78232dcf..2dd50461c5 100644 --- a/src/rate_of_closure/ui/pyqt6/plot_canvas_pane.py +++ b/src/rate_of_closure/ui/pyqt6/plot_canvas_pane.py @@ -35,6 +35,7 @@ def __init__(self, label: str, parent: QWidget | None = None) -> None: self.setFrameShape(QFrame.Shape.StyledPanel) self.setAccessibleName(f"{label} Plot Viewport") self._data: PlotData | None = None + self._custom_renderer: Callable[[Figure], None] | None = None self._zoom = 1.0 self._figure = Figure(figsize=(5.2, 3.5), tight_layout=True) self._canvas = FigureCanvas(self._figure) @@ -89,7 +90,35 @@ def _button(text: str, tooltip: str, handler: Callable[[], None]) -> QPushButton def render_data(self, data: PlotData) -> None: """Render new data and reset the viewport to a readable fit.""" self._data = data - render_plot(data, self._figure) + self._custom_renderer = None + self._render_and_fit() + + def render_custom(self, renderer: Callable[[Figure], None]) -> None: + """Render caller-owned plot data with the same managed controls.""" + if not callable(renderer): + raise TypeError("renderer must be callable") + self._data = None + self._custom_renderer = renderer + self._render_and_fit() + + def clear(self) -> None: + """Remove current data so Auto Fit cannot resurrect a stale result.""" + self._data = None + self._custom_renderer = None + self._figure.clear() + self._toolbar.update() + self._zoom = 1.0 + self._zoom_label.setText("100%") + self._canvas.draw_idle() + + def _render_and_fit(self) -> None: + if self._data is not None: + render_plot(self._data, self._figure) + elif self._custom_renderer is not None: + self._custom_renderer(self._figure) + else: + return + self._toolbar.update() self._wrap_canvas_titles() self._zoom = 1.0 self._zoom_label.setText("100%") @@ -140,13 +169,7 @@ def zoom_out(self) -> None: def auto_fit(self) -> None: """Restore data-derived axis limits and a 5% margin.""" - if self._data is None: - return - render_plot(self._data, self._figure) - self._zoom = 1.0 - self._zoom_label.setText("100%") - self._apply_legend() - self._canvas.draw_idle() + self._render_and_fit() def _scale_limits(self, factor: float) -> None: if not self._figure.axes: diff --git a/src/rate_of_closure/ui/pyqt6/wind_strategy_basis.py b/src/rate_of_closure/ui/pyqt6/wind_strategy_basis.py new file mode 100644 index 0000000000..d74644ec84 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/wind_strategy_basis.py @@ -0,0 +1,39 @@ +"""Pure calculation-basis formatting for wind-strategy results.""" + +from __future__ import annotations + +import math + +from shared.python.swing_sim.flight import ( + StrategyAnalysisRequest, + WindStrategyAnalysis, +) + + +def format_wind_strategy_basis( + request: StrategyAnalysisRequest, + result: WindStrategyAnalysis, +) -> str: + """Describe every captured numerical and policy factor behind a result.""" + analysis = request.analysis + uncertainty = request.uncertainty + target = request.target + policies = ", ".join( + f"{strategy.label} " + f"{math.degrees(strategy.crosswind_aim_gain_rad_per_mps):+.3f} deg/(m/s)" + for strategy in request.strategies + ) + return ( + f"Calculation basis ({result.schema_version}; {result.provenance}): " + f"model {analysis.model_name}; {uncertainty.trials} paired trials; " + f"seed {uncertainty.seed}; target {target.forward_m:+.3f} m forward, " + f"{target.right_m:+.3f} m right; hold radius " + f"{analysis.target_radius_m:.3f} m; maximum time " + f"{analysis.max_time_s:g} s; time step {analysis.time_step_s:g} s; " + f"failure cost {analysis.failure_cost:g}; CVaR alpha " + f"{analysis.miss_distance_cvar_alpha:g}; " + f"estimated-crosswind aim policy: {policies}." + ) + + +__all__ = ["format_wind_strategy_basis"] diff --git a/src/rate_of_closure/ui/pyqt6/wind_strategy_launch.py b/src/rate_of_closure/ui/pyqt6/wind_strategy_launch.py new file mode 100644 index 0000000000..9f39826b1b --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/wind_strategy_launch.py @@ -0,0 +1,196 @@ +"""Pure request and export helpers for the PyQt wind-strategy workflow.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass + +from rate_of_closure.model import MPH_PER_MPS +from rate_of_closure.simulation import launch_from_delivery, launch_from_direct +from rate_of_closure.variation.scalar_ensemble_io import scalar_ensemble_csv +from shared.python.swing_sim.flight import ( + LaunchConditions, + ScalarDistribution, + StrategyAnalysisConfig, + StrategyAnalysisRequest, + TargetPoint, + WindEstimateError, + WindStrategy, + WindUncertaintySpec, +) +from shared.python.swing_sim.flight.direction import LaunchDirectionConvention +from shared.python.swing_sim.impact import DeliveryParameters +from shared.python.swing_sim.solver import ( + SpatialTarget, + SurfaceCircleTolerance, + SurfaceCorridorTolerance, +) + +_UINT32_MAX = 2**32 - 1 + + +@dataclass(frozen=True) +class FlightExplorerLaunchValues: + """Values required to build the launch currently shown in Flight Explorer.""" + + direct_mode: bool + speed_mps: float + direct: Mapping[str, float] + delivery: Mapping[str, float] + direction_convention: LaunchDirectionConvention + + +def build_flight_explorer_launch( + values: FlightExplorerLaunchValues, +) -> LaunchConditions: + """Build direct or impact-derived launch conditions from current controls.""" + if values.direct_mode: + return launch_from_direct( + ball_speed_mph=values.speed_mps * MPH_PER_MPS, + launch_angle_deg=values.direct["launch_angle_deg"], + launch_direction_deg=values.direct["launch_direction_deg"], + spin_rpm=values.direct["spin_rpm"], + spin_axis_tilt_deg=values.direct["spin_axis_tilt_deg"], + direction_convention=values.direction_convention, + ) + return launch_from_delivery( + DeliveryParameters( + clubhead_speed_mps=values.speed_mps, + club_path_deg=values.delivery["club_path_deg"], + face_angle_deg=values.delivery["face_angle_deg"], + attack_angle_deg=values.delivery["attack_angle_deg"], + dynamic_loft_deg=values.delivery["dynamic_loft_deg"], + impact_offset_toe_mm=values.delivery["impact_offset_toe_mm"], + impact_offset_high_mm=values.delivery["impact_offset_high_mm"], + ) + ) + + +@dataclass(frozen=True) +class WindStrategyLaunchContext: + """Current launch, canonical spatial target, and selected flight model.""" + + launch: LaunchConditions + target: SpatialTarget + model_name: str + + def __post_init__(self) -> None: + if self.target.kind != "landing_area": + raise ValueError("wind strategy analysis requires a landing-area target") + if not self.model_name.strip(): + raise ValueError("flight model name must be nonempty") + + +@dataclass(frozen=True) +class WindStrategySettings: + """User-authored wind ensemble settings in displayed engineering units.""" + + trials: int + true_speed_mps: float + true_from_bearing_deg: float + speed_bias_mps: float + speed_std_mps: float + bearing_bias_deg: float + bearing_std_deg: float + correlation: float + aim_gain_deg_per_mps: float + seed: int + + def __post_init__(self) -> None: + values = ( + self.true_speed_mps, + self.true_from_bearing_deg, + self.speed_bias_mps, + self.speed_std_mps, + self.bearing_bias_deg, + self.bearing_std_deg, + self.correlation, + self.aim_gain_deg_per_mps, + ) + if not all(math.isfinite(value) for value in values): + raise ValueError("wind strategy settings must be finite") + if not 1 <= self.trials <= 100_000: + raise ValueError("trials must be in [1, 100000]") + if not 0 <= self.seed <= _UINT32_MAX: + raise ValueError("seed must be a uint32 integer") + if self.true_speed_mps < 0.0: + raise ValueError("true wind speed must be nonnegative") + if self.speed_std_mps < 0.0 or self.bearing_std_deg < 0.0: + raise ValueError("estimate standard deviations must be nonnegative") + if not -1.0 <= self.correlation <= 1.0: + raise ValueError("correlation must be in [-1, 1]") + + +def _target_radius(target: SpatialTarget) -> float: + tolerance = target.tolerance + if isinstance(tolerance, SurfaceCircleTolerance): + return float(tolerance.radius_m) + if isinstance(tolerance, SurfaceCorridorTolerance): + return float(min(tolerance.half_length_m, tolerance.half_width_m)) + raise ValueError("landing target requires a surface tolerance") + + +def target_hold_note(target: SpatialTarget) -> str: + """Describe how the core circular target-hold metric treats the target.""" + tolerance = target.tolerance + if isinstance(tolerance, SurfaceCircleTolerance): + return f"Target hold uses the current {tolerance.radius_m:g} m circle." + radius = _target_radius(target) + return ( + "Target hold uses a conservative inscribed circle " + f"({radius:g} m radius) for the current corridor." + ) + + +def build_strategy_request( + context: WindStrategyLaunchContext, + settings: WindStrategySettings, +) -> StrategyAnalysisRequest: + """Build one immutable shared-core request without changing its physics.""" + forward_m, _elevation_m, right_m = context.target.point.app_coordinates_m + uncertainty = WindUncertaintySpec( + trials=settings.trials, + seed=settings.seed, + true_speed_mps=ScalarDistribution( + "fixed", settings.true_speed_mps, minimum=0.0 + ), + true_from_bearing_deg=ScalarDistribution( + "fixed", settings.true_from_bearing_deg + ), + estimate_error=WindEstimateError( + settings.speed_bias_mps, + settings.speed_std_mps, + settings.bearing_bias_deg, + settings.bearing_std_deg, + settings.correlation, + ), + provenance="rate-of-closure/pyqt/user-declared", + ) + return StrategyAnalysisRequest( + uncertainty=uncertainty, + strategies=( + WindStrategy( + "current-launch", + "Current Launch", + context.launch, + math.radians(settings.aim_gain_deg_per_mps), + ), + ), + target=TargetPoint(forward_m, right_m), + analysis=StrategyAnalysisConfig( + model_name=context.model_name, + target_radius_m=_target_radius(context.target), + ), + ) + + +__all__ = [ + "FlightExplorerLaunchValues", + "WindStrategyLaunchContext", + "WindStrategySettings", + "build_flight_explorer_launch", + "build_strategy_request", + "scalar_ensemble_csv", + "target_hold_note", +] diff --git a/src/rate_of_closure/ui/pyqt6/wind_strategy_lifecycle.py b/src/rate_of_closure/ui/pyqt6/wind_strategy_lifecycle.py new file mode 100644 index 0000000000..8ed2cf9fdb --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/wind_strategy_lifecycle.py @@ -0,0 +1,107 @@ +"""Fail-closed result invalidation and worker teardown for wind strategy UI.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING + +from PyQt6.QtGui import QCloseEvent +from PyQt6.QtWidgets import QGroupBox + +from rate_of_closure.ui.pyqt6.wind_strategy_launch import WindStrategyLaunchContext + +if TYPE_CHECKING: + from PyQt6.QtWidgets import QComboBox, QLabel, QPushButton, QTableWidget + + from rate_of_closure.ui.pyqt6.plot_canvas_pane import PlotCanvasPane + from rate_of_closure.ui.pyqt6.wind_strategy_worker import WindStrategyWorker + from rate_of_closure.variation.scalar_ensemble_contract import ( + ScalarEnsembleDataset, + ) + from shared.python.swing_sim.flight import StrategyAnalysisRequest + +logger = logging.getLogger(__name__) +_SHUTDOWN_WAIT_MS = 10_000 + + +class WindStrategyLifecycleMixin: + """Own invalidation and ensure a QThread cannot survive widget teardown.""" + + _active_context: WindStrategyLaunchContext | None + _context_provider: Callable[[], WindStrategyLaunchContext] + _dataset: ScalarEnsembleDataset | None + _worker: WindStrategyWorker | None + _request: StrategyAnalysisRequest | None + _summary: QTableWidget + _x_axis: QComboBox + _y_axis: QComboBox + _availability: QLabel + _basis: QLabel + _export: QPushButton + _plot: PlotCanvasPane + _status: QLabel + + if TYPE_CHECKING: + + def _set_running(self, running: bool) -> None: ... + + def _invalidate_settings(self, *_args: object) -> None: + self.invalidate_result("Wind inputs changed") + + def _check_context(self) -> None: + if self._active_context is None: + return + try: + current = self._context_provider() + except (TypeError, ValueError): + self.invalidate_result("Current target is invalid") + return + if current != self._active_context: + self.invalidate_result("Launch, target, or flight model changed") + + def invalidate_result(self, reason: str) -> None: + """Cancel work and remove results when any consumed factor changes.""" + if self._request is None and self._dataset is None: + return + if self._worker is not None and self._worker.isRunning(): + self._worker.cancel() + self._active_context = None + self._clear_result() + self._status.setText(f"{reason} — rerun wind strategy analysis.") + + def _clear_result(self) -> None: + self._request = None + self._dataset = None + self._summary.setRowCount(0) + self._x_axis.clear() + self._y_axis.clear() + self._availability.setText("No current ensemble result.") + self._basis.setText("Calculation basis: no current result.") + self._export.setEnabled(False) + self._plot.clear() + + def stop(self) -> None: + """Cancel and join any running worker during shutdown or tests.""" + worker = self._worker + if worker is None: + return + self._request = None + self._active_context = None + worker.cancel() + if not worker.wait(_SHUTDOWN_WAIT_MS): + logger.warning("wind worker exceeded shutdown grace period; joining") + worker.wait() + self._worker = None + self._set_running(False) + + +class WindStrategyGroupBox(WindStrategyLifecycleMixin, QGroupBox): + """Qt group box that guarantees worker shutdown before teardown.""" + + def closeEvent(self, event: QCloseEvent | None) -> None: # noqa: N802 + self.stop() + QGroupBox.closeEvent(self, event) + + +__all__ = ["WindStrategyGroupBox", "WindStrategyLifecycleMixin"] diff --git a/src/rate_of_closure/ui/pyqt6/wind_strategy_panel.py b/src/rate_of_closure/ui/pyqt6/wind_strategy_panel.py new file mode 100644 index 0000000000..453fd8c435 --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/wind_strategy_panel.py @@ -0,0 +1,397 @@ +"""PyQt wind-strategy ensemble controls, summary, scatter, and export.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from functools import partial +from pathlib import Path + +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import ( + QApplication, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QProgressBar, + QPushButton, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from rate_of_closure.ui.pyqt6.plot_canvas_pane import PlotCanvasPane +from rate_of_closure.ui.pyqt6.wind_strategy_basis import format_wind_strategy_basis +from rate_of_closure.ui.pyqt6.wind_strategy_launch import ( + WindStrategyLaunchContext, + WindStrategySettings, + build_strategy_request, + scalar_ensemble_csv, + target_hold_note, +) +from rate_of_closure.ui.pyqt6.wind_strategy_lifecycle import WindStrategyGroupBox +from rate_of_closure.ui.pyqt6.wind_strategy_plot import ( + draw_wind_strategy_scatter, + scatter_availability_text, +) +from rate_of_closure.ui.pyqt6.wind_strategy_worker import WindStrategyWorker +from rate_of_closure.variation.scalar_ensemble_contract import ScalarEnsembleDataset +from rate_of_closure.variation.wind_strategy_plot_adapter import ( + build_wind_strategy_plot_dataset, +) +from shared.python.swing_sim.flight import StrategyAnalysisRequest, WindStrategyAnalysis + +logger = logging.getLogger(__name__) +_CONTEXT_POLL_MS = 250 + + +class WindStrategyPanel(WindStrategyGroupBox): + """Run and inspect current-launch performance under uncertain wind.""" + + def __init__( + self, + context_provider: Callable[[], WindStrategyLaunchContext], + parent: QWidget | None = None, + ) -> None: + super().__init__("Wind Strategy Ensemble", parent) + self._context_provider = context_provider + self._worker: WindStrategyWorker | None = None + self._request: StrategyAnalysisRequest | None = None + self._dataset: ScalarEnsembleDataset | None = None + self._active_context: WindStrategyLaunchContext | None = None + self._spins: dict[str, QDoubleSpinBox] = {} + self._plot = PlotCanvasPane("Wind Strategy Trial Scatter") + self._build_ui() + self._connect_invalidation() + self._context_timer = QTimer(self) + self._context_timer.setInterval(_CONTEXT_POLL_MS) + self._context_timer.timeout.connect(self._check_context) + self._context_timer.start() + app = QApplication.instance() + if app is not None: + app.aboutToQuit.connect(self.stop) + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + self._workspace = QTabWidget() + self._workspace.setAccessibleName("Wind Strategy Setup and Results") + setup = QWidget() + setup_layout = QVBoxLayout(setup) + self._input_grid = self._build_input_form() + setup_layout.addLayout(self._input_grid) + setup_layout.addStretch(1) + self._workspace.addTab(setup, "Setup") + results = QWidget() + results_layout = QVBoxLayout(results) + self._workspace.addTab(results, "Results") + layout.addWidget(self._workspace, stretch=1) + layout.addLayout(self._build_run_row()) + self._progress = QProgressBar() + self._progress.setRange(0, 1) + self._status = QLabel("Ready. Set launch inputs, then analyze.") + self._status.setWordWrap(True) + self._status.setAccessibleName("Wind Strategy Status") + layout.addWidget(self._progress) + layout.addWidget(self._status) + self._basis = QLabel("Calculation basis: no current result.") + self._basis.setWordWrap(True) + self._basis.setAccessibleName("Wind Strategy Calculation Basis") + results_layout.addWidget(self._basis) + self._summary = QTableWidget(0, 7) + self._summary.setHorizontalHeaderLabels( + ["Strategy", "Complete", "Failed", "Expected Cost"] + + ["Hold %", "CVaR m", "Info Delta"] + ) + self._summary.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._summary.setAccessibleName("Wind Strategy Summary") + self._summary.setMaximumHeight(125) + results_layout.addWidget(self._summary) + results_layout.addLayout(self._build_axis_form()) + self._availability = QLabel("No ensemble result yet.") + self._availability.setWordWrap(True) + self._availability.setAccessibleName("Wind Scatter Availability") + results_layout.addWidget(self._availability) + results_layout.addWidget(self._plot, stretch=1) + + def _build_input_form(self) -> QGridLayout: + grid = QGridLayout() + self._trials = QSpinBox() + self._trials.setRange(1, 100_000) + self._trials.setValue(100) + self._trials.setAccessibleName("Wind Strategy Trial Count") + self._trials.setToolTip( + "Number of deterministic paired trials, from 1 to 100000." + ) + self._seed = QLineEdit("4199") + self._seed.setAccessibleName("Wind Strategy Random Seed") + self._seed.setToolTip("Reproducible uint32 seed, from 0 to 4294967295.") + fields: list[tuple[str, QWidget]] = [ + ("Trials", self._trials), + ("Seed", self._seed), + ] + specifications = ( + ("true_speed", "True Wind Speed", 0.0, 80.0, 5.0, " m/s", 2), + ("true_bearing", "True Wind From Bearing", -180.0, 180.0, 90.0, "°", 1), + ("speed_bias", "Speed Estimate Bias", -30.0, 30.0, 0.0, " m/s", 2), + ("speed_std", "Speed Estimate Std Dev", 0.0, 30.0, 1.0, " m/s", 2), + ("bearing_bias", "Bearing Estimate Bias", -180.0, 180.0, 0.0, "°", 1), + ("bearing_std", "Bearing Estimate Std Dev", 0.0, 180.0, 5.0, "°", 1), + ("correlation", "Error Correlation", -1.0, 1.0, 0.0, "", 2), + ("aim_gain", "Crosswind Aim Gain", -10.0, 10.0, 0.0, "°/(m/s)", 3), + ) + for key, label, low, high, value, suffix, decimals in specifications: + spin = self._double_spin(low, high, value, suffix, decimals) + spin.setAccessibleName(label) + spin.setToolTip(f"Set {label.lower()}; displayed units are explicit.") + self._spins[key] = spin + fields.append((label, spin)) + for index, (label, field) in enumerate(fields): + row, pair = divmod(index, 2) + grid.addWidget(QLabel(label), row, pair * 2) + grid.addWidget(field, row, pair * 2 + 1) + grid.setColumnStretch(1, 1) + grid.setColumnStretch(3, 1) + return grid + + def _connect_invalidation(self) -> None: + self._trials.valueChanged.connect(self._invalidate_settings) + self._seed.textChanged.connect(self._invalidate_settings) + for spin in self._spins.values(): + spin.valueChanged.connect(self._invalidate_settings) + + @staticmethod + def _double_spin( + low: float, + high: float, + value: float, + suffix: str, + decimals: int, + ) -> QDoubleSpinBox: + spin = QDoubleSpinBox() + spin.setRange(low, high) + spin.setDecimals(decimals) + spin.setValue(value) + spin.setSuffix(suffix) + spin.setKeyboardTracking(False) + return spin + + def _build_run_row(self) -> QHBoxLayout: + row = QHBoxLayout() + self._run = QPushButton("Analyze Wind Strategy") + self._run.setAccessibleName("Run Wind Strategy Analysis") + self._run.setToolTip("Run paired true/estimated-wind trials off the UI thread.") + self._run.clicked.connect(self._on_run) + self._cancel = QPushButton("Cancel") + self._cancel.setEnabled(False) + self._cancel.setToolTip( + "Cancel cooperatively after the in-flight outcome finishes." + ) + self._cancel.clicked.connect(self._on_cancel) + self._export = QPushButton("Export All Rows CSV…") + self._export.setEnabled(False) + self._export.setToolTip( + "Export every raw trial, status, scalar, and unavailable cell." + ) + self._export.clicked.connect(self._on_export) + row.addWidget(self._run) + row.addWidget(self._cancel) + row.addWidget(self._export) + return row + + def _build_axis_form(self) -> QFormLayout: + form = QFormLayout() + self._x_axis = QComboBox() + self._y_axis = QComboBox() + self._x_axis.setToolTip("Choose any declared scalar for the horizontal axis.") + self._y_axis.setToolTip("Choose any declared scalar for the vertical axis.") + self._x_axis.currentIndexChanged.connect(self._redraw) + self._y_axis.currentIndexChanged.connect(self._redraw) + form.addRow("Horizontal Axis", self._x_axis) + form.addRow("Vertical Axis", self._y_axis) + return form + + def settings(self) -> WindStrategySettings: + """Return the validated user-authored settings without hidden defaults.""" + value = lambda key: self._spins[key].value() # noqa: E731 + try: + seed = int(self._seed.text().strip()) + except ValueError as exc: + raise ValueError("seed must be a uint32 integer") from exc + return WindStrategySettings( + self._trials.value(), + value("true_speed"), + value("true_bearing"), + value("speed_bias"), + value("speed_std"), + value("bearing_bias"), + value("bearing_std"), + value("correlation"), + value("aim_gain"), + seed, + ) + + def _on_run(self) -> None: + if self._worker is not None and self._worker.isRunning(): + return + try: + context = self._context_provider() + request = build_strategy_request(context, self.settings()) + except (TypeError, ValueError) as exc: + self._status.setText(f"Cannot analyze: {exc}") + return + self._clear_result() + self._request = request + self._active_context = context + self._set_running(True) + self._status.setText(f"Running… {target_hold_note(context.target)}") + worker = WindStrategyWorker(request) + worker.progressed.connect(self._on_progress) + worker.succeeded.connect(self._on_succeeded) + worker.cancelled.connect(self._on_cancelled) + worker.failed.connect(self._on_failed) + worker.finished.connect(self._on_finished) + self._worker = worker + worker.start() + + def _set_running(self, running: bool) -> None: + self._run.setEnabled(not running) + self._cancel.setEnabled(running) + if running: + total = self._trials.value() + self._progress.setRange(0, total) + self._progress.setValue(0) + + def _on_progress(self, completed: int, total: int) -> None: + if self._request is None: + return + self._progress.setRange(0, total) + self._progress.setValue(completed) + self._status.setText(f"Running wind strategies: {completed}/{total} outcomes.") + + def _on_cancel(self) -> None: + if self._worker is not None: + self._worker.cancel() + self._status.setText("Cancelling after the current outcome…") + + def _on_cancelled(self) -> None: + if self._request is not None: + self._status.setText("Cancelled. No partial ensemble was published.") + + def _on_failed(self, message: str) -> None: + if self._request is None: + return + self._status.setText(f"Wind strategy analysis failed: {message}") + + def _on_succeeded(self, result: WindStrategyAnalysis) -> None: + if self._request is None: + logger.info("discarded stale wind strategy result after invalidation") + return + self.apply_result(self._request, result) + + def _on_finished(self) -> None: + self._set_running(False) + self._worker = None + + def apply_result( + self, + request: StrategyAnalysisRequest, + result: WindStrategyAnalysis, + ) -> None: + """Publish one completed result into every null-safe consumer.""" + try: + dataset = build_wind_strategy_plot_dataset(request, result) + except (TypeError, ValueError) as exc: + logger.warning("wind strategy result adaptation failed: %s", exc) + self._on_failed(str(exc)) + return + self._request = request + self._dataset = dataset + try: + self._active_context = self._context_provider() + except (TypeError, ValueError): + self._active_context = None + self._populate_summary(result) + self._basis.setText(format_wind_strategy_basis(request, result)) + self._populate_axes(dataset) + self._export.setEnabled(True) + self._progress.setRange(0, len(result.outcomes)) + self._progress.setValue(len(result.outcomes)) + self._status.setText( + f"Completed {len(result.outcomes)} outcomes across " + f"{len(result.summaries)} strategy." + ) + self._workspace.setCurrentIndex(1) + + def _populate_summary(self, result: WindStrategyAnalysis) -> None: + self._summary.setRowCount(len(result.summaries)) + for row, summary in enumerate(result.summaries): + values = ( + summary.label, + str(summary.completed_trials), + str(summary.failed_trials), + f"{summary.expected_cost:.4g}", + f"{100.0 * summary.target_hold_probability:.1f}", + f"{summary.miss_distance_cvar_m:.3f}", + f"{summary.expected_information_cost_delta:.4g}", + ) + for column, value in enumerate(values): + self._summary.setItem(row, column, QTableWidgetItem(value)) + self._summary.resizeColumnsToContents() + + def _populate_axes(self, dataset: ScalarEnsembleDataset) -> None: + for combo in (self._x_axis, self._y_axis): + combo.blockSignals(True) + combo.clear() + for variable in dataset.variables: + combo.addItem(f"{variable.label} [{variable.unit}]", variable.key) + combo.blockSignals(False) + self._select_axis(self._x_axis, "true_wind_left_mps") + self._select_axis(self._y_axis, "actual_landing_right_m") + self._redraw() + + @staticmethod + def _select_axis(combo: QComboBox, key: str) -> None: + combo.setCurrentIndex(max(0, combo.findData(key))) + + def _redraw(self, *_args: object) -> None: + if self._dataset is None or self._x_axis.currentIndex() < 0: + return + scatter = self._dataset.scatter( + str(self._x_axis.currentData()), str(self._y_axis.currentData()) + ) + self._plot.render_custom(partial(draw_wind_strategy_scatter, scatter=scatter)) + self._availability.setText(scatter_availability_text(scatter)) + + def _on_export(self) -> None: + if self._dataset is None: + self._status.setText("Nothing to export: run the analysis first.") + return + selected, _filter = QFileDialog.getSaveFileName( + self, + "Export Wind Strategy Rows", + "wind_strategy_rows.csv", + "CSV files (*.csv)", + ) + if not selected: + return + try: + Path(selected).write_text( + scalar_ensemble_csv(self._dataset) + "\n", encoding="utf-8" + ) + except OSError as exc: + self._status.setText(f"CSV export failed: {exc}") + else: + self._status.setText( + f"Exported {len(self._dataset.rows)} raw rows to {selected}." + ) + + +__all__ = ["WindStrategyPanel"] diff --git a/src/rate_of_closure/ui/pyqt6/wind_strategy_plot.py b/src/rate_of_closure/ui/pyqt6/wind_strategy_plot.py new file mode 100644 index 0000000000..6620d45dfb --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/wind_strategy_plot.py @@ -0,0 +1,55 @@ +"""Managed wind-strategy scatter rendering and availability text.""" + +from __future__ import annotations + +from matplotlib.figure import Figure + +from rate_of_closure.variation.scalar_ensemble_contract import ScalarScatterData + +_COHORT_COLORS = { + "completed": "#2f8bd6", + "nonconverged": "#eb9f3c", + "invalid": "#d35f5f", +} + + +def draw_wind_strategy_scatter( + figure: Figure, + scatter: ScalarScatterData, +) -> None: + """Draw every paired-finite cohort into a managed Matplotlib figure.""" + figure.clear() + axes = figure.add_subplot(111) + for cohort, color in _COHORT_COLORS.items(): + points = [point for point in scatter.points if point.cohort == cohort] + if points: + axes.scatter( + [point.x for point in points], + [point.y for point in points], + s=22, + alpha=0.75, + label=cohort.title(), + color=color, + ) + axes.set_xlabel(f"{scatter.x_variable.label} [{scatter.x_variable.unit}]") + axes.set_ylabel(f"{scatter.y_variable.label} [{scatter.y_variable.unit}]") + axes.set_title("Wind Strategy Trial Scatter") + axes.grid(alpha=0.25) + if axes.collections: + axes.legend(loc="best", fontsize=8) + + +def scatter_availability_text(scatter: ScalarScatterData) -> str: + """Format exact overall and per-cohort paired-finite counts.""" + available = scatter.availability.overall + cohorts = ", ".join( + f"{key}: {item.paired_finite}/{item.total_rows}" + for key, item in scatter.availability.by_cohort.items() + ) + return ( + f"Paired finite {available.paired_finite}/{available.total_rows}; " + f"unavailable {available.unavailable}. {cohorts}" + ) + + +__all__ = ["draw_wind_strategy_scatter", "scatter_availability_text"] diff --git a/src/rate_of_closure/ui/pyqt6/wind_strategy_worker.py b/src/rate_of_closure/ui/pyqt6/wind_strategy_worker.py new file mode 100644 index 0000000000..feaa806aaf --- /dev/null +++ b/src/rate_of_closure/ui/pyqt6/wind_strategy_worker.py @@ -0,0 +1,64 @@ +"""Responsive QThread wrapper for shared wind-strategy analysis.""" + +from __future__ import annotations + +import logging +import threading + +from PyQt6.QtCore import QThread, pyqtSignal + +from shared.python.swing_sim.flight import StrategyAnalysisRequest +from shared.python.swing_sim.flight.wind_strategy import ( + WindStrategyCancelledError, + WindStrategyProgress, + analyze_wind_strategies, +) + +logger = logging.getLogger(__name__) + + +class WindStrategyWorker(QThread): + """Run one immutable request off the GUI thread with exact progress.""" + + progressed = pyqtSignal(int, int) # noqa: N815 - Qt signal convention + succeeded = pyqtSignal(object) # noqa: N815 + cancelled = pyqtSignal() # noqa: N815 + failed = pyqtSignal(str) # noqa: N815 + + def __init__(self, request: StrategyAnalysisRequest) -> None: + super().__init__() + if not isinstance(request, StrategyAnalysisRequest): + raise TypeError("request must be a StrategyAnalysisRequest") + self._request = request + self._cancel_event = threading.Event() + + @property + def cancel_event(self) -> threading.Event: + """Return the event passed directly into the shared core.""" + return self._cancel_event + + def cancel(self) -> None: + """Request cooperative cancellation at the next outcome boundary.""" + self._cancel_event.set() + + def _progress(self, report: WindStrategyProgress) -> None: + self.progressed.emit(report.completed_outcomes, report.total_outcomes) + + def run(self) -> None: # pragma: no cover - signal behavior is tested + """Execute and translate typed completion states into Qt signals.""" + try: + result = analyze_wind_strategies( + self._request, + progress_cb=self._progress, + cancel_event=self._cancel_event, + ) + except WindStrategyCancelledError: + self.cancelled.emit() + except Exception as exc: # noqa: BLE001 - expose worker failures in UI + logger.warning("wind strategy analysis failed: %s", exc) + self.failed.emit(str(exc)) + else: + self.succeeded.emit(result) + + +__all__ = ["WindStrategyWorker"] diff --git a/src/rate_of_closure/ui/pyqt6/workspace_navigation.py b/src/rate_of_closure/ui/pyqt6/workspace_navigation.py index d1e007cbe8..ffae61985d 100644 --- a/src/rate_of_closure/ui/pyqt6/workspace_navigation.py +++ b/src/rate_of_closure/ui/pyqt6/workspace_navigation.py @@ -169,7 +169,7 @@ def _restore_primary_navigation(self) -> None: bar = self._primary_tab_bar() for destination, module_id in enumerate(order): bar.moveTab(self._module_index(module_id), destination) - visible = self._sanitized_visibility(state.get("visible")) + visible = self._sanitized_visibility(state.get("visible"), supplied_order) for module_id in _DEFAULT_TAB_IDS: self._tabs.setTabVisible( self._module_index(module_id), module_id in visible @@ -206,11 +206,17 @@ def _sanitized_order(supplied: object) -> list[str]: return order @staticmethod - def _sanitized_visibility(supplied: object) -> set[str]: - """Migrate legacy state and always retain required modules.""" + def _sanitized_visibility(supplied: object, supplied_order: object) -> set[str]: + """Migrate legacy state, revealing modules absent from the saved order.""" if not isinstance(supplied, list): return set(_DEFAULT_TAB_IDS) visible = {item for item in supplied if item in _DEFAULT_TAB_IDS} + known = ( + {item for item in supplied_order if item in _DEFAULT_TAB_IDS} + if isinstance(supplied_order, list) + else set() + ) + visible.update(item for item in _DEFAULT_TAB_IDS if item not in known) visible.update(_REQUIRED_TAB_IDS) return visible diff --git a/src/rate_of_closure/variation/canonical_numeric_json.py b/src/rate_of_closure/variation/canonical_numeric_json.py new file mode 100644 index 0000000000..ad16352962 --- /dev/null +++ b/src/rate_of_closure/variation/canonical_numeric_json.py @@ -0,0 +1,53 @@ +"""Deterministic JSON encoding for cross-runtime numeric contract payloads.""" + +from __future__ import annotations + +import json +import math +from collections.abc import Mapping +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + +_FLOAT_QUANTUM = Decimal("0.00000000001") + + +def _canonical_float_token(value: float) -> str: + if not math.isfinite(value): + raise ValueError("canonical JSON requires finite floats") + if value == 0: + return "0" + if value.is_integer(): + return str(int(value)) + rounded = Decimal.from_float(value).quantize(_FLOAT_QUANTUM, rounding=ROUND_HALF_UP) + if rounded.is_zero(): + return "0" + return format(rounded, "f").rstrip("0").rstrip(".") + + +def canonical_numeric_json(value: Any) -> str: + """Serialize JSON-compatible data with stable fixed-point float tokens.""" + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + return _canonical_float_token(value) + if isinstance(value, int): + return str(value) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, Mapping): + if not all(isinstance(key, str) for key in value): + raise TypeError("canonical JSON object keys must be strings") + items = ( + f"{json.dumps(key, ensure_ascii=False)}:" + f"{canonical_numeric_json(value[key])}" + for key in sorted(value) + ) + return "{" + ",".join(items) + "}" + if isinstance(value, (list, tuple)): + return "[" + ",".join(canonical_numeric_json(item) for item in value) + "]" + raise TypeError(f"unsupported canonical JSON value: {type(value).__name__}") + + +__all__ = ["canonical_numeric_json"] diff --git a/src/rate_of_closure/variation/capability_observation_adapter.py b/src/rate_of_closure/variation/capability_observation_adapter.py new file mode 100644 index 0000000000..9d1684c4d3 --- /dev/null +++ b/src/rate_of_closure/variation/capability_observation_adapter.py @@ -0,0 +1,377 @@ +"""Adapt shared capability observations to the app scalar-ensemble contract.""" + +from __future__ import annotations + +import math +from collections.abc import Iterable + +from rate_of_closure.variation.scalar_ensemble_contract import ( + SCALAR_ENSEMBLE_SCHEMA_VERSION, + ScalarCohortDefinition, + ScalarEnsembleDataset, + ScalarEnsembleProvenance, + ScalarEnsembleRow, + ScalarEnsembleStage, + ScalarVariableCategory, + ScalarVariableDefinition, + scalar_ensemble_row_id, +) +from shared.python.contracts import require +from shared.python.swing_sim.flight.capability_contract import TargetDefinition +from shared.python.swing_sim.flight.capability_observation import ( + CapabilitySampleObservation, + CapabilitySampleParameter, + CapabilitySampleStatus, +) +from shared.python.swing_sim.flight.result_contract import ( + FlightMetricId, + SignRule, + flight_metric_catalog, +) +from shared.python.swing_sim.solver.targets import TargetRegion + +from .canonical_numeric_json import canonical_numeric_json + +MAX_CAPABILITY_OBSERVATION_ROWS = 100_000 +_ADAPTER_ID = "capability-sample-observation/scalar-ensemble/v1" +_SOURCE_SCHEMA = "capability-sample-observation/v1" + +_STAGES = ( + ScalarEnsembleStage("nominal", "Nominal Parameters"), + ScalarEnsembleStage("perturbed", "Perturbed Parameters"), + ScalarEnsembleStage("evaluation", "Evaluator Metrics"), + ScalarEnsembleStage("target", "Target Diagnostics"), +) +_CATEGORIES = ( + ScalarVariableCategory("parameter", "Capability Parameters"), + ScalarVariableCategory("metric", "Evaluator Metrics"), + ScalarVariableCategory("target", "Target Diagnostics"), +) +_COHORTS = ( + ScalarCohortDefinition("complete", "Complete"), + ScalarCohortDefinition("no_impact", "No Impact"), + ScalarCohortDefinition("failed", "Failed"), +) +_TARGET_VARIABLE_DECLARATIONS = ( + ("target_downrange_residual", "Target Downrange Residual", "m"), + ("target_lateral_residual", "Target Lateral Residual", "m"), + ("target_residual", "Target Center Miss Distance", "m"), + ("target_signed_distance", "Target Signed Distance", "m"), + ("target_solver_residual", "Target Solver Residual", "m"), + ("target_contains", "Inside Target", "1"), +) +_TARGET_VARIABLES = tuple( + ScalarVariableDefinition(key, label, unit, "target", "target") + for key, label, unit in _TARGET_VARIABLE_DECLARATIONS +) + + +def _ordered_observations( + observations: tuple[CapabilitySampleObservation, ...], +) -> tuple[CapabilitySampleObservation, ...]: + ordered = tuple(sorted(observations, key=lambda item: item.attempt_ordinal)) + require(bool(ordered), "capability observations must be nonempty") + attempts = tuple(item.attempt_ordinal for item in ordered) + require( + len(set(attempts)) == len(attempts), "attempt_ordinal values must be unique" + ) + require( + attempts == tuple(range(len(ordered))), + "attempt_ordinal values must form a contiguous prefix from zero", + ) + require( + len({item.problem_id for item in ordered}) == 1, + "observations must share one problem_id", + ) + require( + len({item.total_count for item in ordered}) == 1, + "observations must share one total_count", + ) + scalar_ids = set(_metric_ids()) + require( + all( + metric.metric_id in scalar_ids + for item in ordered + for metric in item.metrics + ), + "capability observations may contain only scalar flight metrics", + ) + return ordered + + +def _parameter_declarations( + observations: tuple[CapabilitySampleObservation, ...], +) -> tuple[CapabilitySampleParameter, ...]: + by_club: dict[str, tuple[tuple[str, str], ...]] = {} + declared: dict[str, CapabilitySampleParameter] = {} + for observation in observations: + signature = tuple( + (item.parameter_id, item.unit) for item in observation.parameters + ) + previous = by_club.setdefault(observation.club_id, signature) + require(previous == signature, "parameter declarations changed within a club") + for parameter in observation.parameters: + prior = declared.get(parameter.parameter_id) + require( + prior is None or prior.unit == parameter.unit, + "parameter declarations use conflicting units", + parameter.parameter_id, + ) + declared.setdefault(parameter.parameter_id, parameter) + return tuple(declared.values()) + + +def _metric_ids() -> tuple[FlightMetricId, ...]: + return tuple( + definition.metric_id + for definition in flight_metric_catalog().definitions + if definition.sign_rule is not SignRule.VECTOR_COMPONENTS + ) + + +def _variables( + parameters: tuple[CapabilitySampleParameter, ...], + metric_ids: tuple[FlightMetricId, ...], +) -> tuple[ScalarVariableDefinition, ...]: + variables: list[ScalarVariableDefinition] = [] + for parameter in parameters: + label = _ascii_parameter_label(parameter.parameter_id) + variables.extend( + ( + ScalarVariableDefinition( + f"nominal.{parameter.parameter_id}", + f"Nominal {label}", + parameter.unit, + "nominal", + "parameter", + ), + ScalarVariableDefinition( + f"perturbed.{parameter.parameter_id}", + f"Perturbed {label}", + parameter.unit, + "perturbed", + "parameter", + ), + ) + ) + catalog = flight_metric_catalog() + variables.extend( + ScalarVariableDefinition( + f"metric.{metric_id.value}", + catalog.definition(metric_id).label, + catalog.definition(metric_id).unit, + "evaluation", + "metric", + ) + for metric_id in metric_ids + ) + return tuple(variables) + _TARGET_VARIABLES + + +def _ascii_parameter_label(parameter_id: str) -> str: + """Build a label without Unicode or locale-dependent case conversion.""" + words = parameter_id.split("_") + return " ".join( + chr(ord(word[0]) - 32) + word[1:] if word and "a" <= word[0] <= "z" else word + for word in words + ) + + +def _target_region(target: TargetDefinition) -> TargetRegion: + return TargetRegion( + target.kind, + target.distance_m, + target.radius_m, + target.lateral_m, + target.band_half_length_m, + target.half_width_m, + ) + + +def _target_values( + observation: CapabilitySampleObservation, target: TargetRegion +) -> dict[str, float]: + metrics = {item.metric_id: item.value for item in observation.metrics} + require( + FlightMetricId.CARRY_DISTANCE in metrics + and FlightMetricId.CARRY_OFFLINE in metrics, + "complete observation requires carry and offline metrics", + ) + carry = metrics[FlightMetricId.CARRY_DISTANCE] + offline = metrics[FlightMetricId.CARRY_OFFLINE] + center_carry, center_offline = target.center + return { + "target_downrange_residual": carry - center_carry, + "target_lateral_residual": offline - center_offline, + "target_residual": math.hypot(carry - center_carry, offline - center_offline), + "target_signed_distance": target.signed_distance(carry, offline), + "target_solver_residual": target.residual_m(carry, offline), + "target_contains": float(target.contains(carry, offline)), + } + + +def _row_values( + observation: CapabilitySampleObservation, + variable_keys: tuple[str, ...], + target: TargetRegion, +) -> dict[str, float | None]: + values: dict[str, float | None] = dict.fromkeys(variable_keys) + for parameter in observation.parameters: + values[f"nominal.{parameter.parameter_id}"] = parameter.nominal_value + values[f"perturbed.{parameter.parameter_id}"] = parameter.perturbed_value + if observation.effective_status is CapabilitySampleStatus.COMPLETE: + for metric in observation.metrics: + values[f"metric.{metric.metric_id.value}"] = metric.value + values.update(_target_values(observation, target)) + return values + + +def _row_attributes( + observation: CapabilitySampleObservation, + metric_ids: tuple[FlightMetricId, ...], +) -> dict[str, str | None]: + provenance = {item.metric_id: item.provenance for item in observation.metrics} + attributes: dict[str, str | None] = { + "club_id": observation.club_id, + "attempt_ordinal": str(observation.attempt_ordinal), + "attempted_count": str(observation.attempted_count), + "total_count": str(observation.total_count), + "candidate_ordinal": str(observation.candidate_ordinal), + "club_candidate_ordinal": str(observation.club_candidate_ordinal), + "sample_ordinal": str(observation.sample_ordinal), + "source_status": ( + None + if observation.source_status is None + else observation.source_status.value + ), + "effective_status": observation.effective_status.value, + "reason_code": observation.reason_code, + "source_reason": observation.source_reason, + } + attributes.update( + { + f"metric.{metric_id.value}.provenance": provenance.get(metric_id) + for metric_id in metric_ids + } + ) + return attributes + + +def _rows( + observations: tuple[CapabilitySampleObservation, ...], + variables: tuple[ScalarVariableDefinition, ...], + metric_ids: tuple[FlightMetricId, ...], + target: TargetRegion, +) -> tuple[ScalarEnsembleRow, ...]: + variable_keys = tuple(item.key for item in variables) + return tuple( + ScalarEnsembleRow( + scalar_ensemble_row_id( + observation.sample_ordinal, + f"candidate:{observation.candidate_ordinal}/club:{observation.club_id}", + ), + observation.sample_ordinal, + observation.effective_status.value, + _row_values(observation, variable_keys, target), + f"candidate:{observation.candidate_ordinal}/club:{observation.club_id}", + _row_attributes(observation, metric_ids), + ) + for observation in observations + ) + + +def _build_dataset( + observations: tuple[CapabilitySampleObservation, ...], + target: TargetDefinition, + source_provenance: str, +) -> ScalarEnsembleDataset: + ordered = _ordered_observations(observations) + parameters = _parameter_declarations(ordered) + metric_ids = _metric_ids() + variables = _variables(parameters, metric_ids) + rows = _rows(ordered, variables, metric_ids, _target_region(target)) + return ScalarEnsembleDataset( + SCALAR_ENSEMBLE_SCHEMA_VERSION, + ordered[0].problem_id, + ScalarEnsembleProvenance(_ADAPTER_ID, _SOURCE_SCHEMA, source_provenance), + _STAGES, + _CATEGORIES, + variables, + _COHORTS, + rows, + ) + + +class CapabilityObservationEnsembleBuilder: + """Bounded in-memory observation sink that rejects before overflowing.""" + + def __init__( + self, + target: TargetDefinition, + max_rows: int, + source_provenance: str, + ) -> None: + require( + type(max_rows) is int and 1 <= max_rows <= MAX_CAPABILITY_OBSERVATION_ROWS, + "max_rows must be an integer within " + f"[1, {MAX_CAPABILITY_OBSERVATION_ROWS}]", + max_rows, + ) + require(bool(source_provenance.strip()), "source_provenance must be nonempty") + self._target = target + self._max_rows = max_rows + self._source_provenance = source_provenance + self._observations: list[CapabilitySampleObservation] = [] + + @property + def retained_count(self) -> int: + """Return the number of accepted rows, never greater than max_rows.""" + return len(self._observations) + + def accept(self, observation: CapabilitySampleObservation) -> None: + """Accept one row or reject it before bounded storage is exceeded.""" + require( + self.retained_count < self._max_rows, + f"observation row exceeds max_rows {self._max_rows}", + ) + require( + isinstance(observation, CapabilitySampleObservation), + "observation must be a CapabilitySampleObservation", + ) + self._observations.append(observation) + + def __call__(self, observation: CapabilitySampleObservation) -> None: + """Accept one observation when used directly as an optimizer sink.""" + self.accept(observation) + + def build(self) -> ScalarEnsembleDataset: + """Build an immutable dataset from the accepted trace prefix.""" + return _build_dataset( + tuple(self._observations), self._target, self._source_provenance + ) + + +def build_capability_observation_ensemble( + observations: Iterable[CapabilitySampleObservation], + target: TargetDefinition, + max_rows: int, + source_provenance: str, +) -> ScalarEnsembleDataset: + """Stream observations through the bounded builder and return the dataset.""" + builder = CapabilityObservationEnsembleBuilder(target, max_rows, source_provenance) + for observation in observations: + builder.accept(observation) + return builder.build() + + +def capability_observation_ensemble_json(dataset: ScalarEnsembleDataset) -> str: + """Serialize one ensemble with deterministic cross-runtime numeric rounding.""" + return canonical_numeric_json(dataset.to_wire()) + + +__all__ = [ + "CapabilityObservationEnsembleBuilder", + "MAX_CAPABILITY_OBSERVATION_ROWS", + "build_capability_observation_ensemble", + "capability_observation_ensemble_json", +] diff --git a/src/rate_of_closure/variation/scalar_ensemble_io.py b/src/rate_of_closure/variation/scalar_ensemble_io.py new file mode 100644 index 0000000000..282ec51d9f --- /dev/null +++ b/src/rate_of_closure/variation/scalar_ensemble_io.py @@ -0,0 +1,69 @@ +"""Lossless exports and honest summaries for scalar-ensemble datasets.""" + +from __future__ import annotations + +import csv +import io + +from rate_of_closure.variation.scalar_ensemble_contract import ScalarEnsembleDataset + + +def _spreadsheet_safe(value: object) -> object: + if isinstance(value, str) and value.lstrip("'").startswith(("=", "+", "-", "@")): + return f"'{value}" + return "" if value is None else value + + +def scalar_ensemble_csv(dataset: ScalarEnsembleDataset) -> str: + """Serialize every raw row, nullable variable, and attribute to CSV.""" + attribute_keys = sorted( + { + key + for row in dataset.rows + for key in (() if row.attributes is None else row.attributes) + } + ) + fixed = ["row_id", "trial_index", "series_id", "cohort"] + variable_keys = [variable.key for variable in dataset.variables] + attributes = [f"attribute:{key}" for key in attribute_keys] + output = io.StringIO() + writer = csv.writer(output, lineterminator="\n") + writer.writerow( + [_spreadsheet_safe(item) for item in fixed + variable_keys + attributes] + ) + for row in dataset.rows: + row_attributes = {} if row.attributes is None else row.attributes + values: list[object] = [ + row.row_id, + row.trial_index, + row.series_id, + row.cohort, + *(row.values[key] for key in variable_keys), + *(row_attributes.get(key) for key in attribute_keys), + ] + writer.writerow([_spreadsheet_safe(value) for value in values]) + return output.getvalue().removesuffix("\n") + + +def non_complete_reason_summary(dataset: ScalarEnsembleDataset) -> str: + """Report why non-complete rows ended so counts are not read as defects. + + A horizon nonconvergence is normalized into the ``failed`` cohort by the + observation contract, which has no separate member for it. Naming the + retained reason keeps the count truthful. + """ + counts: dict[str, int] = {} + for row in dataset.rows: + if row.cohort == "complete": + continue + attributes = {} if row.attributes is None else row.attributes + reason = attributes.get("reason_code") or "unspecified" + counts[reason] = counts.get(reason, 0) + 1 + if not counts: + return "" + ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0])) + detail = "; ".join(f"{reason} x{count}" for reason, count in ordered) + return f" Non-complete reasons: {detail}." + + +__all__ = ["non_complete_reason_summary", "scalar_ensemble_csv"] diff --git a/src/rate_of_closure/web/package-lock.json b/src/rate_of_closure/web/package-lock.json index 4c54699114..bf2d484caa 100644 --- a/src/rate_of_closure/web/package-lock.json +++ b/src/rate_of_closure/web/package-lock.json @@ -14,7 +14,6 @@ }, "devDependencies": { "@eslint/js": "^9.21.0", - "@tauri-apps/cli": "latest", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -1653,238 +1652,6 @@ "win32" ] }, - "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", - "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.4", - "@tauri-apps/cli-darwin-x64": "2.11.4", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", - "@tauri-apps/cli-linux-arm64-musl": "2.11.4", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-musl": "2.11.4", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", - "@tauri-apps/cli-win32-x64-msvc": "2.11.4" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", - "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", - "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/src/rate_of_closure/web/package.json b/src/rate_of_closure/web/package.json index 588cbce090..f36e5f620f 100644 --- a/src/rate_of_closure/web/package.json +++ b/src/rate_of_closure/web/package.json @@ -9,9 +9,6 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "test": "vitest run", "preview": "vite preview", - "tauri": "tauri", - "tauri:dev": "tauri dev", - "tauri:build": "tauri build", "type-check": "tsc --noEmit" }, "dependencies": { @@ -21,7 +18,6 @@ }, "devDependencies": { "@eslint/js": "^9.21.0", - "@tauri-apps/cli": "latest", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx new file mode 100644 index 0000000000..1c8c9deeb3 --- /dev/null +++ b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { runCapabilityOptimization, type CapabilityRunOutput } from "../model/capabilityRun"; +import type { CapabilityRunner } from "../model/capabilityWorkerClient"; +import { CapabilityOptimizationPanel } from "./CapabilityOptimizationPanel"; + +describe("CapabilityOptimizationPanel", () => { + it("runs a bounded workflow and exposes alternatives plus raw diagnostics", async () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + const runner: CapabilityRunner = (document, onProgress) => ({ + promise: Promise.resolve(runCapabilityOptimization(document, onProgress)), + cancel: vi.fn(), + }); + render(); + fireEvent.change(screen.getByLabelText("Candidate budget"), { target: { value: "1" } }); + fireEvent.change(screen.getByLabelText("Trials per candidate"), { target: { value: "2" } }); + fireEvent.change(screen.getByLabelText("Alternatives retained"), { target: { value: "1" } }); + + fireEvent.click(screen.getByRole("button", { name: "Run optimization" })); + + expect(await screen.findByText(/Attempted 2; complete/)).toBeInTheDocument(); + expect(screen.getByRole("table", { name: "Ranked capability alternatives" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Capability raw observation rows" })).toBeInTheDocument(); + expect(screen.getByText(/Paired finite 2\/2/)).toBeInTheDocument(); + const axis = screen.getByLabelText("Horizontal axis"); + const labels = [...axis.querySelectorAll("option")].map(({ textContent }) => textContent); + expect(new Set(labels).size).toBe(labels.length); + }); + + it("shows boundary validation errors without dispatching a worker", () => { + const runner = vi.fn() as unknown as CapabilityRunner; + render(); + fireEvent.change(screen.getByLabelText("Ball speed center"), { target: { value: "0" } }); + + fireEvent.click(screen.getByRole("button", { name: "Run optimization" })); + + expect(screen.getByRole("alert")).toHaveTextContent("ballSpeedMps"); + expect(runner).not.toHaveBeenCalled(); + }); + + it("exposes integration settings that are persisted and run", () => { + render(); + + expect(screen.getByLabelText("Maximum flight time")).toHaveValue("10"); + expect(screen.getByLabelText("Trajectory sample interval")).toHaveValue("0.01"); + }); + + it("allows normal keyboard entry of a negative decimal", async () => { + const user = userEvent.setup(); + const runner = vi.fn(() => ({ + promise: new Promise(() => undefined), + cancel: vi.fn(), + })); + render(); + const tilt = screen.getByLabelText("Fixed spin-axis tilt (+ fade/right)"); + + expect(tilt).toHaveAttribute("type", "text"); + expect(tilt).toHaveAttribute("inputmode", "decimal"); + await user.clear(tilt); + await user.type(tilt, "-3.5"); + await user.tab(); + await user.click(screen.getByRole("button", { name: "Run optimization" })); + + expect(tilt).toHaveValue("-3.5"); + expect(runner.mock.calls[0][0].evaluatorConfig.spinDefaults[0].spinAxisTiltDeg) + .toBe(-3.5); + }); +}); diff --git a/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.tsx b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.tsx new file mode 100644 index 0000000000..7e17e88685 --- /dev/null +++ b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; + +import { useCapabilityOptimization } from "../hooks/useCapabilityOptimization"; +import { + buildCapabilityWorkflow, + capabilityWorkflowToJson, + type CapabilityWorkflowInputs, +} from "../model/capabilityWorkflow"; +import type { CapabilityRunner } from "../model/capabilityWorkerClient"; +import { CapabilityResults } from "./CapabilityResults"; +import { BUTTON_CLASS, INPUT_CLASS, PANEL_CLASS, downloadText } from "./variationUi"; + +const NUMERIC_FIELDS: ReadonlyArray<{ key: keyof CapabilityWorkflowInputs; + label: string; unit: string; min?: number; max?: number; step?: number }> = [ + { key: "ballSpeedMps", label: "Ball speed center", unit: "m/s", min: 1, max: 100, step: 0.1 }, + { key: "ballSpeedStdMps", label: "Ball speed standard deviation", unit: "m/s", min: 0, max: 30, step: 0.1 }, + { key: "launchAngleDeg", label: "Launch angle center", unit: "deg", min: -10, max: 45, step: 0.1 }, + { key: "launchAngleStdDeg", label: "Launch angle standard deviation", unit: "deg", min: 0, max: 30, step: 0.1 }, + { key: "launchDirectionDeg", label: "Launch direction center (+ right)", unit: "deg", min: -30, max: 30, step: 0.1 }, + { key: "launchDirectionStdDeg", label: "Direction standard deviation", unit: "deg", min: 0, max: 30, step: 0.1 }, + { key: "totalSpinRpm", label: "Fixed total spin", unit: "rpm", min: 0, max: 20_000, step: 10 }, + { key: "spinAxisTiltDeg", label: "Fixed spin-axis tilt (+ fade/right)", unit: "deg", min: -90, max: 90, step: 0.1 }, + { key: "targetDistanceM", label: "Target distance", unit: "m", min: 0.1, max: 1_000, step: 1 }, + { key: "targetLateralM", label: "Target lateral (+ right)", unit: "m", min: -500, max: 500, step: 1 }, + { key: "targetRadiusM", label: "Target radius", unit: "m", min: 0.1, max: 500, step: 1 }, + { key: "maxTimeS", label: "Maximum flight time", unit: "s", min: 0.001, max: 120, step: 0.1 }, + { key: "trajectorySampleIntervalS", label: "Trajectory sample interval", unit: "s", min: 0.001, max: 0.1, step: 0.001 }, + { key: "candidateBudget", label: "Candidate budget", unit: "candidates", min: 1, max: 100_000, step: 1 }, + { key: "ensembleSize", label: "Trials per candidate", unit: "trials", min: 1, max: 100_000, step: 1 }, + { key: "alternativesCount", label: "Alternatives retained", unit: "shots", min: 1, max: 100_000, step: 1 }, + { key: "seed", label: "Deterministic seed", unit: "int31", min: 0, max: 2 ** 31 - 1, step: 1 }, +]; +const OBJECTIVES = ["maximize_carry", "minimize_expected_miss", "maximize_target_hold", + "minimize_variability", "minimize_downside", "distance_control_pareto"] as const; + +function NumericInput({ field, value, update }: { + readonly field: (typeof NUMERIC_FIELDS)[number]; readonly value: number; + readonly update: (value: number) => void; +}) { + const [draft, setDraft] = useState(String(value)); + useEffect(() => setDraft(String(value)), [value]); + const commit = (source: string): void => { + const parsed = Number(source); + if (source.trim() && Number.isFinite(parsed)) update(parsed); + else setDraft(String(value)); + }; + return event.currentTarget.select()} + onBlur={(event) => commit(event.currentTarget.value)} + onChange={(event) => { + const next = event.target.value; setDraft(next); const parsed = Number(next); + if (next.trim() && !/[eE.+-]$/.test(next) && Number.isFinite(parsed)) update(parsed); + }} />; +} + +function InputsForm({ inputs, update }: { readonly inputs: CapabilityWorkflowInputs; + readonly update: (key: keyof CapabilityWorkflowInputs, value: string | number) => void }) { + return
+ + + + {NUMERIC_FIELDS.map((field) => )} +
; +} + +function WorkflowActions({ state }: { + readonly state: ReturnType; +}) { + const [saveError, setSaveError] = useState(null); + const save = (): void => { + try { downloadText("capability-workflow.json", capabilityWorkflowToJson( + buildCapabilityWorkflow(state.inputs)), "application/json"); setSaveError(null); } + catch (reason: unknown) { setSaveError(reason instanceof Error ? reason.message : String(reason)); } + }; + return <>
+ + + + + {state.status}{state.progress.total > 0 + ? ` — ${state.progress.completed}/${state.progress.total}` : ""} +
{(state.error || saveError) &&

{state.error ?? saveError}

}; +} + +export function CapabilityOptimizationPanel({ runner }: { + readonly runner?: CapabilityRunner; +}): JSX.Element { + const state = useCapabilityOptimization(runner); + return
+

Shot Capability Optimizer

+

Still-air carry-to-first-ground-crossing model. Fixed spin is explicit and sourced; wind, bounce, roll, and total distance are not included.

+ + + {state.output && } +
; +} diff --git a/src/rate_of_closure/web/src/components/CapabilityResults.test.tsx b/src/rate_of_closure/web/src/components/CapabilityResults.test.tsx new file mode 100644 index 0000000000..eb1751dcbd --- /dev/null +++ b/src/rate_of_closure/web/src/components/CapabilityResults.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { runCapabilityOptimization } from "../model/capabilityRun"; +import { + buildCapabilityWorkflow, + defaultCapabilityWorkflowInputs, +} from "../model/capabilityWorkflow"; +import { CapabilityResults } from "./CapabilityResults"; + +const output = (ensembleSize: number) => runCapabilityOptimization( + buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), + candidateBudget: 1, + ensembleSize, + alternativesCount: 1, + }), +); + +describe("CapabilityResults", () => { + it("exposes the complete ranked-alternative diagnostic contract", () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + render(); + + const table = screen.getByRole("table", { name: "Ranked capability alternatives" }); + [ + "Score", "Miss CVaR", "Downside carry", "Outcomes", "Failure rate", + "Evidence", "Pareto", + ].forEach((heading) => { + expect(within(table).getByRole("columnheader", { name: heading })).toBeInTheDocument(); + }); + expect(within(table).getByText(/ball_speed=.*m\/s/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export results CSV" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export results JSON" })).toBeInTheDocument(); + }); + + it("resets raw-row paging when a smaller result replaces the output", async () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Next rows" })); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + + view.rerender(); + + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next rows" })).toBeDisabled(); + }); +}); diff --git a/src/rate_of_closure/web/src/components/CapabilityResults.tsx b/src/rate_of_closure/web/src/components/CapabilityResults.tsx new file mode 100644 index 0000000000..c855084a4c --- /dev/null +++ b/src/rate_of_closure/web/src/components/CapabilityResults.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState } from "react"; + +import type { CapabilityRunOutput } from "../model/capabilityRun"; +import { stableCapabilityObservationEnsembleJson } from "../model/capabilityObservationEnsemble"; +import { + capabilityAlternativesCsv, stableCapabilityResultExportJson, +} from "../model/capabilityResultExport"; +import { + buildScalarEnsembleScatter, type ScalarVariableDefinition, +} from "../model/scalarEnsembleContract"; +import { nonCompleteReasonSummary, scalarEnsembleToCsv } from "../model/scalarEnsembleCsv"; +import { ScalarEnsembleScatter } from "./ScalarEnsembleScatter"; +import { BUTTON_CLASS, INPUT_CLASS, downloadText } from "./variationUi"; + +const PAGE_SIZE = 25; + +const axisLabel = ( + variable: ScalarVariableDefinition, variables: readonly ScalarVariableDefinition[], +): string => { + const duplicate = variables.some((item) => item.key !== variable.key + && item.label === variable.label && item.unit === variable.unit); + const stage = variable.stage_key.charAt(0).toUpperCase() + variable.stage_key.slice(1); + return `${duplicate ? `${stage} · ` : ""}${variable.label} [${variable.unit}]`; +}; + +function Alternatives({ output }: { readonly output: CapabilityRunOutput }) { + const units = Object.fromEntries(output.ensemble.variables + .filter(({ key }) => key.startsWith("nominal.")) + .map(({ key, unit }) => [key.slice("nominal.".length), unit])); + const recommendation = (item: CapabilityRunOutput["result"]["alternatives"][number]): string => + item.parameters.map((value) => { + const unit = units[value.parameterId]; + if (unit === undefined) throw new RangeError(`missing unit for ${value.parameterId}`); + return `${value.parameterId}=${value.value.toPrecision(5)} ${unit}`; + }).join(" · "); + return
+ + + + + + + + {output.result.alternatives.map((item) => + + + + + + + + + + + + + )}
Rank / ClubRecommendationScoreCarryMean missDispersionTarget holdMiss CVaRDownside carryOutcomesFailure rateEvidencePareto
{item.rank}. {item.clubId}{recommendation(item)}{item.score.toPrecision(5)}{item.meanCarryM.toFixed(2)} m{item.expectedMissM.toFixed(2)} m{item.dispersionRmsM.toFixed(2)} m{(100 * item.targetHoldProbability).toFixed(1)}%{item.cvarMissM.toFixed(2)} m{item.downsideCarryM.toFixed(2)} m{item.successfulCount}/{item.sampleCount} complete · { + item.noImpactCount} no impact · {item.failedCount} failed{(100 * item.failureFraction).toFixed(1)}%{(100 * item.confidence).toFixed(1)}% · { + item.extrapolated ? "extrapolated" : "within envelope"} · { + item.limitingConstraints.join(", ") || "no limits"}{item.paretoEfficient ? "efficient" : "dominated"}
; +} + +function RawRows({ output }: { readonly output: CapabilityRunOutput }) { + const [page, setPage] = useState(0); + useEffect(() => setPage(0), [output.ensemble]); + const rows = output.ensemble.rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + const pages = Math.max(1, Math.ceil(output.ensemble.rows.length / PAGE_SIZE)); + return
+
+ + Page {page + 1} of {pages} + +
+
+ + + {rows.map((row) => + + )} +
RowCohortSeriesAvailable scalars
{row.row_id}{row.cohort}{row.series_id}{ + Object.values(row.values).filter((value) => value !== null).length}
+
; +} + +export function CapabilityResults({ output }: { readonly output: CapabilityRunOutput }) { + const variables = output.ensemble.variables; + const defaultX = variables.find(({ key }) => key === "perturbed.ball_speed")?.key ?? variables[0].key; + const defaultY = variables.find(({ key }) => key === "metric.carry_distance")?.key ?? variables[1].key; + const [xKey, setXKey] = useState(defaultX); const [yKey, setYKey] = useState(defaultY); + const scatter = useMemo(() => buildScalarEnsembleScatter(output.ensemble, xKey, yKey), + [output.ensemble, xKey, yKey]); + const availability = scatter.availability.overall; + return
+

Attempted {output.result.evaluationsAttempted}; complete { + output.result.evaluationsCompleted}; failed {output.result.failedCount}; no impact { + output.result.noImpactCount}. Status: {output.result.status}.{ + nonCompleteReasonSummary(output.ensemble)}

+ +
{[["Horizontal axis", xKey, setXKey], + ["Vertical axis", yKey, setYKey]].map(([label, value, setter]) => + )}
+

Paired finite {availability.paired_finite}/{ + availability.total_rows}; unavailable {availability.unavailable}.

+ + +
+ + +
+
; +} diff --git a/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx b/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx index 68b877bf78..7ac4b94429 100644 --- a/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx +++ b/src/rate_of_closure/web/src/components/FlightExplorerPanel.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { useState } from "react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { FlightExplorerPanel } from "./FlightExplorerPanel"; +import * as flightExplorer from "../model/flightExplorer"; import { DEFAULT_TARGET, spatialTargetFromRegion } from "../model/targets"; function FlightExplorerHarness() { @@ -23,7 +24,7 @@ beforeAll(() => { }); describe("FlightExplorerPanel input editing", () => { - it("applies one canonical spatial target to its summary and flight plots", () => { + it("applies one canonical spatial target to its summary and flight plots", async () => { render(); const downrange = screen.getByLabelText("Target downrange m"); @@ -38,6 +39,11 @@ describe("FlightExplorerPanel input editing", () => { .toHaveAttribute("aria-description", expect.stringContaining("180.0 m downrange")); expect(screen.getByRole("status", { name: "Spatial target assessment" })) .toHaveTextContent("Run Flight to evaluate this target"); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(screen.getByRole("region", { name: "Wind strategy uncertainty analysis" })) + .toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Run Flight" })); expect(screen.getByRole("status", { name: "Spatial target assessment" })) @@ -98,4 +104,16 @@ describe("FlightExplorerPanel input editing", () => { expect(screen.getByLabelText("Flight side profile (height vs carry)")) .toBeInTheDocument(); }); + + it("keeps the page mounted and disables wind analysis for an invalid launch snapshot", async () => { + const launchSpy = vi.spyOn(flightExplorer, "directLaunch").mockImplementation(() => { + throw new Error("transient launch value is invalid"); + }); + + expect(() => render()).not.toThrow(); + + expect(await screen.findByRole("alert")).toHaveTextContent(/transient launch value is invalid/i); + expect(screen.getByRole("button", { name: "Run wind strategy analysis" })).toBeDisabled(); + launchSpy.mockRestore(); + }); }); diff --git a/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx b/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx index 7a0fba790c..b2235a81d0 100644 --- a/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx +++ b/src/rate_of_closure/web/src/components/FlightExplorerPanel.tsx @@ -9,13 +9,14 @@ * until the P7 WASM kernels land. */ -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { DecimalInput } from "./DecimalInput"; import { FieldInfo } from "./FieldInfo"; import { FlightCanvases } from "./FlightCanvases"; import { FlightPlayback3D } from "./FlightPlayback3D"; import { SpatialTargetSection } from "./SpatialTargetSection"; +import type { Launch } from "../model/flight"; import { compareWind, directLaunch, @@ -34,6 +35,10 @@ import type { SpatialTargetTs } from "../model/spatialTarget"; const SPEED_UNITS: Record = { mph: 1.0, "m/s": 2.236936292054402 }; +const LazyWindStrategyPanel = lazy(() => import("./WindStrategyPanel").then((module) => ({ + default: module.WindStrategyPanel, +}))); + const DIRECTION_CONVENTIONS: Array<{ value: string; label: string; @@ -109,13 +114,22 @@ export function FlightExplorerPanel({ const [error, setError] = useState(null); const directionSigns = launchDirectionSignLabels(directionConvention); + const currentLaunch = () => directLaunch({ + ballSpeedMph: speed * (SPEED_UNITS[speedUnit] / SPEED_UNITS.mph), + launchDirectionConvention: directionConvention, + ...fields, + }); + let windStrategyLaunch: Launch | null = null; + let windStrategyLaunchError: string | null = null; + try { + windStrategyLaunch = currentLaunch(); + } catch (reason: unknown) { + windStrategyLaunchError = reason instanceof Error ? reason.message : String(reason); + } + const run = () => { try { - const launch = directLaunch({ - ballSpeedMph: speed * (SPEED_UNITS[speedUnit] / SPEED_UNITS.mph), - launchDirectionConvention: directionConvention, - ...fields, - }); + const launch = currentLaunch(); const comparison = windEnabled ? compareWind(launch, meteorologicalWind(windSpeedMph / SPEED_UNITS["m/s"], windFromDeg)) : null; @@ -354,6 +368,13 @@ export function FlightExplorerPanel({ /> + + Loading wind strategy workspace… + }> + + ); diff --git a/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx b/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx index dab656fba1..f6af993116 100644 --- a/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx +++ b/src/rate_of_closure/web/src/components/FlightPlayback3D.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FlightPlayback3D } from "./FlightPlayback3D"; @@ -80,6 +80,35 @@ describe("FlightPlayback3D", () => { expect(cancel).toHaveBeenCalled(); }); + it("loops at landing and keeps one animation frame scheduled", () => { + let nextId = 0; + const callbacks = new Map(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + nextId += 1; + callbacks.set(nextId, callback); + return nextId; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => { + callbacks.delete(id); + }); + render(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Loop Ball Flight Playback" })); + fireEvent.change(screen.getByRole("slider", { name: "Ball Flight Time" }), { + target: { value: "1.999" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Play Ball Flight" })); + const callback = [...callbacks.values()][0]; + if (!callback) throw new Error("Expected one scheduled animation frame"); + callbacks.clear(); + act(() => callback(performance.now() + 10)); + + expect(screen.getByRole("button", { name: "Pause Ball Flight" })).toBeEnabled(); + expect(callbacks.size).toBe(1); + const slider = screen.getByRole("slider", { name: "Ball Flight Time" }); + expect(Number((slider as HTMLInputElement).value)).toBeLessThan(0.1); + }); + it("keeps the active labeled target rendered while orbiting and zooming", () => { const fillText = vi.fn(); const context: unknown = new Proxy(function () {} as object, { diff --git a/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx b/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx index 4ca985f2ee..e94f2e00de 100644 --- a/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx +++ b/src/rate_of_closure/web/src/components/FlightPlayback3D.tsx @@ -32,6 +32,7 @@ export function FlightPlayback3D({ points, comparisonPoints = [], spatialTarget const timeRef = useRef(0); const [time, setTime] = useState(0); const [playing, setPlaying] = useState(false); + const [loop, setLoop] = useState(false); const [speed, setSpeed] = useState(1); const [camera, setCamera] = useState(INITIAL_CAMERA); const timeline = useMemo( @@ -61,15 +62,18 @@ export function FlightPlayback3D({ points, comparisonPoints = [], spatialTarget const animate = (now: number) => { const elapsed = Math.max(0, now - previous) / 1000; previous = now; - const next = Math.min(duration, timeRef.current + elapsed * speed); + const unboundedNext = timeRef.current + elapsed * speed; + const next = loop && unboundedNext >= duration + ? unboundedNext % duration + : Math.min(duration, unboundedNext); timeRef.current = next; setTime(next); - if (next >= duration) setPlaying(false); + if (!loop && next >= duration) setPlaying(false); else animationId = window.requestAnimationFrame(animate); }; animationId = window.requestAnimationFrame(animate); return () => window.cancelAnimationFrame(animationId); - }, [playing, duration, speed]); + }, [playing, duration, speed, loop]); const frame = useMemo( () => timeline?.frameAt(time) ?? null, @@ -179,6 +183,15 @@ export function FlightPlayback3D({ points, comparisonPoints = [], spatialTarget ))} + {time.toFixed(2)} / {duration.toFixed(2)} s diff --git a/src/rate_of_closure/web/src/components/PrimaryViewTabs.test.tsx b/src/rate_of_closure/web/src/components/PrimaryViewTabs.test.tsx index 13a7c24e53..ff9f1f6d4f 100644 --- a/src/rate_of_closure/web/src/components/PrimaryViewTabs.test.tsx +++ b/src/rate_of_closure/web/src/components/PrimaryViewTabs.test.tsx @@ -44,6 +44,7 @@ describe("PrimaryViewTabs", () => { "plots", "flight", "launch-monitor-analytics", + "capability-optimization", "variation", "putting", "glossary", diff --git a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx index 515aeb0402..0617ce71b0 100644 --- a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx +++ b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx @@ -1,3 +1,5 @@ +import { lazy, Suspense } from "react"; + import type { ImpactAppModel } from "../hooks/useImpactAppModel"; import type { PrimaryViewId } from "../model/viewPreferences"; import { Derivation } from "./Derivation"; @@ -10,6 +12,12 @@ import { PuttingPanel } from "./PuttingPanel"; import { SimulationPanel } from "./SimulationPanel"; import { VariationPanel } from "./VariationPanel"; +const LazyCapabilityOptimizationPanel = lazy(() => + import("./CapabilityOptimizationPanel").then((module) => ({ + default: module.CapabilityOptimizationPanel, + })), +); + interface WorkspacePanelProps { readonly active: PrimaryViewId; readonly model: ImpactAppModel; @@ -52,6 +60,12 @@ export function PrimaryWorkspacePanel(props: WorkspacePanelProps) { spatialTarget={model.spatialTarget} onSpatialTargetChange={model.setSpatialTarget} />; case "launch-monitor-analytics": return ; + case "capability-optimization": + return ( + Loading…}> + + + ); case "plots": return ; case "simulation": diff --git a/src/rate_of_closure/web/src/components/ScalarEnsembleScatter.test.tsx b/src/rate_of_closure/web/src/components/ScalarEnsembleScatter.test.tsx new file mode 100644 index 0000000000..cc99f9ff17 --- /dev/null +++ b/src/rate_of_closure/web/src/components/ScalarEnsembleScatter.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildScalarEnsembleScatter, + createScalarEnsemble, + scalarEnsembleRowId, +} from "../model/scalarEnsembleContract"; +import { ScalarEnsembleScatter } from "./ScalarEnsembleScatter"; + +const scatter = buildScalarEnsembleScatter(createScalarEnsemble({ + result_id: "scatter-test", + provenance: { adapter_id: "test", source_schema_version: "test/v1", source_provenance: "fixture" }, + stages: [{ key: "input", label: "Input" }, { key: "output", label: "Output" }], + categories: [{ key: "parameter", label: "Parameter" }, { key: "metric", label: "Metric" }], + variables: [ + { key: "speed", label: "Speed", unit: "mph", stage_key: "input", category_key: "parameter" }, + { key: "carry", label: "Carry", unit: "m", stage_key: "output", category_key: "metric" }, + ], + cohorts: [ + { key: "complete", label: "Complete" }, + { key: "no_impact", label: "No Impact" }, + { key: "failed", label: "Failed" }, + ], + rows: [ + { row_id: scalarEnsembleRowId(0), trial_index: 0, cohort: "complete", values: { speed: 90, carry: 180 } }, + { row_id: scalarEnsembleRowId(1), trial_index: 1, cohort: "no_impact", values: { speed: 95, carry: null } }, + ], +}), "speed", "carry"); + +describe("ScalarEnsembleScatter", () => { + it("publishes numeric axis ranges and a cohort legend", () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + render(); + + expect(screen.getByText(/Speed range .* mph; Carry range .* m/)).toBeInTheDocument(); + expect(screen.getByLabelText("Engineering cohort legend")).toHaveTextContent("Complete 1"); + expect(screen.getByLabelText("Engineering cohort legend")).toHaveTextContent("No Impact 0"); + expect(screen.getByLabelText("Engineering cohort legend")).toHaveTextContent("Failed 0"); + }); +}); diff --git a/src/rate_of_closure/web/src/components/ScalarEnsembleScatter.tsx b/src/rate_of_closure/web/src/components/ScalarEnsembleScatter.tsx new file mode 100644 index 0000000000..815e76d67b --- /dev/null +++ b/src/rate_of_closure/web/src/components/ScalarEnsembleScatter.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from "react"; + +import type { ScalarScatterData } from "../model/scalarEnsembleContract"; +import { BUTTON_CLASS } from "./variationUi"; + +const PALETTE = ["#38bdf8", "#f59e0b", "#ef4444", "#a78bfa", "#34d399"]; +const LEFT = 72; +const RIGHT = 18; +const TOP = 16; +const BOTTOM = 48; +const TICK_COUNT = 5; + +const bounds = (values: readonly number[]): readonly [number, number] => { + if (!values.length) return [-1, 1]; + const low = Math.min(...values); const high = Math.max(...values); + const pad = Math.max((high - low) * 0.08, Math.max(Math.abs(low), 1) * 1e-6); + return [low - pad, high + pad]; +}; + +const zoomedBounds = ( + values: readonly number[], zoom: number, +): readonly [number, number] => { + const [low, high] = bounds(values); + const middle = (low + high) / 2; + const span = (high - low) / zoom; + return [middle - span / 2, middle + span / 2]; +}; + +const ticks = (low: number, high: number): readonly number[] => Array.from( + { length: TICK_COUNT }, (_, index) => low + (high - low) * index / (TICK_COUNT - 1), +); + +const tickText = (value: number): string => new Intl.NumberFormat("en-US", { + maximumFractionDigits: 3, +}).format(value); + +const cohortLabel = (cohort: string): string => cohort.split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); + +function drawGrid( + context: CanvasRenderingContext2D, width: number, height: number, + xBounds: readonly [number, number], yBounds: readonly [number, number], +): void { + context.font = "12px sans-serif"; + context.fillStyle = "#94a3b8"; + context.strokeStyle = "#1e293b"; + ticks(...xBounds).forEach((value, index) => { + const x = LEFT + index * (width - LEFT - RIGHT) / (TICK_COUNT - 1); + context.beginPath(); context.moveTo(x, TOP); context.lineTo(x, height - BOTTOM); context.stroke(); + context.textAlign = "center"; context.fillText(tickText(value), x, height - BOTTOM + 18); + }); + ticks(...yBounds).forEach((value, index) => { + const y = height - BOTTOM - index * (height - TOP - BOTTOM) / (TICK_COUNT - 1); + context.beginPath(); context.moveTo(LEFT, y); context.lineTo(width - RIGHT, y); context.stroke(); + context.textAlign = "right"; context.fillText(tickText(value), LEFT - 8, y + 4); + }); +} + +function drawScatter( + canvas: HTMLCanvasElement, + scatter: ScalarScatterData, + zoom: number, +): void { + const context = canvas.getContext("2d"); + if (!context) return; + const width = canvas.width; const height = canvas.height; + context.clearRect(0, 0, width, height); + context.fillStyle = "#020617"; context.fillRect(0, 0, width, height); + const [xLow, xHigh] = zoomedBounds(scatter.points.map(({ x }) => x), zoom); + const [yLow, yHigh] = zoomedBounds(scatter.points.map(({ y }) => y), zoom); + const xSpan = xHigh - xLow; const ySpan = yHigh - yLow; + const cohorts = scatter.availability.by_cohort; + const cohortIds = Object.keys(cohorts); + drawGrid(context, width, height, [xLow, xHigh], [yLow, yHigh]); + context.strokeStyle = "#64748b"; + context.strokeRect(LEFT, TOP, width - LEFT - RIGHT, height - TOP - BOTTOM); + scatter.points.forEach((point) => { + const x = LEFT + (point.x - xLow) / xSpan * (width - LEFT - RIGHT); + const y = height - BOTTOM - (point.y - yLow) / ySpan * (height - TOP - BOTTOM); + if (x < LEFT || x > width - RIGHT || y < TOP || y > height - BOTTOM) return; + context.beginPath(); context.arc(x, y, 2.5, 0, Math.PI * 2); + context.fillStyle = PALETTE[Math.max(0, cohortIds.indexOf(point.cohort)) % PALETTE.length]; + context.fill(); + }); +} + +export function ScalarEnsembleScatter({ scatter, label }: { + readonly scatter: ScalarScatterData; readonly label: string; +}): JSX.Element { + const canvas = useRef(null); + const [zoom, setZoom] = useState(1); + useEffect(() => setZoom(1), [scatter]); + useEffect(() => { + if (canvas.current) drawScatter(canvas.current, scatter, zoom); + }, [scatter, zoom]); + const [xLow, xHigh] = zoomedBounds(scatter.points.map(({ x }) => x), zoom); + const [yLow, yHigh] = zoomedBounds(scatter.points.map(({ y }) => y), zoom); + const cohorts = Object.entries(scatter.availability.by_cohort); + return
+
+ + + + {Math.round(zoom * 100)}% +
+ +

+ {scatter.x_variable.label} range {tickText(xLow)} to {tickText(xHigh)} { + scatter.x_variable.unit}; {scatter.y_variable.label} range {tickText(yLow)} to { + tickText(yHigh)} {scatter.y_variable.unit} +

+
    { + cohorts.map(([cohort, availability], index) =>
  • +
  • )}
+

+ {scatter.x_variable.label} [{scatter.x_variable.unit}] versus {scatter.y_variable.label} [{scatter.y_variable.unit}] +

+
; +} diff --git a/src/rate_of_closure/web/src/components/WindStrategyPanel.test.tsx b/src/rate_of_closure/web/src/components/WindStrategyPanel.test.tsx new file mode 100644 index 0000000000..27c3b19ef5 --- /dev/null +++ b/src/rate_of_closure/web/src/components/WindStrategyPanel.test.tsx @@ -0,0 +1,209 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { WindStrategyPanel } from "./WindStrategyPanel"; +import { directLaunch } from "../model/flightExplorer"; +import { analyzeWindStrategies, type WindStrategyRequest } from "../model/windUncertainty"; +import type { WindStrategyRunController, WindStrategyRunner } from "../model/windStrategyWorkerClient"; +import { spatialTargetFromRegion, DEFAULT_TARGET } from "../model/targets"; +import { + createSpatialTarget, sphereTolerance, surfaceCorridorTolerance, targetPointFromFrame, +} from "../model/spatialTarget"; + +const launch = directLaunch({ + ballSpeedMph: 150, + launchAngleDeg: 12, + launchDirectionDeg: 1, + spinRpm: 2500, + spinAxisTiltDeg: -4, +}); +const target = spatialTargetFromRegion(DEFAULT_TARGET); + +describe("WindStrategyPanel", () => { + it("runs the current launch against the canonical target and exposes all cohorts", async () => { + let captured: WindStrategyRequest | null = null; + const runner: WindStrategyRunner = (request, onProgress) => { + captured = request; + onProgress({ completed: 1, total: request.uncertainty.trials }); + return { + promise: Promise.resolve(analyzeWindStrategies(request)), + cancel: vi.fn(), + }; + }; + render(); + + expect(screen.getByLabelText("Wind strategy trials")).toBeInTheDocument(); + expect(screen.getByLabelText("Wind strategy seed")).toBeInTheDocument(); + expect(screen.getByLabelText("True wind speed")).toBeInTheDocument(); + expect(screen.getByLabelText("Wind speed estimate bias")).toBeInTheDocument(); + expect(screen.getByLabelText("Wind speed estimate standard deviation")).toBeInTheDocument(); + expect(screen.getByLabelText("Wind estimate correlation")).toBeInTheDocument(); + expect(screen.getByLabelText("Crosswind aim gain")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Wind strategy trials"), { target: { value: "2" } }); + fireEvent.change(screen.getByLabelText("True wind speed"), { target: { value: "4" } }); + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + + await waitFor(() => expect(screen.getByRole("status", { + name: "Wind strategy run status", + })).toHaveTextContent("Completed 2 trials")); + expect(captured).not.toBeNull(); + expect(captured!.strategies[0].launch).toEqual(launch); + expect(captured!.target).toEqual({ + forward_m: target.point.appCoordinatesM[0], + right_m: target.point.appCoordinatesM[2], + }); + expect(screen.getByRole("table", { name: "Wind strategy summary" })) + .toHaveTextContent("Completed"); + const basis = screen.getByRole("region", { name: "Wind strategy calculation basis" }); + expect(basis).toHaveTextContent("waterloo_penner"); + expect(basis).toHaveTextContent("2 / 4199"); + expect(basis).toHaveTextContent(/230\.000 m forward, 0\.000 m right; radius 10\.000 m/); + expect(basis).toHaveTextContent("0.010000 s step; 10.000 s maximum"); + expect(basis).toHaveTextContent("100.0000"); + expect(basis).toHaveTextContent("0.90000"); + expect(basis).toHaveTextContent(/Current launch: .*rad\/\(m\/s\).*0\.2000 deg\/\(m\/s\)/); + expect(screen.getByText(/Completed: 2\/2 plotted/)).toBeInTheDocument(); + expect(screen.getByText(/Nonconverged: 0\/0 plotted/)).toBeInTheDocument(); + expect(screen.getByText(/Invalid: 0\/0 plotted/)).toBeInTheDocument(); + const scatterGraphic = screen.getByRole("img", { name: /Wind strategy scatter/ }); + expect(scatterGraphic).toBeInTheDocument(); + expect(within(scatterGraphic).getAllByTestId("wind-scatter-gridline")).toHaveLength(6); + expect(within(scatterGraphic).getAllByTestId("wind-scatter-tick")).toHaveLength(6); + expect(within(scatterGraphic).getByTestId("wind-scatter-marks")) + .toHaveAttribute("clip-path", expect.stringMatching(/^url\(#wind-scatter-clip-.+\)$/)); + expect(screen.getByRole("status", { name: "Wind scatter zoom" })).toHaveTextContent("100%"); + fireEvent.click(screen.getByRole("button", { name: "Zoom in wind scatter" })); + expect(screen.getByRole("status", { name: "Wind scatter zoom" })).toHaveTextContent("125%"); + fireEvent.change(screen.getByLabelText("Wind scatter horizontal axis"), { + target: { value: "true_wind_speed_mps" }, + }); + await waitFor(() => expect(screen.getByRole("status", { name: "Wind scatter zoom" })) + .toHaveTextContent("100%")); + fireEvent.click(screen.getByRole("button", { name: "Zoom in wind scatter" })); + fireEvent.click(screen.getByRole("button", { name: "Zoom out wind scatter" })); + expect(screen.getByRole("status", { name: "Wind scatter zoom" })).toHaveTextContent("100%"); + fireEvent.click(screen.getByRole("button", { name: "Auto Fit" })); + expect(screen.getByRole("status", { name: "Wind scatter zoom" })).toHaveTextContent("100%"); + expect(screen.getByTestId("wind-scatter-legend")).toBeInTheDocument(); + expect(screen.getByText("invalid").closest("g")?.querySelector("circle")) + .toHaveAttribute("fill", "#ef4444"); + fireEvent.change(screen.getByLabelText("Wind scatter legend position"), { + target: { value: "bottom-right" }, + }); + expect(screen.getByLabelText("Wind scatter legend position")).toHaveValue("bottom-right"); + fireEvent.click(screen.getByRole("checkbox", { name: "Show wind scatter legend" })); + expect(screen.queryByTestId("wind-scatter-legend")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export raw wind strategy CSV" })) + .toBeEnabled(); + }); + + it("cancels and terminates an active worker run", () => { + const cancel = vi.fn(); + const runner: WindStrategyRunner = (): WindStrategyRunController => ({ + promise: new Promise(() => undefined), + cancel, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel wind strategy analysis" })); + + expect(cancel).toHaveBeenCalledOnce(); + expect(screen.getByRole("status", { name: "Wind strategy run status" })) + .toHaveTextContent("Cancelled"); + }); + + it("shows runner errors without retaining stale output", async () => { + const runner: WindStrategyRunner = () => ({ + promise: Promise.reject(new Error("worker simulation failed")), + cancel: vi.fn(), + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("worker simulation failed"); + expect(screen.queryByRole("table", { name: "Wind strategy summary" })) + .not.toBeInTheDocument(); + }); + + it("fails closed instead of projecting an aerial waypoint onto the ground", () => { + const runner = vi.fn(); + const aerial = createSpatialTarget({ + label: "Apex gate", + kind: "aerial_waypoint", + point: targetPointFromFrame([120, 22, 0], "app"), + tolerance: sphereTolerance(3), + elevationSource: "absolute", + groundSource: null, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + + expect(screen.getByRole("alert")).toHaveTextContent(/requires.*landing area/i); + expect(runner).not.toHaveBeenCalled(); + }); + + it("uses the conservative inscribed radius for a narrow landing corridor", () => { + let captured: WindStrategyRequest | null = null; + const runner: WindStrategyRunner = (request) => { + captured = request; + return { promise: new Promise(() => undefined), cancel: vi.fn() }; + }; + const corridor = createSpatialTarget({ + label: "Narrow fairway", + kind: "landing_area", + point: targetPointFromFrame([180, 0, 0], "app"), + tolerance: surfaceCorridorTolerance(12, 2), + elevationSource: "course_surface", + groundSource: "flat-ground/v1", + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + + expect(captured!.analysis.target_radius_m).toBe(2); + expect(captured!.analysis.miss_scale_m).toBe(2); + }); + + it("invalidates captured results when launch, target, or wind settings change", async () => { + const runner: WindStrategyRunner = (request) => ({ + promise: Promise.resolve(analyzeWindStrategies(request)), cancel: vi.fn(), + }); + const view = render(); + fireEvent.change(screen.getByLabelText("Wind strategy trials"), { target: { value: "1" } }); + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + expect(await screen.findByRole("table", { name: "Wind strategy summary" })).toBeVisible(); + + const movedTarget = createSpatialTarget({ + ...target, + point: targetPointFromFrame([target.point.appCoordinatesM[0] + 5, 0, 0], "app"), + }); + view.rerender(); + await waitFor(() => expect(screen.queryByRole("table", { + name: "Wind strategy summary", + })).not.toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Wind strategy run status" })) + .toHaveTextContent(/launch or target changed/i); + + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + expect(await screen.findByRole("table", { name: "Wind strategy summary" })).toBeVisible(); + view.rerender(); + await waitFor(() => expect(screen.queryByRole("table", { + name: "Wind strategy summary", + })).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Run wind strategy analysis" })); + expect(await screen.findByRole("table", { name: "Wind strategy summary" })).toBeVisible(); + fireEvent.change(screen.getByLabelText("Wind speed estimate bias"), { + target: { value: "1.25" }, + }); + expect(screen.queryByRole("table", { name: "Wind strategy summary" })) + .not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Wind strategy run status" })) + .toHaveTextContent(/wind settings changed/i); + }); +}); diff --git a/src/rate_of_closure/web/src/components/WindStrategyPanel.tsx b/src/rate_of_closure/web/src/components/WindStrategyPanel.tsx new file mode 100644 index 0000000000..ec8c8b10fa --- /dev/null +++ b/src/rate_of_closure/web/src/components/WindStrategyPanel.tsx @@ -0,0 +1,368 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { BUTTON_CLASS, downloadText, INPUT_CLASS, PANEL_CLASS } from "./variationUi"; +import { WindStrategyScatter } from "./WindStrategyScatter"; +import type { Launch } from "../model/flight"; +import { buildScalarEnsembleScatter, type ScalarEnsembleResult } from "../model/scalarEnsembleContract"; +import { scalarEnsembleToCsv } from "../model/scalarEnsembleCsv"; +import type { SpatialTargetTs } from "../model/spatialTarget"; +import { + WIND_UNCERTAINTY_SCHEMA_VERSION, + type WindOutcomeStatus, + type WindStrategyAnalysis, + type WindStrategyRequest, +} from "../model/windUncertainty"; +import { buildWindStrategyPlotData } from "../model/windStrategyPlotData"; +import { + runWindStrategyInWorker, + type WindStrategyProgress, + type WindStrategyRunController, + type WindStrategyRunner, +} from "../model/windStrategyWorkerClient"; + +interface Props { + readonly launch: Launch | null; + readonly launchError?: string | null; + readonly target: SpatialTargetTs; + readonly runner?: WindStrategyRunner; +} + +interface Controls { + trials: number; + seed: number; + trueSpeedMps: number; + trueBearingDeg: number; + speedBiasMps: number; + speedStdMps: number; + bearingBiasDeg: number; + bearingStdDeg: number; + correlation: number; + aimGainDegPerMps: number; +} + +interface CompletedRun { + request: WindStrategyRequest; + analysis: WindStrategyAnalysis; + ensemble: ScalarEnsembleResult; +} + +const DEFAULT_CONTROLS: Controls = { + trials: 200, + seed: 4199, + trueSpeedMps: 4.5, + trueBearingDeg: 90, + speedBiasMps: 0, + speedStdMps: 0.8, + bearingBiasDeg: 0, + bearingStdDeg: 8, + correlation: 0, + aimGainDegPerMps: 0.2, +}; + +const CONTROL_FIELDS: ReadonlyArray<{ + key: keyof Controls; + label: string; + unit: string; + min?: number; + max?: number; + step?: number; +}> = [ + { key: "trials", label: "Wind strategy trials", unit: "trials", min: 1, max: 100000, step: 1 }, + { key: "seed", label: "Wind strategy seed", unit: "uint32", min: 0, max: 4294967295, step: 1 }, + { key: "trueSpeedMps", label: "True wind speed", unit: "m/s", min: 0, step: 0.1 }, + { key: "trueBearingDeg", label: "True wind from bearing", unit: "deg", step: 1 }, + { key: "speedBiasMps", label: "Wind speed estimate bias", unit: "m/s", step: 0.1 }, + { key: "speedStdMps", label: "Wind speed estimate standard deviation", unit: "m/s", min: 0, step: 0.1 }, + { key: "bearingBiasDeg", label: "Wind bearing estimate bias", unit: "deg", step: 1 }, + { key: "bearingStdDeg", label: "Wind bearing estimate standard deviation", unit: "deg", min: 0, step: 1 }, + { key: "correlation", label: "Wind estimate correlation", unit: "r", min: -1, max: 1, step: 0.05 }, + { key: "aimGainDegPerMps", label: "Crosswind aim gain", unit: "deg/(m/s)", step: 0.05 }, +]; + +const targetRadius = (target: SpatialTargetTs): number => { + const tolerance = target.tolerance; + if (tolerance.kind === "surface_corridor") { + return Math.min(tolerance.halfLengthM, tolerance.halfWidthM); + } + if (tolerance.kind === "surface_circle") return tolerance.radiusM; + throw new RangeError("Wind strategy landing targets require a surface tolerance"); +}; + +function buildRequest(launch: Launch, target: SpatialTargetTs, controls: Controls): WindStrategyRequest { + if (target.kind !== "landing_area") { + throw new RangeError("Wind strategy analysis requires the current target to be a landing area"); + } + return { + uncertainty: { + schema_version: WIND_UNCERTAINTY_SCHEMA_VERSION, + trials: controls.trials, + seed: controls.seed, + true_speed_mps: { kind: "fixed", center: controls.trueSpeedMps, spread: 0, minimum: 0 }, + true_from_bearing_deg: { kind: "fixed", center: controls.trueBearingDeg, spread: 0 }, + estimate_error: { + speed_bias_mps: controls.speedBiasMps, + speed_std_mps: controls.speedStdMps, + bearing_bias_deg: controls.bearingBiasDeg, + bearing_std_deg: controls.bearingStdDeg, + correlation: controls.correlation, + }, + provenance: `flight-explorer/wind-strategy/seed-${controls.seed}`, + }, + strategies: [{ + id: "current-launch", label: "Current launch", launch, + crosswind_aim_gain_rad_per_mps: controls.aimGainDegPerMps * Math.PI / 180, + }], + target: { + forward_m: target.point.appCoordinatesM[0], + right_m: target.point.appCoordinatesM[2], + }, + analysis: { + model_name: "waterloo_penner", max_time_s: 10, time_step_s: 0.01, + miss_scale_m: Math.max(targetRadius(target), 1), failure_cost: 100, + target_radius_m: targetRadius(target), miss_distance_cvar_alpha: 0.9, + }, + }; +} + +const inputSignature = (launch: Launch | null, target: SpatialTargetTs): string => JSON.stringify({ + launch: launch && [ + launch.ballSpeedMps, launch.launchAngleRad, launch.azimuthRad, + launch.spinRpm, ...launch.spinAxis, + ], + target: { + label: target.label, kind: target.kind, point: target.point.appCoordinatesM, + tolerance: target.tolerance, elevationSource: target.elevationSource, + groundSource: target.groundSource, + }, +}); + +export function WindStrategyPanel({ + launch, launchError = null, target, runner = runWindStrategyInWorker, +}: Props) { + const [controls, setControls] = useState(DEFAULT_CONTROLS); + const [progress, setProgress] = useState(null); + const [status, setStatus] = useState("Ready"); + const [error, setError] = useState(null); + const [completed, setCompleted] = useState(null); + const [xKey, setXKey] = useState("actual_landing_forward_m"); + const [yKey, setYKey] = useState("actual_landing_right_m"); + const active = useRef(null); + const runId = useRef(0); + const signature = inputSignature(launch, target); + const previousSignature = useRef(signature); + + const invalidate = (message: string): void => { + runId.current += 1; + active.current?.cancel(); + active.current = null; + setCompleted(null); + setProgress(null); + setError(null); + setStatus(message); + }; + + useEffect(() => () => { + runId.current += 1; + active.current?.cancel(); + }, []); + + useEffect(() => { + if (launch !== null) return; + runId.current += 1; + active.current?.cancel(); + active.current = null; + setCompleted(null); + setProgress(null); + setStatus("Unavailable until launch inputs are valid"); + }, [launch]); + + useEffect(() => { + if (signature === previousSignature.current) return; + previousSignature.current = signature; + invalidate("Launch or target changed — run again"); + }, [signature]); + + const run = (): void => { + active.current?.cancel(); + const currentRunId = ++runId.current; + setCompleted(null); + setError(null); + setProgress({ completed: 0, total: controls.trials }); + setStatus("Running wind strategy analysis"); + try { + if (!launch) throw new RangeError(launchError ?? "Current launch inputs are invalid"); + const request = buildRequest(launch, target, controls); + const controller = runner(request, (next) => { + if (currentRunId === runId.current) setProgress(next); + }); + active.current = controller; + void controller.promise.then((analysis) => { + if (currentRunId !== runId.current) return; + // This adapter replays only the deterministic sampler to verify agreement; + // it does not create another stochastic authority or rerun flight physics. + const ensemble = buildWindStrategyPlotData(request, analysis); + setCompleted({ request, analysis, ensemble }); + setStatus(`Completed ${analysis.wind_trials.length} trials`); + active.current = null; + }).catch((reason: unknown) => { + if (currentRunId !== runId.current) return; + setError(reason instanceof Error ? reason.message : String(reason)); + setStatus("Wind strategy analysis failed"); + active.current = null; + }); + } catch (reason: unknown) { + setError(reason instanceof Error ? reason.message : String(reason)); + setStatus("Wind strategy analysis failed"); + } + }; + + const cancel = (): void => { + if (!active.current) return; + runId.current += 1; + active.current.cancel(); + active.current = null; + setStatus("Cancelled"); + setProgress(null); + }; + + return
+

+ Wind Strategy Uncertainty +

+

+ Paired true-versus-estimated wind trials use the current launch and the current canonical landing target. +

+
+ {CONTROL_FIELDS.map((field) => )} +
+
+ + + + {status}{progress && active.current + ? ` — ${progress.completed}/${progress.total} outcomes` : ""} + +
+ {launch === null &&

+ Wind strategy analysis is unavailable: {launchError ?? "current launch inputs are invalid"}. +

} + {error &&

{error}

} + {completed && launch && } +
; +} + +function WindStrategyResults({ run, xKey, yKey, setXKey, setYKey }: { + run: CompletedRun; xKey: string; yKey: string; + setXKey: (key: string) => void; setYKey: (key: string) => void; +}): JSX.Element { + const scatter = useMemo( + () => buildScalarEnsembleScatter(run.ensemble, xKey, yKey), + [run.ensemble, xKey, yKey], + ); + return
+ + +
+ + +
+

+ {run.ensemble.cohorts.map(({ key, label }) => { + const availability = scatter.availability.by_cohort[key]; + return `${label}: ${availability.paired_finite}/${availability.total_rows} plotted` + + (availability.unavailable ? `, ${availability.unavailable} unavailable` : ""); + }).join(" · ")} +

+ + +
; +} + +function CalculationBasis({ request }: { request: WindStrategyRequest }): JSX.Element { + const analysis = request.analysis; + const policy = request.strategies.map((strategy) => { + const gain = strategy.crosswind_aim_gain_rad_per_mps; + return `${strategy.label}: ${gain.toPrecision(7)} rad/(m/s) ` + + `(${(gain * 180 / Math.PI).toFixed(4)} deg/(m/s))`; + }).join(" · "); + const entries = [ + ["Model", analysis.model_name], + ["Trials / seed", `${request.uncertainty.trials} / ${request.uncertainty.seed}`], + ["Target", `${request.target.forward_m.toFixed(3)} m forward, ` + + `${request.target.right_m.toFixed(3)} m right; radius ${analysis.target_radius_m.toFixed(3)} m`], + ["Integration", `${analysis.time_step_s.toPrecision(5)} s step; ${analysis.max_time_s.toPrecision(5)} s maximum`], + ["Failure cost", analysis.failure_cost.toPrecision(7)], + ["Miss-distance CVaR alpha", analysis.miss_distance_cvar_alpha.toPrecision(5)], + ["Aim-gain policy", policy], + ]; + return
+

+ Captured Calculation Basis +

+
+ {entries.map(([term, value]) =>
+
{term}
+
{value}
+
)} +
+
; +} + +function SummaryTable({ analysis }: { analysis: WindStrategyAnalysis }): JSX.Element { + return
+ + + + {analysis.summaries.map((summary) => + + + + + + )}
StrategyCompletedFailedMean missTarget holdCVaR
{summary.label}{summary.completed_trials}{summary.failed_trials}{meanMiss(analysis, summary.strategy_id)}{(100 * summary.target_hold_probability).toFixed(1)}%{summary.miss_distance_cvar_m.toFixed(2)} m
; +} + +const meanMiss = (analysis: WindStrategyAnalysis, strategyId: string): string => { + const values = analysis.outcomes.filter((item) => item.strategy_id === strategyId) + .map((item) => item.miss_distance_m).filter((value): value is number => value !== null); + return values.length ? `${(values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(2)} m` : "—"; +}; + +function AxisSelect({ label, value, variables, onChange }: { + label: string; value: string; variables: ScalarEnsembleResult["variables"]; + onChange: (key: string) => void; +}): JSX.Element { + return ; +} diff --git a/src/rate_of_closure/web/src/components/WindStrategyScatter.tsx b/src/rate_of_closure/web/src/components/WindStrategyScatter.tsx new file mode 100644 index 0000000000..3dc0ef824c --- /dev/null +++ b/src/rate_of_closure/web/src/components/WindStrategyScatter.tsx @@ -0,0 +1,104 @@ +import { useEffect, useId, useState } from "react"; + +import { BUTTON_CLASS, INPUT_CLASS } from "./variationUi"; +import type { ScalarScatterData } from "../model/scalarEnsembleContract"; +import type { WindOutcomeStatus } from "../model/windUncertainty"; + +const COLORS: Record = { + completed: "#38bdf8", nonconverged: "#f59e0b", invalid: "#ef4444", +}; +const COHORTS: WindOutcomeStatus[] = ["completed", "nonconverged", "invalid"]; + +const bounds = (values: number[]): [number, number] => { + if (!values.length) return [-1, 1]; + const low = Math.min(...values); const high = Math.max(...values); + const padding = Math.max((high - low) * 0.08, Math.max(Math.abs(low), 1) * 1e-6); + return [low - padding, high + padding]; +}; + +const zoomBounds = ([low, high]: [number, number], zoom: number): [number, number] => { + const center = (low + high) / 2; const halfSpan = (high - low) / (2 * zoom); + return [center - halfSpan, center + halfSpan]; +}; + +const ticks = (low: number, high: number): number[] => [low, (low + high) / 2, high]; +const tickLabel = (value: number): string => { + const magnitude = Math.abs(value); + return magnitude !== 0 && (magnitude >= 10000 || magnitude < 0.001) + ? value.toExponential(2) : Number(value.toPrecision(4)).toString(); +}; + +export function WindStrategyScatter({ scatter }: { + scatter: ScalarScatterData; +}): JSX.Element { + const [zoom, setZoom] = useState(1); + const [showLegend, setShowLegend] = useState(true); + const [legendPosition, setLegendPosition] = useState<"top-right" | "bottom-right">("top-right"); + const clipId = `wind-scatter-clip-${useId().replace(/:/g, "")}`; + useEffect(() => setZoom(1), [scatter]); + const [x0, x1] = zoomBounds(bounds(scatter.points.map(({ x }) => x)), zoom); + const [y0, y1] = zoomBounds(bounds(scatter.points.map(({ y }) => y)), zoom); + const xTicks = ticks(x0, x1); const yTicks = ticks(y0, y1); + const legendY = legendPosition === "top-right" ? 30 : 235; + return
+
+ + + + + {Math.round(zoom * 100)}% + + +
+ + + {xTicks.map((value, index) => { + const x = 60 + index * 280; + return + {tickLabel(value)}; + })} + {yTicks.map((value, index) => { + const y = 290 - index * 135; + return + {tickLabel(value)}; + })} + + + + {scatter.points.map((point) => + {`${point.row_id}: ${point.x}, ${point.y}`})} + {showLegend && + + {COHORTS.map((cohort, index) => + + {cohort} + )} + } + {!scatter.points.length && + No finite paired values for these axes} + + {scatter.x_variable.label} [{scatter.x_variable.unit}] + + {scatter.y_variable.label} [{scatter.y_variable.unit}] + +
; +} diff --git a/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts b/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts new file mode 100644 index 0000000000..9c6dbcee38 --- /dev/null +++ b/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from "react"; + +import type { CapabilityRunOutput } from "../model/capabilityRun"; +import { + buildCapabilityWorkflow, + capabilityWorkflowFromJson, + capabilityWorkflowInputs, + defaultCapabilityWorkflowInputs, + type CapabilityWorkflowInputs, +} from "../model/capabilityWorkflow"; +import { + runCapabilityInWorker, + type CapabilityRunController, + type CapabilityRunner, +} from "../model/capabilityWorkerClient"; + +export interface CapabilityOptimizationState { + readonly inputs: CapabilityWorkflowInputs; readonly output: CapabilityRunOutput | null; + readonly status: string; readonly error: string | null; + readonly progress: { readonly completed: number; readonly total: number }; + readonly running: boolean; + update: (key: keyof CapabilityWorkflowInputs, value: string | number) => void; + run: () => void; cancel: () => void; load: (file: File) => Promise; +} + +const message = (reason: unknown): string => + reason instanceof Error ? reason.message : String(reason); + +export function useCapabilityOptimization( + runner: CapabilityRunner = runCapabilityInWorker, +): CapabilityOptimizationState { + const [inputs, setInputs] = useState(defaultCapabilityWorkflowInputs); + const [output, setOutput] = useState(null); + const [status, setStatus] = useState("Ready"); const [error, setError] = useState(null); + const [progress, setProgress] = useState({ completed: 0, total: 0 }); + const [running, setRunning] = useState(false); + const active = useRef(null); const runId = useRef(0); + const invalidate = (next: string): void => { + runId.current += 1; active.current?.cancel(); active.current = null; + setRunning(false); + setOutput(null); setError(null); setStatus(next); setProgress({ completed: 0, total: 0 }); + }; + useEffect(() => () => { runId.current += 1; active.current?.cancel(); }, []); + const update = (key: keyof CapabilityWorkflowInputs, value: string | number): void => { + invalidate("Inputs changed — run again"); setInputs((current) => ({ ...current, [key]: value })); + }; + const run = (): void => { + invalidate("Validating calculation basis"); const currentRun = ++runId.current; + try { + const document = buildCapabilityWorkflow(inputs); + setProgress({ completed: 0, total: document.request.candidateBudget * document.request.ensembleSize }); + setStatus("Running in background"); const controller = runner(document, (next) => { + if (currentRun === runId.current) setProgress(next); + }); + active.current = controller; setRunning(true); void controller.promise.then((result) => { + if (currentRun !== runId.current) return; + active.current = null; setRunning(false); setOutput(result); setStatus("Completed"); + }).catch((reason: unknown) => { + if (currentRun !== runId.current) return; + active.current = null; setRunning(false); setStatus("Failed"); setError(message(reason)); + }); + } catch (reason: unknown) { setStatus("Invalid inputs"); setError(message(reason)); } + }; + const load = async (file: File): Promise => { + try { setInputs(capabilityWorkflowInputs(capabilityWorkflowFromJson(await file.text()))); + invalidate("Workflow loaded — run when ready"); } + catch (reason: unknown) { setError(message(reason)); } + }; + return { inputs, output, status, error, progress, running, + update, run, cancel: () => invalidate("Cancelled"), load }; +} diff --git a/src/rate_of_closure/web/src/model/__fixtures__/ball_flight_metrics_golden_v1.json b/src/rate_of_closure/web/src/model/__fixtures__/ball_flight_metrics_golden_v1.json index 5060a10e5e..9e579d5e7a 100644 --- a/src/rate_of_closure/web/src/model/__fixtures__/ball_flight_metrics_golden_v1.json +++ b/src/rate_of_closure/web/src/model/__fixtures__/ball_flight_metrics_golden_v1.json @@ -1,7 +1,7 @@ { "schema_version": "ball-flight-metrics-parity/v1", - "catalog_sha256": "32699f61de6108f766e911fc6a02a0a08fe80204e83745a069ee55dca184ac14", - "analytic_result_sha256": "dcb78bb3138aa826f9099bc137813ef2a6b2550117624507f593410400d88772", + "catalog_sha256": "7c6e6d77c88d52335179ac04e2a4cc55504598cc17ea2d3bef6e50546aeb4f6c", + "analytic_result_sha256": "5e4c3506e2ce5b19a7eedb99dfff7ad1a94365a161d8cb7436169ed6abdea621", "analytic_case": { "trajectory": [ {"time_s": 0.0, "position_m": [0.0, 0.0, 0.0], "velocity_m_s": [10.0, 10.0, 1.0]}, @@ -20,7 +20,7 @@ "flight_time": 2.5, "landing_angle": 30.83814404628488, "launch_direction": 5.710593137499642, - "spin_axis_tilt": 91.1457628381751, + "spin_axis_tilt": -91.1457628381751, "target_downrange_residual": 1.0, "target_lateral_residual": -2.0, "target_residual": 2.23606797749979, diff --git a/src/rate_of_closure/web/src/model/__fixtures__/capability_flight_evaluator_parity_v1.json b/src/rate_of_closure/web/src/model/__fixtures__/capability_flight_evaluator_parity_v1.json new file mode 100644 index 0000000000..e7afec0a19 --- /dev/null +++ b/src/rate_of_closure/web/src/model/__fixtures__/capability_flight_evaluator_parity_v1.json @@ -0,0 +1,32 @@ +{ + "schema_version": "capability-flight-evaluator-parity/v1", + "sample": { + "ball_speed": 74.65568, + "launch_angle": 10.9, + "launch_direction": 0.0 + }, + "spin_default": { + "club_id": "driver", + "total_spin_rpm": 2686.0, + "spin_axis_tilt_deg": 0.0, + "provenance": "test-fixture" + }, + "expected_scalars": { + "apex_height": {"value": 28.226086329104245, "absolute_tolerance": 0.2}, + "ball_speed": {"value": 74.65568, "absolute_tolerance": 1e-8}, + "carry_distance": {"value": 247.48388437261087, "absolute_tolerance": 0.5}, + "carry_offline": {"value": 0.0, "absolute_tolerance": 0.01}, + "curve": {"value": 0.0, "absolute_tolerance": 0.01}, + "flight_time": {"value": 6.278339756170403, "absolute_tolerance": 0.02}, + "landing_angle": {"value": 35.1201956230131, "absolute_tolerance": 0.2}, + "launch_direction": {"value": 0.0, "absolute_tolerance": 1e-8}, + "spin_axis_tilt": {"value": 0.0, "absolute_tolerance": 1e-8}, + "target_downrange_residual": {"value": 0.48388437261087347, "absolute_tolerance": 0.5}, + "target_lateral_residual": {"value": 0.0, "absolute_tolerance": 0.01}, + "target_residual": {"value": 0.48388437261087347, "absolute_tolerance": 0.5}, + "terminal_direction": {"value": 0.0, "absolute_tolerance": 0.01}, + "terminal_speed": {"value": 31.191002052325423, "absolute_tolerance": 0.2}, + "total_spin": {"value": 2686.0, "absolute_tolerance": 1e-8}, + "vertical_launch_angle": {"value": 10.9, "absolute_tolerance": 1e-8} + } +} diff --git a/src/rate_of_closure/web/src/model/__fixtures__/capability_workflow_parser_cases_v1.json b/src/rate_of_closure/web/src/model/__fixtures__/capability_workflow_parser_cases_v1.json new file mode 100644 index 0000000000..13fbe07631 --- /dev/null +++ b/src/rate_of_closure/web/src/model/__fixtures__/capability_workflow_parser_cases_v1.json @@ -0,0 +1,77 @@ +{ + "schema_version": "capability-workflow-parser-cases/v1", + "cases": [ + { + "id": "valid-text-profile-id", + "path": ["profile", "profile_id"], + "value": "fixture-driver-profile", + "accepted": true + }, + { + "id": "accept-integral-json-number", + "path": ["request", "candidate_budget"], + "value": 3.0, + "accepted": true + }, + { + "id": "reject-fractional-integer-field", + "path": ["request", "candidate_budget"], + "value": 3.5, + "accepted": false + }, + { + "id": "reject-string-config-number", + "path": ["evaluator_config", "max_time_s"], + "value": "7.5", + "accepted": false + }, + { + "id": "reject-boolean-config-number", + "path": ["evaluator_config", "max_time_s"], + "value": true, + "accepted": false + }, + { + "id": "reject-string-spin-number", + "path": ["evaluator_config", "spin_defaults", 0, "total_spin_rpm"], + "value": "2600", + "accepted": false + }, + { + "id": "reject-numeric-spin-club-id", + "path": ["evaluator_config", "spin_defaults", 0, "club_id"], + "value": 17, + "accepted": false + }, + { + "id": "reject-numeric-spin-provenance", + "path": ["evaluator_config", "spin_defaults", 0, "provenance"], + "value": 17, + "accepted": false + }, + { + "id": "reject-string-target-number", + "path": ["request", "target", "distance_m"], + "value": "230", + "accepted": false + }, + { + "id": "reject-string-profile-number", + "path": ["profile", "clubs", 0, "parameters", 0, "baseline"], + "value": "67", + "accepted": false + }, + { + "id": "reject-numeric-profile-id", + "path": ["profile", "profile_id"], + "value": 17, + "accepted": false + }, + { + "id": "reject-numeric-request-id", + "path": ["request", "problem_id"], + "value": 17, + "accepted": false + } + ] +} diff --git a/src/rate_of_closure/web/src/model/ballFlightMetricContract.ts b/src/rate_of_closure/web/src/model/ballFlightMetricContract.ts index c04b9b96fb..fc1aabd3b6 100644 --- a/src/rate_of_closure/web/src/model/ballFlightMetricContract.ts +++ b/src/rate_of_closure/web/src/model/ballFlightMetricContract.ts @@ -70,7 +70,7 @@ const IDENTITIES: Record = { launch_direction: identity("Launch Direction", "Horizontal heading of initial velocity from the target line.", "deg", "derived", "positive_right", "launch", "atan2(v_z,v_x)", "nonzero horizontal speed", true), spin_vector: identity("Spin Vector", "Post-separation angular-velocity vector in target-frame components.", "rpm", "input", "vector_components", "launch", "vector(omega_x,omega_y,omega_z)", "post-impact spin state"), total_spin: identity("Total Spin", "Magnitude of the post-separation spin vector.", "rpm", "derived", "nonnegative", "launch", "norm(spin_vector)", "post-impact spin state", true), - spin_axis_tilt: identity("Spin Axis Tilt", "Signed tilt of the spin axis from +right toward +up.", "deg", "derived", "positive_up", "launch", "atan2(omega_y,omega_z)", "nonzero spin", true), + spin_axis_tilt: identity("Spin Axis Tilt", "Signed tilt from +right toward -up; positive produces fade/right curvature.", "deg", "derived", "positive_right", "launch", "atan2(-omega_y,omega_z)", "nonzero spin", true), landing_position: identity("Landing Position", "Linearly interpolated first descending ground crossing.", "m", "derived", "vector_components", "landing", "interpolate(position,y=0)", "ground crossing"), landing_velocity: identity("Landing Velocity", "Velocity interpolated at the first descending ground crossing.", "m/s", "derived", "vector_components", "landing", "interpolate(velocity,y=0)", "ground crossing"), carry_distance: identity("Carry Distance", "Horizontal distance from launch to first ground contact; excludes bounce and roll.", "m", "derived", "nonnegative", "landing", "hypot(delta_x,delta_z)", "ground crossing", true), diff --git a/src/rate_of_closure/web/src/model/ballFlightMetrics.test.ts b/src/rate_of_closure/web/src/model/ballFlightMetrics.test.ts index 41ee40c084..e1c9d0957e 100644 --- a/src/rate_of_closure/web/src/model/ballFlightMetrics.test.ts +++ b/src/rate_of_closure/web/src/model/ballFlightMetrics.test.ts @@ -13,6 +13,8 @@ import { type FlightMetricInputs, type FlightRunManifest, } from "./ballFlightMetrics"; +import { cross, deliveryDiagnostics, type DeliveryInput } from "./impactPhysics"; +import { spinAxisTiltDeg } from "./spinAxisConvention"; const manifest: FlightRunManifest = { modelId: "analytic_fixture", modelVersion: "1.0.0", @@ -105,4 +107,46 @@ describe("ball-flight result contract", () => { }, }, manifest)).toThrow(/bounceCount must be an integer/); }); + + it("uses positive spin-axis tilt for fade/right curvature", () => { + const tilt = 10 * Math.PI / 180; + const result = deriveFlightMetricResult({ + ...analyticInputs(), + spinVectorRpm: [0, -1000 * Math.sin(tilt), 1000 * Math.cos(tilt)], + }, manifest); + + expect(result.scalar("spin_axis_tilt")).toBeCloseTo(10, 10); + expect(flightMetricCatalog().definition("spin_axis_tilt").signRule) + .toBe("positive_right"); + }); + + it("projects gyro spin out consistently across result and impact producers", () => { + const spin: [number, number, number] = [500, -100, 1000]; + const expected = spinAxisTiltDeg(spin); + const result = deriveFlightMetricResult({ + ...analyticInputs(), spinVectorRpm: spin, + }, manifest); + const input: DeliveryInput = { + clubheadSpeedMps: 40, clubPathDeg: 12, faceAngleDeg: -4, + attackAngleDeg: -5, dynamicLoftDeg: 23, + impactOffsetToeMm: 0, impactOffsetHighMm: 0, + }; + const diagnostics = deliveryDiagnostics(input); + const radians = (degrees: number) => degrees * Math.PI / 180; + const velocity = [ + Math.cos(radians(input.attackAngleDeg)) * Math.cos(radians(input.clubPathDeg)), + Math.sin(radians(input.attackAngleDeg)), + Math.cos(radians(input.attackAngleDeg)) * Math.sin(radians(input.clubPathDeg)), + ] as [number, number, number]; + const normal = [ + Math.cos(radians(input.dynamicLoftDeg)) * Math.cos(radians(input.faceAngleDeg)), + Math.sin(radians(input.dynamicLoftDeg)), + Math.cos(radians(input.dynamicLoftDeg)) * Math.sin(radians(input.faceAngleDeg)), + ] as [number, number, number]; + const impactExpected = spinAxisTiltDeg(cross(velocity, normal)); + + expect(expected).toBeCloseTo(Math.atan2(100, 1000) * 180 / Math.PI, 10); + expect(result.scalar("spin_axis_tilt")).toBeCloseTo(expected as number, 10); + expect(diagnostics.spinAxisTiltDeg).toBeCloseTo(impactExpected as number, 10); + }); }); diff --git a/src/rate_of_closure/web/src/model/ballFlightMetrics.ts b/src/rate_of_closure/web/src/model/ballFlightMetrics.ts index 4873c0fca4..0950a3a4d5 100644 --- a/src/rate_of_closure/web/src/model/ballFlightMetrics.ts +++ b/src/rate_of_closure/web/src/model/ballFlightMetrics.ts @@ -7,6 +7,7 @@ import { type FlightMetricId, type ValueStatus, } from "./ballFlightMetricContract"; +import { spinAxisTiltDeg } from "./spinAxisConvention"; export type AvailabilityReason = "insufficient_trajectory" | "no_ground_crossing" | "zero_horizontal_speed" | "zero_spin" | "target_not_configured" | @@ -163,6 +164,7 @@ const launchValues = (inputs: FlightMetricInputs): Map MIN_SPEED ? available("launch_direction", degrees(Math.atan2(initial[2], initial[0])), "derived.initial_velocity") : unavailable("launch_direction", "zero_horizontal_speed")); - values.set("spin_axis_tilt", spinMagnitude > MIN_SPEED - ? available("spin_axis_tilt", degrees(Math.atan2(inputs.spinVectorRpm[1], inputs.spinVectorRpm[2])), "derived.spin_vector_rpm") + values.set("spin_axis_tilt", spinAxisTilt !== null + ? available("spin_axis_tilt", spinAxisTilt, "derived.spin_vector_rpm") : unavailable("spin_axis_tilt", "zero_spin")); return values; }; diff --git a/src/rate_of_closure/web/src/model/capabilityFlightEvaluator.test.ts b/src/rate_of_closure/web/src/model/capabilityFlightEvaluator.test.ts new file mode 100644 index 0000000000..406a725c53 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityFlightEvaluator.test.ts @@ -0,0 +1,260 @@ +/** Contract tests for the model-backed capability evaluator. */ + +import { describe, expect, it } from "vitest"; + +import fixture from "./__fixtures__/capability_optimizer_golden_v1.json"; +import parityFixture from "./__fixtures__/capability_flight_evaluator_parity_v1.json"; +import { + parseOptimizationRequest, + parsePlayerCapabilityProfile, +} from "./capabilityContract"; +import { makeCapabilityFlightEvaluator } from "./capabilityFlightEvaluator"; +import { optimizeCapability } from "./capabilityOptimizer"; + +const PINNED = Object.freeze({ + ball_speed: 74.65568, + launch_angle: 10.9, + launch_direction: 0.0, +}); +const SPIN_DEFAULTS = Object.freeze([Object.freeze({ + clubId: "driver", totalSpinRpm: 2686, spinAxisTiltDeg: 0, + provenance: "test-fixture", +})]); + +const parameter = (parameterId: string, unit: string, baseline: number, + lowerBound: number, upperBound: number) => ({ + parameter_id: parameterId, unit, baseline, bias: 0, standard_deviation: 0, + lower_bound: lowerBound, upper_bound: upperBound, + evidence_lower_bound: lowerBound, evidence_upper_bound: upperBound, +}); + +const profile = (includeSpin = false) => { + const parameters = [ + parameter("ball_speed", "m/s", PINNED.ball_speed, 40, 80), + parameter("launch_angle", "deg", PINNED.launch_angle, 0, 30), + parameter("launch_direction", "deg", 0, -15, 15), + ]; + if (includeSpin) parameters.push( + parameter("total_spin", "rpm", 2686, 0, 6000), + parameter("spin_axis_tilt", "deg", 0, -45, 45), + ); + const matrix = parameters.map((_, row) => + parameters.map((__, column) => row === column ? 1 : 0)); + return parsePlayerCapabilityProfile({ + schema_version: "player-capability-profile/v1", profile_id: "player", + clubs: [{ club_id: "driver", parameters, matrix_kind: "correlation", + matrix, provenance: "test", confidence: 1 }], + provenance: "test", confidence: 1, + }); +}; + +const request = () => parseOptimizationRequest({ + schema_version: "capability-optimization-request/v1", problem_id: "flight-evaluator", + objective: "maximize_target_hold", club_ids: ["driver"], + target: { kind: "green", distance_m: 247, lateral_m: 0, radius_m: 10, + band_half_length_m: 15, half_width_m: 15 }, + candidate_budget: 1, ensemble_size: 1, alternatives_count: 1, + seed: 7, cvar_alpha: 0.8, minimum_success_fraction: 0.5, +}); + +const metricMap = (evaluation: ReturnType>) => + new Map(evaluation.metrics.map((item) => [item.metricId, item])); + +describe("capability flight evaluator", () => { + it("runs Waterloo/Penner with every available scalar and target value", () => { + const evaluation = makeCapabilityFlightEvaluator( + profile(), request(), { spinDefaults: SPIN_DEFAULTS }, + )("driver", PINNED); + const metrics = metricMap(evaluation); + + expect(evaluation.status).toBe("complete"); + expect(metrics.get("ball_speed")?.value).toBeCloseTo(74.65568, 5); + expect(metrics.get("vertical_launch_angle")?.value).toBeCloseTo(10.9, 8); + expect(metrics.get("carry_distance")?.value).toBeGreaterThan(247.484 * 0.99); + expect(metrics.get("carry_distance")?.value).toBeLessThan(247.484 * 1.01); + expect(metrics.has("target_residual")).toBe(true); + expect(metrics.has("initial_velocity")).toBe(false); + expect(metrics.size).toBe(16); + expect(evaluation.metrics.every((item) => item.provenance + .includes("waterloo_penner:waterloo-penner-coefficients/v1"))).toBe(true); + expect(evaluation.metrics.every((item) => item.provenance + .includes("spin:fixed_club_default:test-fixture"))).toBe(true); + for (const [metricId, expected] of Object.entries( + parityFixture.expected_scalars, + )) { + const value = evaluation.metrics.find( + (item) => item.metricId === metricId, + )?.value as number; + expect(Math.abs(value - expected.value)) + .toBeLessThanOrEqual(expected.absolute_tolerance); + } + }); + + it("preserves positive-right direction and fade-side spin tilt", () => { + const evaluator = makeCapabilityFlightEvaluator(profile(true), request()); + const direction = evaluator("driver", { + ...PINNED, launch_direction: 5, total_spin: 2686, spin_axis_tilt: 0, + }); + const tilt = evaluator("driver", { + ...PINNED, total_spin: 2686, spin_axis_tilt: 10, + }); + const negativeTilt = evaluator("driver", { + ...PINNED, total_spin: 2686, spin_axis_tilt: -10, + }); + const directionMetrics = metricMap(direction); + const tiltMetrics = metricMap(tilt); + + expect(directionMetrics.get("launch_direction")?.value).toBeCloseTo(5, 8); + expect(directionMetrics.get("carry_offline")?.value).toBeGreaterThan(1); + expect(tiltMetrics.get("spin_axis_tilt")?.value).toBeCloseTo(10, 8); + expect(tiltMetrics.get("carry_offline")?.value).toBeGreaterThan(1); + expect(metricMap(negativeTilt).get("carry_offline")?.value).toBeLessThan(-1); + }); + + it("plugs into the existing profile fixture and optimizer", () => { + const parsedProfile = parsePlayerCapabilityProfile(fixture.profile); + const parsedRequest = parseOptimizationRequest({ + ...fixture.request, club_ids: ["driver"], candidate_budget: 1, + ensemble_size: 1, alternatives_count: 1, + }); + const result = optimizeCapability( + parsedProfile, + parsedRequest, + makeCapabilityFlightEvaluator( + parsedProfile, parsedRequest, { spinDefaults: SPIN_DEFAULTS }, + ), + ); + + expect(result.status).toBe("solved"); + expect(result.evaluationsCompleted).toBe(1); + expect(result.alternatives[0].meanCarryM).toBeGreaterThan(100); + }); + + it("types short-horizon nonconvergence and supports zero spin", () => { + const short = makeCapabilityFlightEvaluator( + profile(), request(), { maxTimeS: 0.001, spinDefaults: SPIN_DEFAULTS }, + ); + expect(short("driver", PINNED)).toEqual({ + status: "nonconverged", metrics: [], + reason: "no_ground_crossing_before_max_time", + }); + const zeroSpin = makeCapabilityFlightEvaluator(profile(), request(), { + spinDefaults: [{ ...SPIN_DEFAULTS[0], totalSpinRpm: 0 }], + })("driver", PINNED); + expect(zeroSpin.status).toBe("complete"); + expect(metricMap(zeroSpin).has("spin_axis_tilt")).toBe(false); + const coarse = makeCapabilityFlightEvaluator(profile(), request(), { + trajectorySampleIntervalS: 0.1, spinDefaults: SPIN_DEFAULTS, + })("driver", PINNED); + expect(coarse.status).toBe("complete"); + }); + + it("requires an auditable spin default for each club", () => { + const driverProfile = profile(); + const iron = { ...driverProfile.clubs[0], clubId: "iron" }; + const twoClubProfile = { + ...driverProfile, clubs: [...driverProfile.clubs, iron], + }; + const twoClubRequest = { + ...request(), clubIds: ["driver", "iron"], + }; + expect(() => makeCapabilityFlightEvaluator( + twoClubProfile, twoClubRequest, { spinDefaults: SPIN_DEFAULTS }, + )).toThrow(/iron.*explicit spin default/); + + const evaluator = makeCapabilityFlightEvaluator( + twoClubProfile, twoClubRequest, { spinDefaults: [ + { ...SPIN_DEFAULTS[0], provenance: "driver-fixture" }, + { clubId: "iron", totalSpinRpm: 6200, spinAxisTiltDeg: 0, + provenance: "iron-fixture" }, + ] }, + ); + const result = evaluator("iron", PINNED); + expect(result.status).toBe("complete"); + expect(result.metrics.every((item) => + item.provenance.includes("spin:fixed_club_default:iron-fixture"))).toBe(true); + }); + + it("rejects schema, units, bounds, and invalid configuration", () => { + const evaluator = makeCapabilityFlightEvaluator( + profile(), request(), { spinDefaults: SPIN_DEFAULTS }, + ); + expect(() => evaluator("driver", { ...PINNED, unused: 1 })).toThrow("fields"); + expect(() => evaluator("driver", { ...PINNED, ball_speed: 100 })).toThrow("safe bounds"); + const invalid = structuredClone(profile()); + const club = invalid.clubs[0] as unknown as { parameters: { unit: string }[] }; + club.parameters[0].unit = "mph"; + expect(() => makeCapabilityFlightEvaluator( + invalid, request(), { spinDefaults: SPIN_DEFAULTS }, + )).toThrow(/ball_speed.*m\/s/); + expect(() => makeCapabilityFlightEvaluator(profile(), request(), { maxTimeS: 0 })) + .toThrow("maxTimeS"); + expect(() => makeCapabilityFlightEvaluator(profile(), request())) + .toThrow("explicit spin default"); + expect(() => makeCapabilityFlightEvaluator(profile(), request(), { + trajectorySampleIntervalS: 9, spinDefaults: SPIN_DEFAULTS, + })).toThrow(/\[0.001, 0.1\]/); + expect(() => makeCapabilityFlightEvaluator(profile(), request(), { + trajectorySampleIntervalS: 0.0015, spinDefaults: SPIN_DEFAULTS, + })).toThrow("align"); + }); + + it.each([ + ["ball_speed", "lowerBound", 0, /ball_speed.*greater than zero/], + ["launch_angle", "upperBound", 100, /launch_angle.*\[-90, 90\]/], + ["launch_direction", "upperBound", 300, /launch_direction.*\[-180, 180\]/], + ["total_spin", "lowerBound", -1, /total_spin.*\[0, Infinity\]/], + ["spin_axis_tilt", "upperBound", 120, /spin_axis_tilt.*\[-90, 90\]/], + ])("rejects %s profiles outside the physical domain", ( + parameterId, field, value, message, + ) => { + const invalid = structuredClone(profile( + parameterId === "total_spin" || parameterId === "spin_axis_tilt", + )); + const parameters = invalid.clubs[0].parameters as unknown as + Record[]; + const selected = parameters.find((item) => item.parameterId === parameterId)!; + selected[field as string] = value as number; + expect(() => makeCapabilityFlightEvaluator( + invalid, request(), { spinDefaults: SPIN_DEFAULTS }, + )).toThrow(message as RegExp); + }); + // Bounds mirror the Shot Optimizer default profile, which permits the + // descending launches the fixture profile's narrower envelope excludes. + const wideProfile = () => { + const parameters = [ + parameter("ball_speed", "m/s", 67, 1, 100), + parameter("launch_angle", "deg", 12.5, -10, 45), + parameter("launch_direction", "deg", 0, -30, 30), + ]; + const matrix = parameters.map((_, row) => + parameters.map((__, column) => row === column ? 1 : 0)); + return parsePlayerCapabilityProfile({ + schema_version: "player-capability-profile/v1", profile_id: "player", + clubs: [{ club_id: "driver", parameters, matrix_kind: "correlation", + matrix, provenance: "test", confidence: 1 }], + provenance: "test", confidence: 1, + }); + }; + + it.each([ + [-4.323746585003578, 83.2427975264564], + [-3.169046046624327, 85.84087373780972], + ])("types a descending launch at %s deg as nonconverged, not an exception", ( + launchAngle, ballSpeed, + ) => { + const evaluate = makeCapabilityFlightEvaluator( + wideProfile(), request(), { spinDefaults: SPIN_DEFAULTS }, + ); + + const evaluation = evaluate("driver", { + ball_speed: ballSpeed, launch_angle: launchAngle, launch_direction: 9.2, + }); + + // The Python runtime reports these same samples as nonconverged. The + // ground interpolation previously extrapolated to a negative time and + // threw a RangeError that surfaced as an untyped evaluator_exception. + expect(evaluation.status).toBe("nonconverged"); + expect(evaluation.metrics).toHaveLength(0); + }); +}); diff --git a/src/rate_of_closure/web/src/model/capabilityFlightEvaluator.ts b/src/rate_of_closure/web/src/model/capabilityFlightEvaluator.ts new file mode 100644 index 0000000000..ec420b25c5 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityFlightEvaluator.ts @@ -0,0 +1,273 @@ +/** Model-backed capability evaluator for robust launch optimization. */ + +import { deriveFlightMetricResult, type FlightMetricInputs } from "./ballFlightMetrics"; +import type { + CapabilityEvaluator, + ClubCapability, + OptimizationRequest, + PlayerCapabilityProfile, +} from "./capabilityContract"; +import { simulateFlight, type Launch } from "./flight"; +import { fromFlightFrame, scale, type Vec3 } from "./impactPhysics"; +import type { EvaluatedMetric, SolverEvaluation } from "./inverseFlightContract"; + +export interface CapabilityFlightEvaluatorConfig { + readonly maxTimeS: number; + readonly trajectorySampleIntervalS: number; + readonly spinDefaults: readonly CapabilitySpinDefault[]; +} + +export interface CapabilitySpinDefault { + readonly clubId: string; + readonly totalSpinRpm: number; + readonly spinAxisTiltDeg: number; + readonly provenance: string; +} + +const MODEL_ID = "waterloo_penner"; +const MODEL_VERSION = "waterloo-penner-coefficients/v1"; +const PROVENANCE = `${MODEL_ID}:${MODEL_VERSION}:typescript-rk4`; +const INTEGRATION_STEP_S = 0.001; +const REQUIRED = Object.freeze(["ball_speed", "launch_angle", "launch_direction"]); +const OPTIONAL = Object.freeze(["total_spin", "spin_axis_tilt"]); +const UNITS: Readonly> = Object.freeze({ + ball_speed: "m/s", launch_angle: "deg", launch_direction: "deg", + total_spin: "rpm", spin_axis_tilt: "deg", +}); +const PHYSICAL_DOMAINS: Readonly> = + Object.freeze({ + ball_speed: [0, Number.POSITIVE_INFINITY, false], + launch_angle: [-90, 90, true], + launch_direction: [-180, 180, true], + total_spin: [0, Number.POSITIVE_INFINITY, true], + spin_axis_tilt: [-90, 90, true], + }); +const DEFAULT_CONFIG: CapabilityFlightEvaluatorConfig = Object.freeze({ + maxTimeS: 10, trajectorySampleIntervalS: 0.01, + spinDefaults: Object.freeze([]), +}); + +interface Binding { + readonly clubs: ReadonlyMap; + readonly spinDefaults: ReadonlyMap; + readonly targetPositionM: Vec3; + readonly config: CapabilityFlightEvaluatorConfig; +} + +interface BoundLaunch { + readonly value: Launch; + readonly spinSource: string; +} + +const finite = (value: unknown, name: string): number => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new RangeError(`${name} must be finite`); + } + return value; +}; + +const physical = (parameterId: string, value: number): void => { + const [lower, upper, inclusiveLower] = PHYSICAL_DOMAINS[parameterId]; + const lowerValid = inclusiveLower ? value >= lower : value > lower; + if (lowerValid && value <= upper) return; + if (parameterId === "ball_speed") { + throw new RangeError("ball_speed must be greater than zero"); + } + throw new RangeError(parameterId + " must lie within [" + lower + ", " + upper + "]"); +}; + +const config = ( + overrides: Partial, +): CapabilityFlightEvaluatorConfig => { + const merged = { ...DEFAULT_CONFIG, ...overrides }; + const parsed = { + maxTimeS: finite(merged.maxTimeS, "maxTimeS"), + trajectorySampleIntervalS: finite( + merged.trajectorySampleIntervalS, "trajectorySampleIntervalS"), + spinDefaults: Object.freeze((merged.spinDefaults ?? []).map((item) => { + if (!item.clubId.trim() || !item.provenance.trim()) { + throw new RangeError("spin default clubId and provenance must be nonempty"); + } + const parsedDefault = Object.freeze({ + clubId: item.clubId.trim(), + totalSpinRpm: finite(item.totalSpinRpm, "totalSpinRpm"), + spinAxisTiltDeg: finite(item.spinAxisTiltDeg, "spinAxisTiltDeg"), + provenance: item.provenance.trim(), + }); + physical("total_spin", parsedDefault.totalSpinRpm); + physical("spin_axis_tilt", parsedDefault.spinAxisTiltDeg); + return parsedDefault; + })), + }; + if (parsed.maxTimeS <= 0) throw new RangeError("maxTimeS must be > 0"); + if (parsed.trajectorySampleIntervalS < INTEGRATION_STEP_S || + parsed.trajectorySampleIntervalS > 0.1) { + throw new RangeError("trajectorySampleIntervalS must lie within [0.001, 0.1]"); + } + const ratio = parsed.trajectorySampleIntervalS / INTEGRATION_STEP_S; + if (Math.abs(ratio - Math.round(ratio)) > 1e-9) { + throw new RangeError("trajectorySampleIntervalS must align to the integration step"); + } + const defaultIds = parsed.spinDefaults.map((item) => item.clubId); + if (new Set(defaultIds).size !== defaultIds.length) { + throw new RangeError("spin default clubIds must be unique"); + } + return Object.freeze(parsed); +}; + +const validateClub = ( + club: ClubCapability, + spinDefaults: ReadonlyMap, +): void => { + const declared = new Set(club.parameters.map((item) => item.parameterId)); + const missing = REQUIRED.filter((parameterId) => !declared.has(parameterId)); + if (missing.length > 0) throw new RangeError(`${club.clubId} is missing: ${missing.join(",")}`); + if ([...declared].some((parameterId) => !REQUIRED.includes(parameterId) && + !OPTIONAL.includes(parameterId))) { + throw new RangeError(`${club.clubId} declares unsupported capability parameters`); + } + const declaredSpin = OPTIONAL.filter((parameterId) => declared.has(parameterId)); + if (declaredSpin.length === 1) { + throw new RangeError("total_spin and spin_axis_tilt must be declared together"); + } + if (declaredSpin.length === 0 && !spinDefaults.has(club.clubId)) { + throw new RangeError(club.clubId + " requires an explicit spin default"); + } + club.parameters.forEach((item) => { + if (item.unit !== UNITS[item.parameterId]) { + throw new RangeError(`${item.parameterId} must use ${UNITS[item.parameterId]}, not ${item.unit}`); + } + physical(item.parameterId, item.lowerBound); + physical(item.parameterId, item.upperBound); + }); +}; + +const binding = ( + profile: PlayerCapabilityProfile, + request: OptimizationRequest, + activeConfig: CapabilityFlightEvaluatorConfig, +): Binding => { + const spinDefaults = new Map( + activeConfig.spinDefaults.map((item) => [item.clubId, item] as const), + ); + const clubs = new Map(request.clubIds.map((clubId) => { + const club = profile.clubs.find((item) => item.clubId === clubId); + if (!club) throw new RangeError(`unknown clubId: ${clubId}`); + validateClub(club, spinDefaults); + return [clubId, club] as const; + })); + return Object.freeze({ + clubs, + spinDefaults, + config: activeConfig, + targetPositionM: [request.target.distanceM, 0, request.target.lateralM] as Vec3, + }); +}; + +const sample = ( + club: ClubCapability, + values: Readonly>, +): Readonly> => { + const parameters = new Map(club.parameters.map((item) => [item.parameterId, item])); + if (Object.keys(values).sort().join("|") !== [...parameters.keys()].sort().join("|")) { + throw new RangeError("capability sample fields do not match the club profile"); + } + const parsed = Object.fromEntries(Object.entries(values).map(([key, value]) => + [key, finite(value, key)])); + Object.entries(parsed).forEach(([parameterId, value]) => { + const parameter = parameters.get(parameterId)!; + physical(parameterId, value); + if (value < parameter.lowerBound || value > parameter.upperBound) { + throw new RangeError(`${parameterId} lies outside declared safe bounds`); + } + }); + return Object.freeze(parsed); +}; + +const launch = ( + values: Readonly>, + spinDefault: CapabilitySpinDefault | undefined, +): BoundLaunch => { + const direction = -values.launch_direction * Math.PI / 180; + const usesDefault = values.total_spin === undefined; + const tiltDeg = values.spin_axis_tilt ?? spinDefault!.spinAxisTiltDeg; + const tilt = tiltDeg * Math.PI / 180; + const value = Object.freeze({ + ballSpeedMps: values.ball_speed, + launchAngleRad: values.launch_angle * Math.PI / 180, + azimuthRad: direction, + spinRpm: values.total_spin ?? spinDefault!.totalSpinRpm, + // Positive target-frame tilt is toward fade/right: (0, -sin, cos). + spinAxis: [0, -Math.cos(tilt), -Math.sin(tilt)] as Vec3, + }); + const spinSource = usesDefault + ? "fixed_club_default:" + spinDefault!.provenance + : "sampled_profile"; + return Object.freeze({ value, spinSource }); +}; + +const inputs = (launchValue: Launch, activeBinding: Binding): FlightMetricInputs => { + const activeConfig = activeBinding.config; + const sampleEvery = Math.round(activeConfig.trajectorySampleIntervalS / INTEGRATION_STEP_S); + const flight = simulateFlight(launchValue, activeConfig.maxTimeS, INTEGRATION_STEP_S, sampleEvery); + return Object.freeze({ + trajectory: Object.freeze(flight.trajectory.map((point) => Object.freeze({ + timeS: point.time, positionM: fromFlightFrame(point.position), + velocityMps: fromFlightFrame(point.velocity), + }))), + spinVectorRpm: scale(fromFlightFrame(launchValue.spinAxis), launchValue.spinRpm), + targetPositionM: activeBinding.targetPositionM, + }); +}; + +const crossedGround = (metricInputs: FlightMetricInputs): boolean => + metricInputs.trajectory.some((point, index) => index > 0 && + metricInputs.trajectory[index - 1].positionM[1] > 0 && point.positionM[1] <= 0); + +const evaluation = ( + metricInputs: FlightMetricInputs, + spinSource: string, +): SolverEvaluation => { + const crossed = crossedGround(metricInputs); + const result = deriveFlightMetricResult(metricInputs, { + modelId: MODEL_ID, modelVersion: MODEL_VERSION, + integrationStatus: crossed ? "complete" : "nonconverged", + terminationReason: crossed ? "ground_crossing" : "max_time_reached", + environment: { air_model: "standard", integrator: "typescript-rk4" }, + wind: { model: "still_air" }, uncertaintyStatus: "deterministic", + frameId: "target_frame:x_downrange,y_up,z_right", + }); + const unavailable = ["carry_distance", "carry_offline"].some((metricId) => + result.value(metricId).status === "unavailable"); + if (!crossed || unavailable) return Object.freeze({ + status: "nonconverged", metrics: Object.freeze([]), + reason: "no_ground_crossing_before_max_time", + }); + const metrics = result.values.filter((value) => typeof value.numeric === "number") + .map((value): EvaluatedMetric => Object.freeze({ + metricId: value.metricId, value: value.numeric as number, + provenance: `ball-flight-result/v1|${PROVENANCE}|spin:${spinSource}|${value.provenance}`, + })); + return Object.freeze({ status: "complete", metrics: Object.freeze(metrics), reason: null }); +}; + +/** Bind a profile/request to an optimizer-compatible full-flight evaluator. */ +export function makeCapabilityFlightEvaluator( + profile: PlayerCapabilityProfile, + request: OptimizationRequest, + overrides: Partial = {}, +): CapabilityEvaluator { + const activeBinding = binding(profile, request, config(overrides)); + return (clubId, parameters) => { + const club = activeBinding.clubs.get(clubId); + if (!club) throw new RangeError(`unknown requested clubId: ${clubId}`); + const boundLaunch = launch( + sample(club, parameters), + activeBinding.spinDefaults.get(clubId), + ); + return evaluation( + inputs(boundLaunch.value, activeBinding), + boundLaunch.spinSource, + ); + }; +} diff --git a/src/rate_of_closure/web/src/model/capabilityObservationContract.ts b/src/rate_of_closure/web/src/model/capabilityObservationContract.ts new file mode 100644 index 0000000000..9b17c60397 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityObservationContract.ts @@ -0,0 +1,248 @@ +/** Immutable per-sample capability optimizer observation contract. */ + +import { FLIGHT_METRIC_IDS, type FlightMetricId } from "./ballFlightMetricContract"; +import type { EvaluatedMetric, EvaluationStatus } from "./inverseFlightContract"; + +export type CapabilityEffectiveStatus = "complete" | "no_impact" | "failed"; + +export interface CapabilityObservedParameter { + readonly parameterId: string; readonly unit: string; + readonly nominalValue: number; readonly perturbedValue: number; +} + +export interface CapabilitySampleObservation { + readonly schemaVersion: "capability-sample-observation/v1"; readonly problemId: string; + readonly attemptOrdinal: number; readonly attemptedCount: number; readonly totalCount: number; + readonly candidateOrdinal: number; readonly clubCandidateOrdinal: number; readonly sampleOrdinal: number; + readonly clubId: string; readonly parameters: readonly CapabilityObservedParameter[]; + readonly sourceStatus: EvaluationStatus | null; readonly effectiveStatus: CapabilityEffectiveStatus; + readonly reasonCode: string | null; readonly sourceReason: string | null; + readonly metrics: readonly EvaluatedMetric[]; +} + +export interface CapabilityOptimizationOptions { + readonly observationSink?: (observation: CapabilitySampleObservation) => void; + readonly shouldCancel?: () => boolean; +} + +export interface CapabilityObservedParameterWire { + readonly parameter_id: string; readonly unit: string; + readonly nominal_value: number; readonly perturbed_value: number; +} + +export interface CapabilityObservedMetricWire { + readonly metric_id: string; readonly value: number; readonly provenance: string; +} + +export interface CapabilitySampleObservationWire { + readonly schema_version: "capability-sample-observation/v1"; readonly problem_id: string; + readonly attempt_ordinal: number; readonly attempted_count: number; readonly total_count: number; + readonly candidate_ordinal: number; readonly club_candidate_ordinal: number; readonly sample_ordinal: number; + readonly club_id: string; readonly parameters: readonly CapabilityObservedParameterWire[]; + readonly source_status: EvaluationStatus | null; readonly effective_status: CapabilityEffectiveStatus; + readonly reason_code: string | null; readonly source_reason: string | null; + readonly metrics: readonly CapabilityObservedMetricWire[]; +} + +const OBSERVATION_KEYS = [ + "schemaVersion", "problemId", "attemptOrdinal", "attemptedCount", "totalCount", + "candidateOrdinal", "clubCandidateOrdinal", "sampleOrdinal", "clubId", "parameters", + "sourceStatus", "effectiveStatus", "reasonCode", "sourceReason", "metrics", +] as const; +const PARAMETER_KEYS = ["parameterId", "unit", "nominalValue", "perturbedValue"] as const; +const METRIC_KEYS = ["metricId", "value", "provenance"] as const; +const SOURCE_STATUSES = new Set([ + "complete", "no_impact", "failed", "nonconverged", +]); +const EFFECTIVE_STATUSES = new Set([ + "complete", "no_impact", "failed", +]); +const SOURCELESS_FAILURE_CODES = new Set(["evaluator_exception", "invalid_evaluator_result"]); +const KNOWN_METRIC_IDS = new Set(FLIGHT_METRIC_IDS); + +const record = (value: unknown, label: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Record; +}; + +const exactKeys = ( + value: Record, expected: readonly string[], label: string, +): void => { + const actual = Object.keys(value).sort().join("|"); + if (actual !== [...expected].sort().join("|")) { + throw new RangeError(`${label} fields do not match the v1 schema`); + } +}; + +const nonempty = (value: unknown, label: string): string => { + if (typeof value !== "string" || !value.trim()) { + throw new RangeError(`${label} must be a nonempty string`); + } + return value; +}; + +const nullableText = (value: unknown, label: string): string | null => { + if (value === null) return null; + return nonempty(value, label); +}; + +const finite = (value: unknown, label: string): number => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new RangeError(`${label} must be finite`); + } + return value; +}; + +const ordinal = (value: unknown, label: string): number => { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw new RangeError(`${label} must be a nonnegative integer`); + } + return value; +}; + +const parseParameter = (value: unknown, index: number): CapabilityObservedParameter => { + const item = record(value, `parameters[${index}]`); + exactKeys(item, PARAMETER_KEYS, `parameters[${index}]`); + return Object.freeze({ + parameterId: nonempty(item.parameterId, `parameters[${index}].parameterId`), + unit: nonempty(item.unit, `parameters[${index}].unit`), + nominalValue: finite(item.nominalValue, `parameters[${index}].nominalValue`), + perturbedValue: finite(item.perturbedValue, `parameters[${index}].perturbedValue`), + }); +}; + +const parseMetric = (value: unknown, index: number): EvaluatedMetric => { + const item = record(value, `metrics[${index}]`); + exactKeys(item, METRIC_KEYS, `metrics[${index}]`); + const metricId = nonempty(item.metricId, `metrics[${index}].metricId`); + if (!KNOWN_METRIC_IDS.has(metricId)) throw new RangeError(`unknown metricId: ${metricId}`); + return Object.freeze({ + metricId: metricId as FlightMetricId, + value: finite(item.value, `metrics[${index}].value`), + provenance: nonempty(item.provenance, `metrics[${index}].provenance`), + }); +}; + +const requireUnique = (values: readonly string[], label: string): void => { + if (new Set(values).size !== values.length) throw new RangeError(`${label} must be unique`); +}; + +interface StatusContractFields { + readonly source: EvaluationStatus | null; + readonly effective: CapabilityEffectiveStatus; + readonly code: string | null; + readonly reason: string | null; + readonly metrics: readonly EvaluatedMetric[]; +} + +const requireStatusContract = (fields: StatusContractFields): void => { + const { source, effective, code, reason, metrics } = fields; + let valid = false; + if (effective === "complete") valid = source === "complete" && code === null && reason === null; + if (effective === "no_impact") valid = source === "no_impact" && code !== null && code === reason; + if (effective === "failed" && source === null) { + valid = code !== null && SOURCELESS_FAILURE_CODES.has(code) && reason === null; + } + if (effective === "failed" && source === "complete") { + valid = code === "missing_required_landing_metrics" && reason === null; + } + if (effective === "failed" && (source === "failed" || source === "nonconverged")) { + valid = code !== null && code === reason; + } + if (!valid) throw new RangeError("observation status and reason fields are inconsistent"); + const metricIds = new Set(metrics.map(({ metricId }) => metricId)); + const hasLanding = metricIds.has("carry_distance") && metricIds.has("carry_offline"); + if (effective === "complete" && !hasLanding) { + throw new RangeError("complete observation requires carry_distance and carry_offline metrics"); + } + if (effective === "failed" && source === "complete" && hasLanding) { + throw new RangeError("missing_required_landing_metrics requires an incomplete landing pair"); + } + if ((effective === "no_impact" || source !== "complete") && metrics.length !== 0) { + throw new RangeError("non-complete source statuses require zero metrics"); + } +}; + +/** Parse, validate, defensively copy, and deeply freeze one v1 observation. */ +export function parseCapabilitySampleObservation(payload: unknown): CapabilitySampleObservation { + const root = record(payload, "observation"); + exactKeys(root, OBSERVATION_KEYS, "observation"); + if (root.schemaVersion !== "capability-sample-observation/v1") { + throw new RangeError("unsupported schemaVersion"); + } + const attemptOrdinal = ordinal(root.attemptOrdinal, "attemptOrdinal"); + const attemptedCount = ordinal(root.attemptedCount, "attemptedCount"); + const totalCount = ordinal(root.totalCount, "totalCount"); + if (attemptedCount !== attemptOrdinal + 1 || totalCount < attemptedCount) { + throw new RangeError("observation attempt counts are inconsistent"); + } + if (!Array.isArray(root.parameters) || !root.parameters.length) { + throw new TypeError("parameters must be a nonempty array"); + } + if (!Array.isArray(root.metrics)) throw new TypeError("metrics must be an array"); + const parameters = Object.freeze(root.parameters.map(parseParameter)); + const metrics = Object.freeze(root.metrics.map(parseMetric)); + requireUnique(parameters.map(({ parameterId }) => parameterId), "parameter IDs"); + requireUnique(metrics.map(({ metricId }) => metricId), "metric IDs"); + const sourceStatus = root.sourceStatus as EvaluationStatus | null; + const effectiveStatus = root.effectiveStatus as CapabilityEffectiveStatus; + if (sourceStatus !== null && !SOURCE_STATUSES.has(sourceStatus)) { + throw new RangeError("sourceStatus is not supported"); + } + if (!EFFECTIVE_STATUSES.has(effectiveStatus)) throw new RangeError("effectiveStatus is not supported"); + const reasonCode = nullableText(root.reasonCode, "reasonCode"); + const sourceReason = nullableText(root.sourceReason, "sourceReason"); + requireStatusContract({ + source: sourceStatus, effective: effectiveStatus, + code: reasonCode, reason: sourceReason, metrics, + }); + return Object.freeze({ + schemaVersion: "capability-sample-observation/v1", + problemId: nonempty(root.problemId, "problemId"), attemptOrdinal, attemptedCount, totalCount, + candidateOrdinal: ordinal(root.candidateOrdinal, "candidateOrdinal"), + clubCandidateOrdinal: ordinal(root.clubCandidateOrdinal, "clubCandidateOrdinal"), + sampleOrdinal: ordinal(root.sampleOrdinal, "sampleOrdinal"), + clubId: nonempty(root.clubId, "clubId"), parameters, sourceStatus, effectiveStatus, + reasonCode, sourceReason, metrics, + }); +} + +export class CapabilityOptimizationCancelled extends Error { + readonly attemptedCount: number; + readonly totalCount: number; + + constructor(attemptedCount: number, totalCount: number) { + if (!Number.isInteger(attemptedCount) || attemptedCount < 0) { + throw new RangeError("attemptedCount must be a nonnegative integer"); + } + if (!Number.isInteger(totalCount) || totalCount < attemptedCount) { + throw new RangeError("totalCount must be an integer not less than attemptedCount"); + } + super(`capability optimization cancelled after ${attemptedCount} of ${totalCount} evaluations`); + this.name = "CapabilityOptimizationCancelled"; + this.attemptedCount = attemptedCount; + this.totalCount = totalCount; + } +} + +/** Serialize one observation to the exact cross-runtime snake_case wire schema. */ +export const capabilitySampleObservationWire = ( + observation: CapabilitySampleObservation, +): CapabilitySampleObservationWire => Object.freeze({ + schema_version: observation.schemaVersion, problem_id: observation.problemId, + attempt_ordinal: observation.attemptOrdinal, attempted_count: observation.attemptedCount, + total_count: observation.totalCount, candidate_ordinal: observation.candidateOrdinal, + club_candidate_ordinal: observation.clubCandidateOrdinal, sample_ordinal: observation.sampleOrdinal, + club_id: observation.clubId, + parameters: Object.freeze(observation.parameters.map((item) => Object.freeze({ + parameter_id: item.parameterId, unit: item.unit, + nominal_value: item.nominalValue, perturbed_value: item.perturbedValue, + }))), + source_status: observation.sourceStatus, effective_status: observation.effectiveStatus, + reason_code: observation.reasonCode, source_reason: observation.sourceReason, + metrics: Object.freeze(observation.metrics.map((item) => Object.freeze({ + metric_id: item.metricId, value: item.value, provenance: item.provenance, + }))), +}); diff --git a/src/rate_of_closure/web/src/model/capabilityObservationEnsemble.test.ts b/src/rate_of_closure/web/src/model/capabilityObservationEnsemble.test.ts new file mode 100644 index 0000000000..eb5b1311d4 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityObservationEnsemble.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; + +import { + parseCapabilitySampleObservation, + type CapabilitySampleObservation, +} from "./capabilityObservationContract"; +import { + CapabilityObservationEnsembleBuilder, + buildCapabilityObservationEnsemble, + stableCapabilityObservationEnsembleJson, +} from "./capabilityObservationEnsemble"; +import type { TargetDefinition } from "./capabilityContract"; +import { flightMetricCatalog } from "./ballFlightMetricContract"; + +const target: TargetDefinition = { + kind: "green", distanceM: 100, lateralM: 2, radiusM: 10, + bandHalfLengthM: 15, halfWidthM: 16, +}; + +const observation = ( + attemptOrdinal: number, + effectiveStatus: "complete" | "no_impact" | "failed", +): CapabilitySampleObservation => ({ + schemaVersion: "capability-sample-observation/v1", + problemId: "observer-fixture", attemptOrdinal, + attemptedCount: attemptOrdinal + 1, totalCount: 2, + candidateOrdinal: attemptOrdinal, clubCandidateOrdinal: attemptOrdinal, + sampleOrdinal: 0, clubId: attemptOrdinal === 0 ? "iron-7" : "driver", + parameters: [ + { parameterId: "ball_speed", unit: "m/s", nominalValue: 50, perturbedValue: 51 + attemptOrdinal }, + { parameterId: "launch_angle", unit: "deg", nominalValue: 12, perturbedValue: 13 + attemptOrdinal }, + ], + sourceStatus: effectiveStatus, effectiveStatus, + reasonCode: effectiveStatus === "complete" ? null : "missed_ball", + sourceReason: effectiveStatus === "complete" ? null : "missed_ball", + metrics: effectiveStatus === "complete" ? [ + { metricId: "carry_distance", value: 105, provenance: "fixture.carry" }, + { metricId: "carry_offline", value: 5, provenance: "fixture.offline" }, + { metricId: "apex_height", value: 31, provenance: "fixture.apex" }, + ] : [], +}); + +const build = (observations: readonly CapabilitySampleObservation[], maxRows = 4) => + buildCapabilityObservationEnsemble({ + observations, target, maxRows, sourceProvenance: "fixture/evaluator-v1", + }); + +const records = (value: unknown): Record[] => + value as Record[]; + +const ASCII_DIGEST = "df36f765afdf508d00a3d264911ce5b6f07e25da3744b187596d67487ea3be5f"; // pragma: allowlist secret +const UNICODE_DIGEST = "18086b5e97d576598bbfa63407b6eda786a3a7ce20509654de282400bd32efd0"; // pragma: allowlist secret + +const mutate = ( + apply: (draft: Record) => void, +): CapabilitySampleObservation => { + const draft = structuredClone(observation(0, "complete")) as unknown as Record; + apply(draft); + return draft as unknown as CapabilitySampleObservation; +}; + +describe("capability observation scalar ensemble", () => { + it("declares every parameter/metric and emits deterministic nullable rows", () => { + const result = build([observation(1, "no_impact"), observation(0, "complete")]); + expect(result.rows.map(({ row_id }) => row_id)).toEqual([ + "series:candidate%3A0%2Fclub%3Airon-7/trial:0", + "series:candidate%3A1%2Fclub%3Adriver/trial:0", + ]); + const variableKeys = result.variables.map(({ key }) => key); + expect(variableKeys.slice(0, 4)).toEqual([ + "nominal.ball_speed", "perturbed.ball_speed", + "nominal.launch_angle", "perturbed.launch_angle", + ]); + expect(variableKeys.filter((key) => key.startsWith("metric."))) + .toEqual(flightMetricCatalog().definitions + .filter(({ signRule }) => signRule !== "vector_components") + .map(({ metricId }) => `metric.${metricId}`)); + expect(variableKeys.slice(-6)).toEqual([ + "target_downrange_residual", "target_lateral_residual", "target_residual", + "target_signed_distance", "target_solver_residual", "target_contains", + ]); + const [complete, noImpact] = result.rows; + expect(complete.values).toMatchObject({ + "nominal.ball_speed": 50, "perturbed.ball_speed": 51, + "metric.carry_distance": 105, target_downrange_residual: 5, + target_lateral_residual: 3, target_contains: 1, + }); + expect(complete.values.target_residual).toBeCloseTo(Math.sqrt(34)); + expect(complete.values.target_signed_distance).toBeCloseTo(Math.sqrt(34) - 10); + expect(complete.values.target_solver_residual).toBeCloseTo(0.05 * Math.sqrt(34)); + expect(complete.values["metric.total_distance"]).toBeNull(); + expect(complete.attributes).toMatchObject({ + effective_status: "complete", source_status: "complete", + "metric.carry_distance.provenance": "fixture.carry", + }); + expect(noImpact.cohort).toBe("no_impact"); + expect(Object.entries(noImpact.values) + .filter(([key]) => key.startsWith("metric.") || key.startsWith("target_")) + .every(([, value]) => value === null)).toBe(true); + expect(noImpact.attributes).toMatchObject({ + effective_status: "no_impact", source_status: "no_impact", + reason_code: "missed_ball", source_reason: "missed_ball", + "metric.carry_distance.provenance": null, + }); + }); +}); + +describe("capability observation ensemble bounds", () => { + it("rejects row overflow instead of truncating and bounds maxRows", () => { + expect(() => build([observation(0, "complete"), observation(1, "failed")], 1)) + .toThrow(/exceeds maxRows/); + for (const maxRows of [0, -1, 100_001, 1.5, true] as unknown as number[]) { + expect(() => build([observation(0, "complete")], maxRows)).toThrow(/maxRows/); + } + const builder = new CapabilityObservationEnsembleBuilder({ + target, maxRows: 1, sourceProvenance: "fixture/evaluator-v1", + }); + builder.accept(observation(0, "complete")); + expect(() => builder.accept(observation(1, "failed"))).toThrow(/maxRows/); + expect(builder.retainedCount).toBe(1); + expect(builder.build().rows).toHaveLength(1); + }); + + it("rejects a non-contract observation without retaining it", () => { + const builder = new CapabilityObservationEnsembleBuilder({ + target, maxRows: 2, sourceProvenance: "fixture/evaluator-v1", + }); + const invalid = { + ...observation(0, "complete"), schemaVersion: "capability-sample-observation/v0", + } as unknown as CapabilitySampleObservation; + expect(() => builder.accept(invalid)).toThrow(/schemaVersion/); + expect(builder.retainedCount).toBe(0); + }); +}); + +describe("capability observation validation", () => { + it("deep-validates every observation field before retention", () => { + const invalid = [ + mutate((row) => { row.problemId = " "; }), + mutate((row) => { row.attemptOrdinal = 0.5; }), + mutate((row) => { row.totalCount = 0; }), + mutate((row) => { records(row.parameters)[1].parameterId = "ball_speed"; }), + mutate((row) => { records(row.parameters)[0].nominalValue = Number.NaN; }), + mutate((row) => { records(row.metrics)[1].metricId = "carry_distance"; }), + mutate((row) => { records(row.metrics)[0].metricId = "unknown_metric"; }), + mutate((row) => { records(row.metrics)[0].value = Number.POSITIVE_INFINITY; }), + mutate((row) => { records(row.metrics)[0].provenance = " "; }), + mutate((row) => { row.sourceReason = "unexpected"; }), + mutate((row) => { row.sourceStatus = "failed"; row.effectiveStatus = "complete"; }), + ]; + invalid.forEach((row) => { + const builder = new CapabilityObservationEnsembleBuilder({ + target, maxRows: 2, sourceProvenance: "fixture/evaluator-v1", + }); + expect(() => builder.accept(row)).toThrow(); + expect(builder.retainedCount).toBe(0); + }); + }); + + it("defensively copies and freezes accepted observations", () => { + const raw = structuredClone(observation(0, "complete")); + const mutableTarget = structuredClone(target); + const parsed = parseCapabilitySampleObservation(raw); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed.parameters)).toBe(true); + expect(Object.isFrozen(parsed.parameters[0])).toBe(true); + const builder = new CapabilityObservationEnsembleBuilder({ + target: mutableTarget, maxRows: 2, sourceProvenance: "fixture/evaluator-v1", + }); + builder.accept(raw); + (raw as { problemId: string }).problemId = "mutated"; + (raw.parameters as unknown as { parameterId: string }[])[0].parameterId = "mutated"; + (mutableTarget as { distanceM: number }).distanceM = 999; + expect(builder.build().result_id).toBe("observer-fixture"); + expect(builder.build().variables[0].key).toBe("nominal.ball_speed"); + expect(builder.build().rows[0].values.target_downrange_residual).toBe(5); + }); + + it("enforces the exact status-to-metrics contract", () => { + const incompleteLanding = mutate((row) => { + row.sourceStatus = "complete"; row.effectiveStatus = "failed"; + row.reasonCode = "missing_required_landing_metrics"; row.sourceReason = null; + row.metrics = [records(row.metrics)[0]]; + }); + expect(() => build([incompleteLanding])).not.toThrow(); + const invalid = [ + mutate((row) => { row.metrics = [records(row.metrics)[0]]; }), + mutate((row) => { + row.sourceStatus = "no_impact"; row.effectiveStatus = "no_impact"; + row.reasonCode = "missed_ball"; row.sourceReason = "missed_ball"; + }), + mutate((row) => { + row.sourceStatus = "failed"; row.effectiveStatus = "failed"; + row.reasonCode = "solver_failed"; row.sourceReason = "solver_failed"; + }), + mutate((row) => { + row.sourceStatus = "nonconverged"; row.effectiveStatus = "failed"; + row.reasonCode = "iteration_limit"; row.sourceReason = "iteration_limit"; + }), + mutate((row) => { + row.sourceStatus = null; row.effectiveStatus = "failed"; + row.reasonCode = "evaluator_exception"; row.sourceReason = null; + }), + mutate((row) => { + row.sourceStatus = null; row.effectiveStatus = "failed"; + row.reasonCode = "invalid_evaluator_result"; row.sourceReason = null; + }), + ]; + records(invalid[0].metrics).splice(1); + invalid.forEach((row) => expect(() => build([row])).toThrow(/status|metrics|carry/)); + }); +}); + +describe("capability observation identity", () => { + it("rejects ambiguous identities and declaration drift", () => { + const duplicate = { ...observation(1, "complete"), attemptOrdinal: 0, attemptedCount: 1 }; + expect(() => build([observation(0, "complete"), duplicate])).toThrow(/attemptOrdinal/); + const prefix = { ...observation(0, "complete"), totalCount: 3 }; + const gap = { ...observation(2, "complete"), totalCount: 3 }; + expect(() => build([prefix, gap])) + .toThrow(/contiguous prefix/); + const drift = { + ...observation(1, "complete"), + parameters: [ + { parameterId: "launch_angle", unit: "deg", nominalValue: 12, perturbedValue: 13 }, + { parameterId: "ball_speed", unit: "mph", nominalValue: 50, perturbedValue: 51 }, + ], + } satisfies CapabilitySampleObservation; + expect(() => build([observation(0, "complete"), drift])).toThrow(/parameter declarations/); + }); + + it("cannot confuse structurally different declarations containing delimiters", () => { + const first = mutate((row) => { + row.clubId = "same-club"; + row.parameters = [ + { parameterId: "a\u0000b\u0001c", unit: "d", nominalValue: 1, perturbedValue: 2 }, + ]; + }); + const second = structuredClone(first); + (second as { attemptOrdinal: number }).attemptOrdinal = 1; + (second as { attemptedCount: number }).attemptedCount = 2; + (second as { candidateOrdinal: number }).candidateOrdinal = 1; + (second as { clubCandidateOrdinal: number }).clubCandidateOrdinal = 1; + (second as { parameters: CapabilitySampleObservation["parameters"] }).parameters = [ + { parameterId: "a", unit: "b", nominalValue: 1, perturbedValue: 2 }, + { parameterId: "c", unit: "d", nominalValue: 3, perturbedValue: 4 }, + ]; + expect(() => build([first, second])).toThrow(/parameter declarations/); + }); + + it("derives labels using ASCII-only initial-letter casing", () => { + const unicode = mutate((row) => { + row.parameters = [ + { parameterId: "alpha_\u00e9LAN_\u03c9mega", unit: "1", nominalValue: 1, perturbedValue: 2 }, + ]; + }); + expect(build([unicode]).variables.slice(0, 2).map(({ label }) => label)).toEqual([ + "Nominal Alpha \u00e9LAN \u03c9mega", "Perturbed Alpha \u00e9LAN \u03c9mega", + ]); + }); +}); + +describe("capability observation wire parity", () => { + it("has a stable runtime-neutral wire representation", async () => { + const json = stableCapabilityObservationEnsembleJson( + build([observation(1, "no_impact"), observation(0, "complete")]), + ); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(json)); + expect(Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0")).join("")) + .toBe(ASCII_DIGEST); + }); + + it("has stable Unicode code-point ordering across runtimes", async () => { + const unicode = mutate((row) => { + row.problemId = "観測-ß"; row.clubId = "ドライバー"; row.totalCount = 1; + row.parameters = [ + { parameterId: "zeta", unit: "m/s", nominalValue: 1, perturbedValue: 2 }, + { parameterId: "Ω", unit: "deg", nominalValue: 3, perturbedValue: 4 }, + ]; + records(row.metrics)[0].provenance = "測定.é"; + }); + const json = stableCapabilityObservationEnsembleJson(build([unicode])); + expect(json).toContain("観測-ß"); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(json)); + expect(Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0")).join("")) + .toBe(UNICODE_DIGEST); + }); + + it("emits canonical raw number tokens at rounding and exponent edges", () => { + const edge = mutate((row) => { + row.totalCount = 1; + row.parameters = [ + { parameterId: "half", unit: "1", nominalValue: 1.000000000005, perturbedValue: -1.000000000005 }, + { parameterId: "tiny", unit: "1", nominalValue: -0, perturbedValue: 0.0000001 }, + { parameterId: "large", unit: "1", nominalValue: 1e21, perturbedValue: 1e-12 }, + ]; + }); + const json = stableCapabilityObservationEnsembleJson(build([edge])); + expect(json).toContain('"nominal.half":1.00000000001'); + expect(json).toContain('"perturbed.half":-1.00000000001'); + expect(json).toContain('"nominal.tiny":0'); + expect(json).toContain('"perturbed.tiny":0.0000001'); + expect(json).toContain('"nominal.large":1000000000000000000000'); + expect(json).toContain('"perturbed.large":0'); + expect(json).not.toMatch(/"(?:nominal|perturbed)\.[^"]+":"/); + expect(json).not.toContain("e+21"); + }); +}); diff --git a/src/rate_of_closure/web/src/model/capabilityObservationEnsemble.ts b/src/rate_of_closure/web/src/model/capabilityObservationEnsemble.ts new file mode 100644 index 0000000000..5e7fa77d29 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityObservationEnsemble.ts @@ -0,0 +1,315 @@ +/** Bounded capability-observation adapter for scalar-ensemble/v1. */ + +import type { TargetDefinition } from "./capabilityContract"; +import { + parseCapabilitySampleObservation, + type CapabilityObservedParameter, + type CapabilitySampleObservation, +} from "./capabilityObservationContract"; +import { flightMetricCatalog, type FlightMetricId } from "./ballFlightMetricContract"; +import { + createScalarEnsemble, + scalarEnsembleRowId, + type ScalarEnsembleResult, + type ScalarEnsembleRow, + type ScalarVariableDefinition, +} from "./scalarEnsembleContract"; +import { contains, residualM, signedDistance, type TargetRegionTs } from "./targets"; + +export const MAX_CAPABILITY_OBSERVATION_ROWS = 100_000; +const ADAPTER_ID = "capability-sample-observation/scalar-ensemble/v1"; +const SOURCE_SCHEMA = "capability-sample-observation/v1"; +export type CapabilityCohort = "complete" | "no_impact" | "failed"; + +export interface CapabilityObservationEnsembleInput { + readonly observations: Iterable; + readonly target: TargetDefinition; + readonly maxRows: number; + readonly sourceProvenance: string; +} + +const TARGET_VARIABLES: readonly ScalarVariableDefinition[] = Object.freeze([ + { key: "target_downrange_residual", label: "Target Downrange Residual", unit: "m", stage_key: "target", category_key: "target" }, + { key: "target_lateral_residual", label: "Target Lateral Residual", unit: "m", stage_key: "target", category_key: "target" }, + { key: "target_residual", label: "Target Center Miss Distance", unit: "m", stage_key: "target", category_key: "target" }, + { key: "target_signed_distance", label: "Target Signed Distance", unit: "m", stage_key: "target", category_key: "target" }, + { key: "target_solver_residual", label: "Target Solver Residual", unit: "m", stage_key: "target", category_key: "target" }, + { key: "target_contains", label: "Inside Target", unit: "1", stage_key: "target", category_key: "target" }, +]); + +const orderedObservations = ( + observations: readonly CapabilitySampleObservation[], +): readonly CapabilitySampleObservation[] => { + if (!observations.length) throw new RangeError("capability observations must be nonempty"); + const ordered = [...observations].sort((left, right) => left.attemptOrdinal - right.attemptOrdinal); + if (new Set(ordered.map(({ attemptOrdinal }) => attemptOrdinal)).size !== ordered.length) { + throw new RangeError("attemptOrdinal values must be unique"); + } + if (ordered.some(({ attemptOrdinal }, index) => attemptOrdinal !== index)) { + throw new RangeError("attemptOrdinal values must form a contiguous prefix from zero"); + } + if (new Set(ordered.map(({ problemId }) => problemId)).size !== 1) { + throw new RangeError("observations must share one problemId"); + } + if (new Set(ordered.map(({ totalCount }) => totalCount)).size !== 1) { + throw new RangeError("observations must share one totalCount"); + } + const scalarIds = new Set(metricIds()); + if (ordered.some(({ metrics }) => metrics.some(({ metricId }) => !scalarIds.has(metricId)))) { + throw new RangeError("capability observations may contain only scalar flight metrics"); + } + return ordered; +}; + +const declarations = ( + observations: readonly CapabilitySampleObservation[], +): readonly CapabilityObservedParameter[] => { + const byClub = new Map(); + const declared = new Map(); + observations.forEach((observation) => { + const previous = byClub.get(observation.clubId); + const same = previous?.length === observation.parameters.length + && previous.every((item, index) => { + const current = observation.parameters[index]; + return item.parameterId === current.parameterId && item.unit === current.unit; + }); + if (previous !== undefined && !same) { + throw new RangeError("parameter declarations changed within a club"); + } + byClub.set(observation.clubId, observation.parameters); + observation.parameters.forEach((parameter) => { + const prior = declared.get(parameter.parameterId); + if (prior && prior.unit !== parameter.unit) { + throw new RangeError("parameter declarations use conflicting units"); + } + if (!prior) declared.set(parameter.parameterId, parameter); + }); + }); + return [...declared.values()]; +}; + +const metricIds = (): readonly FlightMetricId[] => flightMetricCatalog().definitions + .filter(({ signRule }) => signRule !== "vector_components") + .map(({ metricId }) => metricId); + +const variables = ( + parameters: readonly CapabilityObservedParameter[], metrics: readonly FlightMetricId[], +): readonly ScalarVariableDefinition[] => { + const definitions: ScalarVariableDefinition[] = []; + parameters.forEach((parameter) => { + const label = parameter.parameterId.split("_").map((part) => { + const initial = part.charCodeAt(0); + return initial >= 97 && initial <= 122 + ? String.fromCharCode(initial - 32) + part.slice(1) + : part; + }).join(" "); + definitions.push( + { key: `nominal.${parameter.parameterId}`, label: `Nominal ${label}`, unit: parameter.unit, stage_key: "nominal", category_key: "parameter" }, + { key: `perturbed.${parameter.parameterId}`, label: `Perturbed ${label}`, unit: parameter.unit, stage_key: "perturbed", category_key: "parameter" }, + ); + }); + const catalog = flightMetricCatalog(); + metrics.forEach((metricId) => { + const definition = catalog.definition(metricId); + definitions.push({ key: `metric.${metricId}`, label: definition.label, unit: definition.unit, stage_key: "evaluation", category_key: "metric" }); + }); + return [...definitions, ...TARGET_VARIABLES]; +}; + +const targetRegion = (target: TargetDefinition): TargetRegionTs => ({ + kind: target.kind, distanceM: target.distanceM, lateralM: target.lateralM, + radiusM: target.radiusM, bandHalfLengthM: target.bandHalfLengthM, + halfWidthM: target.halfWidthM, +}); + +const targetValues = ( + observation: CapabilitySampleObservation, target: TargetRegionTs, +): Readonly> => { + const metrics = new Map(observation.metrics.map(({ metricId, value }) => [metricId, value])); + const carry = metrics.get("carry_distance"); + const offline = metrics.get("carry_offline"); + if (carry === undefined || offline === undefined) { + throw new RangeError("complete observation requires carry and offline metrics"); + } + const centerOffline = target.kind === "green" ? target.lateralM : 0; + const downrangeResidual = carry - target.distanceM; + const lateralResidual = offline - centerOffline; + return { + target_downrange_residual: downrangeResidual, + target_lateral_residual: lateralResidual, + target_residual: Math.hypot(downrangeResidual, lateralResidual), + target_signed_distance: signedDistance(target, carry, offline), + target_solver_residual: residualM(target, carry, offline), + target_contains: Number(contains(target, carry, offline)), + }; +}; + +const rowValues = ( + observation: CapabilitySampleObservation, + variableKeys: readonly string[], + target: TargetRegionTs, +): Readonly> => { + const values: Record = Object.fromEntries( + variableKeys.map((key) => [key, null]), + ); + observation.parameters.forEach((parameter) => { + values[`nominal.${parameter.parameterId}`] = parameter.nominalValue; + values[`perturbed.${parameter.parameterId}`] = parameter.perturbedValue; + }); + if (observation.effectiveStatus === "complete") { + observation.metrics.forEach(({ metricId, value }) => { values[`metric.${metricId}`] = value; }); + Object.assign(values, targetValues(observation, target)); + } + return values; +}; + +const rowAttributes = ( + observation: CapabilitySampleObservation, metrics: readonly FlightMetricId[], +): Readonly> => { + const provenance = new Map(observation.metrics.map(({ metricId, provenance: source }) => + [metricId, source])); + return { + club_id: observation.clubId, attempt_ordinal: String(observation.attemptOrdinal), + attempted_count: String(observation.attemptedCount), total_count: String(observation.totalCount), + candidate_ordinal: String(observation.candidateOrdinal), + club_candidate_ordinal: String(observation.clubCandidateOrdinal), + sample_ordinal: String(observation.sampleOrdinal), source_status: observation.sourceStatus, + effective_status: observation.effectiveStatus, reason_code: observation.reasonCode, + source_reason: observation.sourceReason, + ...Object.fromEntries(metrics.map((metricId) => + [`metric.${metricId}.provenance`, provenance.get(metricId) ?? null])), + }; +}; + +const rows = ( + observations: readonly CapabilitySampleObservation[], + definitions: readonly ScalarVariableDefinition[], + metrics: readonly FlightMetricId[], target: TargetRegionTs, +): readonly ScalarEnsembleRow[] => { + const variableKeys = definitions.map(({ key }) => key); + return observations.map((observation) => { + const seriesId = `candidate:${observation.candidateOrdinal}/club:${observation.clubId}`; + return { + row_id: scalarEnsembleRowId(observation.sampleOrdinal, seriesId), + trial_index: observation.sampleOrdinal, series_id: seriesId, + cohort: observation.effectiveStatus, + values: rowValues(observation, variableKeys, target), + attributes: rowAttributes(observation, metrics), + }; + }); +}; + +export type CapabilityObservationEnsembleBuilderInput = Omit< + CapabilityObservationEnsembleInput, "observations" +>; + +const buildAccepted = ( + accepted: readonly CapabilitySampleObservation[], + input: CapabilityObservationEnsembleBuilderInput, +): ScalarEnsembleResult => { + const observations = orderedObservations(accepted); + const metrics = metricIds(); + const definitions = variables(declarations(observations), metrics); + return createScalarEnsemble({ + result_id: observations[0].problemId, + provenance: { adapter_id: ADAPTER_ID, source_schema_version: SOURCE_SCHEMA, source_provenance: input.sourceProvenance }, + stages: [ + { key: "nominal", label: "Nominal Parameters" }, + { key: "perturbed", label: "Perturbed Parameters" }, + { key: "evaluation", label: "Evaluator Metrics" }, + { key: "target", label: "Target Diagnostics" }, + ], + categories: [ + { key: "parameter", label: "Capability Parameters" }, + { key: "metric", label: "Evaluator Metrics" }, + { key: "target", label: "Target Diagnostics" }, + ], + variables: definitions, + cohorts: [ + { key: "complete", label: "Complete" }, + { key: "no_impact", label: "No Impact" }, + { key: "failed", label: "Failed" }, + ], + rows: rows(observations, definitions, metrics, targetRegion(input.target)), + }); +}; + +/** Bounded observation sink that rejects before retaining row maxRows + 1. */ +export class CapabilityObservationEnsembleBuilder { + private readonly input: CapabilityObservationEnsembleBuilderInput; + private readonly observations: CapabilitySampleObservation[] = []; + + constructor(input: CapabilityObservationEnsembleBuilderInput) { + if (!Number.isInteger(input.maxRows) || input.maxRows < 1 || + input.maxRows > MAX_CAPABILITY_OBSERVATION_ROWS) { + throw new RangeError(`maxRows must be an integer within [1, ${MAX_CAPABILITY_OBSERVATION_ROWS}]`); + } + if (!input.sourceProvenance.trim()) throw new RangeError("sourceProvenance must be nonempty"); + this.input = Object.freeze({ + ...input, + target: Object.freeze({ ...input.target }), + }); + } + + get retainedCount(): number { return this.observations.length; } + + accept(observation: CapabilitySampleObservation): void { + if (this.retainedCount >= this.input.maxRows) { + throw new RangeError(`observation row exceeds maxRows ${this.input.maxRows}`); + } + this.observations.push(parseCapabilitySampleObservation(observation)); + } + + build(): ScalarEnsembleResult { + return buildAccepted(this.observations, this.input); + } +} + +/** Stream observations through the bounded builder; overflow is never truncated. */ +export function buildCapabilityObservationEnsemble( + input: CapabilityObservationEnsembleInput, +): ScalarEnsembleResult { + const builder = new CapabilityObservationEnsembleBuilder({ + target: input.target, maxRows: input.maxRows, + sourceProvenance: input.sourceProvenance, + }); + for (const observation of input.observations) builder.accept(observation); + return builder.build(); +} + +const codePointCompare = (left: string, right: string): number => { + const leftPoints = [...left].map((item) => item.codePointAt(0) ?? 0); + const rightPoints = [...right].map((item) => item.codePointAt(0) ?? 0); + for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index += 1) { + const difference = leftPoints[index] - rightPoints[index]; + if (difference !== 0) return difference; + } + return leftPoints.length - rightPoints.length; +}; + +const canonicalNumber = (value: number): string => { + if (!Number.isFinite(value)) throw new RangeError("stable JSON numbers must be finite"); + if (value === 0) return "0"; + if (Number.isInteger(value)) return BigInt(value).toString(); + const fixed = value.toFixed(11); + const trimmed = fixed.includes(".") ? fixed.replace(/0+$/, "").replace(/\.$/, "") : fixed; + return trimmed === "-0" ? "0" : trimmed; +}; + +const canonicalJson = (value: unknown): string => { + if (value === null) return "null"; + if (typeof value === "number") return canonicalNumber(value); + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object") { + const entries = Object.entries(value).sort(([left], [right]) => codePointCompare(left, right)); + return `{${entries.map(([key, item]) => + `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`; + } + throw new TypeError("stable JSON supports only JSON-compatible values"); +}; + +/** Serialize with stable key order and the shared 11-decimal numeric policy. */ +export const stableCapabilityObservationEnsembleJson = ( + ensemble: ScalarEnsembleResult, +): string => canonicalJson(ensemble); diff --git a/src/rate_of_closure/web/src/model/capabilityOptimizer.ts b/src/rate_of_closure/web/src/model/capabilityOptimizer.ts index ab1ef6de64..9f21a265ba 100644 --- a/src/rate_of_closure/web/src/model/capabilityOptimizer.ts +++ b/src/rate_of_closure/web/src/model/capabilityOptimizer.ts @@ -10,8 +10,23 @@ import { type PlayerCapabilityProfile, } from "./capabilityContract"; import { covarianceFactor, primeBases, radicalInverse } from "./capabilityMath"; +import { CapabilityOptimizationCancelled } from "./capabilityObservationContract"; +import type { + CapabilityEffectiveStatus, + CapabilityObservedParameter, + CapabilityOptimizationOptions, + CapabilitySampleObservation, +} from "./capabilityObservationContract"; +import { FLIGHT_METRIC_IDS } from "./ballFlightMetricContract"; +import type { EvaluatedMetric, EvaluationStatus, SolverEvaluation } from "./inverseFlightContract"; import { contains, type TargetRegionTs } from "./targets"; +export { CapabilityOptimizationCancelled, capabilitySampleObservationWire } from "./capabilityObservationContract"; +export type { + CapabilityEffectiveStatus, CapabilityObservedParameter, CapabilityOptimizationOptions, + CapabilitySampleObservation, CapabilitySampleObservationWire, +} from "./capabilityObservationContract"; + const FAILURE_PENALTY = 1_000_000; const BOUNDARY_TOLERANCE = 1e-9; const PROVENANCE = Object.freeze({ @@ -24,11 +39,41 @@ const PROVENANCE = Object.freeze({ interface Landing { readonly carryM: number; readonly offlineM: number } interface Counts { readonly completed: number; readonly noImpact: number; readonly failed: number } interface CandidateSummary { readonly alternative: OptimizationAlternative | null; readonly counts: Counts } +interface CandidateContext { readonly candidateOrdinal: number; readonly clubCandidateOrdinal: number } +interface CandidateEvaluationInput { + readonly club: ClubCapability; readonly nominal: Readonly>; + readonly profile: PlayerCapabilityProfile; readonly request: OptimizationRequest; + readonly evaluator: CapabilityEvaluator; readonly context: CandidateContext; + readonly options: CapabilityOptimizationOptions; +} +interface NormalizedEvaluation { + readonly sourceStatus: EvaluationStatus | null; readonly effectiveStatus: CapabilityEffectiveStatus; + readonly reasonCode: string | null; readonly sourceReason: string | null; + readonly metrics: readonly EvaluatedMetric[]; readonly landing: Landing | null; +} +interface ObservationIdentity { + readonly attemptOrdinal: number; readonly attemptedCount: number; readonly totalCount: number; + readonly candidateOrdinal: number; readonly clubCandidateOrdinal: number; readonly sampleOrdinal: number; +} +interface SampleObservationInput { + readonly club: ClubCapability; readonly nominal: Readonly>; + readonly perturbed: Readonly>; readonly evaluation: NormalizedEvaluation; + readonly request: OptimizationRequest; readonly context: CandidateContext; readonly sampleOrdinal: number; +} interface RiskMetrics { readonly meanCarryM: number; readonly expectedMissM: number; readonly holdProbability: number; readonly dispersionRmsM: number; readonly cvarMissM: number; readonly downsideCarryM: number; } +interface ConstraintInput { + readonly club: ClubCapability; readonly nominal: Readonly>; + readonly successFraction: number; readonly extrapolated: boolean; readonly request: OptimizationRequest; +} +interface SummaryInput { + readonly club: ClubCapability; readonly nominal: Readonly>; + readonly landings: readonly Landing[]; readonly counts: Counts; + readonly profile: PlayerCapabilityProfile; readonly request: OptimizationRequest; +} const clubById = (profile: PlayerCapabilityProfile, clubId: string): ClubCapability => { const club = profile.clubs.find((item) => item.clubId === clubId); @@ -74,7 +119,7 @@ const perturbedParameters = ( ])); }; -const parseLanding = (evaluation: ReturnType): Landing | null => { +const parseLanding = (evaluation: SolverEvaluation): Landing | null => { if (evaluation.status !== "complete") return null; const carry = evaluation.metrics.find((item) => item.metricId === "carry_distance")?.value; const offline = evaluation.metrics.find((item) => item.metricId === "carry_offline")?.value; @@ -82,19 +127,116 @@ const parseLanding = (evaluation: ReturnType): Landing | nu return Object.freeze({ carryM: carry as number, offlineM: offline as number }); }; +const validEvaluationSemantics = ( + status: EvaluationStatus, metrics: readonly EvaluatedMetric[], reason: string | null, +): boolean => { + if (new Set(metrics.map(({ metricId }) => metricId)).size !== metrics.length) return false; + if (status === "complete") return metrics.length > 0 && reason === null; + return metrics.length === 0 && reason !== null && reason.trim() !== ""; +}; + +const asEvaluation = (value: unknown): SolverEvaluation | null => { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const source = value as Record; + const statuses: readonly EvaluationStatus[] = ["complete", "no_impact", "failed", "nonconverged"]; + if (!statuses.includes(source.status as EvaluationStatus) || !Array.isArray(source.metrics)) return null; + if (source.reason !== null && typeof source.reason !== "string") return null; + const metrics: EvaluatedMetric[] = []; + for (const valueMetric of source.metrics) { + if (!valueMetric || typeof valueMetric !== "object" || Array.isArray(valueMetric)) return null; + const metric = valueMetric as Record; + if (typeof metric.metricId !== "string" + || !FLIGHT_METRIC_IDS.includes(metric.metricId as EvaluatedMetric["metricId"]) + || typeof metric.value !== "number" || !Number.isFinite(metric.value) + || typeof metric.provenance !== "string" || metric.provenance.trim() === "") return null; + metrics.push(Object.freeze({ + metricId: metric.metricId as EvaluatedMetric["metricId"], + value: metric.value, + provenance: metric.provenance, + })); + } + const status = source.status as EvaluationStatus; + const reason = source.reason as string | null; + if (!validEvaluationSemantics(status, metrics, reason)) return null; + return Object.freeze({ + status, + metrics: Object.freeze(metrics), + reason, + }); +}; + +const failedEvaluation = (reasonCode: string): NormalizedEvaluation => Object.freeze({ + sourceStatus: null, effectiveStatus: "failed", reasonCode, sourceReason: null, + metrics: Object.freeze([]), landing: null, +}); + +const normalizeEvaluation = (evaluator: () => unknown): NormalizedEvaluation => { + let raw: unknown; + try { raw = evaluator(); } + catch { return failedEvaluation("evaluator_exception"); } + const evaluation = asEvaluation(raw); + if (!evaluation) return failedEvaluation("invalid_evaluator_result"); + const landing = parseLanding(evaluation); + if (evaluation.status === "complete") return Object.freeze({ + sourceStatus: evaluation.status, + effectiveStatus: landing ? "complete" : "failed", + reasonCode: landing ? null : "missing_required_landing_metrics", + sourceReason: null, metrics: evaluation.metrics, landing, + }); + return Object.freeze({ + sourceStatus: evaluation.status, + effectiveStatus: evaluation.status === "no_impact" ? "no_impact" : "failed", + reasonCode: evaluation.reason, sourceReason: evaluation.reason, + metrics: evaluation.metrics, landing: null, + }); +}; + +const observedParameters = ( + club: ClubCapability, + nominal: Readonly>, + perturbed: Readonly>, +): readonly CapabilityObservedParameter[] => Object.freeze(club.parameters.map((parameter) => Object.freeze({ + parameterId: parameter.parameterId, unit: parameter.unit, + nominalValue: nominal[parameter.parameterId], perturbedValue: perturbed[parameter.parameterId], +}))); + +const observationIdentity = ( + request: OptimizationRequest, context: CandidateContext, sampleOrdinal: number, +): ObservationIdentity => { + const { candidateOrdinal, clubCandidateOrdinal } = context; + const totalCount = request.candidateBudget * request.ensembleSize; + const attemptOrdinal = candidateOrdinal * request.ensembleSize + sampleOrdinal; + const ordinals = [candidateOrdinal, clubCandidateOrdinal, sampleOrdinal, attemptOrdinal, totalCount]; + if (ordinals.some((value) => !Number.isInteger(value) || value < 0)) { + throw new RangeError("observation identity values must be nonnegative integers"); + } + if (candidateOrdinal >= request.candidateBudget || sampleOrdinal >= request.ensembleSize + || attemptOrdinal >= totalCount) throw new RangeError("observation identity exceeds optimization bounds"); + return Object.freeze({ + attemptOrdinal, attemptedCount: attemptOrdinal + 1, totalCount, + candidateOrdinal, clubCandidateOrdinal, sampleOrdinal, + }); +}; + +const sampleObservation = (input: SampleObservationInput): CapabilitySampleObservation => { + const { club, nominal, perturbed, evaluation, request, context, sampleOrdinal } = input; + const identity = observationIdentity(request, context, sampleOrdinal); + return Object.freeze({ + schemaVersion: "capability-sample-observation/v1", problemId: request.problemId, + ...identity, clubId: club.clubId, parameters: observedParameters(club, nominal, perturbed), + sourceStatus: evaluation.sourceStatus, effectiveStatus: evaluation.effectiveStatus, + reasonCode: evaluation.reasonCode, sourceReason: evaluation.sourceReason, metrics: evaluation.metrics, + }); +}; + const tailMean = (values: readonly number[], alpha: number, reverse: boolean): number => { const count = Math.max(1, Math.ceil(values.length * (1 - alpha))); const ordered = [...values].sort((left, right) => reverse ? right - left : left - right); return ordered.slice(0, count).reduce((sum, value) => sum + value, 0) / count; }; -const limitingConstraints = ( - club: ClubCapability, - nominal: Readonly>, - successFraction: number, - extrapolated: boolean, - request: OptimizationRequest, -): readonly string[] => { +const limitingConstraints = (input: ConstraintInput): readonly string[] => { + const { club, nominal, successFraction, extrapolated, request } = input; const limiting = club.parameters.flatMap((item) => { if (Math.abs(nominal[item.parameterId] - item.lowerBound) <= BOUNDARY_TOLERANCE) return [`${item.parameterId}:lower_safe_bound`]; if (Math.abs(nominal[item.parameterId] - item.upperBound) <= BOUNDARY_TOLERANCE) return [`${item.parameterId}:upper_safe_bound`]; @@ -118,14 +260,8 @@ const objectiveScore = ( return Math.abs(risk.meanCarryM - targetDistance) + risk.dispersionRmsM; }; -const summarize = ( - club: ClubCapability, - nominal: Readonly>, - landings: readonly Landing[], - counts: Counts, - profile: PlayerCapabilityProfile, - request: OptimizationRequest, -): OptimizationAlternative | null => { +const summarize = (input: SummaryInput): OptimizationAlternative | null => { + const { club, nominal, landings, counts, profile, request } = input; if (landings.length === 0) return null; const target = targetRegion(request); const centerOffline = target.kind === "green" ? target.lateralM : 0; @@ -155,35 +291,37 @@ const summarize = ( targetHoldProbability: holdProbability, cvarMissM, downsideCarryM, sampleCount: request.ensembleSize, successfulCount: counts.completed, noImpactCount: counts.noImpact, failedCount: counts.failed, failureFraction, confidence, - limitingConstraints: limitingConstraints(club, nominal, successFraction, extrapolated, request), + limitingConstraints: limitingConstraints(Object.freeze({ + club, nominal, successFraction, extrapolated, request, + })), extrapolated, paretoEfficient: false, }); }; -const evaluateCandidate = ( - club: ClubCapability, - nominal: Readonly>, - profile: PlayerCapabilityProfile, - request: OptimizationRequest, - evaluator: CapabilityEvaluator, -): CandidateSummary => { +const evaluateCandidate = (input: CandidateEvaluationInput): CandidateSummary => { + const { club, nominal, profile, request, evaluator, context, options } = input; const landings: Landing[] = []; let counts: Counts = { completed: 0, noImpact: 0, failed: 0 }; for (let sampleIndex = 0; sampleIndex < request.ensembleSize; sampleIndex += 1) { - let evaluation: ReturnType; - try { evaluation = evaluator(club.clubId, perturbedParameters(club, nominal, sampleIndex, request.seed)); } - catch { counts = { ...counts, failed: counts.failed + 1 }; continue; } - const landing = parseLanding(evaluation); - if (!landing) { - counts = evaluation.status === "no_impact" - ? { ...counts, noImpact: counts.noImpact + 1 } - : { ...counts, failed: counts.failed + 1 }; - continue; - } - counts = { ...counts, completed: counts.completed + 1 }; - landings.push(landing); + const attemptOrdinal = context.candidateOrdinal * request.ensembleSize + sampleIndex; + const totalCount = request.candidateBudget * request.ensembleSize; + if (options.shouldCancel?.()) throw new CapabilityOptimizationCancelled(attemptOrdinal, totalCount); + const perturbed = perturbedParameters(club, nominal, sampleIndex, request.seed); + const evaluation = normalizeEvaluation(() => evaluator(club.clubId, perturbed)); + options.observationSink?.(sampleObservation(Object.freeze({ + club, nominal, perturbed, evaluation, request, context, sampleOrdinal: sampleIndex, + }))); + if (evaluation.effectiveStatus === "complete") { + counts = { ...counts, completed: counts.completed + 1 }; + landings.push(evaluation.landing as Landing); + } else if (evaluation.effectiveStatus === "no_impact") { + counts = { ...counts, noImpact: counts.noImpact + 1 }; + } else counts = { ...counts, failed: counts.failed + 1 }; } - return { alternative: summarize(club, nominal, landings, counts, profile, request), counts }; + return { + alternative: summarize(Object.freeze({ club, nominal, landings, counts, profile, request })), + counts, + }; }; const paretoMark = ( @@ -206,7 +344,17 @@ export function optimizeCapability( profile: PlayerCapabilityProfile, request: OptimizationRequest, evaluator: CapabilityEvaluator, + options: CapabilityOptimizationOptions = {}, ): OptimizationResult { + if (options.observationSink !== undefined && typeof options.observationSink !== "function") { + throw new TypeError("observationSink must be a function"); + } + if (options.shouldCancel !== undefined && typeof options.shouldCancel !== "function") { + throw new TypeError("shouldCancel must be a function"); + } + const hooks = Object.freeze({ + observationSink: options.observationSink, shouldCancel: options.shouldCancel, + }); const clubs = request.clubIds.map((clubId) => clubById(profile, clubId)); const indices = new Map(clubs.map((club) => [club.clubId, 0])); const alternatives: OptimizationAlternative[] = []; @@ -215,7 +363,11 @@ export function optimizeCapability( const club = clubs[evaluationIndex % clubs.length]; const candidateIndex = indices.get(club.clubId) as number; indices.set(club.clubId, candidateIndex + 1); - const summary = evaluateCandidate(club, candidateParameters(club, candidateIndex, request.seed), profile, request, evaluator); + const summary = evaluateCandidate(Object.freeze({ + club, nominal: candidateParameters(club, candidateIndex, request.seed), profile, request, evaluator, + context: Object.freeze({ candidateOrdinal: evaluationIndex, clubCandidateOrdinal: candidateIndex }), + options: hooks, + })); aggregate = { completed: aggregate.completed + summary.counts.completed, noImpact: aggregate.noImpact + summary.counts.noImpact, failed: aggregate.failed + summary.counts.failed }; if (summary.alternative) alternatives.push(summary.alternative); } diff --git a/src/rate_of_closure/web/src/model/capabilityOptimizerObservation.test.ts b/src/rate_of_closure/web/src/model/capabilityOptimizerObservation.test.ts new file mode 100644 index 0000000000..9f6801b958 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityOptimizerObservation.test.ts @@ -0,0 +1,226 @@ +import { expect, it, vi } from "vitest"; + +import fixture from "./__fixtures__/capability_optimizer_golden_v1.json"; +import { + parseOptimizationRequest, + parsePlayerCapabilityProfile, + type CapabilityEvaluator, + type SolverEvaluation, +} from "./capabilityContract"; +import { + CapabilityOptimizationCancelled, + capabilitySampleObservationWire, + optimizeCapability, + type CapabilitySampleObservation, +} from "./capabilityOptimizer"; + +const complete = (carry = 200, offline = 2): SolverEvaluation => ({ + status: "complete", + metrics: [ + { metricId: "carry_distance", value: carry, provenance: "test.carry" }, + { metricId: "carry_offline", value: offline, provenance: "test.offline" }, + { metricId: "apex_height", value: 31, provenance: "test.apex" }, + ], + reason: null, +}); + +const setup = (candidateBudget: number, ensembleSize: number, clubIds?: readonly string[]) => ({ + profile: parsePlayerCapabilityProfile(fixture.profile), + request: parseOptimizationRequest({ + ...fixture.request, + candidate_budget: candidateBudget, + ensemble_size: ensembleSize, + alternatives_count: 1, + club_ids: clubIds ?? fixture.request.club_ids, + }), +}); + +it("emits immutable samples in exact round-robin and ensemble order", () => { + const { profile, request } = setup(3, 2); + const observations: CapabilitySampleObservation[] = []; + + optimizeCapability(profile, request, () => complete(), { + observationSink: (observation) => observations.push(observation), + }); + + expect(observations.map((item) => [ + item.attemptOrdinal, + item.attemptedCount, + item.totalCount, + item.candidateOrdinal, + item.clubCandidateOrdinal, + item.sampleOrdinal, + item.clubId, + ])).toEqual([ + [0, 1, 6, 0, 0, 0, "iron-7"], + [1, 2, 6, 0, 0, 1, "iron-7"], + [2, 3, 6, 1, 0, 0, "driver"], + [3, 4, 6, 1, 0, 1, "driver"], + [4, 5, 6, 2, 1, 0, "iron-7"], + [5, 6, 6, 2, 1, 1, "iron-7"], + ]); + expect(observations[0].parameters.map((item) => item.parameterId)) + .toEqual(profile.clubs[0].parameters.map((item) => item.parameterId)); + expect(observations[0].metrics.map((item) => item.metricId)) + .toEqual(["carry_distance", "carry_offline", "apex_height"]); + expect(observations[0]).toMatchObject({ + schemaVersion: "capability-sample-observation/v1", + problemId: request.problemId, + sourceStatus: "complete", + effectiveStatus: "complete", + reasonCode: null, + sourceReason: null, + }); + }); + +it("freezes observations and serializes the exact cross-runtime wire shape", () => { + const { profile, request } = setup(1, 1); + const observations: CapabilitySampleObservation[] = []; + optimizeCapability(profile, request, () => complete(), { + observationSink: (observation) => observations.push(observation), + }); + + expect(Object.isFrozen(observations[0])).toBe(true); + expect(Object.isFrozen(observations[0].parameters)).toBe(true); + expect(Object.isFrozen(observations[0].parameters[0])).toBe(true); + expect(Object.isFrozen(observations[0].metrics)).toBe(true); + expect(Object.isFrozen(observations[0].metrics[0])).toBe(true); + const wire = capabilitySampleObservationWire(observations[0]); + expect(Object.keys(wire)).toEqual([ + "schema_version", "problem_id", "attempt_ordinal", "attempted_count", "total_count", + "candidate_ordinal", "club_candidate_ordinal", "sample_ordinal", "club_id", "parameters", + "source_status", "effective_status", "reason_code", "source_reason", "metrics", + ]); + expect(wire).toMatchObject({ + schema_version: "capability-sample-observation/v1", + problem_id: request.problemId, + attempt_ordinal: 0, + attempted_count: 1, + total_count: 1, + candidate_ordinal: 0, + club_candidate_ordinal: 0, + sample_ordinal: 0, + club_id: "iron-7", + source_status: "complete", + effective_status: "complete", + reason_code: null, + source_reason: null, + }); + expect(wire.parameters[0]) + .toEqual({ + parameter_id: observations[0].parameters[0].parameterId, + unit: observations[0].parameters[0].unit, + nominal_value: observations[0].parameters[0].nominalValue, + perturbed_value: observations[0].parameters[0].perturbedValue, + }); + expect(wire.metrics[0]).toEqual({ + metric_id: "carry_distance", value: 200, provenance: "test.carry", + }); + }); + +it("normalizes all evaluator statuses, exceptions, and malformed results", () => { + const { profile, request } = setup(7, 1, ["iron-7"]); + const outcomes: unknown[] = [ + complete(), + { status: "no_impact", metrics: [], reason: "missed_ball" }, + { status: "failed", metrics: [], reason: "solver_failed" }, + { status: "nonconverged", metrics: [], reason: "iteration_limit" }, + new Error("evaluator exploded"), + { unexpected: true }, + { status: "complete", metrics: [{ metricId: "carry_distance", value: 200, provenance: "test" }], reason: null }, + ]; + const observations: CapabilitySampleObservation[] = []; + let index = 0; + const evaluator = (() => { + const outcome = outcomes[index++]; + if (outcome instanceof Error) throw outcome; + return outcome; + }) as CapabilityEvaluator; + + const result = optimizeCapability(profile, request, evaluator, { + observationSink: (observation) => observations.push(observation), + }); + + expect(observations.map((item) => [ + item.sourceStatus, item.effectiveStatus, item.reasonCode, item.sourceReason, + ])).toEqual([ + ["complete", "complete", null, null], + ["no_impact", "no_impact", "missed_ball", "missed_ball"], + ["failed", "failed", "solver_failed", "solver_failed"], + ["nonconverged", "failed", "iteration_limit", "iteration_limit"], + [null, "failed", "evaluator_exception", null], + [null, "failed", "invalid_evaluator_result", null], + ["complete", "failed", "missing_required_landing_metrics", null], + ]); + expect(result).toMatchObject({ evaluationsCompleted: 1, noImpactCount: 1, failedCount: 5 }); + }); + +it("rejects every semantic SolverEvaluation contract violation", () => { + const { profile, request } = setup(5, 1, ["iron-7"]); + const metric = { metricId: "carry_distance", value: 200, provenance: "test" }; + const outcomes: unknown[] = [ + { status: "complete", metrics: [metric, metric], reason: null }, + { status: "complete", metrics: [metric], reason: "unexpected" }, + { status: "no_impact", metrics: [metric], reason: "missed_ball" }, + { status: "failed", metrics: [], reason: null }, + { status: "nonconverged", metrics: [], reason: " " }, + ]; + const observations: CapabilitySampleObservation[] = []; + let index = 0; + const evaluator = (() => outcomes[index++]) as CapabilityEvaluator; + + const result = optimizeCapability(profile, request, evaluator, { + observationSink: (observation) => observations.push(observation), + }); + + expect(observations).toHaveLength(5); + expect(observations.every((observation) => + observation.sourceStatus === null + && observation.effectiveStatus === "failed" + && observation.reasonCode === "invalid_evaluator_result" + && observation.sourceReason === null + && observation.metrics.length === 0)).toBe(true); + expect(result).toMatchObject({ + status: "nonconverged", evaluationsCompleted: 0, noImpactCount: 0, failedCount: 5, + }); + }); + +it("cancels before the next evaluator call with typed progress", () => { + const { profile, request } = setup(4, 2); + const evaluator = vi.fn(() => complete()); + const observations: CapabilitySampleObservation[] = []; + + expect(() => optimizeCapability(profile, request, evaluator, { + observationSink: (observation) => observations.push(observation), + shouldCancel: () => evaluator.mock.calls.length >= 3, + })).toThrow(CapabilityOptimizationCancelled); + expect(evaluator).toHaveBeenCalledTimes(3); + expect(observations).toHaveLength(3); + try { + optimizeCapability(profile, request, evaluator, { shouldCancel: () => true }); + } catch (error) { + expect(error).toMatchObject({ attemptedCount: 0, totalCount: 8 }); + } + expect(() => new CapabilityOptimizationCancelled(-1, 8)).toThrow(/nonnegative integer/); + expect(() => new CapabilityOptimizationCancelled(3, 2)).toThrow(/not less than/); + }); + +it("propagates sink errors and leaves the compact result invariant", () => { + const { profile, request } = setup(2, 2); + const baseline = optimizeCapability(profile, request, () => complete()); + const observations: CapabilitySampleObservation[] = []; + const observed = optimizeCapability(profile, request, () => complete(), { + observationSink: (observation) => observations.push(observation), + }); + + expect(observed).toEqual(baseline); + expect("observations" in observed).toBe(false); + expect(observations).toHaveLength(4); + + const sentinel = new Error("sink failed"); + const evaluator = vi.fn(() => complete()); + expect(() => optimizeCapability(profile, request, evaluator, { + observationSink: () => { throw sentinel; }, + })).toThrow(sentinel); + expect(evaluator).toHaveBeenCalledTimes(1); + }); diff --git a/src/rate_of_closure/web/src/model/capabilityResultExport.test.ts b/src/rate_of_closure/web/src/model/capabilityResultExport.test.ts new file mode 100644 index 0000000000..e05a29e379 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityResultExport.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { runCapabilityOptimization } from "./capabilityRun"; +import { + buildCapabilityWorkflow, + defaultCapabilityWorkflowInputs, +} from "./capabilityWorkflow"; +import { + capabilityAlternativesCsv, + stableCapabilityResultExportJson, +} from "./capabilityResultExport"; + +const output = runCapabilityOptimization(buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), candidateBudget: 1, ensembleSize: 2, + alternativesCount: 1, +})); + +describe("capability result exports", () => { + it("exports every ranked diagnostic with parameter units", () => { + const csv = capabilityAlternativesCsv(output.result, output.ensemble); + + expect(csv.split("\n")[0]).toBe( + "rank,club_id,parameters,score,mean_carry_m,expected_miss_m,dispersion_rms_m," + + "target_hold_probability,cvar_miss_m,downside_carry_m,sample_count,successful_count," + + "no_impact_count,failed_count,failure_fraction,confidence,extrapolated,pareto_efficient," + + "limiting_constraints", + ); + expect(csv).toContain("ball_speed="); + expect(csv).toContain("m/s"); + }); + + it("uses a versioned stable envelope compatible with the Python wire shape", () => { + const payload = JSON.parse(stableCapabilityResultExportJson(output.result, output.ensemble)); + + expect(payload.schema_version).toBe("capability-result-export/v1"); + expect(payload.result.alternatives[0]).toEqual(expect.objectContaining({ + cvar_miss_m: expect.any(Number), downside_carry_m: expect.any(Number), + sample_count: 2, successful_count: expect.any(Number), + failure_fraction: expect.any(Number), extrapolated: expect.any(Boolean), + pareto_efficient: expect.any(Boolean), + })); + expect(payload.parameter_units).toEqual(expect.arrayContaining([ + { parameter_id: "ball_speed", unit: "m/s" }, + ])); + }); + + it("fails closed when result and ensemble provenance do not match", () => { + const mismatched = { ...output.ensemble, result_id: "different-problem" }; + + expect(() => capabilityAlternativesCsv(output.result, mismatched)) + .toThrow("result and ensemble IDs must match"); + expect(() => stableCapabilityResultExportJson(output.result, mismatched)) + .toThrow("result and ensemble IDs must match"); + }); +}); diff --git a/src/rate_of_closure/web/src/model/capabilityResultExport.ts b/src/rate_of_closure/web/src/model/capabilityResultExport.ts new file mode 100644 index 0000000000..f800ffd28f --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityResultExport.ts @@ -0,0 +1,131 @@ +/** Stable, versioned exports for ranked capability alternatives. */ + +import type { + OptimizationAlternative, + OptimizationResult, +} from "./capabilityContract"; +import type { ScalarEnsembleResult } from "./scalarEnsembleContract"; +import { spreadsheetCsvCell } from "./scalarEnsembleCsv"; + +export const CAPABILITY_RESULT_EXPORT_SCHEMA = "capability-result-export/v1"; + +const parameterUnits = ( + ensemble: ScalarEnsembleResult, +): Readonly> => { + const entries = ensemble.variables + .filter(({ key }) => key.startsWith("nominal.")) + .map(({ key, unit }) => [key.slice("nominal.".length), unit] as const); + if (!entries.length) throw new RangeError("capability result export requires parameter units"); + if (new Set(entries.map(([parameterId]) => parameterId)).size !== entries.length) { + throw new RangeError("capability result export requires unique parameter units"); + } + return Object.freeze(Object.fromEntries(entries)); +}; + +const requireMatchedResult = ( + result: OptimizationResult, + ensemble: ScalarEnsembleResult, +): void => { + if (result.problemId !== ensemble.result_id) { + throw new RangeError("result and ensemble IDs must match"); + } +}; + +const alternativeWire = (item: OptimizationAlternative) => ({ + club_id: item.clubId, + confidence: item.confidence, + cvar_miss_m: item.cvarMissM, + dispersion_rms_m: item.dispersionRmsM, + downside_carry_m: item.downsideCarryM, + expected_miss_m: item.expectedMissM, + extrapolated: item.extrapolated, + failed_count: item.failedCount, + failure_fraction: item.failureFraction, + limiting_constraints: item.limitingConstraints, + mean_carry_m: item.meanCarryM, + no_impact_count: item.noImpactCount, + parameters: item.parameters.map(({ parameterId, value }) => ({ + parameter_id: parameterId, value, + })), + pareto_efficient: item.paretoEfficient, + rank: item.rank, + sample_count: item.sampleCount, + score: item.score, + successful_count: item.successfulCount, + target_hold_probability: item.targetHoldProbability, +}); + +const resultWire = (result: OptimizationResult) => ({ + alternatives: result.alternatives.map(alternativeWire), + evaluations_attempted: result.evaluationsAttempted, + evaluations_completed: result.evaluationsCompleted, + failed_count: result.failedCount, + no_impact_count: result.noImpactCount, + problem_id: result.problemId, + provenance: Object.entries(result.provenance).map(([key, value]) => ({ key, value })), + schema_version: result.schemaVersion, + status: result.status, +}); + +const stable = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(stable); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([key, item]) => [key, stable(item)])); + } + return value; +}; + +const parameterText = ( + item: OptimizationAlternative, + units: Readonly>, +): string => item.parameters.map(({ parameterId, value }) => { + const unit = units[parameterId]; + if (unit === undefined) throw new RangeError(`missing unit for ${parameterId}`); + return `${parameterId}=${value.toPrecision(12)} ${unit}`; +}).join("; "); + +const alternativeRow = ( + item: OptimizationAlternative, + units: Readonly>, +): readonly (string | number | boolean)[] => [ + item.rank, item.clubId, parameterText(item, units), item.score, item.meanCarryM, + item.expectedMissM, item.dispersionRmsM, item.targetHoldProbability, + item.cvarMissM, item.downsideCarryM, item.sampleCount, item.successfulCount, + item.noImpactCount, item.failedCount, item.failureFraction, item.confidence, + item.extrapolated, item.paretoEfficient, item.limitingConstraints.join("; "), +]; + +const HEADERS = [ + "rank", "club_id", "parameters", "score", "mean_carry_m", + "expected_miss_m", "dispersion_rms_m", "target_hold_probability", + "cvar_miss_m", "downside_carry_m", "sample_count", "successful_count", + "no_impact_count", "failed_count", "failure_fraction", "confidence", + "extrapolated", "pareto_efficient", "limiting_constraints", +] as const; + +/** Export every ranked diagnostic with unambiguous parameter units. */ +export const capabilityAlternativesCsv = ( + result: OptimizationResult, + ensemble: ScalarEnsembleResult, +): string => { + requireMatchedResult(result, ensemble); + const units = parameterUnits(ensemble); + return [HEADERS, ...result.alternatives.map((item) => alternativeRow(item, units))] + .map((row) => row.map(spreadsheetCsvCell).join(",")).join("\n"); +}; + +/** Export the strict result and its external unit declarations. */ +export const stableCapabilityResultExportJson = ( + result: OptimizationResult, + ensemble: ScalarEnsembleResult, +): string => { + requireMatchedResult(result, ensemble); + return JSON.stringify(stable({ + parameter_units: Object.entries(parameterUnits(ensemble)) + .map(([parameter_id, unit]) => ({ parameter_id, unit })), + result: resultWire(result), + schema_version: CAPABILITY_RESULT_EXPORT_SCHEMA, + })); +}; diff --git a/src/rate_of_closure/web/src/model/capabilityRun.test.ts b/src/rate_of_closure/web/src/model/capabilityRun.test.ts new file mode 100644 index 0000000000..d51f175a0c --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityRun.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runCapabilityOptimization } from "./capabilityRun"; +import { + buildCapabilityWorkflow, + defaultCapabilityWorkflowInputs, +} from "./capabilityWorkflow"; + +describe("capability optimization run facade", () => { + it("retains every observation and reports exact final progress", () => { + const document = buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), + candidateBudget: 1, ensembleSize: 2, alternativesCount: 1, + }); + const progress = vi.fn(); + + const output = runCapabilityOptimization(document, progress); + + expect(output.result.evaluationsAttempted).toBe(2); + expect(output.ensemble.rows).toHaveLength(2); + expect(progress).toHaveBeenLastCalledWith({ completed: 2, total: 2 }); + }); +}); diff --git a/src/rate_of_closure/web/src/model/capabilityRun.ts b/src/rate_of_closure/web/src/model/capabilityRun.ts new file mode 100644 index 0000000000..0d80ea6b52 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityRun.ts @@ -0,0 +1,48 @@ +/** Complete synchronous capability run used only inside worker/test boundaries. */ + +import { + CapabilityObservationEnsembleBuilder, + type CapabilityCohort, +} from "./capabilityObservationEnsemble"; +import { makeCapabilityFlightEvaluator } from "./capabilityFlightEvaluator"; +import { optimizeCapability } from "./capabilityOptimizer"; +import type { OptimizationResult } from "./capabilityContract"; +import type { ScalarEnsembleResult } from "./scalarEnsembleContract"; +import type { CapabilityWorkflowDocument } from "./capabilityWorkflow"; + +export interface CapabilityProgress { + readonly completed: number; readonly total: number; +} + +export interface CapabilityRunOutput { + readonly result: OptimizationResult; + readonly ensemble: ScalarEnsembleResult; +} + +/** Run one immutable workflow; callers must place this CPU work off the UI thread. */ +export function runCapabilityOptimization( + document: CapabilityWorkflowDocument, + onProgress: (progress: CapabilityProgress) => void = () => undefined, +): CapabilityRunOutput { + const total = document.request.candidateBudget * document.request.ensembleSize; + const builder = new CapabilityObservationEnsembleBuilder({ + target: document.request.target, maxRows: total, + sourceProvenance: document.profile.provenance, + }); + const evaluator = makeCapabilityFlightEvaluator( + document.profile, document.request, document.evaluatorConfig, + ); + let lastReported = 0; + const result = optimizeCapability(document.profile, document.request, evaluator, { + observationSink: (observation) => { + builder.accept(observation); + const completed = observation.attemptedCount; + const interval = Math.max(1, Math.floor(total / 100)); + if (completed === total || completed === 1 || completed - lastReported >= interval) { + lastReported = completed; + onProgress({ completed, total }); + } + }, + }); + return Object.freeze({ result, ensemble: builder.build() }); +} diff --git a/src/rate_of_closure/web/src/model/capabilityWorkerClient.test.ts b/src/rate_of_closure/web/src/model/capabilityWorkerClient.test.ts new file mode 100644 index 0000000000..3af42461d3 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityWorkerClient.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CapabilityWorkflowDocument } from "./capabilityWorkflow"; +import { createCapabilityWorkerRunner } from "./capabilityWorkerClient"; + +const document = {} as CapabilityWorkflowDocument; + +class WorkerDouble { + static instance: WorkerDouble | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + postMessage = vi.fn(); + terminate = vi.fn(); + + constructor() { WorkerDouble.instance = this; } +} + +describe("capability worker client", () => { + it("forwards progress and terminates on cancellation", async () => { + const progress = vi.fn(); + const runner = createCapabilityWorkerRunner( + () => new WorkerDouble() as unknown as Worker, + ); + const controller = runner(document, progress); + const worker = WorkerDouble.instance!; + + worker.onmessage?.({ data: { type: "progress", completed: 2, total: 5 } } as MessageEvent); + expect(progress).toHaveBeenCalledWith({ completed: 2, total: 5 }); + + controller.cancel(); + await expect(controller.promise).rejects.toMatchObject({ name: "AbortError" }); + worker.onmessage?.({ data: { type: "progress", completed: 5, total: 5 } } as MessageEvent); + expect(progress).toHaveBeenCalledTimes(1); + expect(worker.postMessage).toHaveBeenCalledWith({ type: "run", document }); + expect(worker.terminate).toHaveBeenCalledOnce(); + }); + + it("rejects and terminates when completed worker output is malformed", async () => { + const runner = createCapabilityWorkerRunner( + () => new WorkerDouble() as unknown as Worker, + ); + const controller = runner(document, vi.fn()); + const worker = WorkerDouble.instance!; + + worker.onmessage?.({ + data: { type: "complete", output: { result: {}, ensemble: {} } }, + } as MessageEvent); + + await expect(controller.promise).rejects.toThrow(); + expect(worker.terminate).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/rate_of_closure/web/src/model/capabilityWorkerClient.ts b/src/rate_of_closure/web/src/model/capabilityWorkerClient.ts new file mode 100644 index 0000000000..4e67dd4b8c --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityWorkerClient.ts @@ -0,0 +1,87 @@ +/** Browser-worker boundary for cancellable capability optimization. */ + +import { parseOptimizationResult } from "./capabilityContract"; +import type { CapabilityCohort } from "./capabilityObservationEnsemble"; +import type { CapabilityProgress, CapabilityRunOutput } from "./capabilityRun"; +import type { CapabilityWorkflowDocument } from "./capabilityWorkflow"; +import { + createScalarEnsemble, + type ScalarEnsembleInput, +} from "./scalarEnsembleContract"; + +export interface CapabilityRunController { + readonly promise: Promise; + cancel: () => void; +} + +export type CapabilityRunner = ( + document: CapabilityWorkflowDocument, + onProgress: (progress: CapabilityProgress) => void, +) => CapabilityRunController; + +export type CapabilityWorkerRequest = { + readonly type: "run"; readonly document: CapabilityWorkflowDocument; +}; + +export type CapabilityWorkerResponse = + | ({ readonly type: "progress" } & CapabilityProgress) + | { readonly type: "complete"; readonly output: CapabilityRunOutput } + | { readonly type: "error"; readonly message: string }; + +type WorkerFactory = () => Worker; + +const defaultWorkerFactory: WorkerFactory = () => new Worker( + new URL("../workers/capabilityOptimization.worker.ts", import.meta.url), + { type: "module", name: "capability-optimization" }, +); + +const validatedOutput = (output: CapabilityRunOutput): CapabilityRunOutput => + Object.freeze({ + result: parseOptimizationResult(output.result), + ensemble: createScalarEnsemble( + output.ensemble as unknown as ScalarEnsembleInput, + ), + }); + +export function createCapabilityWorkerRunner( + workerFactory: WorkerFactory = defaultWorkerFactory, +): CapabilityRunner { + return (document, onProgress) => { + const worker = workerFactory(); + let rejectRun: (reason: unknown) => void = () => undefined; + let settled = false; + const finish = (action: () => void): void => { + if (settled) return; + settled = true; + worker.terminate(); + action(); + }; + const promise = new Promise((resolve, reject) => { + rejectRun = reject; + worker.onmessage = (event: MessageEvent) => { + if (settled) return; + const message = event.data; + if (message.type === "progress") { + onProgress({ completed: message.completed, total: message.total }); + } + else if (message.type === "complete") { + try { + const output = validatedOutput(message.output); + finish(() => resolve(output)); + } catch (reason: unknown) { + finish(() => reject(reason)); + } + } else finish(() => reject(new Error(message.message))); + }; + worker.onerror = (event) => finish(() => reject( + new Error(event.message || "Capability optimization worker failed"), + )); + worker.postMessage({ type: "run", document } satisfies CapabilityWorkerRequest); + }); + return { promise, cancel: () => finish(() => rejectRun( + new DOMException("Capability optimization cancelled", "AbortError"), + )) }; + }; +} + +export const runCapabilityInWorker = createCapabilityWorkerRunner(); diff --git a/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts b/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts new file mode 100644 index 0000000000..b1390f3ca5 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import parserCasesFixture from "./__fixtures__/capability_workflow_parser_cases_v1.json"; + +import { + CAPABILITY_WORKFLOW_SCHEMA_VERSION, + buildCapabilityWorkflow, + capabilityWorkflowFromJson, + capabilityWorkflowToJson, + defaultCapabilityWorkflowInputs, +} from "./capabilityWorkflow"; + +interface ParserCase { + readonly id: string; + readonly path: readonly (string | number)[]; + readonly value: unknown; + readonly accepted: boolean; +} + +const parserCases = parserCasesFixture.cases as readonly ParserCase[]; + +const mutatedWorkflow = (testCase: ParserCase): string => { + const payload: unknown = JSON.parse(capabilityWorkflowToJson( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + )); + let cursor = payload; + testCase.path.slice(0, -1).forEach((key) => { + cursor = (cursor as Record)[key]; + }); + const terminal = testCase.path[testCase.path.length - 1]; + if (terminal === undefined) throw new RangeError("fixture path must be nonempty"); + (cursor as Record)[terminal] = testCase.value; + return JSON.stringify(payload); +}; + +describe("capability workflow", () => { + it("uses the supported shared parser fixture schema", () => { + expect(parserCasesFixture.schema_version) + .toBe("capability-workflow-parser-cases/v1"); + }); + + it("builds a model-ready and auditable default driver workflow", () => { + const document = buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()); + + expect(document.schemaVersion).toBe(CAPABILITY_WORKFLOW_SCHEMA_VERSION); + expect(document.request.clubIds).toEqual(["driver"]); + expect(document.profile.clubs[0].parameters[0].parameterId).toBe("ball_speed"); + expect(document.request.target.distanceM).toBe(230); + expect(document.evaluatorConfig.spinDefaults[0].provenance).toContain("user-authored"); + }); + + it("round-trips strict nested profile, request, and evaluator contracts", () => { + const source = buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), + clubId: "driver-fit-a", + targetDistanceM: 245, + targetLateralM: -4, + totalSpinRpm: 2250, + spinAxisTiltDeg: -3.5, + candidateBudget: 4, + ensembleSize: 6, + }); + + const encoded = capabilityWorkflowToJson(source); + expect(capabilityWorkflowFromJson(encoded)).toEqual(source); + expect(JSON.parse(encoded).schema_version).toBe(CAPABILITY_WORKFLOW_SCHEMA_VERSION); + }); + + it.each([ + [{ ballSpeedMps: 0 }, "ballSpeedMps"], + [{ totalSpinRpm: -1 }, "totalSpinRpm"], + [{ totalSpinRpm: 20_001 }, "totalSpinRpm"], + [{ maxTimeS: 0 }, "maxTimeS"], + [{ maxTimeS: 121 }, "maxTimeS"], + [{ trajectorySampleIntervalS: 0.0015 }, "align"], + [{ seed: 2 ** 31 }, "seed"], + [{ candidateBudget: 501, ensembleSize: 201 }, "100000"], + [{ alternativesCount: 3, candidateBudget: 2 }, "alternativesCount"], + [{ spinAxisTiltDeg: 91 }, "spinAxisTiltDeg"], + ] as const)("rejects unsafe or unrenderable input %o", (change, message) => { + expect(() => buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), ...change, + })).toThrow(message); + }); + + it("rejects extra document fields", () => { + const parsed = JSON.parse(capabilityWorkflowToJson( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + )); + expect(() => capabilityWorkflowFromJson(JSON.stringify({ + ...parsed, unexpected: true, + }))).toThrow("fields"); + }); + + it("rejects a spin default bound to a different club", () => { + const parsed = JSON.parse(capabilityWorkflowToJson( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + )); + parsed.evaluator_config.spin_defaults[0].club_id = "other-club"; + + expect(() => capabilityWorkflowFromJson(JSON.stringify(parsed))) + .toThrow("spin default clubIds"); + }); + + it.each(parserCases)("applies shared strict parser case $id", (testCase) => { + const parse = () => capabilityWorkflowFromJson(mutatedWorkflow(testCase)); + + if (testCase.accepted) expect(parse).not.toThrow(); + else expect(parse).toThrow(); + }); +}); diff --git a/src/rate_of_closure/web/src/model/capabilityWorkflow.ts b/src/rate_of_closure/web/src/model/capabilityWorkflow.ts new file mode 100644 index 0000000000..071c579fb3 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityWorkflow.ts @@ -0,0 +1,333 @@ +/** Versioned authoring and persistence facade for capability optimization. */ + +import { + parseOptimizationRequest, + parsePlayerCapabilityProfile, + type CapabilityObjective, + type CapabilityParameter, + type OptimizationRequest, + type PlayerCapabilityProfile, +} from "./capabilityContract"; +import type { + CapabilityFlightEvaluatorConfig, + CapabilitySpinDefault, +} from "./capabilityFlightEvaluator"; + +export const CAPABILITY_WORKFLOW_SCHEMA_VERSION = + "capability-optimization-workflow/v1" as const; +export const MAX_WORKFLOW_OBSERVATIONS = 100_000; +const PROVENANCE = "rate-of-closure/capability-workflow/user-authored/v1"; +const AUTHORING_BOUNDS: ReadonlyArray = [ + ["ballSpeedMps", 1, 100], ["ballSpeedStdMps", 0, 30], + ["launchAngleDeg", -10, 45], ["launchAngleStdDeg", 0, 30], + ["launchDirectionDeg", -30, 30], ["launchDirectionStdDeg", 0, 30], + ["totalSpinRpm", 0, 20_000], ["spinAxisTiltDeg", -90, 90], + ["targetDistanceM", 0.1, 1_000], ["targetLateralM", -500, 500], + ["targetRadiusM", 0.1, 500], ["maxTimeS", 0.001, 120], + ["trajectorySampleIntervalS", 0.001, 0.1], + ["candidateBudget", 1, MAX_WORKFLOW_OBSERVATIONS], + ["ensembleSize", 1, MAX_WORKFLOW_OBSERVATIONS], + ["alternativesCount", 1, MAX_WORKFLOW_OBSERVATIONS], + ["seed", 0, 2 ** 31 - 1], +]; + +export interface CapabilityWorkflowInputs { + readonly profileId: string; readonly clubId: string; + readonly ballSpeedMps: number; readonly ballSpeedStdMps: number; + readonly launchAngleDeg: number; readonly launchAngleStdDeg: number; + readonly launchDirectionDeg: number; readonly launchDirectionStdDeg: number; + readonly totalSpinRpm: number; readonly spinAxisTiltDeg: number; + readonly targetDistanceM: number; readonly targetLateralM: number; + readonly targetRadiusM: number; readonly objective: CapabilityObjective; + readonly candidateBudget: number; readonly ensembleSize: number; + readonly alternativesCount: number; readonly seed: number; + readonly maxTimeS: number; readonly trajectorySampleIntervalS: number; +} + +export interface CapabilityWorkflowDocument { + readonly schemaVersion: typeof CAPABILITY_WORKFLOW_SCHEMA_VERSION; + readonly profile: PlayerCapabilityProfile; + readonly request: OptimizationRequest; + readonly evaluatorConfig: CapabilityFlightEvaluatorConfig; +} + +export const defaultCapabilityWorkflowInputs = (): CapabilityWorkflowInputs => ({ + profileId: "representative-driver-profile", clubId: "driver", + ballSpeedMps: 67, ballSpeedStdMps: 1.5, + launchAngleDeg: 12.5, launchAngleStdDeg: 1, + launchDirectionDeg: 0, launchDirectionStdDeg: 1.5, + totalSpinRpm: 2600, spinAxisTiltDeg: 0, + targetDistanceM: 230, targetLateralM: 0, targetRadiusM: 12, + objective: "maximize_target_hold", candidateBudget: 8, + ensembleSize: 12, alternativesCount: 3, seed: 4197, + maxTimeS: 10, trajectorySampleIntervalS: 0.01, +}); + +const finite = (value: unknown, name: string): number => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new RangeError(`${name} must be finite`); + } + return value; +}; + +const text = (value: unknown, name: string): string => { + if (typeof value !== "string" || !value.trim()) { + throw new RangeError(`${name} must be nonempty text`); + } + return value.trim(); +}; + +const validateAuthoringInputs = (input: CapabilityWorkflowInputs): void => { + AUTHORING_BOUNDS.forEach(([key, minimum, maximum]) => { + const value = input[key]; + if (typeof value !== "number" || !Number.isFinite(value) || + value < minimum || value > maximum) { + throw new RangeError(`${key} must lie within [${minimum}, ${maximum}]`); + } + }); +}; + +const parameter = ( + parameterId: string, unit: string, baseline: number, standardDeviation: number, +): CapabilityParameter => { + const domains: Readonly> = { + ball_speed: [1, 100, 20, 90], launch_angle: [-10, 45, 0, 35], + launch_direction: [-30, 30, -15, 15], + }; + const [lowerBound, upperBound, evidenceLowerBound, evidenceUpperBound] = + domains[parameterId]; + return Object.freeze({ + parameterId, unit, lowerBound, upperBound, evidenceLowerBound, + evidenceUpperBound, baseline: finite(baseline, `${parameterId} baseline`), + bias: 0, standardDeviation: finite( + standardDeviation, `${parameterId} standard deviation`), + }); +}; + +const profile = (input: CapabilityWorkflowInputs): PlayerCapabilityProfile => { + const parameters = Object.freeze([ + parameter("ball_speed", "m/s", input.ballSpeedMps, input.ballSpeedStdMps), + parameter("launch_angle", "deg", input.launchAngleDeg, input.launchAngleStdDeg), + parameter("launch_direction", "deg", input.launchDirectionDeg, input.launchDirectionStdDeg), + ]); + return parsePlayerCapabilityProfile({ + schema_version: "player-capability-profile/v1", + profile_id: text(input.profileId, "profileId"), provenance: PROVENANCE, + confidence: 0.8, clubs: [{ club_id: text(input.clubId, "clubId"), + parameters: parameters.map(parameterWire), matrix_kind: "correlation", + matrix: [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + provenance: PROVENANCE, confidence: 0.8 }], + }); +}; + +const parameterWire = (item: CapabilityParameter): Record => ({ + parameter_id: item.parameterId, unit: item.unit, + lower_bound: item.lowerBound, upper_bound: item.upperBound, + evidence_lower_bound: item.evidenceLowerBound, + evidence_upper_bound: item.evidenceUpperBound, baseline: item.baseline, + bias: item.bias, standard_deviation: item.standardDeviation, +}); + +const request = ( + input: CapabilityWorkflowInputs, clubId: string, +): OptimizationRequest => parseOptimizationRequest({ + schema_version: "capability-optimization-request/v1", + problem_id: `capability-${text(input.profileId, "profileId")}`, + objective: input.objective, club_ids: [clubId], + target: { kind: "green", distance_m: finite(input.targetDistanceM, "targetDistanceM"), + lateral_m: finite(input.targetLateralM, "targetLateralM"), + radius_m: finite(input.targetRadiusM, "targetRadiusM"), + band_half_length_m: finite(input.targetRadiusM, "targetRadiusM"), + half_width_m: finite(input.targetRadiusM, "targetRadiusM") }, + candidate_budget: input.candidateBudget, ensemble_size: input.ensembleSize, + alternatives_count: input.alternativesCount, seed: input.seed, + cvar_alpha: 0.9, minimum_success_fraction: 0.8, +}); + +const evaluatorConfig = ( + input: CapabilityWorkflowInputs, clubId: string, +): CapabilityFlightEvaluatorConfig => Object.freeze({ + maxTimeS: finite(input.maxTimeS, "maxTimeS"), + trajectorySampleIntervalS: finite( + input.trajectorySampleIntervalS, "trajectorySampleIntervalS"), + spinDefaults: Object.freeze([Object.freeze({ clubId, + totalSpinRpm: finite(input.totalSpinRpm, "totalSpinRpm"), + spinAxisTiltDeg: finite(input.spinAxisTiltDeg, "spinAxisTiltDeg"), + provenance: PROVENANCE })]), +}); + +const validateDocument = ( + document: CapabilityWorkflowDocument, +): CapabilityWorkflowDocument => { + const available = new Set(document.profile.clubs.map(({ clubId }) => clubId)); + if (document.request.clubIds.some((clubId) => !available.has(clubId))) { + throw new RangeError("request clubIds must exist in the profile"); + } + const requested = new Set(document.request.clubIds); + const spinIds = document.evaluatorConfig.spinDefaults.map(({ clubId }) => clubId); + if (new Set(spinIds).size !== spinIds.length) { + throw new RangeError("spin default clubIds must be unique"); + } + const spinClubs = new Set( + spinIds, + ); + if (spinClubs.size !== requested.size || + [...spinClubs].some((clubId) => !requested.has(clubId))) { + throw new RangeError("spin default clubIds must exactly match request clubIds"); + } + const attempts = document.request.candidateBudget * document.request.ensembleSize; + if (attempts > MAX_WORKFLOW_OBSERVATIONS) { + throw new RangeError(`workflow may not exceed ${MAX_WORKFLOW_OBSERVATIONS} observations`); + } + if (document.evaluatorConfig.maxTimeS <= 0) { + throw new RangeError("maxTimeS must be > 0"); + } + const interval = document.evaluatorConfig.trajectorySampleIntervalS; + if (interval < 0.001 || interval > 0.1) { + throw new RangeError("trajectorySampleIntervalS must lie within [0.001, 0.1]"); + } + if (Math.abs(interval / 0.001 - Math.round(interval / 0.001)) > 1e-9) { + throw new RangeError("trajectorySampleIntervalS must align to the 0.001 s step"); + } + if (document.evaluatorConfig.spinDefaults.some(({ totalSpinRpm }) => + totalSpinRpm < 0)) { + throw new RangeError("totalSpinRpm must lie within [0, Infinity]"); + } + if (document.evaluatorConfig.spinDefaults.some(({ spinAxisTiltDeg }) => + spinAxisTiltDeg < -90 || spinAxisTiltDeg > 90)) { + throw new RangeError("spinAxisTiltDeg must lie within [-90, 90]"); + } + if (document.request.alternativesCount > document.request.candidateBudget) { + throw new RangeError("alternativesCount must not exceed candidateBudget"); + } + validateAuthoringInputs(capabilityWorkflowInputs(document)); + return Object.freeze(document); +}; + +export function buildCapabilityWorkflow( + input: CapabilityWorkflowInputs, +): CapabilityWorkflowDocument { + if (input.ballSpeedMps <= 0) { + throw new RangeError("ballSpeedMps must be greater than zero"); + } + if (input.alternativesCount > input.candidateBudget) { + throw new RangeError("alternativesCount must not exceed candidateBudget"); + } + const parsedProfile = profile(input); + const clubId = parsedProfile.clubs[0].clubId; + return validateDocument({ schemaVersion: CAPABILITY_WORKFLOW_SCHEMA_VERSION, + profile: parsedProfile, request: request(input, clubId), + evaluatorConfig: evaluatorConfig(input, clubId) }); +} + +const profileWire = (value: PlayerCapabilityProfile): Record => ({ + schema_version: value.schemaVersion, profile_id: value.profileId, + provenance: value.provenance, confidence: value.confidence, + clubs: value.clubs.map((club) => ({ club_id: club.clubId, + parameters: club.parameters.map(parameterWire), matrix_kind: club.matrixKind, + matrix: club.matrix, provenance: club.provenance, confidence: club.confidence })), +}); + +const requestWire = (value: OptimizationRequest): Record => ({ + schema_version: value.schemaVersion, problem_id: value.problemId, + objective: value.objective, club_ids: value.clubIds, + target: { kind: value.target.kind, distance_m: value.target.distanceM, + lateral_m: value.target.lateralM, radius_m: value.target.radiusM, + band_half_length_m: value.target.bandHalfLengthM, + half_width_m: value.target.halfWidthM }, + candidate_budget: value.candidateBudget, ensemble_size: value.ensembleSize, + alternatives_count: value.alternativesCount, seed: value.seed, + cvar_alpha: value.cvarAlpha, + minimum_success_fraction: value.minimumSuccessFraction, +}); + +const configWire = (value: CapabilityFlightEvaluatorConfig): Record => ({ + max_time_s: value.maxTimeS, + trajectory_sample_interval_s: value.trajectorySampleIntervalS, + spin_defaults: value.spinDefaults.map((item) => ({ club_id: item.clubId, + total_spin_rpm: item.totalSpinRpm, spin_axis_tilt_deg: item.spinAxisTiltDeg, + provenance: item.provenance })), +}); + +const exact = (value: Record, fields: readonly string[], name: string): void => { + if (Object.keys(value).sort().join("|") !== [...fields].sort().join("|")) { + throw new RangeError(`${name} fields do not match v1 schema`); + } +}; + +const record = (value: unknown, name: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new RangeError(`${name} must be an object`); + } + return value as Record; +}; + +const parseConfig = (value: unknown): CapabilityFlightEvaluatorConfig => { + const source = record(value, "evaluator_config"); + exact(source, ["max_time_s", "spin_defaults", "trajectory_sample_interval_s"], "evaluator_config"); + if (!Array.isArray(source.spin_defaults)) throw new RangeError("spin_defaults must be an array"); + const spinDefaults = source.spin_defaults.map((item): CapabilitySpinDefault => { + const spin = record(item, "spin default"); + exact(spin, ["club_id", "provenance", "spin_axis_tilt_deg", "total_spin_rpm"], "spin default"); + return Object.freeze({ clubId: text(spin.club_id, "clubId"), + totalSpinRpm: finite(spin.total_spin_rpm, "totalSpinRpm"), + spinAxisTiltDeg: finite(spin.spin_axis_tilt_deg, "spinAxisTiltDeg"), + provenance: text(spin.provenance, "provenance") }); + }); + return Object.freeze({ maxTimeS: finite(source.max_time_s, "maxTimeS"), + trajectorySampleIntervalS: finite( + source.trajectory_sample_interval_s, "trajectorySampleIntervalS"), + spinDefaults: Object.freeze(spinDefaults) }); +}; + +export const capabilityWorkflowToJson = ( + document: CapabilityWorkflowDocument, +): string => JSON.stringify({ evaluator_config: configWire(document.evaluatorConfig), + profile: profileWire(document.profile), request: requestWire(document.request), + schema_version: document.schemaVersion }); + +export function capabilityWorkflowFromJson(source: string): CapabilityWorkflowDocument { + const value: unknown = JSON.parse(source); + const payload = record(value, "capability workflow"); + exact(payload, ["evaluator_config", "profile", "request", "schema_version"], "capability workflow"); + if (payload.schema_version !== CAPABILITY_WORKFLOW_SCHEMA_VERSION) { + throw new RangeError("unsupported capability workflow schema_version"); + } + return validateDocument({ schemaVersion: CAPABILITY_WORKFLOW_SCHEMA_VERSION, + profile: parsePlayerCapabilityProfile(payload.profile), + request: parseOptimizationRequest(payload.request), + evaluatorConfig: parseConfig(payload.evaluator_config) }); +} + +/** Project a strict persisted single-club document back into editable inputs. */ +export function capabilityWorkflowInputs( + document: CapabilityWorkflowDocument, +): CapabilityWorkflowInputs { + if (document.profile.clubs.length !== 1 || + document.evaluatorConfig.spinDefaults.length !== 1) { + throw new RangeError("interactive workflow supports exactly one club and spin default"); + } + const club = document.profile.clubs[0]; + const parameters = new Map(club.parameters.map((item) => [item.parameterId, item])); + const ballSpeed = parameters.get("ball_speed"); + const launchAngle = parameters.get("launch_angle"); + const launchDirection = parameters.get("launch_direction"); + if (!ballSpeed || !launchAngle || !launchDirection || parameters.size !== 3) { + throw new RangeError("interactive workflow requires the three launch parameters"); + } + const spin = document.evaluatorConfig.spinDefaults[0]; + const target = document.request.target; + return Object.freeze({ profileId: document.profile.profileId, clubId: club.clubId, + ballSpeedMps: ballSpeed.baseline, ballSpeedStdMps: ballSpeed.standardDeviation, + launchAngleDeg: launchAngle.baseline, launchAngleStdDeg: launchAngle.standardDeviation, + launchDirectionDeg: launchDirection.baseline, + launchDirectionStdDeg: launchDirection.standardDeviation, + totalSpinRpm: spin.totalSpinRpm, spinAxisTiltDeg: spin.spinAxisTiltDeg, + targetDistanceM: target.distanceM, targetLateralM: target.lateralM, + targetRadiusM: target.radiusM, objective: document.request.objective, + candidateBudget: document.request.candidateBudget, + ensembleSize: document.request.ensembleSize, + alternativesCount: document.request.alternativesCount, seed: document.request.seed, + maxTimeS: document.evaluatorConfig.maxTimeS, + trajectorySampleIntervalS: document.evaluatorConfig.trajectorySampleIntervalS }); +} diff --git a/src/rate_of_closure/web/src/model/flight.ts b/src/rate_of_closure/web/src/model/flight.ts index 52e23ad39b..e28b2f206c 100644 --- a/src/rate_of_closure/web/src/model/flight.ts +++ b/src/rate_of_closure/web/src/model/flight.ts @@ -1,125 +1,125 @@ -/** - * Launch derivation + Waterloo/Penner flight model for the web clone - * (epic #4103). Ports swing_sim/flight/launch.py and the Waterloo/Penner - * model of swing_sim/flight/models.py with fixed-step RK4 (Python uses - * scipy RK45; the parity tests band the difference). - * - * NOTE (P7): replaced by the tools-core ball_flight WASM kernel. - */ - -import { - AIR_DENSITY_KG_M3, - GOLF_BALL_MASS_KG, - GOLF_BALL_RADIUS_M, - GRAVITY_M_S2, - MAX_LIFT_COEFFICIENT, - add, - cross, - norm, - scale, - sub, - type Vec3, +/** + * Launch derivation + Waterloo/Penner flight model for the web clone + * (epic #4103). Ports swing_sim/flight/launch.py and the Waterloo/Penner + * model of swing_sim/flight/models.py with fixed-step RK4 (Python uses + * scipy RK45; the parity tests band the difference). + * + * NOTE (P7): replaced by the tools-core ball_flight WASM kernel. + */ + +import { + AIR_DENSITY_KG_M3, + GOLF_BALL_MASS_KG, + GOLF_BALL_RADIUS_M, + GRAVITY_M_S2, + MAX_LIFT_COEFFICIENT, + add, + cross, + norm, + scale, + sub, + type Vec3, } from "./impactPhysics"; import { windVelocityAt, type WindScenario } from "./wind"; - -const RPM_TO_RAD_S = (2.0 * Math.PI) / 60.0; -const deg = (r: number): number => (r * 180.0) / Math.PI; - -// --- Launch derivation + Waterloo/Penner flight -------------------------- - -export interface Launch { - ballSpeedMps: number; - launchAngleRad: number; - azimuthRad: number; - spinRpm: number; + +const RPM_TO_RAD_S = (2.0 * Math.PI) / 60.0; +const deg = (r: number): number => (r * 180.0) / Math.PI; + +// --- Launch derivation + Waterloo/Penner flight -------------------------- + +export interface Launch { + ballSpeedMps: number; + launchAngleRad: number; + azimuthRad: number; + spinRpm: number; spinAxis: Vec3; // flight frame, unit windScenario?: WindScenario; -} - -/** Port of swing_sim/flight/launch.py (flight-frame inputs). */ -export function deriveLaunch(velFlight: Vec3, spinFlight: Vec3): Launch { - const speed = norm(velFlight); - const horiz = Math.hypot(velFlight[0], velFlight[1]); - const launchAngleRad = - horiz < 1e-12 ? Math.PI / 2.0 : Math.atan2(velFlight[2], horiz); - const azimuthRad = horiz > 1e-12 ? Math.atan2(velFlight[1], velFlight[0]) : 0.0; - const spinRadS = norm(spinFlight); - const spinAxis: Vec3 = - spinRadS > 1e-12 ? scale(spinFlight, 1.0 / spinRadS) : [0, -1, 0]; - return { - ballSpeedMps: speed, - launchAngleRad, - azimuthRad, - spinRpm: spinRadS / RPM_TO_RAD_S, - spinAxis, - }; -} - -export interface FlightPoint { - time: number; - position: Vec3; // flight frame - velocity: Vec3; -} - -export interface FlightResult { - trajectory: FlightPoint[]; - carryM: number; - maxHeightM: number; - flightTimeS: number; - landingAngleDeg: number; - lateralM: number; -} - -/** - * Waterloo/Penner model (quadratic Cd, power-law Cl), fixed-step RK4 with - * linear interpolation to the ground crossing. Python uses scipy RK45; the - * parity tests band the difference. - */ -export function simulateFlight( - launch: Launch, - maxTime = 10.0, - dt = 0.001, - sampleEvery = 10, -): FlightResult { - const [cd0, cd1, cd2, cl1, cl2] = [0.21, 0.05, 0.02, 0.7, 0.645]; - const area = Math.PI * GOLF_BALL_RADIUS_M ** 2; - const omega = scale(launch.spinAxis, launch.spinRpm * RPM_TO_RAD_S); - const omegaMag = norm(omega); - +} + +/** Port of swing_sim/flight/launch.py (flight-frame inputs). */ +export function deriveLaunch(velFlight: Vec3, spinFlight: Vec3): Launch { + const speed = norm(velFlight); + const horiz = Math.hypot(velFlight[0], velFlight[1]); + const launchAngleRad = + horiz < 1e-12 ? Math.PI / 2.0 : Math.atan2(velFlight[2], horiz); + const azimuthRad = horiz > 1e-12 ? Math.atan2(velFlight[1], velFlight[0]) : 0.0; + const spinRadS = norm(spinFlight); + const spinAxis: Vec3 = + spinRadS > 1e-12 ? scale(spinFlight, 1.0 / spinRadS) : [0, -1, 0]; + return { + ballSpeedMps: speed, + launchAngleRad, + azimuthRad, + spinRpm: spinRadS / RPM_TO_RAD_S, + spinAxis, + }; +} + +export interface FlightPoint { + time: number; + position: Vec3; // flight frame + velocity: Vec3; +} + +export interface FlightResult { + trajectory: FlightPoint[]; + carryM: number; + maxHeightM: number; + flightTimeS: number; + landingAngleDeg: number; + lateralM: number; +} + +/** + * Waterloo/Penner model (quadratic Cd, power-law Cl), fixed-step RK4 with + * linear interpolation to the ground crossing. Python uses scipy RK45; the + * parity tests band the difference. + */ +export function simulateFlight( + launch: Launch, + maxTime = 10.0, + dt = 0.001, + sampleEvery = 10, +): FlightResult { + const [cd0, cd1, cd2, cl1, cl2] = [0.21, 0.05, 0.02, 0.7, 0.645]; + const area = Math.PI * GOLF_BALL_RADIUS_M ** 2; + const omega = scale(launch.spinAxis, launch.spinRpm * RPM_TO_RAD_S); + const omegaMag = norm(omega); + const accel = (time: number, position: Vec3, velocity: Vec3): Vec3 => { const wind = launch.windScenario ? windVelocityAt(launch.windScenario, time, position) : [0, 0, 0] as Vec3; const v = sub(velocity, wind); const speed = norm(v); - if (speed < 0.1) return [0, 0, -GRAVITY_M_S2]; - const vu = scale(v, 1.0 / speed); - const s = (omegaMag * GOLF_BALL_RADIUS_M) / speed; - const cd = cd0 + cd1 * s + cd2 * s * s; - const cl = Math.min(MAX_LIFT_COEFFICIENT, s > 0 ? cl1 * s ** cl2 : 0.0); - const q = 0.5 * AIR_DENSITY_KG_M3 * speed * speed * (area / GOLF_BALL_MASS_KG); - let acc = scale(vu, -q * cd); - if (omegaMag > 0) { - const c = cross(scale(omega, 1.0 / omegaMag), vu); - const cNorm = norm(c); - if (cNorm > 1e-10) acc = add(acc, scale(c, (q * cl) / cNorm)); - } - return [acc[0], acc[1], acc[2] - GRAVITY_M_S2]; - }; - - let pos: Vec3 = [0, 0, 0]; - let vel: Vec3 = [ - launch.ballSpeedMps * Math.cos(launch.launchAngleRad) * Math.cos(launch.azimuthRad), - launch.ballSpeedMps * Math.cos(launch.launchAngleRad) * Math.sin(launch.azimuthRad), - launch.ballSpeedMps * Math.sin(launch.launchAngleRad), - ]; - const trajectory: FlightPoint[] = [{ time: 0, position: pos, velocity: vel }]; - let maxHeight = 0.0; - let t = 0.0; - let step = 0; - - while (t < maxTime) { - // RK4 on (pos, vel). + if (speed < 0.1) return [0, 0, -GRAVITY_M_S2]; + const vu = scale(v, 1.0 / speed); + const s = (omegaMag * GOLF_BALL_RADIUS_M) / speed; + const cd = cd0 + cd1 * s + cd2 * s * s; + const cl = Math.min(MAX_LIFT_COEFFICIENT, s > 0 ? cl1 * s ** cl2 : 0.0); + const q = 0.5 * AIR_DENSITY_KG_M3 * speed * speed * (area / GOLF_BALL_MASS_KG); + let acc = scale(vu, -q * cd); + if (omegaMag > 0) { + const c = cross(scale(omega, 1.0 / omegaMag), vu); + const cNorm = norm(c); + if (cNorm > 1e-10) acc = add(acc, scale(c, (q * cl) / cNorm)); + } + return [acc[0], acc[1], acc[2] - GRAVITY_M_S2]; + }; + + let pos: Vec3 = [0, 0, 0]; + let vel: Vec3 = [ + launch.ballSpeedMps * Math.cos(launch.launchAngleRad) * Math.cos(launch.azimuthRad), + launch.ballSpeedMps * Math.cos(launch.launchAngleRad) * Math.sin(launch.azimuthRad), + launch.ballSpeedMps * Math.sin(launch.launchAngleRad), + ]; + const trajectory: FlightPoint[] = [{ time: 0, position: pos, velocity: vel }]; + let maxHeight = 0.0; + let t = 0.0; + let step = 0; + + while (t < maxTime) { + // RK4 on (pos, vel). const k1v = accel(t, pos, vel); const k1p = vel; const halfTime = t + dt / 2; @@ -132,45 +132,50 @@ export function simulateFlight( const k4Position = add(pos, scale(k3p, dt)); const k4p = add(vel, scale(k3v, dt)); const k4v = accel(t + dt, k4Position, k4p); - const nextVel = add( - vel, - scale(add(add(k1v, scale(add(k2v, k3v), 2)), k4v), dt / 6), - ); - const nextPos = add( - pos, - scale(add(add(k1p, scale(add(k2p, k3p), 2)), k4p), dt / 6), - ); - t += dt; - step += 1; - - if (nextPos[2] < 0.0 && t > dt) { - // Linear interpolation to the ground crossing. - const frac = pos[2] / (pos[2] - nextPos[2]); - const tGround = t - dt + frac * dt; - const posGround = add(pos, scale(sub(nextPos, pos), frac)); - const velGround = add(vel, scale(sub(nextVel, vel), frac)); - trajectory.push({ time: tGround, position: posGround, velocity: velGround }); - pos = posGround; - vel = velGround; - t = tGround; - break; - } - pos = nextPos; - vel = nextVel; - maxHeight = Math.max(maxHeight, pos[2]); - if (step % sampleEvery === 0) { - trajectory.push({ time: t, position: pos, velocity: vel }); - } - } - - const vHoriz = Math.hypot(vel[0], vel[1]); - return { - trajectory, - carryM: Math.hypot(pos[0], pos[1]), - maxHeightM: maxHeight, - flightTimeS: t, - landingAngleDeg: vHoriz > 0.1 ? deg(Math.atan2(-vel[2], vHoriz)) : 90.0, - lateralM: pos[1], - }; -} - + const nextVel = add( + vel, + scale(add(add(k1v, scale(add(k2v, k3v), 2)), k4v), dt / 6), + ); + const nextPos = add( + pos, + scale(add(add(k1p, scale(add(k2p, k3p), 2)), k4p), dt / 6), + ); + t += dt; + step += 1; + + if (nextPos[2] < 0.0 && t > dt) { + // Linear interpolation to the ground crossing. A launch that starts at + // z = 0 travelling downward is already below ground by the time the + // t > dt guard lets this run, which makes the raw ratio negative and + // would extrapolate backwards past t = 0. Clamping keeps the reported + // crossing inside the step that detected it. + const drop = pos[2] - nextPos[2]; + const frac = drop > 0 ? Math.min(1, Math.max(0, pos[2] / drop)) : 0; + const tGround = t - dt + frac * dt; + const posGround = add(pos, scale(sub(nextPos, pos), frac)); + const velGround = add(vel, scale(sub(nextVel, vel), frac)); + trajectory.push({ time: tGround, position: posGround, velocity: velGround }); + pos = posGround; + vel = velGround; + t = tGround; + break; + } + pos = nextPos; + vel = nextVel; + maxHeight = Math.max(maxHeight, pos[2]); + if (step % sampleEvery === 0) { + trajectory.push({ time: t, position: pos, velocity: vel }); + } + } + + const vHoriz = Math.hypot(vel[0], vel[1]); + return { + trajectory, + carryM: Math.hypot(pos[0], pos[1]), + maxHeightM: maxHeight, + flightTimeS: t, + landingAngleDeg: vHoriz > 0.1 ? deg(Math.atan2(-vel[2], vHoriz)) : 90.0, + lateralM: pos[1], + }; +} + diff --git a/src/rate_of_closure/web/src/model/helptext.test.ts b/src/rate_of_closure/web/src/model/helptext.test.ts index 52f9c4068e..dc34d03aae 100644 --- a/src/rate_of_closure/web/src/model/helptext.test.ts +++ b/src/rate_of_closure/web/src/model/helptext.test.ts @@ -16,6 +16,7 @@ const TABS = [ "Plots", "Flight Explorer", "Launch Monitor Analytics", + "Shot Optimizer", "Variation", "Putting", "Glossary", diff --git a/src/rate_of_closure/web/src/model/helptext.ts b/src/rate_of_closure/web/src/model/helptext.ts index b2d14b647a..4b90823662 100644 --- a/src/rate_of_closure/web/src/model/helptext.ts +++ b/src/rate_of_closure/web/src/model/helptext.ts @@ -111,6 +111,13 @@ export const HELP_TEXTS: Record = { "Results include pair-specific sample counts, multiplicity-adjusted p-values, confidence intervals, OLS coefficient uncertainty, residual diagnostics, grouped estimates, and a deterministic dataset fingerprint. TrackMan-Comparable and Foresight-Comparable labels describe documented interpretation frames only; they do not claim device emulation or certification. Export both retained records and the complete analysis evidence as JSON.", ], }, + "Shot Optimizer": { + title: "How to Use This Page", + paragraphs: [ + "Build an auditable player-and-club capability profile, then search for robust launch conditions using the full Waterloo/Penner ball-flight model. Set the ball-speed, launch-angle, and launch-direction centers and standard deviations; fixed total spin and spin-axis tilt are shown explicitly with user-authored provenance. Set the landing target, objective, candidate count, trials per candidate, retained alternatives, and deterministic seed before running.", + "Optimization runs in a background worker with exact progress and cancellation. Ranked alternatives report carry, expected miss, dispersion, target-hold probability, confidence, and limiting constraints. Select any nominal input, perturbed input, flight metric, or target diagnostic for the scatter axes; paired-finite and unavailable counts remain visible, and the paged raw table preserves failed trials without inventing values. Save or load the strict versioned workflow and export every observation as lossless CSV or stable JSON. The current evaluator is still-air carry to first ground crossing: wind, bounce, roll, and total distance are not silently included.", + ], + }, Variation: { title: "How to Use This Page", paragraphs: [ diff --git a/src/rate_of_closure/web/src/model/impactPhysics.ts b/src/rate_of_closure/web/src/model/impactPhysics.ts index 118d68942f..5367884341 100644 --- a/src/rate_of_closure/web/src/model/impactPhysics.ts +++ b/src/rate_of_closure/web/src/model/impactPhysics.ts @@ -1,4 +1,5 @@ import { GOLF_BALL_RADIUS_M } from "./ballSetup"; +import { spinAxisTiltDeg } from "./spinAxisConvention"; export { GOLF_BALL_RADIUS_M } from "./ballSetup"; export type Vec3 = [number, number, number]; @@ -95,8 +96,7 @@ export function deliveryDiagnostics(input: DeliveryInput): DeliveryDiagnostics { return { spinLoftDeg: Math.acos(cosine) * 180 / Math.PI, faceToPathDeg: input.faceAngleDeg - input.clubPathDeg, - spinAxisTiltDeg: Math.atan2(-axis[1], Math.hypot(axis[0], axis[2])) - * 180 / Math.PI, + spinAxisTiltDeg: spinAxisTiltDeg(axis) ?? 0, }; } diff --git a/src/rate_of_closure/web/src/model/scalarEnsembleCsv.test.ts b/src/rate_of_closure/web/src/model/scalarEnsembleCsv.test.ts new file mode 100644 index 0000000000..69565f2d1b --- /dev/null +++ b/src/rate_of_closure/web/src/model/scalarEnsembleCsv.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { createScalarEnsemble, scalarEnsembleRowId } from "./scalarEnsembleContract"; +import { nonCompleteReasonSummary, scalarEnsembleToCsv } from "./scalarEnsembleCsv"; + +const reasonEnsemble = ( + rows: ReadonlyArray<{ cohort: "complete" | "failed"; reason_code: string | null }>, +) => createScalarEnsemble({ + result_id: "reason-test", + provenance: { + adapter_id: "reason-test/v1", source_schema_version: "source/v1", + source_provenance: "fixture", + }, + stages: [{ key: "input", label: "Input" }], + categories: [{ key: "launch", label: "Launch" }], + variables: [ + { key: "speed", label: "Speed", unit: "m/s", stage_key: "input", category_key: "launch" }, + ], + cohorts: [ + { key: "complete" as const, label: "Complete" }, + { key: "failed" as const, label: "Failed" }, + ], + rows: rows.map((row, index) => ({ + row_id: scalarEnsembleRowId(index, "run"), trial_index: index, + series_id: "run", cohort: row.cohort, + values: { speed: 1 }, + attributes: { reason_code: row.reason_code }, + })), +}); + +describe("scalarEnsembleToCsv", () => { + it("retains every row, declared scalar, cohort, series, trial, and attribute", () => { + const result = createScalarEnsemble({ + result_id: "csv-test", + provenance: { + adapter_id: "csv-test/v1", source_schema_version: "source/v1", + source_provenance: "fixture", + }, + stages: [{ key: "input", label: "Input" }], + categories: [{ key: "wind", label: "Wind" }], + variables: [ + { key: "speed", label: "Speed", unit: "m/s", stage_key: "input", category_key: "wind" }, + { key: "miss", label: "Miss", unit: "m", stage_key: "input", category_key: "wind" }, + ], + cohorts: [ + { key: "completed" as const, label: "Completed" }, + { key: "invalid" as const, label: "Invalid" }, + ], + rows: [ + { + row_id: scalarEnsembleRowId(0, "stock"), trial_index: 0, + series_id: "stock", cohort: "completed" as const, + values: { speed: 4, miss: 1.25 }, + attributes: { reason: null, label: "Stock", formula: "=SUM(1,2)" }, + }, + { + row_id: scalarEnsembleRowId(1, "stock"), trial_index: 1, + series_id: "stock", cohort: "invalid" as const, + values: { speed: 5, miss: null }, + attributes: { reason: "bad, value", label: "Stock", formula: null }, + }, + ], + }); + + expect(scalarEnsembleToCsv(result)).toBe([ + "row_id,trial_index,series_id,cohort,speed,miss,attribute:formula,attribute:label,attribute:reason", + 'series:stock/trial:0,0,stock,completed,4,1.25,"\'=SUM(1,2)",Stock,', + 'series:stock/trial:1,1,stock,invalid,5,,,Stock,"bad, value"', + ].join("\n")); + }); +}); + +describe("nonCompleteReasonSummary", () => { + it("names the retained reason so a horizon timeout is not read as breakage", () => { + const summary = nonCompleteReasonSummary(reasonEnsemble([ + { cohort: "complete", reason_code: null }, + { cohort: "failed", reason_code: "no_ground_crossing_before_max_time" }, + { cohort: "failed", reason_code: "no_ground_crossing_before_max_time" }, + ])); + + expect(summary).toBe( + " Non-complete reasons: no_ground_crossing_before_max_time x2."); + }); + + it("orders by descending count then reason and labels missing reasons", () => { + const summary = nonCompleteReasonSummary(reasonEnsemble([ + { cohort: "failed", reason_code: null }, + { cohort: "failed", reason_code: "overflow" }, + { cohort: "failed", reason_code: "overflow" }, + ])); + + expect(summary).toBe(" Non-complete reasons: overflow x2; unspecified x1."); + }); + + it("is empty when every retained row completed", () => { + expect(nonCompleteReasonSummary(reasonEnsemble([ + { cohort: "complete", reason_code: null }, + ]))).toBe(""); + }); +}); diff --git a/src/rate_of_closure/web/src/model/scalarEnsembleCsv.ts b/src/rate_of_closure/web/src/model/scalarEnsembleCsv.ts new file mode 100644 index 0000000000..8d934ec4ae --- /dev/null +++ b/src/rate_of_closure/web/src/model/scalarEnsembleCsv.ts @@ -0,0 +1,61 @@ +/** Lossless CSV serialization and honest summaries for the scalar ensemble. */ + +import type { ScalarEnsembleResult } from "./scalarEnsembleContract"; + +/** Encode one spreadsheet-safe RFC 4180-style CSV cell. */ +export const spreadsheetCsvCell = ( + value: string | number | boolean | null | undefined, +): string => { + if (value === null || value === undefined) return ""; + const text = String(value); + const safe = typeof value === "string" && /^[=+\-@]/.test(text) ? `'${text}` : text; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +}; + +/** Serialize all raw rows and values; nulls remain explicit empty CSV cells. */ +export function scalarEnsembleToCsv( + ensemble: ScalarEnsembleResult, +): string { + const attributeKeys = [...new Set(ensemble.rows.flatMap( + (row) => Object.keys(row.attributes ?? {}), + ))].sort(); + const variableKeys = ensemble.variables.map(({ key }) => key); + const header = [ + "row_id", "trial_index", "series_id", "cohort", ...variableKeys, + ...attributeKeys.map((key) => `attribute:${key}`), + ]; + const rows = ensemble.rows.map((row) => [ + row.row_id, row.trial_index, row.series_id, row.cohort, + ...variableKeys.map((key) => row.values[key]), + ...attributeKeys.map((key) => row.attributes?.[key]), + ]); + return [header, ...rows] + .map((row) => row.map(spreadsheetCsvCell).join(",")) + .join("\n"); +} + +/** + * Report why non-complete rows ended so counts are not read as defects. + * + * A horizon nonconvergence is normalized into the `failed` cohort by the + * observation contract, which has no separate member for it. Naming the + * retained reason keeps the count truthful. + */ +export function nonCompleteReasonSummary( + ensemble: ScalarEnsembleResult, +): string { + const counts = new Map(); + for (const row of ensemble.rows) { + if (row.cohort === "complete") continue; + const reason = row.attributes?.reason_code ?? "unspecified"; + const key = typeof reason === "string" && reason ? reason : "unspecified"; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + if (counts.size === 0) return ""; + const detail = [...counts.entries()] + .sort(([leftKey, leftCount], [rightKey, rightCount]) => + rightCount - leftCount || leftKey.localeCompare(rightKey)) + .map(([reason, count]) => `${reason} x${count}`) + .join("; "); + return ` Non-complete reasons: ${detail}.`; +} diff --git a/src/rate_of_closure/web/src/model/spinAxisConvention.ts b/src/rate_of_closure/web/src/model/spinAxisConvention.ts new file mode 100644 index 0000000000..7a8c5dc3f6 --- /dev/null +++ b/src/rate_of_closure/web/src/model/spinAxisConvention.ts @@ -0,0 +1,14 @@ +/** Shared fade/right-positive spin-axis tilt convention. */ + +export type SpinVector = readonly [number, number, number]; + +const ZERO_SPIN_TOLERANCE = 1e-12; + +/** Project forward gyro spin out before reporting target-frame axis tilt. */ +export function spinAxisTiltDeg(spinVector: SpinVector): number | null { + if (spinVector.some((value) => !Number.isFinite(value))) { + throw new RangeError("spinVector must contain three finite components"); + } + if (Math.hypot(...spinVector) <= ZERO_SPIN_TOLERANCE) return null; + return Math.atan2(-spinVector[1], spinVector[2]) * 180 / Math.PI; +} diff --git a/src/rate_of_closure/web/src/model/variation.ts b/src/rate_of_closure/web/src/model/variation.ts index 500dc5f726..fc9976b176 100644 --- a/src/rate_of_closure/web/src/model/variation.ts +++ b/src/rate_of_closure/web/src/model/variation.ts @@ -10,6 +10,7 @@ import { deriveLaunch, simulateFlight, type Launch } from "./flight"; import { MPH_PER_MPS, solveImpact, toFlightFrame, type Vec3 } from "./simulation"; import { resolvedBase, sampleInputs } from "./variationSampling"; +import { spinAxisTiltDeg } from "./spinAxisConvention"; import { isGlobalSpec, stableSpecId, @@ -103,13 +104,6 @@ const launchFromImperial = (variables: Record): Launch => { }; }; -const spinAxisTiltDeg = (spin: Vec3): number => { - const magnitude = Math.hypot(...spin); - if (magnitude < 1e-12) return 0; - const axis = spin.map((value) => value / magnitude) as Vec3; - return Math.atan2(-axis[1], Math.hypot(axis[0], axis[2])) / RAD; -}; - /** Evaluate one sampled variable set through launch or delivery physics. */ export function evaluateRun( variables: Record, @@ -159,7 +153,7 @@ export function evaluateRun( launch_angle_deg: launch.launchAngleRad / RAD, launch_azimuth_deg: -launch.azimuthRad / RAD, spin_rpm: launch.spinRpm, - spin_axis_deg: spinAxisTiltDeg(impact.ballAngularVelocity), + spin_axis_deg: spinAxisTiltDeg(impact.ballAngularVelocity) ?? 0, carry_m: flight.carryM, lateral_m: -flight.lateralM, apex_m: flight.maxHeightM, diff --git a/src/rate_of_closure/web/src/model/viewPreferences.ts b/src/rate_of_closure/web/src/model/viewPreferences.ts index 7542165057..3caf5ba77b 100644 --- a/src/rate_of_closure/web/src/model/viewPreferences.ts +++ b/src/rate_of_closure/web/src/model/viewPreferences.ts @@ -7,6 +7,7 @@ export const PRIMARY_VIEWS = [ { id: "plots", label: "Plots", required: false }, { id: "flight", label: "Flight Explorer", required: false }, { id: "launch-monitor-analytics", label: "Launch Monitor Analytics", required: false }, + { id: "capability-optimization", label: "Shot Optimizer", required: false }, { id: "variation", label: "Variation", required: false }, { id: "putting", label: "Putting", required: false }, { id: "glossary", label: "Glossary", required: false }, diff --git a/src/rate_of_closure/web/src/model/windStrategyWorkerClient.test.ts b/src/rate_of_closure/web/src/model/windStrategyWorkerClient.test.ts new file mode 100644 index 0000000000..19fa24241e --- /dev/null +++ b/src/rate_of_closure/web/src/model/windStrategyWorkerClient.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WindStrategyRequest } from "./windUncertainty"; +import { createWindStrategyWorkerRunner } from "./windStrategyWorkerClient"; + +const request = {} as WindStrategyRequest; + +class WorkerDouble { + static instance: WorkerDouble | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + postMessage = vi.fn(); + terminate = vi.fn(); + + constructor() { + WorkerDouble.instance = this; + } +} + +describe("wind strategy worker client", () => { + it("forwards progress, resolves once, and terminates the worker", async () => { + const progress = vi.fn(); + const runner = createWindStrategyWorkerRunner( + () => new WorkerDouble() as unknown as Worker, + ); + const controller = runner(request, progress); + const worker = WorkerDouble.instance!; + const analysis = { schema_version: "wind-strategy-analysis/v2" }; + + worker.onmessage?.({ data: { type: "progress", completed: 1, total: 2 } } as MessageEvent); + worker.onmessage?.({ data: { type: "complete", analysis } } as MessageEvent); + + await expect(controller.promise).resolves.toBe(analysis); + expect(progress).toHaveBeenCalledWith({ completed: 1, total: 2 }); + expect(worker.postMessage).toHaveBeenCalledWith({ type: "run", request }); + expect(worker.terminate).toHaveBeenCalledOnce(); + }); + + it("rejects and terminates when cancelled", async () => { + const runner = createWindStrategyWorkerRunner( + () => new WorkerDouble() as unknown as Worker, + ); + const controller = runner(request, vi.fn()); + + controller.cancel(); + + await expect(controller.promise).rejects.toMatchObject({ name: "AbortError" }); + expect(WorkerDouble.instance!.terminate).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/rate_of_closure/web/src/model/windStrategyWorkerClient.ts b/src/rate_of_closure/web/src/model/windStrategyWorkerClient.ts new file mode 100644 index 0000000000..566c0bd851 --- /dev/null +++ b/src/rate_of_closure/web/src/model/windStrategyWorkerClient.ts @@ -0,0 +1,77 @@ +/** Browser-worker boundary for cancellable wind-strategy analysis. */ + +import type { WindStrategyAnalysis, WindStrategyRequest } from "./windUncertainty"; + +export interface WindStrategyProgress { + readonly completed: number; + readonly total: number; +} + +export interface WindStrategyRunController { + readonly promise: Promise; + cancel: () => void; +} + +export type WindStrategyRunner = ( + request: WindStrategyRequest, + onProgress: (progress: WindStrategyProgress) => void, +) => WindStrategyRunController; + +export type WindStrategyWorkerRequest = { + readonly type: "run"; + readonly request: WindStrategyRequest; +}; + +export type WindStrategyWorkerResponse = + | ({ readonly type: "progress" } & WindStrategyProgress) + | { readonly type: "complete"; readonly analysis: WindStrategyAnalysis } + | { readonly type: "error"; readonly message: string }; + +type WorkerFactory = () => Worker; + +const defaultWorkerFactory: WorkerFactory = () => new Worker( + new URL("../workers/windStrategy.worker.ts", import.meta.url), + { type: "module", name: "wind-strategy-analysis" }, +); + +/** Build a runner with an injectable worker constructor for deterministic tests. */ +export function createWindStrategyWorkerRunner( + workerFactory: WorkerFactory = defaultWorkerFactory, +): WindStrategyRunner { + return (request, onProgress) => { + const worker = workerFactory(); + let rejectRun: (reason: unknown) => void = () => undefined; + let settled = false; + const finish = (action: () => void): void => { + if (settled) return; + settled = true; + worker.terminate(); + action(); + }; + const promise = new Promise((resolve, reject) => { + rejectRun = reject; + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "progress") { + onProgress({ completed: message.completed, total: message.total }); + } else if (message.type === "complete") { + finish(() => resolve(message.analysis)); + } else { + finish(() => reject(new Error(message.message))); + } + }; + worker.onerror = (event) => { + finish(() => reject(new Error(event.message || "Wind strategy worker failed"))); + }; + worker.postMessage({ type: "run", request } satisfies WindStrategyWorkerRequest); + }); + return { + promise, + cancel: () => finish(() => rejectRun(new DOMException( + "Wind strategy analysis cancelled", "AbortError", + ))), + }; + }; +} + +export const runWindStrategyInWorker = createWindStrategyWorkerRunner(); diff --git a/src/rate_of_closure/web/src/model/windUncertainty.test.ts b/src/rate_of_closure/web/src/model/windUncertainty.test.ts index 3a5c7766ab..beba9e1cbd 100644 --- a/src/rate_of_closure/web/src/model/windUncertainty.test.ts +++ b/src/rate_of_closure/web/src/model/windUncertainty.test.ts @@ -46,6 +46,7 @@ describe("sampleWindTrials", () => { describe("analyzeWindStrategies", () => { it("returns paired scatter outcomes and nonnegative CRN regret", () => { + const progress: Array<[number, number]> = []; const launch = directLaunch({ ballSpeedMph: 150, launchAngleDeg: 12, @@ -74,9 +75,12 @@ describe("analyzeWindStrategies", () => { target_radius_m: 10, miss_distance_cvar_alpha: 0.75, }, - }); + }, (completed, total) => progress.push([completed, total])); expect(result.outcomes).toHaveLength(8); + expect(progress).toEqual([ + [0, 8], [1, 8], [2, 8], [3, 8], [4, 8], [5, 8], [6, 8], [7, 8], [8, 8], + ]); expect(result.summaries).toHaveLength(2); expect(result.schema_version).toBe("wind-strategy-analysis/v2"); expect(result.summaries.every((item) => item.completed_trials === 4)).toBe(true); diff --git a/src/rate_of_closure/web/src/model/windUncertainty.ts b/src/rate_of_closure/web/src/model/windUncertainty.ts index d6ff86e3ba..8a9152f2fd 100644 --- a/src/rate_of_closure/web/src/model/windUncertainty.ts +++ b/src/rate_of_closure/web/src/model/windUncertainty.ts @@ -378,11 +378,19 @@ function outcome(request: WindStrategyRequest, trial: WindTrial, strategy: WindS information_cost_delta: actual.cost - perfectInformation.cost, }; } - -export function analyzeWindStrategies(request: WindStrategyRequest): WindStrategyAnalysis { +export function analyzeWindStrategies(request: WindStrategyRequest, + onProgress?: (completed: number, total: number) => void, +): WindStrategyAnalysis { validateRequest(request); const windTrials = sampleWindTrials(request.uncertainty); - const outcomes = windTrials.flatMap((trial) => request.strategies.map((strategy) => outcome(request, trial, strategy))); + const total = windTrials.length * request.strategies.length; + let completed = 0; + onProgress?.(0, total); + const outcomes = windTrials.flatMap((trial) => request.strategies.map((strategy) => { + const result = outcome(request, trial, strategy); + onProgress?.(++completed, total); + return result; + })); return { schema_version: WIND_STRATEGY_ANALYSIS_SCHEMA_VERSION, provenance: request.uncertainty.provenance, diff --git a/src/rate_of_closure/web/src/workers/capabilityOptimization.worker.ts b/src/rate_of_closure/web/src/workers/capabilityOptimization.worker.ts new file mode 100644 index 0000000000..a2037f46d0 --- /dev/null +++ b/src/rate_of_closure/web/src/workers/capabilityOptimization.worker.ts @@ -0,0 +1,31 @@ +/** Dedicated worker entry point for capability optimization. */ + +import { runCapabilityOptimization } from "../model/capabilityRun"; +import type { + CapabilityWorkerRequest, + CapabilityWorkerResponse, +} from "../model/capabilityWorkerClient"; + +interface WorkerScope { + addEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void; + postMessage(message: CapabilityWorkerResponse): void; +} + +const scope = globalThis as unknown as WorkerScope; + +scope.addEventListener("message", (event) => { + if (event.data.type !== "run") return; + try { + const output = runCapabilityOptimization( + event.data.document, + (progress) => scope.postMessage({ type: "progress", ...progress }), + ); + scope.postMessage({ type: "complete", output }); + } catch (error: unknown) { + scope.postMessage({ type: "error", + message: error instanceof Error ? error.message : String(error) }); + } +}); diff --git a/src/rate_of_closure/web/src/workers/windStrategy.worker.ts b/src/rate_of_closure/web/src/workers/windStrategy.worker.ts new file mode 100644 index 0000000000..81b196ee58 --- /dev/null +++ b/src/rate_of_closure/web/src/workers/windStrategy.worker.ts @@ -0,0 +1,34 @@ +/** Dedicated worker entry point for wind-strategy analysis. */ + +import { analyzeWindStrategies } from "../model/windUncertainty"; +import type { + WindStrategyWorkerRequest, + WindStrategyWorkerResponse, +} from "../model/windStrategyWorkerClient"; + +interface WorkerScope { + addEventListener(type: "message", listener: (event: MessageEvent) => void): void; + postMessage(message: WindStrategyWorkerResponse): void; +} + +const scope = globalThis as unknown as WorkerScope; + +scope.addEventListener("message", (event) => { + if (event.data.type !== "run") return; + try { + let lastReported = 0; + const analysis = analyzeWindStrategies(event.data.request, (completed, total) => { + const reportingInterval = Math.max(1, Math.floor(total / 100)); + if (completed === total || completed - lastReported >= reportingInterval) { + lastReported = completed; + scope.postMessage({ type: "progress", completed, total }); + } + }); + scope.postMessage({ type: "complete", analysis }); + } catch (error: unknown) { + scope.postMessage({ + type: "error", + message: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/src/shared/python/swing_sim/flight/__init__.py b/src/shared/python/swing_sim/flight/__init__.py index 7f688f0d97..e08a548f49 100644 --- a/src/shared/python/swing_sim/flight/__init__.py +++ b/src/shared/python/swing_sim/flight/__init__.py @@ -25,6 +25,22 @@ PlayerCapabilityProfile, TargetDefinition, ) +from .capability_flight_evaluator import ( + CapabilityFlightEvaluatorConfig, + CapabilitySpinDefault, + make_capability_flight_evaluator, +) +from .capability_observation import ( + CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION, + CancellationCheck, + CapabilityOptimizationCancelled, + CapabilityOptimizationHooks, + CapabilitySampleMetric, + CapabilitySampleObservation, + CapabilitySampleParameter, + CapabilitySampleStatus, + ObservationSink, +) from .capability_optimizer import CapabilityEvaluator, optimize_capability from .direction import ( DEFINITIONS as LAUNCH_DIRECTION_DEFINITIONS, @@ -129,8 +145,18 @@ "AvailabilityReason", "BallFlightModel", "CapabilityEvaluator", + "CapabilityFlightEvaluatorConfig", + "CapabilitySpinDefault", + "CapabilityOptimizationCancelled", + "CapabilityOptimizationHooks", "CapabilityObjective", "CapabilityParameter", + "CapabilitySampleMetric", + "CapabilitySampleObservation", + "CapabilitySampleParameter", + "CapabilitySampleStatus", + "CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION", + "CancellationCheck", "ClubCapability", "ConstantCoefficientModel", "ConstantCoefficientSpec", @@ -173,6 +199,7 @@ "OptimizationAlternative", "OptimizationRequest", "OptimizationResult", + "ObservationSink", "ParameterValue", "PlayerCapabilityProfile", "SolutionCandidate", @@ -211,6 +238,7 @@ "launch_direction_sign_labels", "launch_direction_to_flight_azimuth", "migrate_launch_direction_mapping", + "make_capability_flight_evaluator", "optimize_capability", "simulate", "simulate_trajectory_rust", diff --git a/src/shared/python/swing_sim/flight/_capability_observation_runtime.py b/src/shared/python/swing_sim/flight/_capability_observation_runtime.py new file mode 100644 index 0000000000..084a7b2f40 --- /dev/null +++ b/src/shared/python/swing_sim/flight/_capability_observation_runtime.py @@ -0,0 +1,191 @@ +"""Private runtime normalization for streamed capability observations.""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass + +from .capability_contract import OptimizationRequest +from .capability_observation import ( + CapabilityOptimizationHooks, + CapabilitySampleMetric, + CapabilitySampleObservation, + CapabilitySampleParameter, + CapabilitySampleStatus, +) +from .capability_profile import ClubCapability, PlayerCapabilityProfile +from .inverse_contract import EvaluatedMetric, EvaluationStatus, SolverEvaluation +from .result_contract import FlightMetricId + + +@dataclass(frozen=True) +class _SampleAttempt: + problem_id: str + attempt_ordinal: int + total_count: int + candidate_ordinal: int + club_candidate_ordinal: int + sample_ordinal: int + club: ClubCapability + nominal: Mapping[str, float] + perturbed: Mapping[str, float] + + +@dataclass(frozen=True) +class _OptimizationContext: + profile: PlayerCapabilityProfile + request: OptimizationRequest + evaluator: Callable[[str, dict[str, float]], SolverEvaluation] + hooks: CapabilityOptimizationHooks + + @property + def total_count(self) -> int: + return int(self.request.candidate_budget * self.request.ensemble_size) + + +@dataclass(frozen=True) +class _CandidateIdentity: + candidate_ordinal: int + club_candidate_ordinal: int + + +@dataclass(frozen=True) +class _NormalizedEvaluation: + evaluation: SolverEvaluation | None + source_status: EvaluationStatus | None + effective_status: CapabilitySampleStatus + reason_code: str | None + source_reason: str | None + + +def _has_required_landing(evaluation: SolverEvaluation) -> bool: + values = {item.metric_id: item.value for item in evaluation.metrics} + return all( + metric_id in values and math.isfinite(values[metric_id]) + for metric_id in (FlightMetricId.CARRY_DISTANCE, FlightMetricId.CARRY_OFFLINE) + ) + + +def _validated_evaluation(value: SolverEvaluation) -> SolverEvaluation | None: + """Rebuild an evaluator result through every canonical runtime contract.""" + try: + status: object = value.status + if not isinstance(status, EvaluationStatus): + return None + metrics = tuple( + EvaluatedMetric(FlightMetricId(item.metric_id), item.value, item.provenance) + for item in value.metrics + ) + return SolverEvaluation(status, metrics, value.reason) + except Exception: + return None + + +def _normalize(raw: object, *, evaluator_failed: bool) -> _NormalizedEvaluation: + if evaluator_failed: + return _NormalizedEvaluation( + None, None, CapabilitySampleStatus.FAILED, "evaluator_exception", None + ) + if not isinstance(raw, SolverEvaluation): + return _invalid_result() + evaluation = _validated_evaluation(raw) + if evaluation is None: + return _invalid_result() + if evaluation.status is EvaluationStatus.COMPLETE: + return _normalize_complete(evaluation) + status = ( + CapabilitySampleStatus.NO_IMPACT + if evaluation.status is EvaluationStatus.NO_IMPACT + else CapabilitySampleStatus.FAILED + ) + return _NormalizedEvaluation( + evaluation, + evaluation.status, + status, + evaluation.reason, + evaluation.reason, + ) + + +def _invalid_result() -> _NormalizedEvaluation: + return _NormalizedEvaluation( + None, + None, + CapabilitySampleStatus.FAILED, + "invalid_evaluator_result", + None, + ) + + +def _normalize_complete(evaluation: SolverEvaluation) -> _NormalizedEvaluation: + if _has_required_landing(evaluation): + return _NormalizedEvaluation( + evaluation, + evaluation.status, + CapabilitySampleStatus.COMPLETE, + None, + None, + ) + return _NormalizedEvaluation( + evaluation, + evaluation.status, + CapabilitySampleStatus.FAILED, + "missing_required_landing_metrics", + None, + ) + + +def _build_observation( + attempt: _SampleAttempt, normalized: _NormalizedEvaluation +) -> CapabilitySampleObservation: + evaluation = normalized.evaluation + metrics = () if evaluation is None else evaluation.metrics + return CapabilitySampleObservation( + attempt.problem_id, + attempt.attempt_ordinal, + attempt.attempt_ordinal + 1, + attempt.total_count, + attempt.candidate_ordinal, + attempt.club_candidate_ordinal, + attempt.sample_ordinal, + attempt.club.club_id, + tuple( + CapabilitySampleParameter( + item.parameter_id, + item.unit, + attempt.nominal[item.parameter_id], + attempt.perturbed[item.parameter_id], + ) + for item in attempt.club.parameters + ), + normalized.source_status, + normalized.effective_status, + normalized.reason_code, + normalized.source_reason, + tuple( + CapabilitySampleMetric(item.metric_id, item.value, item.provenance) + for item in metrics + ), + ) + + +def _evaluate_sample( + attempt: _SampleAttempt, + evaluator: Callable[[str, dict[str, float]], SolverEvaluation], + hooks: CapabilityOptimizationHooks, +) -> _NormalizedEvaluation: + """Evaluate, normalize, and synchronously emit one attempted sample.""" + try: + raw: object = evaluator(attempt.club.club_id, dict(attempt.perturbed)) + except Exception: + normalized = _normalize(None, evaluator_failed=True) + else: + normalized = _normalize(raw, evaluator_failed=False) + observation = _build_observation(attempt, normalized) + if hooks.observation_sink is not None: + hooks.observation_sink(observation) + return normalized + + +__all__: list[str] = [] diff --git a/src/shared/python/swing_sim/flight/_capability_optimizer_metrics.py b/src/shared/python/swing_sim/flight/_capability_optimizer_metrics.py new file mode 100644 index 0000000000..cac5c11d81 --- /dev/null +++ b/src/shared/python/swing_sim/flight/_capability_optimizer_metrics.py @@ -0,0 +1,122 @@ +"""Private landing extraction and risk metrics for capability optimization.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from shared.python.swing_sim.solver.targets import TargetRegion + +from .capability_contract import CapabilityObjective, OptimizationRequest +from .inverse_contract import EvaluationStatus, SolverEvaluation +from .result_contract import FlightMetricId + + +@dataclass(frozen=True) +class _Landing: + carry_m: float + offline_m: float + + +@dataclass(frozen=True) +class _RiskMetrics: + mean_carry_m: float + expected_miss_m: float + hold_probability: float + dispersion_rms_m: float + cvar_miss_m: float + downside_carry_m: float + + +def _landing(evaluation: SolverEvaluation) -> _Landing | None: + if evaluation.status is not EvaluationStatus.COMPLETE: + return None + metrics = {item.metric_id: item.value for item in evaluation.metrics} + carry = metrics.get(FlightMetricId.CARRY_DISTANCE) + offline = metrics.get(FlightMetricId.CARRY_OFFLINE) + if ( + carry is None + or offline is None + or not math.isfinite(carry) + or not math.isfinite(offline) + ): + return None + return _Landing(carry, offline) + + +def _target(request: OptimizationRequest) -> TargetRegion: + from shared.python.swing_sim.solver.targets import TargetRegion + + value = request.target + return TargetRegion( + value.kind, + value.distance_m, + value.radius_m, + value.lateral_m, + value.band_half_length_m, + value.half_width_m, + ) + + +def _tail_mean(values: list[float], alpha: float, *, reverse: bool) -> float: + count = max(1, math.ceil(len(values) * (1.0 - alpha))) + return sum(sorted(values, reverse=reverse)[:count]) / count + + +def _score( + objective: CapabilityObjective, + risk: _RiskMetrics, + target_distance: float, +) -> float: + if objective is CapabilityObjective.MAXIMIZE_CARRY: + return -risk.mean_carry_m + if objective is CapabilityObjective.MINIMIZE_EXPECTED_MISS: + return risk.expected_miss_m + if objective is CapabilityObjective.MAXIMIZE_TARGET_HOLD: + return -risk.hold_probability + risk.expected_miss_m * 1e-6 + if objective is CapabilityObjective.MINIMIZE_VARIABILITY: + return risk.dispersion_rms_m + if objective is CapabilityObjective.MINIMIZE_DOWNSIDE: + return risk.cvar_miss_m + risk.downside_carry_m + return abs(risk.mean_carry_m - target_distance) + risk.dispersion_rms_m + + +def _risk_metrics( + landings: tuple[_Landing, ...], request: OptimizationRequest +) -> _RiskMetrics: + target = _target(request) + center_carry, center_offline = target.center + carries = [item.carry_m for item in landings] + offlines = [item.offline_m for item in landings] + misses = [ + math.hypot(item.carry_m - center_carry, item.offline_m - center_offline) + for item in landings + ] + mean_carry = sum(carries) / len(carries) + mean_offline = sum(offlines) / len(offlines) + dispersion = math.sqrt( + sum( + (carry - mean_carry) ** 2 + (offline - mean_offline) ** 2 + for carry, offline in zip(carries, offlines, strict=True) + ) + / len(carries) + ) + hold = sum( + target.contains(item.carry_m, item.offline_m) for item in landings + ) / len(landings) + return _RiskMetrics( + mean_carry, + sum(misses) / len(misses), + hold, + dispersion, + _tail_mean(misses, request.cvar_alpha, reverse=True), + max( + 0.0, + center_carry - _tail_mean(carries, request.cvar_alpha, reverse=False), + ), + ) + + +__all__: list[str] = [] diff --git a/src/shared/python/swing_sim/flight/capability_flight_evaluator.py b/src/shared/python/swing_sim/flight/capability_flight_evaluator.py new file mode 100644 index 0000000000..1cafe6eb19 --- /dev/null +++ b/src/shared/python/swing_sim/flight/capability_flight_evaluator.py @@ -0,0 +1,328 @@ +"""Model-backed capability evaluator for robust launch optimization.""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass + +from .capability_contract import ( + ClubCapability, + OptimizationRequest, + PlayerCapabilityProfile, +) +from .frames import from_flight_frame +from .inverse_contract import ( + EvaluatedMetric, + EvaluationStatus, + SolverEvaluation, +) +from .pipeline import simulate +from .result_contract import FlightMetricId, ValueStatus +from .result_metrics import ( + FlightMetricInputs, + FlightRunManifest, + MetricTrajectoryPoint, + derive_flight_metric_result, +) +from .types import LaunchConditions + +_MODEL_ID = "waterloo_penner" +_MODEL_VERSION = "waterloo-penner-coefficients/v1" +_PROVENANCE = f"{_MODEL_ID}:{_MODEL_VERSION}:scipy-rk45" +_REQUIRED_PARAMETERS = frozenset({"ball_speed", "launch_angle", "launch_direction"}) +_OPTIONAL_PARAMETERS = frozenset({"total_spin", "spin_axis_tilt"}) +_EXPECTED_UNITS = { + "ball_speed": "m/s", + "launch_angle": "deg", + "launch_direction": "deg", + "total_spin": "rpm", + "spin_axis_tilt": "deg", +} +_PHYSICAL_DOMAINS = { + "ball_speed": (0.0, math.inf, False), + "launch_angle": (-90.0, 90.0, True), + "launch_direction": (-180.0, 180.0, True), + "total_spin": (0.0, math.inf, True), + "spin_axis_tilt": (-90.0, 90.0, True), +} +_RAD_S_TO_RPM = 60.0 / (2.0 * math.pi) +_GROUND_HEIGHT_TOLERANCE_M = 1e-9 + + +def _finite(value: object, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be finite") + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError(f"{name} must be finite") + return parsed + + +@dataclass(frozen=True) +class CapabilitySpinDefault: + """Auditable fixed spin used when one club profile omits spin variables.""" + + club_id: str + total_spin_rpm: float + spin_axis_tilt_deg: float + provenance: str + + def __post_init__(self) -> None: + if not self.club_id.strip() or not self.provenance.strip(): + raise ValueError("spin default club_id and provenance must be nonempty") + spin = _finite(self.total_spin_rpm, "total_spin_rpm") + tilt = _finite(self.spin_axis_tilt_deg, "spin_axis_tilt_deg") + _validate_physical_value("total_spin", spin) + _validate_physical_value("spin_axis_tilt", tilt) + object.__setattr__(self, "club_id", self.club_id.strip()) + object.__setattr__(self, "total_spin_rpm", spin) + object.__setattr__(self, "spin_axis_tilt_deg", tilt) + object.__setattr__(self, "provenance", self.provenance.strip()) + + +@dataclass(frozen=True) +class CapabilityFlightEvaluatorConfig: + """Cross-runtime integration controls and explicit per-club spin defaults.""" + + max_time_s: float = 10.0 + trajectory_sample_interval_s: float = 0.01 + spin_defaults: tuple[CapabilitySpinDefault, ...] = () + + def __post_init__(self) -> None: + max_time = _finite(self.max_time_s, "max_time_s") + interval = _finite( + self.trajectory_sample_interval_s, "trajectory_sample_interval_s" + ) + if max_time <= 0.0: + raise ValueError("max_time_s must be > 0") + if not 0.001 <= interval <= 0.1: + raise ValueError( + "trajectory_sample_interval_s must lie within [0.001, 0.1]" + ) + ratio = interval / 0.001 + if abs(ratio - round(ratio)) > 1e-9: + raise ValueError( + "trajectory_sample_interval_s must align to the 0.001 s step" + ) + defaults = tuple(self.spin_defaults) + identifiers = tuple(item.club_id for item in defaults) + if len(set(identifiers)) != len(identifiers): + raise ValueError("spin default club_ids must be unique") + object.__setattr__(self, "max_time_s", max_time) + object.__setattr__(self, "trajectory_sample_interval_s", interval) + object.__setattr__(self, "spin_defaults", defaults) + + +@dataclass(frozen=True) +class _EvaluatorBinding: + clubs: dict[str, ClubCapability] + spin_defaults: dict[str, CapabilitySpinDefault] + target_position_m: tuple[float, float, float] + config: CapabilityFlightEvaluatorConfig + + +def _validate_club( + club: ClubCapability, spin_defaults: Mapping[str, CapabilitySpinDefault] +) -> None: + units = {item.parameter_id: item.unit for item in club.parameters} + fields = set(units) + if not _REQUIRED_PARAMETERS <= fields: + missing = sorted(_REQUIRED_PARAMETERS - fields) + raise ValueError(f"{club.club_id} is missing required parameters: {missing}") + if not fields <= _REQUIRED_PARAMETERS | _OPTIONAL_PARAMETERS: + raise ValueError(f"{club.club_id} declares unsupported capability parameters") + declared_spin = fields & _OPTIONAL_PARAMETERS + if declared_spin and declared_spin != _OPTIONAL_PARAMETERS: + raise ValueError("total_spin and spin_axis_tilt must be declared together") + if not declared_spin and club.club_id not in spin_defaults: + raise ValueError(f"{club.club_id} requires an explicit spin default") + for parameter_id, unit in units.items(): + expected = _EXPECTED_UNITS[parameter_id] + if unit != expected: + raise ValueError(f"{parameter_id} must use {expected}, not {unit}") + parameter = next( + item for item in club.parameters if item.parameter_id == parameter_id + ) + _validate_physical_value(parameter_id, parameter.lower_bound) + _validate_physical_value(parameter_id, parameter.upper_bound) + + +def _validate_physical_value(parameter_id: str, value: float) -> None: + lower, upper, inclusive_lower = _PHYSICAL_DOMAINS[parameter_id] + lower_valid = value >= lower if inclusive_lower else value > lower + if lower_valid and value <= upper: + return + if parameter_id == "ball_speed": + raise ValueError("ball_speed must be greater than zero") + raise ValueError(f"{parameter_id} must lie within [{lower:g}, {upper:g}]") + + +def _binding( + profile: PlayerCapabilityProfile, + request: OptimizationRequest, + config: CapabilityFlightEvaluatorConfig, +) -> _EvaluatorBinding: + spin_defaults = {item.club_id: item for item in config.spin_defaults} + clubs = {club_id: profile.club(club_id) for club_id in request.club_ids} + for club in clubs.values(): + _validate_club(club, spin_defaults) + target = (request.target.distance_m, 0.0, request.target.lateral_m) + return _EvaluatorBinding(clubs, spin_defaults, target, config) + + +def _validated_sample( + club: ClubCapability, values: Mapping[str, float] +) -> dict[str, float]: + parameters = {item.parameter_id: item for item in club.parameters} + if set(values) != set(parameters): + raise ValueError("capability sample fields do not match the club profile") + parsed = {key: _finite(value, key) for key, value in values.items()} + for parameter_id, value in parsed.items(): + parameter = parameters[parameter_id] + _validate_physical_value(parameter_id, value) + if not parameter.lower_bound <= value <= parameter.upper_bound: + raise ValueError(f"{parameter_id} lies outside declared safe bounds") + return parsed + + +def _launch( + values: Mapping[str, float], spin_default: CapabilitySpinDefault | None +) -> tuple[LaunchConditions, str]: + spin = values.get( + "total_spin", spin_default.total_spin_rpm if spin_default else math.nan + ) + tilt = values.get( + "spin_axis_tilt", spin_default.spin_axis_tilt_deg if spin_default else math.nan + ) + spin_source = ( + "sampled_profile" + if spin_default is None + else f"fixed_club_default:{spin_default.provenance}" + ) + tilt_radians = math.radians(tilt) + return ( + LaunchConditions( + ball_speed=values["ball_speed"], + launch_angle=math.radians(values["launch_angle"]), + azimuth_angle=-math.radians(values["launch_direction"]), + spin_rate=spin, + # Positive target-frame tilt is toward fade/right: (0, -sin, cos). + spin_axis=(0.0, -math.cos(tilt_radians), -math.sin(tilt_radians)), + ), + spin_source, + ) + + +def _metric_inputs( + launch: LaunchConditions, + binding: _EvaluatorBinding, +) -> FlightMetricInputs: + config = binding.config + flight = simulate( + launch, + model_name=_MODEL_ID, + max_time=config.max_time_s, + dt=config.trajectory_sample_interval_s, + ) + trajectory_points: list[MetricTrajectoryPoint] = [] + for point in flight.trajectory: + position = from_flight_frame(point.position) + if abs(position[1]) <= _GROUND_HEIGHT_TOLERANCE_M: + position[1] = 0.0 + trajectory_points.append( + MetricTrajectoryPoint( + point.time, + tuple(position), + tuple(from_flight_frame(point.velocity)), + ) + ) + trajectory = tuple(trajectory_points) + spin_rpm = tuple(from_flight_frame(launch.get_spin_vector()) * _RAD_S_TO_RPM) + return FlightMetricInputs(trajectory, spin_rpm, binding.target_position_m) + + +def _has_ground_crossing(inputs: FlightMetricInputs) -> bool: + heights = tuple(point.position_m[1] for point in inputs.trajectory) + return any( + first > 0.0 and second <= 0.0 + for first, second in zip(heights, heights[1:], strict=False) + ) + + +def _manifest(crossed: bool) -> FlightRunManifest: + return FlightRunManifest( + model_id=_MODEL_ID, + model_version=_MODEL_VERSION, + integration_status="complete" if crossed else "nonconverged", + termination_reason="ground_crossing" if crossed else "max_time_reached", + environment=(("air_model", "standard"), ("integrator", "scipy-rk45")), + wind=(("model", "still_air"),), + uncertainty_status="deterministic", + ) + + +def _as_evaluation(inputs: FlightMetricInputs, spin_source: str) -> SolverEvaluation: + crossed = _has_ground_crossing(inputs) + result = derive_flight_metric_result(inputs, _manifest(crossed)) + required = (FlightMetricId.CARRY_DISTANCE, FlightMetricId.CARRY_OFFLINE) + if not crossed or any( + result.value(metric_id).status is ValueStatus.UNAVAILABLE + for metric_id in required + ): + return SolverEvaluation( + EvaluationStatus.NONCONVERGED, + (), + "no_ground_crossing_before_max_time", + ) + metrics = tuple( + EvaluatedMetric( + value.metric_id, + value.numeric, + f"ball-flight-result/v1|{_PROVENANCE}|spin:{spin_source}|{value.provenance}", + ) + for value in result.values + if isinstance(value.numeric, float) + ) + return SolverEvaluation(EvaluationStatus.COMPLETE, metrics) + + +class _ModelBackedCapabilityEvaluator: + def __init__(self, binding: _EvaluatorBinding) -> None: + self._binding = binding + + def __call__( + self, club_id: str, parameters: Mapping[str, float] + ) -> SolverEvaluation: + """Evaluate one validated club sample through the full flight model.""" + if club_id not in self._binding.clubs: + raise ValueError(f"unknown requested club_id: {club_id}") + club = self._binding.clubs[club_id] + values = _validated_sample(club, parameters) + launch, spin_source = _launch(values, self._binding.spin_defaults.get(club_id)) + try: + return _as_evaluation(_metric_inputs(launch, self._binding), spin_source) + except (FloatingPointError, OverflowError): + return SolverEvaluation(EvaluationStatus.FAILED, (), "flight_model_failure") + + +def make_capability_flight_evaluator( + profile: PlayerCapabilityProfile, + request: OptimizationRequest, + config: CapabilityFlightEvaluatorConfig | None = None, +) -> Callable[[str, Mapping[str, float]], SolverEvaluation]: + """Bind a profile/request to an optimizer-compatible full-flight evaluator. + + Three-variable capability profiles require an explicit, sourced per-club + spin default. Clubs may instead declare and vary both total_spin and + spin_axis_tilt. + """ + active_config = config or CapabilityFlightEvaluatorConfig() + return _ModelBackedCapabilityEvaluator(_binding(profile, request, active_config)) + + +__all__ = [ + "CapabilityFlightEvaluatorConfig", + "CapabilitySpinDefault", + "make_capability_flight_evaluator", +] diff --git a/src/shared/python/swing_sim/flight/capability_observation.py b/src/shared/python/swing_sim/flight/capability_observation.py new file mode 100644 index 0000000000..3e76dc9941 --- /dev/null +++ b/src/shared/python/swing_sim/flight/capability_observation.py @@ -0,0 +1,277 @@ +"""Immutable streaming observations for capability-optimizer sample attempts.""" + +from __future__ import annotations + +import math +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeAlias + +if TYPE_CHECKING: + from enum import StrEnum +else: + from shared.python.compatibility import StrEnum + +from .inverse_contract import EvaluationStatus +from .result_contract import FlightMetricId + +CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION = "capability-sample-observation/v1" + + +class CapabilitySampleStatus(StrEnum): + """Normalized terminal state of one attempted capability sample.""" + + COMPLETE = "complete" + NO_IMPACT = "no_impact" + FAILED = "failed" + + +_SOURCELESS_FAILURE_CODES = {"evaluator_exception", "invalid_evaluator_result"} +_REQUIRED_LANDING_METRICS = { + FlightMetricId.CARRY_DISTANCE, + FlightMetricId.CARRY_OFFLINE, +} + + +@dataclass(frozen=True) +class CapabilitySampleParameter: + """One declared capability parameter at nominal and perturbed values.""" + + parameter_id: str + unit: str + nominal_value: float + perturbed_value: float + + def __post_init__(self) -> None: + if not self.parameter_id.strip() or not self.unit.strip(): + raise ValueError("observation parameter identity must be nonempty") + if not all(math.isfinite(value) for value in self.values): + raise ValueError("observation parameter values must be finite") + + @property + def values(self) -> tuple[float, float]: + """Return numeric values for compact invariant validation.""" + return self.nominal_value, self.perturbed_value + + def to_wire(self) -> dict[str, object]: + """Return the exact v1 wire representation.""" + return { + "parameter_id": self.parameter_id, + "unit": self.unit, + "nominal_value": self.nominal_value, + "perturbed_value": self.perturbed_value, + } + + +@dataclass(frozen=True) +class CapabilitySampleMetric: + """One evaluator metric preserved in evaluator declaration order.""" + + metric_id: FlightMetricId + value: float + provenance: str + + def __post_init__(self) -> None: + object.__setattr__(self, "metric_id", FlightMetricId(self.metric_id)) + if not math.isfinite(self.value): + raise ValueError("observation metric value must be finite") + if not self.provenance.strip(): + raise ValueError("observation metric provenance must be nonempty") + + def to_wire(self) -> dict[str, object]: + """Return the exact v1 wire representation.""" + return { + "metric_id": self.metric_id.value, + "value": self.value, + "provenance": self.provenance, + } + + +@dataclass(frozen=True) +class CapabilitySampleObservation: + """One immutable, non-retained capability sample observation.""" + + problem_id: str + attempt_ordinal: int + attempted_count: int + total_count: int + candidate_ordinal: int + club_candidate_ordinal: int + sample_ordinal: int + club_id: str + parameters: tuple[CapabilitySampleParameter, ...] + source_status: EvaluationStatus | None + effective_status: CapabilitySampleStatus + reason_code: str | None + source_reason: str | None + metrics: tuple[CapabilitySampleMetric, ...] + schema_version: str = CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION + + def __post_init__(self) -> None: + if self.schema_version != CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION: + raise ValueError(f"unsupported schema_version: {self.schema_version}") + if not self.problem_id.strip() or not self.club_id.strip(): + raise ValueError("observation problem_id and club_id must be nonempty") + ordinals = self.identity_ordinals + if any( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + for value in ordinals + ): + raise ValueError("observation ordinals must be nonnegative integers") + if self.attempted_count != self.attempt_ordinal + 1: + raise ValueError("attempted_count must equal attempt_ordinal + 1") + if self.total_count < self.attempted_count: + raise ValueError("total_count must include the attempted sample") + object.__setattr__(self, "parameters", tuple(self.parameters)) + object.__setattr__(self, "metrics", tuple(self.metrics)) + if not self.parameters or not all( + isinstance(item, CapabilitySampleParameter) for item in self.parameters + ): + raise ValueError("observation parameters must be nonempty") + if not all(isinstance(item, CapabilitySampleMetric) for item in self.metrics): + raise ValueError("observation metrics must use the v1 metric contract") + if len({item.parameter_id for item in self.parameters}) != len(self.parameters): + raise ValueError("observation parameter IDs must be unique") + if len({item.metric_id for item in self.metrics}) != len(self.metrics): + raise ValueError("observation metric IDs must be unique") + object.__setattr__( + self, "effective_status", CapabilitySampleStatus(self.effective_status) + ) + if self.source_status is not None: + object.__setattr__( + self, "source_status", EvaluationStatus(self.source_status) + ) + _validate_status_contract(self) + + @property + def identity_ordinals(self) -> tuple[int, ...]: + """Return ordering integers for compact invariant validation.""" + return ( + self.attempt_ordinal, + self.attempted_count, + self.total_count, + self.candidate_ordinal, + self.club_candidate_ordinal, + self.sample_ordinal, + ) + + def to_wire(self) -> dict[str, object]: + """Return the exact ordered v1 wire representation.""" + return { + "schema_version": self.schema_version, + "problem_id": self.problem_id, + "attempt_ordinal": self.attempt_ordinal, + "attempted_count": self.attempted_count, + "total_count": self.total_count, + "candidate_ordinal": self.candidate_ordinal, + "club_candidate_ordinal": self.club_candidate_ordinal, + "sample_ordinal": self.sample_ordinal, + "club_id": self.club_id, + "parameters": [item.to_wire() for item in self.parameters], + "source_status": ( + None if self.source_status is None else self.source_status.value + ), + "effective_status": self.effective_status.value, + "reason_code": self.reason_code, + "source_reason": self.source_reason, + "metrics": [item.to_wire() for item in self.metrics], + } + + +def _validate_status_contract(observation: CapabilitySampleObservation) -> None: + source = observation.source_status + effective = observation.effective_status + code = observation.reason_code + reason = observation.source_reason + if any(value is not None and not value.strip() for value in (code, reason)): + raise ValueError("observation reasons must be nonempty when present") + if effective is CapabilitySampleStatus.COMPLETE: + valid = source is EvaluationStatus.COMPLETE and code is None and reason is None + elif effective is CapabilitySampleStatus.NO_IMPACT: + valid = ( + source is EvaluationStatus.NO_IMPACT and code is not None and code == reason + ) + elif source is None: + valid = code in _SOURCELESS_FAILURE_CODES and reason is None + elif source is EvaluationStatus.COMPLETE: + valid = code == "missing_required_landing_metrics" and reason is None + else: + valid = source in {EvaluationStatus.FAILED, EvaluationStatus.NONCONVERGED} + valid = valid and code is not None and code == reason + if not valid: + raise ValueError("observation status and reason fields are inconsistent") + _validate_status_metrics(observation) + + +def _validate_status_metrics(observation: CapabilitySampleObservation) -> None: + metric_ids = {item.metric_id for item in observation.metrics} + has_landing = _REQUIRED_LANDING_METRICS <= metric_ids + if observation.effective_status is CapabilitySampleStatus.COMPLETE: + if not has_landing: + raise ValueError( + "complete observation requires carry_distance and carry_offline" + ) + return + if observation.source_status is EvaluationStatus.COMPLETE: + if has_landing: + raise ValueError( + "missing_required_landing_metrics must be missing at least one metric" + ) + return + if observation.metrics: + raise ValueError("noncomplete source statuses require no metrics") + + +ObservationSink: TypeAlias = Callable[[CapabilitySampleObservation], None] +CancellationCheck: TypeAlias = Callable[[], bool] + + +@dataclass(frozen=True) +class CapabilityOptimizationHooks: + """Optional synchronous observation and cooperative-cancellation hooks.""" + + observation_sink: ObservationSink | None = None + should_cancel: CancellationCheck | None = None + + def __post_init__(self) -> None: + for name in ("observation_sink", "should_cancel"): + value = getattr(self, name) + if value is not None and not callable(value): + raise TypeError(f"{name} must be callable") + + +class CapabilityOptimizationCancelled(RuntimeError): + """Typed cooperative cancellation with deterministic progress counts.""" + + def __init__(self, attempted_count: int, total_count: int) -> None: + if ( + isinstance(attempted_count, bool) + or not isinstance(attempted_count, int) + or attempted_count < 0 + ): + raise ValueError("attempted_count must be a nonnegative integer") + if ( + isinstance(total_count, bool) + or not isinstance(total_count, int) + or total_count < attempted_count + ): + raise ValueError("total_count must be an integer at least attempted_count") + self.attempted_count = attempted_count + self.total_count = total_count + super().__init__( + f"capability optimization cancelled after {attempted_count} " + f"of {total_count} attempts" + ) + + +__all__ = [ + "CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION", + "CancellationCheck", + "CapabilityOptimizationCancelled", + "CapabilityOptimizationHooks", + "CapabilitySampleMetric", + "CapabilitySampleObservation", + "CapabilitySampleParameter", + "CapabilitySampleStatus", + "ObservationSink", +] diff --git a/src/shared/python/swing_sim/flight/capability_optimizer.py b/src/shared/python/swing_sim/flight/capability_optimizer.py index a00399afff..201a619a59 100644 --- a/src/shared/python/swing_sim/flight/capability_optimizer.py +++ b/src/shared/python/swing_sim/flight/capability_optimizer.py @@ -2,14 +2,21 @@ from __future__ import annotations -import math from collections.abc import Callable from dataclasses import dataclass, replace -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from shared.python.swing_sim.solver.targets import TargetRegion +from ._capability_observation_runtime import ( + _CandidateIdentity, + _evaluate_sample, + _OptimizationContext, + _SampleAttempt, +) +from ._capability_optimizer_metrics import ( + _Landing, + _landing, + _risk_metrics, + _score, +) from .capability_contract import ( CapabilityObjective, ClubCapability, @@ -18,12 +25,16 @@ OptimizationResult, PlayerCapabilityProfile, ) +from .capability_observation import ( + CapabilityOptimizationCancelled, + CapabilityOptimizationHooks, + CapabilitySampleStatus, +) from .capability_sampling import ( sample_candidate_parameters, sample_perturbed_parameters, ) from .inverse_contract import EvaluationStatus, SolverEvaluation -from .result_contract import FlightMetricId CapabilityEvaluator = Callable[[str, dict[str, float]], SolverEvaluation] _FAILURE_PENALTY = 1_000_000.0 @@ -36,24 +47,6 @@ ) -@dataclass(frozen=True) -class _Landing: - carry_m: float - offline_m: float - - -@dataclass(frozen=True) -class _RiskMetrics: - """Robust landing summaries consumed by objective scoring.""" - - mean_carry_m: float - expected_miss_m: float - hold_probability: float - dispersion_rms_m: float - cvar_miss_m: float - downside_carry_m: float - - @dataclass(frozen=True) class _Counts: completed: int = 0 @@ -69,51 +62,23 @@ def add(self, status: EvaluationStatus) -> _Counts: return replace(self, failed=self.failed + 1) -def _landing(evaluation: SolverEvaluation) -> _Landing | None: - if evaluation.status is not EvaluationStatus.COMPLETE: - return None - metrics = {item.metric_id: item.value for item in evaluation.metrics} - carry = metrics.get(FlightMetricId.CARRY_DISTANCE) - offline = metrics.get(FlightMetricId.CARRY_OFFLINE) - if ( - carry is None - or offline is None - or not math.isfinite(carry) - or not math.isfinite(offline) - ): - return None - return _Landing(carry, offline) - - -def _target(request: OptimizationRequest) -> TargetRegion: - from shared.python.swing_sim.solver.targets import TargetRegion - - value = request.target - return TargetRegion( - value.kind, - value.distance_m, - value.radius_m, - value.lateral_m, - value.band_half_length_m, - value.half_width_m, - ) - - -def _tail_mean(values: list[float], alpha: float, *, reverse: bool) -> float: - count = max(1, math.ceil(len(values) * (1.0 - alpha))) - return sum(sorted(values, reverse=reverse)[:count]) / count +@dataclass(frozen=True) +class _CandidateSamples: + club: ClubCapability + nominal: dict[str, float] + landings: tuple[_Landing, ...] + counts: _Counts def _limiting_constraints( - club: ClubCapability, - nominal: dict[str, float], + samples: _CandidateSamples, success_fraction: float, extrapolated: bool, request: OptimizationRequest, ) -> tuple[str, ...]: limiting: list[str] = [] - for item in club.parameters: - value = nominal[item.parameter_id] + for item in samples.club.parameters: + value = samples.nominal[item.parameter_id] if abs(value - item.lower_bound) <= _BOUNDARY_TOLERANCE: limiting.append(f"{item.parameter_id}:lower_safe_bound") elif abs(value - item.upper_bound) <= _BOUNDARY_TOLERANCE: @@ -125,97 +90,50 @@ def _limiting_constraints( return tuple(limiting) -def _score( - objective: CapabilityObjective, - risk: _RiskMetrics, - target_distance: float, -) -> float: - if objective is CapabilityObjective.MAXIMIZE_CARRY: - return -risk.mean_carry_m - if objective is CapabilityObjective.MINIMIZE_EXPECTED_MISS: - return risk.expected_miss_m - if objective is CapabilityObjective.MAXIMIZE_TARGET_HOLD: - return -risk.hold_probability + risk.expected_miss_m * 1e-6 - if objective is CapabilityObjective.MINIMIZE_VARIABILITY: - return risk.dispersion_rms_m - if objective is CapabilityObjective.MINIMIZE_DOWNSIDE: - return risk.cvar_miss_m + risk.downside_carry_m - return abs(risk.mean_carry_m - target_distance) + risk.dispersion_rms_m - - def _summarize( - club: ClubCapability, - nominal: dict[str, float], - landings: list[_Landing], - counts: _Counts, - profile: PlayerCapabilityProfile, - request: OptimizationRequest, + samples: _CandidateSamples, context: _OptimizationContext ) -> OptimizationAlternative | None: - if not landings: + profile, request = context.profile, context.request + if not samples.landings: return None - target = _target(request) - center_carry, center_offline = target.center - carries = [item.carry_m for item in landings] - offlines = [item.offline_m for item in landings] - misses = [ - math.hypot(item.carry_m - center_carry, item.offline_m - center_offline) - for item in landings - ] - mean_carry = sum(carries) / len(carries) - mean_offline = sum(offlines) / len(offlines) - expected_miss = sum(misses) / len(misses) - dispersion = math.sqrt( - sum( - (carry - mean_carry) ** 2 + (offline - mean_offline) ** 2 - for carry, offline in zip(carries, offlines, strict=True) - ) - / len(carries) - ) - hold = sum( - target.contains(item.carry_m, item.offline_m) for item in landings - ) / len(landings) - cvar = _tail_mean(misses, request.cvar_alpha, reverse=True) - downside = max( - 0.0, center_carry - _tail_mean(carries, request.cvar_alpha, reverse=False) - ) - risk = _RiskMetrics(mean_carry, expected_miss, hold, dispersion, cvar, downside) - success_fraction = counts.completed / request.ensemble_size + risk = _risk_metrics(samples.landings, request) + success_fraction = samples.counts.completed / request.ensemble_size failure_fraction = 1.0 - success_fraction extrapolated = any( not item.evidence_lower_bound - <= nominal[item.parameter_id] + <= samples.nominal[item.parameter_id] <= item.evidence_upper_bound - for item in club.parameters + for item in samples.club.parameters ) confidence = ( profile.confidence - * club.confidence + * samples.club.confidence * success_fraction * (0.5 if extrapolated else 1.0) ) constraints = _limiting_constraints( - club, nominal, success_fraction, extrapolated, request + samples, success_fraction, extrapolated, request ) - score = _score(request.objective, risk, center_carry) + score = _score(request.objective, risk, request.target.distance_m) if success_fraction < request.minimum_success_fraction: score += _FAILURE_PENALTY * ( request.minimum_success_fraction - success_fraction ) return OptimizationAlternative( 1, - club.club_id, - tuple(nominal.items()), + samples.club.club_id, + tuple(samples.nominal.items()), score, - mean_carry, - expected_miss, - dispersion, - hold, - cvar, - downside, + risk.mean_carry_m, + risk.expected_miss_m, + risk.dispersion_rms_m, + risk.hold_probability, + risk.cvar_miss_m, + risk.downside_carry_m, request.ensemble_size, - counts.completed, - counts.no_impact, - counts.failed, + samples.counts.completed, + samples.counts.no_impact, + samples.counts.failed, failure_fraction, confidence, constraints, @@ -227,37 +145,49 @@ def _summarize( def _evaluate_candidate( club: ClubCapability, nominal: dict[str, float], - profile: PlayerCapabilityProfile, - request: OptimizationRequest, - evaluator: CapabilityEvaluator, + identity: _CandidateIdentity, + context: _OptimizationContext, ) -> tuple[OptimizationAlternative | None, _Counts]: landings: list[_Landing] = [] counts = _Counts() + request = context.request for sample_index in range(request.ensemble_size): + attempt_ordinal = ( + identity.candidate_ordinal * request.ensemble_size + sample_index + ) + if context.hooks.should_cancel is not None and context.hooks.should_cancel(): + raise CapabilityOptimizationCancelled(attempt_ordinal, context.total_count) parameters = sample_perturbed_parameters( club, nominal, sample_index, request.seed ) - try: - raw_evaluation: object = evaluator(club.club_id, parameters) - except Exception: - counts = counts.add(EvaluationStatus.FAILED) - continue - if not isinstance(raw_evaluation, SolverEvaluation): - counts = counts.add(EvaluationStatus.FAILED) + outcome = _evaluate_sample( + _SampleAttempt( + request.problem_id, + attempt_ordinal, + context.total_count, + identity.candidate_ordinal, + identity.club_candidate_ordinal, + sample_index, + club, + nominal, + parameters, + ), + context.evaluator, + context.hooks, + ) + evaluation = outcome.evaluation + status = EvaluationStatus(outcome.effective_status.value) + counts = counts.add(status) + if outcome.effective_status is not CapabilitySampleStatus.COMPLETE: continue - evaluation = raw_evaluation + if evaluation is None: + raise RuntimeError("normalized complete sample has no evaluation") landing = _landing(evaluation) if landing is None: - status = ( - evaluation.status - if evaluation.status is EvaluationStatus.NO_IMPACT - else EvaluationStatus.FAILED - ) - counts = counts.add(status) - continue - counts = counts.add(EvaluationStatus.COMPLETE) + raise RuntimeError("normalized complete sample has no finite landing") landings.append(landing) - return _summarize(club, nominal, landings, counts, profile, request), counts + samples = _CandidateSamples(club, nominal, tuple(landings), counts) + return _summarize(samples, context), counts def _pareto_mark( @@ -283,10 +213,39 @@ def _pareto_mark( return marked +def _collect_alternatives( + clubs: list[ClubCapability], context: _OptimizationContext +) -> tuple[list[OptimizationAlternative], _Counts]: + alternatives: list[OptimizationAlternative] = [] + aggregate = _Counts() + per_club_indices = {club.club_id: 0 for club in clubs} + for candidate_ordinal in range(context.request.candidate_budget): + club = clubs[candidate_ordinal % len(clubs)] + club_ordinal = per_club_indices[club.club_id] + per_club_indices[club.club_id] += 1 + nominal = sample_candidate_parameters(club, club_ordinal, context.request.seed) + alternative, counts = _evaluate_candidate( + club, + nominal, + _CandidateIdentity(candidate_ordinal, club_ordinal), + context, + ) + aggregate = _Counts( + aggregate.completed + counts.completed, + aggregate.no_impact + counts.no_impact, + aggregate.failed + counts.failed, + ) + if alternative is not None: + alternatives.append(alternative) + return alternatives, aggregate + + def optimize_capability( profile: PlayerCapabilityProfile, request: OptimizationRequest, evaluator: CapabilityEvaluator, + *, + hooks: CapabilityOptimizationHooks | None = None, ) -> OptimizationResult: """Rank robust shot alternatives using injected canonical flight evaluations. @@ -298,24 +257,10 @@ def optimize_capability( deterministic for a deterministic evaluator. """ clubs = [profile.club(club_id) for club_id in request.club_ids] - alternatives: list[OptimizationAlternative] = [] - aggregate = _Counts() - per_club_indices = {club.club_id: 0 for club in clubs} - for evaluation_index in range(request.candidate_budget): - club = clubs[evaluation_index % len(clubs)] - candidate_index = per_club_indices[club.club_id] - per_club_indices[club.club_id] += 1 - nominal = sample_candidate_parameters(club, candidate_index, request.seed) - alternative, counts = _evaluate_candidate( - club, nominal, profile, request, evaluator - ) - aggregate = _Counts( - aggregate.completed + counts.completed, - aggregate.no_impact + counts.no_impact, - aggregate.failed + counts.failed, - ) - if alternative is not None: - alternatives.append(alternative) + total_count = request.candidate_budget * request.ensemble_size + active_hooks = hooks or CapabilityOptimizationHooks() + context = _OptimizationContext(profile, request, evaluator, active_hooks) + alternatives, aggregate = _collect_alternatives(clubs, context) alternatives = _pareto_mark(alternatives, request) alternatives.sort( key=lambda item: ( @@ -333,13 +278,12 @@ def optimize_capability( replace(item, rank=index + 1) for index, item in enumerate(alternatives[: request.alternatives_count]) ) - attempted = request.candidate_budget * request.ensemble_size status = "solved" if ranked else "nonconverged" return OptimizationResult( request.problem_id, status, ranked, - attempted, + total_count, aggregate.completed, aggregate.no_impact, aggregate.failed, diff --git a/src/shared/python/swing_sim/flight/impact_solution_adapter.py b/src/shared/python/swing_sim/flight/impact_solution_adapter.py index dbdd14e4e4..d12b7887d8 100644 --- a/src/shared/python/swing_sim/flight/impact_solution_adapter.py +++ b/src/shared/python/swing_sim/flight/impact_solution_adapter.py @@ -31,6 +31,7 @@ from .pipeline import simulate from .registry import FlightModelType from .result_contract import FlightMetricId +from .spin_axis_convention import spin_axis_tilt_deg _APPROACH_EPSILON_MPS = 1e-9 _PIPELINE_PROVENANCE = "Tools delivery->rigid-body-impact->flight pipeline" @@ -83,13 +84,8 @@ def _metric(metric_id: FlightMetricId, value: float, stage: str) -> EvaluatedMet def _spin_axis_tilt_deg(spin_app: np.ndarray) -> float: - magnitude = float(np.linalg.norm(spin_app)) - if magnitude <= _APPROACH_EPSILON_MPS: - return 0.0 - axis = spin_app / magnitude - return math.degrees( - math.atan2(-float(axis[1]), math.hypot(float(axis[0]), float(axis[2]))) - ) + tilt = spin_axis_tilt_deg(spin_app) + return 0.0 if tilt is None else tilt class CenteredClubDeliveryAdapter: diff --git a/src/shared/python/swing_sim/flight/result_catalog_data.py b/src/shared/python/swing_sim/flight/result_catalog_data.py index 1970c148cf..801d4e2510 100644 --- a/src/shared/python/swing_sim/flight/result_catalog_data.py +++ b/src/shared/python/swing_sim/flight/result_catalog_data.py @@ -87,12 +87,12 @@ class MetricIdentity: ), FlightMetricId.SPIN_AXIS_TILT: MetricIdentity( "Spin Axis Tilt", - "Signed tilt of the spin axis from +right toward +up.", + "Signed tilt from +right toward -up; positive produces fade/right curvature.", "deg", ValueStatus.DERIVED, - SignRule.POSITIVE_UP, + SignRule.POSITIVE_RIGHT, "launch", - "atan2(omega_y,omega_z)", + "atan2(-omega_y,omega_z)", "nonzero spin", True, ), diff --git a/src/shared/python/swing_sim/flight/result_derivation.py b/src/shared/python/swing_sim/flight/result_derivation.py index 68e5c690b0..a70334edef 100644 --- a/src/shared/python/swing_sim/flight/result_derivation.py +++ b/src/shared/python/swing_sim/flight/result_derivation.py @@ -20,6 +20,7 @@ MetricTrajectoryPoint, Vector3, ) +from .spin_axis_convention import spin_axis_tilt_deg _MIN_SPEED_M_S = 1e-12 @@ -48,7 +49,11 @@ def _landing_state(points: tuple[MetricTrajectoryPoint, ...]) -> _LandingState | def _lerp_vector(first: Vector3, second: Vector3, fraction: float) -> Vector3: - return tuple(a + fraction * (b - a) for a, b in zip(first, second, strict=True)) # type: ignore[return-value] + return ( + first[0] + fraction * (second[0] - first[0]), + first[1] + fraction * (second[1] - first[1]), + first[2] + fraction * (second[2] - first[2]), + ) def _norm(value: Vector3) -> float: @@ -106,12 +111,11 @@ def _launch_values( values[FlightMetricId.LAUNCH_DIRECTION] = _unavailable( FlightMetricId.LAUNCH_DIRECTION, AvailabilityReason.ZERO_HORIZONTAL_SPEED ) - if spin_magnitude > _MIN_SPEED_M_S: + spin_axis_tilt = spin_axis_tilt_deg(inputs.spin_vector_rpm) + if spin_axis_tilt is not None: values[FlightMetricId.SPIN_AXIS_TILT] = _available( FlightMetricId.SPIN_AXIS_TILT, - math.degrees( - math.atan2(inputs.spin_vector_rpm[1], inputs.spin_vector_rpm[2]) - ), + spin_axis_tilt, "derived.spin_vector_rpm", ) else: @@ -128,7 +132,7 @@ def _curve(points: tuple[MetricTrajectoryPoint, ...], heading: float) -> float: + math.cos(heading) * (point.position_m[2] - origin[2]) for point in points ) - return max(lateral, key=abs) + return float(max(lateral, key=abs)) def _landing_values( diff --git a/src/shared/python/swing_sim/flight/spin_axis_convention.py b/src/shared/python/swing_sim/flight/spin_axis_convention.py new file mode 100644 index 0000000000..3e95175840 --- /dev/null +++ b/src/shared/python/swing_sim/flight/spin_axis_convention.py @@ -0,0 +1,28 @@ +"""Shared launch-monitor-style spin-axis convention calculations.""" + +from __future__ import annotations + +import math +from collections.abc import Iterable + +_ZERO_SPIN_TOLERANCE = 1e-12 + + +def spin_axis_tilt_deg(spin_vector: Iterable[float]) -> float | None: + """Return fade/right-positive tilt after projecting out forward gyro spin. + + Preconditions: + spin_vector has exactly three finite target-frame components. + + Returns: + None for zero total spin; otherwise the target-frame tilt angle. + """ + values = tuple(float(component) for component in spin_vector) + if len(values) != 3 or not all(math.isfinite(value) for value in values): + raise ValueError("spin_vector must contain three finite components") + if math.sqrt(sum(value * value for value in values)) <= _ZERO_SPIN_TOLERANCE: + return None + return math.degrees(math.atan2(-values[1], values[2])) + + +__all__ = ["spin_axis_tilt_deg"] diff --git a/src/shared/python/swing_sim/flight/tests/test_capability_flight_evaluator.py b/src/shared/python/swing_sim/flight/tests/test_capability_flight_evaluator.py new file mode 100644 index 0000000000..deefba7fb1 --- /dev/null +++ b/src/shared/python/swing_sim/flight/tests/test_capability_flight_evaluator.py @@ -0,0 +1,305 @@ +"""Contract tests for the model-backed capability evaluator.""" + +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest + +from shared.python.swing_sim.flight import ( + CapabilityFlightEvaluatorConfig, + CapabilityObjective, + CapabilityParameter, + CapabilitySpinDefault, + ClubCapability, + EvaluationStatus, + FlightMetricId, + OptimizationRequest, + PlayerCapabilityProfile, + SolverEvaluation, + TargetDefinition, + make_capability_flight_evaluator, + optimize_capability, +) + +_FIXTURE = ( + Path(__file__).parents[5] + / "rate_of_closure" + / "web" + / "src" + / "model" + / "__fixtures__" + / "capability_optimizer_golden_v1.json" +) +_PARITY_FIXTURE = _FIXTURE.parent / "capability_flight_evaluator_parity_v1.json" +PINNED = { + "ball_speed": 74.65568, + "launch_angle": 10.9, + "launch_direction": 0.0, +} + + +def _config( + *, + max_time_s: float = 10.0, + spin_rpm: float = 2686.0, + sample_interval_s: float = 0.01, +) -> CapabilityFlightEvaluatorConfig: + default = CapabilitySpinDefault("driver", spin_rpm, 0.0, "test-fixture") + return CapabilityFlightEvaluatorConfig(max_time_s, sample_interval_s, (default,)) + + +def _parameter( + parameter_id: str, unit: str, value: float, lower: float, upper: float +) -> CapabilityParameter: + return CapabilityParameter( + parameter_id, unit, lower, upper, lower, upper, value, 0.0, 0.0 + ) + + +def _profile(*, include_spin: bool = False) -> PlayerCapabilityProfile: + parameters = [ + _parameter("ball_speed", "m/s", PINNED["ball_speed"], 40.0, 80.0), + _parameter("launch_angle", "deg", PINNED["launch_angle"], 0.0, 30.0), + _parameter("launch_direction", "deg", 0.0, -15.0, 15.0), + ] + if include_spin: + parameters.extend( + ( + _parameter("total_spin", "rpm", 2686.0, 0.0, 6000.0), + _parameter("spin_axis_tilt", "deg", 0.0, -45.0, 45.0), + ) + ) + size = len(parameters) + matrix = tuple( + tuple(1.0 if row == column else 0.0 for column in range(size)) + for row in range(size) + ) + club = ClubCapability( + "driver", tuple(parameters), "correlation", matrix, "test", 1.0 + ) + return PlayerCapabilityProfile("player", (club,), "test", 1.0) + + +def _request(*, max_samples: int = 1) -> OptimizationRequest: + return OptimizationRequest( + "flight-evaluator", + CapabilityObjective.MAXIMIZE_TARGET_HOLD, + ("driver",), + TargetDefinition("green", 247.0, 0.0, 10.0, 15.0, 15.0), + max_samples, + 1, + 1, + 7, + 0.8, + 0.5, + ) + + +def _metric_map(evaluation: SolverEvaluation) -> dict[FlightMetricId, float]: + return {item.metric_id: item.value for item in evaluation.metrics} + + +@pytest.mark.integration +def test_runs_waterloo_penner_with_all_scalars_and_target_values() -> None: + evaluator = make_capability_flight_evaluator(_profile(), _request(), _config()) + evaluation = evaluator("driver", PINNED) + metrics = _metric_map(evaluation) + + assert evaluation.status is EvaluationStatus.COMPLETE + assert metrics[FlightMetricId.BALL_SPEED] == pytest.approx(74.65568) + assert metrics[FlightMetricId.VERTICAL_LAUNCH_ANGLE] == pytest.approx(10.9) + assert metrics[FlightMetricId.CARRY_DISTANCE] == pytest.approx(247.484, rel=0.01) + assert metrics[FlightMetricId.APEX_HEIGHT] == pytest.approx(28.226, rel=0.02) + assert FlightMetricId.TARGET_RESIDUAL in metrics + assert FlightMetricId.INITIAL_VELOCITY not in metrics + assert len(metrics) == 16 + assert all( + "waterloo_penner:waterloo-penner-coefficients/v1" in item.provenance + for item in evaluation.metrics + ) + assert all( + "spin:fixed_club_default:test-fixture" in item.provenance + for item in evaluation.metrics + ) + + fixture = json.loads(_PARITY_FIXTURE.read_text(encoding="utf-8")) + for metric_id, expected in fixture["expected_scalars"].items(): + assert metrics[FlightMetricId(metric_id)] == pytest.approx( + expected["value"], abs=expected["absolute_tolerance"] + ) + + +@pytest.mark.integration +def test_preserves_positive_right_direction_and_fade_side_spin_tilt() -> None: + evaluator = make_capability_flight_evaluator( + _profile(include_spin=True), _request() + ) + direction = evaluator( + "driver", + { + **PINNED, + "launch_direction": 5.0, + "total_spin": 2686.0, + "spin_axis_tilt": 0.0, + }, + ) + tilt = evaluator("driver", {**PINNED, "total_spin": 2686.0, "spin_axis_tilt": 10.0}) + negative_tilt = evaluator( + "driver", {**PINNED, "total_spin": 2686.0, "spin_axis_tilt": -10.0} + ) + direction_metrics = _metric_map(direction) + tilt_metrics = _metric_map(tilt) + + assert direction_metrics[FlightMetricId.LAUNCH_DIRECTION] == pytest.approx(5.0) + assert direction_metrics[FlightMetricId.CARRY_OFFLINE] > 1.0 + assert tilt_metrics[FlightMetricId.SPIN_AXIS_TILT] == pytest.approx(10.0) + assert tilt_metrics[FlightMetricId.CARRY_OFFLINE] > 1.0 + assert _metric_map(negative_tilt)[FlightMetricId.CARRY_OFFLINE] < -1.0 + + +@pytest.mark.integration +def test_plugs_into_existing_profile_fixture_and_optimizer() -> None: + payload = json.loads(_FIXTURE.read_text(encoding="utf-8")) + profile = PlayerCapabilityProfile.from_dict(payload["profile"]) + request = OptimizationRequest.from_dict( + { + **payload["request"], + "club_ids": ["driver"], + "candidate_budget": 1, + "ensemble_size": 1, + "alternatives_count": 1, + } + ) + evaluator = make_capability_flight_evaluator(profile, request, _config()) + result = optimize_capability(profile, request, evaluator) + + assert result.status == "solved" + assert result.evaluations_completed == 1 + assert result.alternatives[0].mean_carry_m > 100.0 + + +@pytest.mark.unit +def test_short_horizon_is_nonconverged_and_zero_spin_remains_complete() -> None: + short = _config(max_time_s=0.001) + evaluation = make_capability_flight_evaluator(_profile(), _request(), short)( + "driver", PINNED + ) + assert evaluation.status is EvaluationStatus.NONCONVERGED + assert evaluation.metrics == () + assert evaluation.reason == "no_ground_crossing_before_max_time" + + zero_spin = _config(spin_rpm=0.0) + completed = make_capability_flight_evaluator(_profile(), _request(), zero_spin)( + "driver", PINNED + ) + assert completed.status is EvaluationStatus.COMPLETE + assert FlightMetricId.SPIN_AXIS_TILT not in _metric_map(completed) + coarse = make_capability_flight_evaluator( + _profile(), _request(), _config(sample_interval_s=0.1) + )("driver", PINNED) + assert coarse.status is EvaluationStatus.COMPLETE + + +@pytest.mark.unit +def test_each_club_requires_its_own_auditable_spin_default() -> None: + driver = _profile().clubs[0] + iron = replace(driver, club_id="iron") + profile = PlayerCapabilityProfile("player", (driver, iron), "test", 1.0) + request = replace(_request(), club_ids=("driver", "iron")) + + with pytest.raises(ValueError, match="iron.*explicit spin default"): + make_capability_flight_evaluator(profile, request, _config()) + + defaults = ( + CapabilitySpinDefault("driver", 2686.0, 0.0, "driver-fixture"), + CapabilitySpinDefault("iron", 6200.0, 0.0, "iron-fixture"), + ) + config = CapabilityFlightEvaluatorConfig(10.0, 0.01, defaults) + evaluator = make_capability_flight_evaluator(profile, request, config) + iron_result = evaluator("iron", PINNED) + + assert iron_result.status is EvaluationStatus.COMPLETE + assert all( + "spin:fixed_club_default:iron-fixture" in item.provenance + for item in iron_result.metrics + ) + + +@pytest.mark.unit +def test_rejects_schema_units_bounds_and_configuration() -> None: + evaluator = make_capability_flight_evaluator(_profile(), _request(), _config()) + with pytest.raises(ValueError, match="fields"): + evaluator("driver", {**PINNED, "unused": 1.0}) + with pytest.raises(ValueError, match="safe bounds"): + evaluator("driver", {**PINNED, "ball_speed": 100.0}) + bad_profile: dict[str, Any] = _profile().to_dict() + bad_profile["clubs"][0]["parameters"][0]["unit"] = "mph" + with pytest.raises(ValueError, match="ball_speed.*m/s"): + make_capability_flight_evaluator( + PlayerCapabilityProfile.from_dict(bad_profile), _request(), _config() + ) + with pytest.raises(ValueError, match="max_time_s"): + CapabilityFlightEvaluatorConfig(max_time_s=0.0) + with pytest.raises(ValueError, match="explicit spin default"): + make_capability_flight_evaluator(_profile(), _request()) + with pytest.raises(ValueError, match=r"\[0.001, 0.1\]"): + CapabilityFlightEvaluatorConfig(10.0, 9.0, ()) + with pytest.raises(ValueError, match="align"): + CapabilityFlightEvaluatorConfig(10.0, 0.0015, ()) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "parameter_id, field, value, message", + [ + ("ball_speed", "lower_bound", 0.0, "ball_speed.*greater than zero"), + ("launch_angle", "upper_bound", 100.0, r"launch_angle.*\[-90, 90\]"), + ( + "launch_direction", + "upper_bound", + 300.0, + r"launch_direction.*\[-180, 180\]", + ), + ("total_spin", "lower_bound", -1.0, r"total_spin.*\[0, inf\]"), + ("spin_axis_tilt", "upper_bound", 120.0, r"spin_axis_tilt.*\[-90, 90\]"), + ], +) +def test_rejects_profiles_outside_physical_flight_domains( + parameter_id: str, field: str, value: float, message: str +) -> None: + payload: dict[str, Any] = _profile( + include_spin=parameter_id in {"total_spin", "spin_axis_tilt"} + ).to_dict() + parameters = payload["clubs"][0]["parameters"] + parameter = next( + item for item in parameters if item["parameter_id"] == parameter_id + ) + parameter[field] = value + if field == "upper_bound": + parameter["evidence_upper_bound"] = value + with pytest.raises(ValueError, match=message): + make_capability_flight_evaluator( + PlayerCapabilityProfile.from_dict(payload), _request(), _config() + ) + + +@pytest.mark.unit +def test_integrator_failure_is_typed_and_deterministic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import shared.python.swing_sim.flight.capability_flight_evaluator as module + + def fail(*_args: object, **_kwargs: object) -> object: + raise OverflowError("private solver detail") + + monkeypatch.setattr(module, "simulate", fail) + evaluator = make_capability_flight_evaluator(_profile(), _request(), _config()) + first = evaluator("driver", PINNED) + second = evaluator("driver", PINNED) + assert first == second + assert first.status is EvaluationStatus.FAILED + assert first.reason == "flight_model_failure" diff --git a/src/shared/python/swing_sim/flight/tests/test_capability_observation.py b/src/shared/python/swing_sim/flight/tests/test_capability_observation.py new file mode 100644 index 0000000000..d59295c90d --- /dev/null +++ b/src/shared/python/swing_sim/flight/tests/test_capability_observation.py @@ -0,0 +1,337 @@ +"""Streaming capability-sample observation and cancellation contracts.""" + +from __future__ import annotations + +import math +from dataclasses import FrozenInstanceError, fields +from typing import Any + +import pytest + +from shared.python.swing_sim.flight.capability_contract import ( + CapabilityObjective, + CapabilityParameter, + ClubCapability, + OptimizationRequest, + PlayerCapabilityProfile, + TargetDefinition, +) +from shared.python.swing_sim.flight.capability_observation import ( + CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION, + CapabilityOptimizationCancelled, + CapabilityOptimizationHooks, + CapabilitySampleObservation, +) +from shared.python.swing_sim.flight.capability_optimizer import optimize_capability +from shared.python.swing_sim.flight.inverse_contract import ( + EvaluatedMetric, + EvaluationStatus, + SolverEvaluation, +) +from shared.python.swing_sim.flight.result_contract import FlightMetricId + +_SOURCE_STATUSES = ( + EvaluationStatus.COMPLETE, + EvaluationStatus.NO_IMPACT, + EvaluationStatus.FAILED, + EvaluationStatus.NONCONVERGED, + None, + EvaluationStatus.COMPLETE, + None, +) +_EFFECTIVE_STATUSES = ( + "complete", + "no_impact", + "failed", + "failed", + "failed", + "failed", + "failed", +) +_REASON_CODES = ( + None, + "missed_ball", + "flight_failed", + "iteration_limit", + "invalid_evaluator_result", + "missing_required_landing_metrics", + "evaluator_exception", +) +_SOURCE_REASONS = ( + None, + "missed_ball", + "flight_failed", + "iteration_limit", + None, + None, + None, +) + + +def _club(club_id: str = "iron-7") -> ClubCapability: + parameters = ( + CapabilityParameter("speed", "m/s", 35, 60, 40, 58, 48, 0, 1), + CapabilityParameter("direction", "deg", -8, 8, -4, 4, 0, 0, 1), + ) + return ClubCapability( + club_id, + parameters, + "correlation", + ((1, 0), (0, 1)), + f"session:{club_id}", + 0.9, + ) + + +def _profile(*clubs: ClubCapability) -> PlayerCapabilityProfile: + return PlayerCapabilityProfile("player", clubs or (_club(),), "fit-v1", 0.8) + + +def _request( + *club_ids: str, samples: int = 2, candidates: int = 2 +) -> OptimizationRequest: + return OptimizationRequest( + "problem-17", + CapabilityObjective.MAXIMIZE_CARRY, + club_ids or ("iron-7",), + TargetDefinition("green", 100, 0, 8, 12, 14), + candidates, + samples, + candidates, + 19, + 0.8, + 0.5, + ) + + +def _complete(_club_id: str, parameters: dict[str, float]) -> SolverEvaluation: + return SolverEvaluation( + EvaluationStatus.COMPLETE, + ( + EvaluatedMetric( + FlightMetricId.CARRY_OFFLINE, + parameters["direction"], + "test.offline", + ), + EvaluatedMetric( + FlightMetricId.CARRY_DISTANCE, + parameters["speed"] * 2, + "test.carry", + ), + ), + ) + + +def test_observations_are_ordered_immutable_and_do_not_change_result() -> None: + profile = _profile(_club("iron-7"), _club("driver")) + request = _request("iron-7", "driver", samples=2, candidates=3) + observations: list[CapabilitySampleObservation] = [] + + observed = optimize_capability( + profile, + request, + _complete, + hooks=CapabilityOptimizationHooks(observation_sink=observations.append), + ) + baseline = optimize_capability(profile, request, _complete) + + assert observed.to_json() == baseline.to_json() + assert "observations" not in {item.name for item in fields(observed)} + assert [item.attempt_ordinal for item in observations] == list(range(6)) + assert [item.attempted_count for item in observations] == list(range(1, 7)) + assert [item.total_count for item in observations] == [6] * 6 + assert [item.candidate_ordinal for item in observations] == [0, 0, 1, 1, 2, 2] + assert [item.club_candidate_ordinal for item in observations] == [0, 0, 0, 0, 1, 1] + assert [item.sample_ordinal for item in observations] == [0, 1, 0, 1, 0, 1] + assert [item.club_id for item in observations] == [ + "iron-7", + "iron-7", + "driver", + "driver", + "iron-7", + "iron-7", + ] + first = observations[0] + assert first.schema_version == CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION + assert [item.parameter_id for item in first.parameters] == ["speed", "direction"] + assert [item.metric_id for item in first.metrics] == [ + FlightMetricId.CARRY_OFFLINE, + FlightMetricId.CARRY_DISTANCE, + ] + with pytest.raises(FrozenInstanceError): + first.attempt_ordinal = 3 + + +def test_status_normalization_preserves_safe_source_details() -> None: + request = _request(samples=7, candidates=1) + evaluations: list[object] = [ + _complete("iron-7", {"speed": 48, "direction": 0}), + SolverEvaluation(EvaluationStatus.NO_IMPACT, (), "missed_ball"), + SolverEvaluation(EvaluationStatus.FAILED, (), "flight_failed"), + SolverEvaluation(EvaluationStatus.NONCONVERGED, (), "iteration_limit"), + object(), + SolverEvaluation( + EvaluationStatus.COMPLETE, + (EvaluatedMetric(FlightMetricId.APEX_HEIGHT, 22, "test.apex"),), + ), + ] + observations: list[CapabilitySampleObservation] = [] + calls = 0 + + def evaluator(_club_id: str, _parameters: dict[str, float]) -> object: + nonlocal calls + index = calls + calls += 1 + if index == 6: + raise RuntimeError("sensitive raw exception text") + return evaluations[index] + + result = optimize_capability( + _profile(), + request, + evaluator, + hooks=CapabilityOptimizationHooks(observation_sink=observations.append), + ) + + assert tuple(item.source_status for item in observations) == _SOURCE_STATUSES + assert tuple(item.effective_status for item in observations) == _EFFECTIVE_STATUSES + assert tuple(item.reason_code for item in observations) == _REASON_CODES + assert tuple(item.source_reason for item in observations) == _SOURCE_REASONS + assert result.evaluations_completed == 1 + assert result.no_impact_count == 1 + assert result.failed_count == 5 + assert "sensitive raw exception text" not in str(observations[-1].to_wire()) + + +def test_wire_shape_uses_ordered_lists_and_exact_identity_fields() -> None: + observations: list[CapabilitySampleObservation] = [] + optimize_capability( + _profile(), + _request(samples=1, candidates=1), + _complete, + hooks=CapabilityOptimizationHooks(observation_sink=observations.append), + ) + + wire = observations[0].to_wire() + assert list(wire) == [ + "schema_version", + "problem_id", + "attempt_ordinal", + "attempted_count", + "total_count", + "candidate_ordinal", + "club_candidate_ordinal", + "sample_ordinal", + "club_id", + "parameters", + "source_status", + "effective_status", + "reason_code", + "source_reason", + "metrics", + ] + assert [item["parameter_id"] for item in wire["parameters"]] == [ + "speed", + "direction", + ] + assert [item["metric_id"] for item in wire["metrics"]] == [ + "carry_offline", + "carry_distance", + ] + + +def test_mutated_nonfinite_evaluation_is_an_invalid_evaluator_result() -> None: + evaluation = _complete("iron-7", {"speed": 48, "direction": 0}) + object.__setattr__(evaluation.metrics[1], "value", math.nan) + observations: list[CapabilitySampleObservation] = [] + + result = optimize_capability( + _profile(), + _request(samples=1, candidates=1), + lambda _club_id, _parameters: evaluation, + hooks=CapabilityOptimizationHooks(observation_sink=observations.append), + ) + + assert result.failed_count == 1 + assert observations[0].source_status is None + assert observations[0].effective_status == "failed" + assert observations[0].reason_code == "invalid_evaluator_result" + assert observations[0].metrics == () + + +def test_unknown_metric_id_is_an_invalid_evaluator_result() -> None: + evaluation = _complete("iron-7", {"speed": 48, "direction": 0}) + object.__setattr__(evaluation.metrics[0], "metric_id", "hostile_metric") + observations: list[CapabilitySampleObservation] = [] + + result = optimize_capability( + _profile(), + _request(samples=1, candidates=1), + lambda _club_id, _parameters: evaluation, + hooks=CapabilityOptimizationHooks(observation_sink=observations.append), + ) + + assert result.failed_count == 1 + assert observations[0].source_status is None + assert observations[0].effective_status == "failed" + assert observations[0].reason_code == "invalid_evaluator_result" + assert observations[0].metrics == () + + +def test_cancellation_is_typed_and_stops_before_next_attempt() -> None: + observations: list[CapabilitySampleObservation] = [] + request = _request(samples=4, candidates=2) + hooks = CapabilityOptimizationHooks( + observation_sink=observations.append, + should_cancel=lambda: len(observations) >= 2, + ) + + with pytest.raises(CapabilityOptimizationCancelled) as caught: + optimize_capability(_profile(), request, _complete, hooks=hooks) + + assert caught.value.attempted_count == 2 + assert caught.value.total_count == 8 + assert len(observations) == 2 + + +@pytest.mark.parametrize("attempted,total", [(-1, 2), (True, 2), (2, 1), (0, 1.5)]) +def test_cancellation_progress_counts_fail_closed(attempted: Any, total: Any) -> None: + with pytest.raises(ValueError): + CapabilityOptimizationCancelled(attempted, total) + + +def test_sink_exceptions_propagate_without_additional_evaluations() -> None: + class SinkFailure(RuntimeError): + pass + + calls = 0 + + def evaluator(club_id: str, parameters: dict[str, float]) -> SolverEvaluation: + nonlocal calls + calls += 1 + return _complete(club_id, parameters) + + def sink(_observation: CapabilitySampleObservation) -> None: + raise SinkFailure("consumer failed") + + with pytest.raises(SinkFailure, match="consumer failed"): + optimize_capability( + _profile(), + _request(samples=3, candidates=2), + evaluator, + hooks=CapabilityOptimizationHooks(observation_sink=sink), + ) + + assert calls == 1 + + +def test_empty_hooks_preserve_legacy_call_behavior() -> None: + profile = _profile() + request = _request() + + assert optimize_capability(profile, request, _complete) == optimize_capability( + profile, + request, + _complete, + hooks=CapabilityOptimizationHooks(), + ) diff --git a/src/shared/python/swing_sim/flight/tests/test_capability_observation_status_contract.py b/src/shared/python/swing_sim/flight/tests/test_capability_observation_status_contract.py new file mode 100644 index 0000000000..b412f4aca7 --- /dev/null +++ b/src/shared/python/swing_sim/flight/tests/test_capability_observation_status_contract.py @@ -0,0 +1,112 @@ +"""Adversarial status and metric invariants for capability observations.""" + +from __future__ import annotations + +import pytest + +from shared.python.swing_sim.flight.capability_observation import ( + CapabilitySampleMetric, + CapabilitySampleObservation, + CapabilitySampleParameter, + CapabilitySampleStatus, +) +from shared.python.swing_sim.flight.inverse_contract import EvaluationStatus +from shared.python.swing_sim.flight.result_contract import FlightMetricId + + +def _observation( + source_status: EvaluationStatus | None, + effective_status: CapabilitySampleStatus, + metrics: tuple[CapabilitySampleMetric, ...], + reason_code: str | None, +) -> CapabilitySampleObservation: + return CapabilitySampleObservation( + "status-contract", + 0, + 1, + 1, + 0, + 0, + 0, + "iron-7", + (CapabilitySampleParameter("speed", "m/s", 40.0, 41.0),), + source_status, + effective_status, + reason_code, + reason_code if source_status not in {None, EvaluationStatus.COMPLETE} else None, + metrics, + ) + + +def _metric(metric_id: FlightMetricId) -> CapabilitySampleMetric: + return CapabilitySampleMetric(metric_id, 1.0, "status.fixture") + + +@pytest.mark.parametrize( + ("source_status", "effective_status", "reason_code"), + [ + (EvaluationStatus.NO_IMPACT, CapabilitySampleStatus.NO_IMPACT, "missed_ball"), + (EvaluationStatus.FAILED, CapabilitySampleStatus.FAILED, "flight_failed"), + ( + EvaluationStatus.NONCONVERGED, + CapabilitySampleStatus.FAILED, + "iteration_limit", + ), + (None, CapabilitySampleStatus.FAILED, "invalid_evaluator_result"), + (None, CapabilitySampleStatus.FAILED, "evaluator_exception"), + ], +) +def test_noncomplete_statuses_reject_metrics( + source_status: EvaluationStatus | None, + effective_status: CapabilitySampleStatus, + reason_code: str, +) -> None: + with pytest.raises(ValueError, match="metrics"): + _observation( + source_status, + effective_status, + (_metric(FlightMetricId.APEX_HEIGHT),), + reason_code, + ) + + +@pytest.mark.parametrize( + "metrics", + [ + (), + (_metric(FlightMetricId.CARRY_DISTANCE),), + (_metric(FlightMetricId.CARRY_OFFLINE),), + (_metric(FlightMetricId.APEX_HEIGHT),), + ], +) +def test_complete_status_requires_both_landing_metrics( + metrics: tuple[CapabilitySampleMetric, ...], +) -> None: + with pytest.raises(ValueError, match="carry_distance and carry_offline"): + _observation( + EvaluationStatus.COMPLETE, + CapabilitySampleStatus.COMPLETE, + metrics, + None, + ) + + +def test_missing_landing_failure_may_retain_valid_complete_source_metrics() -> None: + observation = _observation( + EvaluationStatus.COMPLETE, + CapabilitySampleStatus.FAILED, + (_metric(FlightMetricId.APEX_HEIGHT),), + "missing_required_landing_metrics", + ) + assert observation.metrics[0].metric_id is FlightMetricId.APEX_HEIGHT + + with pytest.raises(ValueError, match="missing at least one"): + _observation( + EvaluationStatus.COMPLETE, + CapabilitySampleStatus.FAILED, + ( + _metric(FlightMetricId.CARRY_DISTANCE), + _metric(FlightMetricId.CARRY_OFFLINE), + ), + "missing_required_landing_metrics", + ) diff --git a/src/shared/python/swing_sim/flight/tests/test_contract_api.py b/src/shared/python/swing_sim/flight/tests/test_contract_api.py index 582ee55379..69fc3dece5 100644 --- a/src/shared/python/swing_sim/flight/tests/test_contract_api.py +++ b/src/shared/python/swing_sim/flight/tests/test_contract_api.py @@ -18,9 +18,19 @@ "DEFAULT_BACKSPIN_AXIS", "AvailabilityReason", "BallFlightModel", + "CAPABILITY_SAMPLE_OBSERVATION_SCHEMA_VERSION", + "CancellationCheck", "CapabilityEvaluator", + "CapabilityFlightEvaluatorConfig", + "CapabilitySpinDefault", + "CapabilityOptimizationCancelled", + "CapabilityOptimizationHooks", "CapabilityObjective", "CapabilityParameter", + "CapabilitySampleMetric", + "CapabilitySampleObservation", + "CapabilitySampleParameter", + "CapabilitySampleStatus", "ClubCapability", "ConstantCoefficientModel", "ConstantCoefficientSpec", @@ -64,6 +74,7 @@ "OptimizationAlternative", "OptimizationRequest", "OptimizationResult", + "ObservationSink", "ParameterValue", "PlayerCapabilityProfile", "PerfectInformationCounterfactual", @@ -101,6 +112,7 @@ "launch_direction_sign_labels", "launch_direction_to_flight_azimuth", "migrate_launch_direction_mapping", + "make_capability_flight_evaluator", "optimize_capability", "simulate", "simulate_trajectory_rust", @@ -176,12 +188,18 @@ def test_value_types_are_frozen_dataclasses() -> None: flight.StrategySummary, flight.WindStrategyAnalysis, flight.CapabilityParameter, + flight.CapabilityFlightEvaluatorConfig, + flight.CapabilitySpinDefault, flight.ClubCapability, flight.PlayerCapabilityProfile, flight.TargetDefinition, flight.OptimizationRequest, flight.OptimizationAlternative, flight.OptimizationResult, + flight.CapabilityOptimizationHooks, + flight.CapabilitySampleMetric, + flight.CapabilitySampleObservation, + flight.CapabilitySampleParameter, ): assert dataclasses.is_dataclass(cls), f"{cls.__name__} not a dataclass" dataclass_type = cast(Any, cls) diff --git a/src/shared/python/swing_sim/flight/tests/test_result_metrics.py b/src/shared/python/swing_sim/flight/tests/test_result_metrics.py index 14cf22a560..f4f400ce9c 100644 --- a/src/shared/python/swing_sim/flight/tests/test_result_metrics.py +++ b/src/shared/python/swing_sim/flight/tests/test_result_metrics.py @@ -6,12 +6,17 @@ import json import math from pathlib import Path +from typing import cast import pytest +from shared.python.swing_sim.flight.impact_solution_adapter import ( + _spin_axis_tilt_deg as impact_spin_axis_tilt_deg, +) from shared.python.swing_sim.flight.result_contract import ( AvailabilityReason, FlightMetricId, + SignRule, ValueStatus, flight_metric_catalog, ) @@ -22,6 +27,7 @@ MetricTrajectoryPoint, derive_flight_metric_result, ) +from shared.python.swing_sim.flight.spin_axis_convention import spin_axis_tilt_deg FIXTURE = ( Path(__file__).parents[5] @@ -107,7 +113,39 @@ def test_qualified_ground_output_is_never_inferred_from_carry() -> None: result.value(FlightMetricId.TOTAL_DISTANCE).provenance == "qualified-ground/v1" ) with pytest.raises(ValueError, match="bounce_count must be an integer"): - GroundModelResult("invalid", 28.0, 2.7, 1.5, 4.5) # type: ignore[arg-type] + GroundModelResult(cast(int, "invalid"), 28.0, 2.7, 1.5, 4.5) + + +def test_positive_spin_axis_tilt_uses_fade_right_convention() -> None: + tilt = math.radians(10.0) + source = _analytic_inputs() + inputs = FlightMetricInputs( + source.trajectory, + (0.0, -1000.0 * math.sin(tilt), 1000.0 * math.cos(tilt)), + source.target_position_m, + ) + + result = derive_flight_metric_result(inputs, _manifest()) + + assert result.scalar(FlightMetricId.SPIN_AXIS_TILT) == pytest.approx(10.0) + assert ( + flight_metric_catalog().definition(FlightMetricId.SPIN_AXIS_TILT).sign_rule + is SignRule.POSITIVE_RIGHT + ) + + +def test_spin_axis_tilt_projects_out_gyro_spin_in_every_producer() -> None: + spin = (500.0, -100.0, 1000.0) + expected = spin_axis_tilt_deg(spin) + source = _analytic_inputs() + result = derive_flight_metric_result( + FlightMetricInputs(source.trajectory, spin, source.target_position_m), + _manifest(), + ) + + assert expected == pytest.approx(math.degrees(math.atan2(100.0, 1000.0))) + assert impact_spin_axis_tilt_deg(spin) == pytest.approx(expected) + assert result.scalar(FlightMetricId.SPIN_AXIS_TILT) == pytest.approx(expected) def test_degenerate_trajectories_return_reasons_and_invalid_order_fails() -> None: diff --git a/src/shared/python/swing_sim/flight/tests/test_wind_uncertainty.py b/src/shared/python/swing_sim/flight/tests/test_wind_uncertainty.py index e30968476b..8500aa93a9 100644 --- a/src/shared/python/swing_sim/flight/tests/test_wind_uncertainty.py +++ b/src/shared/python/swing_sim/flight/tests/test_wind_uncertainty.py @@ -4,6 +4,7 @@ import json import math +import threading from pathlib import Path import numpy as np @@ -25,6 +26,10 @@ analyze_wind_strategies, sample_wind_trials, ) +from shared.python.swing_sim.flight.wind_strategy import ( + WindStrategyCancelledError, + WindStrategyProgress, +) from shared.python.swing_sim.flight.wind_strategy_metrics import ( summarize_strategy_outcomes, ) @@ -148,6 +153,45 @@ def test_strategy_analysis_returns_scatter_cost_and_common_random_regret() -> No assert len({item.true_wind for item in paired}) == 1 +def test_strategy_analysis_reports_exact_outcome_progress() -> None: + launch = LaunchConditions.from_imperial(150.0, 12.0, 2500.0) + request = StrategyAnalysisRequest( + uncertainty=_spec(3), + strategies=( + WindStrategy("straight", "Straight", launch), + WindStrategy("aimed", "Aimed", launch, math.radians(0.1)), + ), + target=TargetPoint(230.0, 0.0), + ) + reports: list[WindStrategyProgress] = [] + + result = analyze_wind_strategies(request, progress_cb=reports.append) + + assert len(result.outcomes) == 6 + assert reports == [WindStrategyProgress(index, 6) for index in range(7)] + + +def test_strategy_analysis_cancellation_is_cooperative_between_outcomes() -> None: + launch = LaunchConditions.from_imperial(150.0, 12.0, 2500.0) + request = StrategyAnalysisRequest( + uncertainty=_spec(4), + strategies=(WindStrategy("straight", "Straight", launch),), + target=TargetPoint(230.0, 0.0), + ) + cancel_event = threading.Event() + + def cancel_after_first(report: WindStrategyProgress) -> None: + if report.completed_outcomes == 1: + cancel_event.set() + + with pytest.raises(WindStrategyCancelledError): + analyze_wind_strategies( + request, + progress_cb=cancel_after_first, + cancel_event=cancel_event, + ) + + def test_nonconverged_flights_receive_declared_failure_cost() -> None: launch = LaunchConditions.from_imperial(150.0, 45.0, 2500.0) request = StrategyAnalysisRequest( diff --git a/src/shared/python/swing_sim/flight/wind_strategy.py b/src/shared/python/swing_sim/flight/wind_strategy.py index 69e812ec57..093a696132 100644 --- a/src/shared/python/swing_sim/flight/wind_strategy.py +++ b/src/shared/python/swing_sim/flight/wind_strategy.py @@ -3,6 +3,8 @@ from __future__ import annotations import math +import threading +from collections.abc import Callable from dataclasses import dataclass, replace from typing import Literal @@ -16,6 +18,36 @@ _GROUND_TOLERANCE_M = 1e-5 +class WindStrategyCancelledError(RuntimeError): + """Raised when a wind-strategy ensemble is cooperatively cancelled.""" + + +@dataclass(frozen=True) +class WindStrategyProgress: + """Exact completed-outcome count for one ensemble run.""" + + completed_outcomes: int + total_outcomes: int + + def __post_init__(self) -> None: + if not 0 <= self.completed_outcomes <= self.total_outcomes: + raise ValueError("progress must satisfy 0 <= completed <= total") + + +@dataclass(frozen=True) +class _RunControl: + progress_cb: Callable[[WindStrategyProgress], None] | None + cancel_event: threading.Event | None + + def check_cancelled(self) -> None: + if self.cancel_event is not None and self.cancel_event.is_set(): + raise WindStrategyCancelledError("wind strategy analysis cancelled") + + def report(self, completed: int, total: int) -> None: + if self.progress_cb is not None: + self.progress_cb(WindStrategyProgress(completed, total)) + + def _positive_finite(value: float, name: str) -> None: if not math.isfinite(value) or value <= 0.0: raise ValueError(f"{name} must be finite and positive") @@ -269,9 +301,13 @@ def _counterfactual(result: _SimulationResult) -> PerfectInformationCounterfactu def _evaluate_strategy( - context: _TrialContext, strategy: WindStrategy + context: _TrialContext, + strategy: WindStrategy, + control: _RunControl, ) -> StrategyShotOutcome: + control.check_cancelled() actual = _safe_simulate_policy(context, strategy, context.estimated_wind) + control.check_cancelled() perfect = _safe_simulate_policy(context, strategy, context.true_wind) return StrategyShotOutcome( context.trial.trial_index, @@ -292,24 +328,35 @@ def _evaluate_strategy( def _evaluate( request: StrategyAnalysisRequest, trials: tuple[WindTrial, ...], + control: _RunControl, ) -> tuple[StrategyShotOutcome, ...]: outcomes: list[StrategyShotOutcome] = [] + total = len(trials) * len(request.strategies) + control.report(0, total) for trial in trials: + control.check_cancelled() true_wind = trial.true_scenario(request.uncertainty.provenance) estimated_wind = trial.estimated_scenario(request.uncertainty.provenance) context = _TrialContext(request, trial, true_wind, estimated_wind) - outcomes.extend( - _evaluate_strategy(context, item) for item in request.strategies - ) + for strategy in request.strategies: + outcomes.append(_evaluate_strategy(context, strategy, control)) + control.report(len(outcomes), total) return tuple(outcomes) -def analyze_wind_strategies(request: StrategyAnalysisRequest) -> WindStrategyAnalysis: +def analyze_wind_strategies( + request: StrategyAnalysisRequest, + *, + progress_cb: Callable[[WindStrategyProgress], None] | None = None, + cancel_event: threading.Event | None = None, +) -> WindStrategyAnalysis: """Run paired strategy trials and summarize actual and counterfactual risk.""" from .wind_strategy_metrics import summarize_strategy_outcomes + control = _RunControl(progress_cb, cancel_event) + control.check_cancelled() trials = sample_wind_trials(request.uncertainty) - outcomes = _evaluate(request, trials) + outcomes = _evaluate(request, trials, control) return WindStrategyAnalysis( schema_version=WIND_STRATEGY_ANALYSIS_SCHEMA_VERSION, provenance=request.uncertainty.provenance, @@ -331,5 +378,7 @@ def analyze_wind_strategies(request: StrategyAnalysisRequest) -> WindStrategyAna "TargetPoint", "WindStrategy", "WindStrategyAnalysis", + "WindStrategyCancelledError", + "WindStrategyProgress", "analyze_wind_strategies", ] diff --git a/tests/ops/test_maturin_swing_core_workflow.py b/tests/ops/test_maturin_swing_core_workflow.py new file mode 100644 index 0000000000..e07992aac0 --- /dev/null +++ b/tests/ops/test_maturin_swing_core_workflow.py @@ -0,0 +1,29 @@ +"""Regression tests for the swing-core maturin workflow.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "maturin-swing-core.yml" + + +def _workflow() -> dict[str, Any]: + return cast(dict[str, Any], yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))) + + +@pytest.mark.unit +def test_swing_core_parity_disables_unrelated_pytest_plugin_autoload() -> None: + """A Rust-only parity lane must not import GUI plugins from runner caches.""" + workflow = _workflow() + parity_step = next( + step + for step in workflow["jobs"]["parity-gate"]["steps"] + if step.get("name") == "Run Rust<->Python parity suite (gate, non-skipped)" + ) + + assert parity_step["env"]["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] == "1" diff --git a/tests/rate_of_closure/test_campaign_release_manifest.py b/tests/rate_of_closure/test_campaign_release_manifest.py new file mode 100644 index 0000000000..5b0f12e1fc --- /dev/null +++ b/tests/rate_of_closure/test_campaign_release_manifest.py @@ -0,0 +1,132 @@ +"""Contract tests for the Rate of Closure campaign release manifest.""" + +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from scripts.rate_campaign_manifest import ( + CampaignManifest, + load_campaign_manifest, + validate_repository_evidence, +) + +REPO_ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = REPO_ROOT / "docs" / "release" / "rate_of_closure_campaign.v1.json" +PRIMARY_ISSUES = { + 4103, + 4120, + 4125, + 4130, + 4142, + 4146, + 4158, + 4181, + 4189, + 4191, + 4201, + 4218, + 4234, + 4260, + 4267, +} + + +@pytest.fixture +def manifest() -> CampaignManifest: + """Load the checked-in campaign authority.""" + return load_campaign_manifest(MANIFEST_PATH) + + +@pytest.mark.unit +def test_manifest_covers_primary_campaign_issues( + manifest: CampaignManifest, +) -> None: + """Every primary campaign program must have one authoritative record.""" + assert {program.issue for program in manifest.programs} == PRIMARY_ISSUES + + +@pytest.mark.unit +def test_manifest_distinguishes_delivery_stages( + manifest: CampaignManifest, +) -> None: + """A feature-stack implementation must not masquerade as a release.""" + stages = {stage.value for stage in manifest.release_stage_definitions} + assert stages == { + "specified_only", + "implemented_on_feature_stack", + "protected_merged_to_parent", + "released_to_main", + } + assert all(program.release.sha is None for program in manifest.programs) + assert all( + program.release.status.value == "not_released" for program in manifest.programs + ) + + +@pytest.mark.unit +def test_released_state_requires_main_sha(manifest: CampaignManifest) -> None: + """Contradictory release claims must fail closed.""" + payload = manifest.model_dump(mode="json") + payload["programs"][0]["delivery_stage"] = "released_to_main" + payload["programs"][0]["release"] = { + "status": "released", + "branch": "main", + "sha": None, + } + with pytest.raises(ValidationError, match="40-character release SHA"): + CampaignManifest.model_validate(payload) + + +@pytest.mark.unit +def test_campaign_release_requires_every_program_release( + manifest: CampaignManifest, +) -> None: + """An umbrella release cannot contradict its component programs.""" + payload = manifest.model_dump(mode="json") + payload["campaign_release"] = { + "status": "released", + "branch": "main", + "sha": "a" * 40, + } + with pytest.raises(ValidationError, match="campaign release"): + CampaignManifest.model_validate(payload) + + +@pytest.mark.unit +def test_placeholders_are_rejected(manifest: CampaignManifest) -> None: + """Narrative placeholders cannot enter release evidence.""" + payload = copy.deepcopy(manifest.model_dump(mode="json")) + payload["programs"][0]["limitations"] = ["TBD after the next run"] + with pytest.raises(ValidationError, match="placeholder"): + CampaignManifest.model_validate(payload) + + +@pytest.mark.unit +def test_dependency_cycles_are_rejected(manifest: CampaignManifest) -> None: + """The declared implementation order must remain acyclic.""" + payload = manifest.model_dump(mode="json") + payload["programs"][0]["depends_on_issues"] = [4120] + with pytest.raises(ValidationError, match="dependency cycle"): + CampaignManifest.model_validate(payload) + + +@pytest.mark.unit +def test_repository_evidence_references_exist( + manifest: CampaignManifest, +) -> None: + """Specs, carrier IDs, and evidence IDs must resolve locally.""" + validate_repository_evidence(manifest, REPO_ROOT) + + +@pytest.mark.unit +def test_schema_is_versioned_and_forbids_extra_fields() -> None: + """The generated consumer schema must be strict and versioned.""" + schema = CampaignManifest.model_json_schema() + assert schema["properties"]["schema_version"]["const"] == ( + "rate-of-closure-campaign/v1" + ) + assert schema["additionalProperties"] is False diff --git a/tests/rate_of_closure/test_capability_gui.py b/tests/rate_of_closure/test_capability_gui.py new file mode 100644 index 0000000000..bd27ab81f2 --- /dev/null +++ b/tests/rate_of_closure/test_capability_gui.py @@ -0,0 +1,88 @@ +"""PyQt capability optimizer workflow and diagnostics.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PyQt6") +pytest.importorskip("pytestqt") + +from rate_of_closure.application.capability_workflow import ( # noqa: E402 + CapabilityWorkflowInputs, +) +from rate_of_closure.ui.pyqt6.capability_controls import ( + CapabilityControls, # noqa: E402 +) +from rate_of_closure.ui.pyqt6.capability_tab import ( # noqa: E402 + CapabilityOptimizationTab, +) + +pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] + + +def test_capability_controls_round_trip_integration_settings(qtbot) -> None: # type: ignore[no-untyped-def] + controls = CapabilityControls() + qtbot.addWidget(controls) + source = CapabilityWorkflowInputs(max_time_s=7.0, trajectory_sample_interval_s=0.02) + + controls.set_inputs(source) + + assert controls.inputs() == source + assert controls._numeric["max_time_s"].isVisibleTo(controls) + assert controls._numeric["trajectory_sample_interval_s"].isVisibleTo(controls) + + +def test_capability_tab_runs_and_exposes_complete_diagnostics(qtbot) -> None: # type: ignore[no-untyped-def] + tab = CapabilityOptimizationTab() + qtbot.addWidget(tab) + tab.controls.set_inputs( + CapabilityWorkflowInputs( + candidate_budget=1, ensemble_size=2, alternatives_count=1 + ) + ) + + tab.run() + qtbot.waitUntil(lambda: tab.status.text().startswith("Completed"), timeout=20_000) + + assert tab.results.isVisibleTo(tab) + assert tab.results.alternatives.rowCount() == 1 + headers = [ + tab.results.alternatives.horizontalHeaderItem(index).text() + for index in range(tab.results.alternatives.columnCount()) + ] + expected_diagnostics = { + "Score", + "Miss CVaR", + "Downside carry", + "Outcomes", + "Failure rate", + "Evidence", + "Pareto", + } + assert expected_diagnostics <= set(headers) + assert tab.results.raw_rows.rowCount() == 2 + assert tab.results.x_axis.count() > 10 + assert tab.results.minimumWidth() >= 520 + axis_labels = [ + tab.results.x_axis.itemText(index) + for index in range(tab.results.x_axis.count()) + ] + assert len(axis_labels) == len(set(axis_labels)) + assert tab.results.plot.toolbar() is not None + assert tab.csv_button.isEnabled() + assert tab.json_button.isEnabled() + assert tab.result_csv_button.isEnabled() + assert tab.result_json_button.isEnabled() + tab.stop() + + +def test_capability_tab_rejects_oversized_interactive_workload(qtbot) -> None: # type: ignore[no-untyped-def] + tab = CapabilityOptimizationTab() + qtbot.addWidget(tab) + tab.controls._integers["candidate_budget"].setValue(1000) + tab.controls._integers["ensemble_size"].setValue(1000) + + tab.run() + + assert "100000" in tab.status.text() + assert tab._worker is None diff --git a/tests/rate_of_closure/test_capability_observation_adapter.py b/tests/rate_of_closure/test_capability_observation_adapter.py new file mode 100644 index 0000000000..24c63c50c8 --- /dev/null +++ b/tests/rate_of_closure/test_capability_observation_adapter.py @@ -0,0 +1,323 @@ +"""Capability observation to scalar-ensemble adapter tests.""" + +from __future__ import annotations + +import hashlib +import json +import math +from dataclasses import replace +from typing import cast + +import pytest + +from rate_of_closure.variation.canonical_numeric_json import canonical_numeric_json +from rate_of_closure.variation.capability_observation_adapter import ( + CapabilityObservationEnsembleBuilder, + build_capability_observation_ensemble, + capability_observation_ensemble_json, +) +from rate_of_closure.variation.scalar_ensemble_contract import ( + ScalarEnsembleDataset, + ScalarEnsembleRow, +) +from shared.python.contracts import ContractViolationError +from shared.python.swing_sim.flight.capability_contract import TargetDefinition +from shared.python.swing_sim.flight.capability_observation import ( + CapabilitySampleMetric, + CapabilitySampleObservation, + CapabilitySampleParameter, + CapabilitySampleStatus, +) +from shared.python.swing_sim.flight.inverse_contract import EvaluationStatus +from shared.python.swing_sim.flight.result_contract import ( + FlightMetricId, + SignRule, + flight_metric_catalog, +) + + +def _observation( + attempt_ordinal: int, effective_status: CapabilitySampleStatus +) -> CapabilitySampleObservation: + complete = effective_status is CapabilitySampleStatus.COMPLETE + club_id = "iron-7" if attempt_ordinal == 0 else "driver" + reason = None if complete else "missed_ball" + metrics = ( + ( + CapabilitySampleMetric( + FlightMetricId.CARRY_DISTANCE, 105.0, "fixture.carry" + ), + CapabilitySampleMetric( + FlightMetricId.CARRY_OFFLINE, 5.0, "fixture.offline" + ), + CapabilitySampleMetric(FlightMetricId.APEX_HEIGHT, 31.0, "fixture.apex"), + ) + if complete + else () + ) + return CapabilitySampleObservation( + problem_id="observer-fixture", + attempt_ordinal=attempt_ordinal, + attempted_count=attempt_ordinal + 1, + total_count=2, + candidate_ordinal=attempt_ordinal, + club_candidate_ordinal=attempt_ordinal, + sample_ordinal=0, + club_id=club_id, + parameters=( + CapabilitySampleParameter( + "ball_speed", "m/s", 50.0, 51.0 + attempt_ordinal + ), + CapabilitySampleParameter( + "launch_angle", "deg", 12.0, 13.0 + attempt_ordinal + ), + ), + source_status=( + EvaluationStatus.COMPLETE + if complete + else EvaluationStatus(effective_status.value) + ), + effective_status=effective_status, + reason_code=reason, + source_reason=reason, + metrics=metrics, + ) + + +TARGET = TargetDefinition("green", 100.0, 2.0, 10.0, 15.0, 16.0) +ASCII_DIGEST = "df36f765afdf508d00a3d264911ce5b6f07e25da3744b187596d67487ea3be5f" # noqa: E501 # pragma: allowlist secret +UNICODE_DIGEST = "18086b5e97d576598bbfa63407b6eda786a3a7ce20509654de282400bd32efd0" # noqa: E501 # pragma: allowlist secret + + +def _build( + observations: tuple[CapabilitySampleObservation, ...], max_rows: int = 4 +) -> ScalarEnsembleDataset: + return build_capability_observation_ensemble( + observations, TARGET, max_rows, "fixture/evaluator-v1" + ) + + +def _assert_schema(dataset: ScalarEnsembleDataset) -> None: + assert [row.row_id for row in dataset.rows] == [ + "series:candidate%3A0%2Fclub%3Airon-7/trial:0", + "series:candidate%3A1%2Fclub%3Adriver/trial:0", + ] + variable_keys = [variable.key for variable in dataset.variables] + assert variable_keys[:4] == [ + "nominal.ball_speed", + "perturbed.ball_speed", + "nominal.launch_angle", + "perturbed.launch_angle", + ] + assert [key for key in variable_keys if key.startswith("metric.")] == [ + f"metric.{definition.metric_id.value}" + for definition in flight_metric_catalog().definitions + if definition.sign_rule is not SignRule.VECTOR_COMPONENTS + ] + assert variable_keys[-6:] == [ + "target_downrange_residual", + "target_lateral_residual", + "target_residual", + "target_signed_distance", + "target_solver_residual", + "target_contains", + ] + + +def _assert_complete_row(complete: ScalarEnsembleRow) -> None: + assert complete.values["nominal.ball_speed"] == 50.0 + assert complete.values["perturbed.ball_speed"] == 51.0 + assert complete.values["metric.carry_distance"] == 105.0 + assert complete.values["target_downrange_residual"] == 5.0 + assert complete.values["target_lateral_residual"] == 3.0 + assert complete.values["target_residual"] == pytest.approx(math.sqrt(34.0)) + assert complete.values["target_signed_distance"] == pytest.approx( + math.sqrt(34.0) - 10.0 + ) + assert complete.values["target_solver_residual"] == pytest.approx( + 0.05 * math.sqrt(34.0) + ) + assert complete.values["target_contains"] == 1.0 + assert complete.values["metric.total_distance"] is None + assert complete.attributes is not None + assert complete.attributes["metric.carry_distance.provenance"] == "fixture.carry" + + +def _assert_no_impact_row(no_impact: ScalarEnsembleRow) -> None: + assert no_impact.cohort == "no_impact" + assert all( + value is None + for key, value in no_impact.values.items() + if key.startswith(("metric.", "target_")) + ) + assert no_impact.attributes is not None + assert no_impact.attributes["metric.carry_distance.provenance"] is None + + +def test_declares_all_values_and_emits_deterministic_nullable_rows() -> None: + dataset = _build( + ( + _observation(1, CapabilitySampleStatus.NO_IMPACT), + _observation(0, CapabilitySampleStatus.COMPLETE), + ) + ) + _assert_schema(dataset) + complete, no_impact = dataset.rows + _assert_complete_row(complete) + _assert_no_impact_row(no_impact) + + +def test_rejects_overflow_instead_of_truncation_and_bounds_max_rows() -> None: + observations = ( + _observation(0, CapabilitySampleStatus.COMPLETE), + _observation(1, CapabilitySampleStatus.FAILED), + ) + with pytest.raises(ContractViolationError, match="exceeds max_rows"): + _build(observations, 1) + for max_rows in (0, -1, 100_001, 1.5, True): + with pytest.raises(ContractViolationError, match="max_rows"): + _build(observations[:1], max_rows) # type: ignore[arg-type] + builder = CapabilityObservationEnsembleBuilder(TARGET, 1, "fixture/evaluator-v1") + builder.accept(observations[0]) + with pytest.raises(ContractViolationError, match="max_rows"): + builder.accept(observations[1]) + assert builder.retained_count == 1 + assert len(builder.build().rows) == 1 + + +def test_builder_rejects_non_contract_observation_without_retaining_it() -> None: + builder = CapabilityObservationEnsembleBuilder(TARGET, 2, "fixture/evaluator-v1") + with pytest.raises(ContractViolationError, match="CapabilitySampleObservation"): + builder.accept(cast(CapabilitySampleObservation, object())) + assert builder.retained_count == 0 + + +def test_rejects_ambiguous_identity_and_parameter_declaration_drift() -> None: + first = _observation(0, CapabilitySampleStatus.COMPLETE) + duplicate = _observation(0, CapabilitySampleStatus.COMPLETE) + with pytest.raises(ContractViolationError, match="attempt_ordinal"): + _build((first, duplicate)) + prefix = replace(first, total_count=3) + gap = replace( + _observation(1, CapabilitySampleStatus.COMPLETE), + attempt_ordinal=2, + attempted_count=3, + total_count=3, + ) + with pytest.raises(ContractViolationError, match="contiguous prefix"): + _build((prefix, gap)) + drift = CapabilitySampleObservation( + **{ + **_observation(1, CapabilitySampleStatus.COMPLETE).__dict__, + "parameters": ( + CapabilitySampleParameter("launch_angle", "deg", 12.0, 13.0), + CapabilitySampleParameter("ball_speed", "mph", 50.0, 51.0), + ), + } + ) + with pytest.raises(ContractViolationError, match="parameter declarations"): + _build((first, drift)) + + +def test_stable_wire_matches_typescript_digest() -> None: + dataset = _build( + ( + _observation(1, CapabilitySampleStatus.NO_IMPACT), + _observation(0, CapabilitySampleStatus.COMPLETE), + ) + ) + digest = hashlib.sha256( + capability_observation_ensemble_json(dataset).encode() + ).hexdigest() + assert digest == ASCII_DIGEST + + +def test_stable_wire_matches_typescript_unicode_digest() -> None: + base = _observation(0, CapabilitySampleStatus.COMPLETE) + unicode_observation = replace( + base, + problem_id="観測-ß", + club_id="ドライバー", + total_count=1, + parameters=( + CapabilitySampleParameter("zeta", "m/s", 1.0, 2.0), + CapabilitySampleParameter("Ω", "deg", 3.0, 4.0), + ), + metrics=( + replace(base.metrics[0], provenance="測定.é"), + *base.metrics[1:], + ), + ) + payload = capability_observation_ensemble_json(_build((unicode_observation,))) + assert "観測-ß" in payload + assert hashlib.sha256(payload.encode()).hexdigest() == UNICODE_DIGEST + + +def test_stable_wire_uses_canonical_numeric_tokens_for_every_float() -> None: + base = _observation(0, CapabilitySampleStatus.COMPLETE) + adversarial = replace( + base, + total_count=1, + parameters=( + CapabilitySampleParameter("positive_half", "1", 1.234567890125, 1e-12), + CapabilitySampleParameter("negative_half", "1", -1.234567890125, -0.0), + CapabilitySampleParameter("threshold", "1", 1e-11, 1e20), + CapabilitySampleParameter( + "half_away", "1", 1.000000000005, -1.000000000005 + ), + ), + ) + + payload = capability_observation_ensemble_json(_build((adversarial,))) + wire = json.loads(payload) + row_values = wire["rows"][0]["values"] + + assert row_values["nominal.positive_half"] == 1.23456789012 + assert row_values["perturbed.positive_half"] == 0 + assert row_values["nominal.negative_half"] == -1.23456789012 + assert row_values["perturbed.negative_half"] == 0 + assert row_values["nominal.threshold"] == 0.00000000001 + assert row_values["perturbed.threshold"] == 100000000000000000000 + assert row_values["nominal.half_away"] == 1.00000000001 + assert row_values["perturbed.half_away"] == -1.00000000001 + assert all( + isinstance(value, int | float) and not isinstance(value, bool) + for value in row_values.values() + if value is not None + ) + assert '"nominal.threshold":0.00000000001' in payload + assert '"perturbed.threshold":100000000000000000000' in payload + + +def test_parameter_labels_only_uppercase_initial_ascii_letters() -> None: + base = _observation(0, CapabilitySampleStatus.COMPLETE) + adversarial = replace( + base, + total_count=1, + parameters=( + CapabilitySampleParameter("ball_speed", "m/s", 1.0, 2.0), + CapabilitySampleParameter("ß_value", "1", 3.0, 4.0), + CapabilitySampleParameter("éCLAIR_rate", "1/s", 5.0, 6.0), + CapabilitySampleParameter("ALPHA_value", "1", 7.0, 8.0), + ), + ) + + dataset = _build((adversarial,)) + labels = { + variable.key: variable.label + for variable in dataset.variables + if variable.key.startswith("nominal.") + } + + assert labels == { + "nominal.ball_speed": "Nominal Ball Speed", + "nominal.ß_value": "Nominal ß Value", + "nominal.éCLAIR_rate": "Nominal éCLAIR Rate", + "nominal.ALPHA_value": "Nominal ALPHA Value", + } + + +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +def test_canonical_numeric_json_rejects_nonfinite_values(value: float) -> None: + with pytest.raises(ValueError, match="finite floats"): + canonical_numeric_json({"value": value}) diff --git a/tests/rate_of_closure/test_capability_result_export.py b/tests/rate_of_closure/test_capability_result_export.py new file mode 100644 index 0000000000..c0ca0378a9 --- /dev/null +++ b/tests/rate_of_closure/test_capability_result_export.py @@ -0,0 +1,102 @@ +"""Capability-result exports retain every engineering diagnostic.""" + +from __future__ import annotations + +import csv +import io +import json + +from rate_of_closure.application.capability_result_export import ( + capability_alternatives_csv, + capability_result_export_json, +) +from rate_of_closure.application.capability_workflow import ( + CapabilityWorkflowInputs, + build_capability_workflow, +) +from rate_of_closure.variation.capability_observation_adapter import ( + CapabilityObservationEnsembleBuilder, +) +from rate_of_closure.variation.scalar_ensemble_contract import ScalarEnsembleDataset +from shared.python.swing_sim.flight.capability_flight_evaluator import ( + make_capability_flight_evaluator, +) +from shared.python.swing_sim.flight.capability_observation import ( + CapabilityOptimizationHooks, +) +from shared.python.swing_sim.flight.capability_optimizer import optimize_capability +from shared.python.swing_sim.flight.capability_result import OptimizationResult + + +def _output() -> tuple[OptimizationResult, ScalarEnsembleDataset]: + document = build_capability_workflow( + CapabilityWorkflowInputs( + candidate_budget=1, ensemble_size=2, alternatives_count=1 + ) + ) + total = document.request.candidate_budget * document.request.ensemble_size + builder = CapabilityObservationEnsembleBuilder( + document.request.target, total, document.profile.provenance + ) + evaluator = make_capability_flight_evaluator( + document.profile, document.request, document.evaluator_config + ) + result = optimize_capability( + document.profile, + document.request, + evaluator, + hooks=CapabilityOptimizationHooks(observation_sink=builder), + ) + return result, builder.build() + + +def test_capability_alternatives_csv_retains_diagnostics_and_units() -> None: + result, dataset = _output() + + source = capability_alternatives_csv(result, dataset) + rows = list(csv.DictReader(io.StringIO(source))) + + assert set(rows[0]) == { + "rank", + "club_id", + "parameters", + "score", + "mean_carry_m", + "expected_miss_m", + "dispersion_rms_m", + "target_hold_probability", + "cvar_miss_m", + "downside_carry_m", + "sample_count", + "successful_count", + "no_impact_count", + "failed_count", + "failure_fraction", + "confidence", + "extrapolated", + "pareto_efficient", + "limiting_constraints", + } + assert "ball_speed=" in rows[0]["parameters"] + assert "m/s" in rows[0]["parameters"] + + +def test_capability_result_json_uses_versioned_export_envelope() -> None: + result, dataset = _output() + + payload = json.loads(capability_result_export_json(result, dataset)) + + assert payload["schema_version"] == "capability-result-export/v1" + alternative = payload["result"]["alternatives"][0] + assert { + "cvar_miss_m", + "downside_carry_m", + "sample_count", + "successful_count", + "no_impact_count", + "failed_count", + "failure_fraction", + "extrapolated", + "pareto_efficient", + } <= alternative.keys() + assert {"parameter_id": "ball_speed", "unit": "m/s"} in payload["parameter_units"] diff --git a/tests/rate_of_closure/test_capability_worker.py b/tests/rate_of_closure/test_capability_worker.py new file mode 100644 index 0000000000..e64e3c1f23 --- /dev/null +++ b/tests/rate_of_closure/test_capability_worker.py @@ -0,0 +1,59 @@ +"""Responsive capability-optimization worker contracts.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PyQt6") +pytest.importorskip("pytestqt") + +from rate_of_closure.application.capability_workflow import ( # noqa: E402 + CapabilityWorkflowInputs, + build_capability_workflow, +) +from rate_of_closure.ui.pyqt6.capability_worker import ( # noqa: E402 + CapabilityOptimizationWorker, +) + +pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] + + +def test_worker_publishes_result_dataset_and_exact_final_progress(qtbot) -> None: # type: ignore[no-untyped-def] + document = build_capability_workflow( + CapabilityWorkflowInputs( + candidate_budget=1, ensemble_size=2, alternatives_count=1 + ) + ) + worker = CapabilityOptimizationWorker(document) + progress: list[tuple[int, int]] = [] + worker.progressed.connect( + lambda completed, total: progress.append((completed, total)) + ) + + with qtbot.waitSignal(worker.succeeded, timeout=20_000) as signal: + worker.start() + + result, dataset = signal.args + assert result.evaluations_attempted == 2 + assert len(dataset.rows) == 2 + assert progress[-1] == (2, 2) + assert worker.wait(5_000) + + +def test_worker_honors_prestart_cancellation_without_partial_publication(qtbot) -> None: # type: ignore[no-untyped-def] + document = build_capability_workflow( + CapabilityWorkflowInputs( + candidate_budget=2, ensemble_size=2, alternatives_count=1 + ) + ) + worker = CapabilityOptimizationWorker(document) + succeeded: list[object] = [] + worker.succeeded.connect(succeeded.append) + worker.cancel() + + with qtbot.waitSignal(worker.cancelled, timeout=5_000) as signal: + worker.start() + + assert signal.args == [0, 4] + assert succeeded == [] + assert worker.wait(5_000) diff --git a/tests/rate_of_closure/test_capability_workflow.py b/tests/rate_of_closure/test_capability_workflow.py new file mode 100644 index 0000000000..6d93180aa2 --- /dev/null +++ b/tests/rate_of_closure/test_capability_workflow.py @@ -0,0 +1,196 @@ +"""Versioned capability-workflow authoring and persistence contracts.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from rate_of_closure.application.capability_workflow import ( + CAPABILITY_WORKFLOW_SCHEMA_VERSION, + CapabilityWorkflowInputs, + build_capability_workflow, + capability_workflow_from_json, + capability_workflow_json, +) +from rate_of_closure.variation.scalar_ensemble_contract import ( + SCALAR_ENSEMBLE_SCHEMA_VERSION, + ScalarCohortDefinition, + ScalarEnsembleDataset, + ScalarEnsembleProvenance, + ScalarEnsembleRow, + ScalarEnsembleStage, + ScalarVariableCategory, + ScalarVariableDefinition, + scalar_ensemble_row_id, +) +from rate_of_closure.variation.scalar_ensemble_io import non_complete_reason_summary + +pytestmark = pytest.mark.unit + +_PARSER_FIXTURE = json.loads( + ( + Path(__file__).parents[2] + / "src/rate_of_closure/web/src/model/__fixtures__" + / "capability_workflow_parser_cases_v1.json" + ).read_text(encoding="utf-8") +) +_PARSER_CASES = _PARSER_FIXTURE["cases"] + + +def _mutated_workflow(case: dict[str, object]) -> str: + payload = json.loads( + capability_workflow_json(build_capability_workflow(CapabilityWorkflowInputs())) + ) + path = case["path"] + assert isinstance(path, list) and path + cursor = payload + for key in path[:-1]: + cursor = cursor[key] + cursor[path[-1]] = case["value"] + return json.dumps(payload) + + +def test_default_driver_workflow_is_model_ready_and_auditable() -> None: + document = build_capability_workflow(CapabilityWorkflowInputs()) + + assert document.schema_version == CAPABILITY_WORKFLOW_SCHEMA_VERSION + assert document.request.club_ids == ("driver",) + assert document.profile.club("driver").parameters[0].parameter_id == "ball_speed" + assert document.request.target.distance_m == pytest.approx(230.0) + assert document.evaluator_config.spin_defaults[0].club_id == "driver" + assert "user-authored" in document.evaluator_config.spin_defaults[0].provenance + + +def test_shared_parser_fixture_schema_is_supported() -> None: + assert _PARSER_FIXTURE["schema_version"] == "capability-workflow-parser-cases/v1" + + +def test_workflow_round_trip_preserves_strict_nested_contracts() -> None: + source = build_capability_workflow( + CapabilityWorkflowInputs( + club_id="driver-fit-a", + target_distance_m=245.0, + target_lateral_m=-4.0, + total_spin_rpm=2250.0, + spin_axis_tilt_deg=-3.5, + candidate_budget=4, + ensemble_size=6, + ) + ) + + encoded = capability_workflow_json(source) + restored = capability_workflow_from_json(encoded) + + assert restored == source + assert json.loads(encoded)["schema_version"] == CAPABILITY_WORKFLOW_SCHEMA_VERSION + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"ball_speed_mps": 0.0}, "ball_speed_mps"), + ({"candidate_budget": 501, "ensemble_size": 201}, "100000"), + ({"alternatives_count": 3, "candidate_budget": 2}, "alternatives_count"), + ({"spin_axis_tilt_deg": 91.0}, "spin_axis_tilt_deg"), + ({"total_spin_rpm": 20_001.0}, "total_spin_rpm"), + ({"max_time_s": 121.0}, "max_time_s"), + ({"seed": 2**31}, "seed"), + ], +) +def test_workflow_rejects_unsafe_or_unrenderable_inputs( + changes: dict[str, object], message: str +) -> None: + values = CapabilityWorkflowInputs().__dict__ | changes + with pytest.raises(ValueError, match=message): + build_capability_workflow(CapabilityWorkflowInputs(**values)) + + +def test_workflow_json_rejects_extra_fields() -> None: + payload = json.loads( + capability_workflow_json(build_capability_workflow(CapabilityWorkflowInputs())) + ) + payload["unexpected"] = True + + with pytest.raises(ValueError, match="fields"): + capability_workflow_from_json(json.dumps(payload)) + + +def test_workflow_json_rejects_spin_default_for_a_different_club() -> None: + payload = json.loads( + capability_workflow_json(build_capability_workflow(CapabilityWorkflowInputs())) + ) + payload["evaluator_config"]["spin_defaults"][0]["club_id"] = "other-club" + + with pytest.raises(ValueError, match="spin default club_ids"): + capability_workflow_from_json(json.dumps(payload)) + + +@pytest.mark.parametrize("case", _PARSER_CASES, ids=lambda case: case["id"]) +def test_shared_workflow_parser_cases_have_strict_types( + case: dict[str, object], +) -> None: + source = _mutated_workflow(case) + + if case["accepted"]: + capability_workflow_from_json(source) + else: + with pytest.raises((TypeError, ValueError)): + capability_workflow_from_json(source) + + +def _reason_dataset( + rows: tuple[tuple[str, str | None], ...], +) -> ScalarEnsembleDataset: + return ScalarEnsembleDataset( + SCALAR_ENSEMBLE_SCHEMA_VERSION, + "reason-example", + ScalarEnsembleProvenance("reason-test/v1", "source/v1", "fixture"), + (ScalarEnsembleStage("input", "Inputs"),), + (ScalarVariableCategory("launch", "Launch"),), + (ScalarVariableDefinition("speed", "Speed", "m/s", "input", "launch"),), + ( + ScalarCohortDefinition("complete", "Complete"), + ScalarCohortDefinition("failed", "Failed"), + ), + tuple( + ScalarEnsembleRow( + scalar_ensemble_row_id(index, "run"), + index, + cohort, + {"speed": 1.0}, + "run", + {"reason_code": reason}, + ) + for index, (cohort, reason) in enumerate(rows) + ), + ) + + +def test_reason_summary_names_a_horizon_timeout_instead_of_implying_breakage() -> None: + dataset = _reason_dataset( + ( + ("complete", None), + ("failed", "no_ground_crossing_before_max_time"), + ("failed", "no_ground_crossing_before_max_time"), + ) + ) + + assert non_complete_reason_summary(dataset) == ( + " Non-complete reasons: no_ground_crossing_before_max_time x2." + ) + + +def test_reason_summary_orders_by_count_then_reason_and_labels_missing() -> None: + dataset = _reason_dataset( + (("failed", None), ("failed", "overflow"), ("failed", "overflow")) + ) + + assert non_complete_reason_summary(dataset) == ( + " Non-complete reasons: overflow x2; unspecified x1." + ) + + +def test_reason_summary_is_empty_when_every_retained_row_completed() -> None: + assert non_complete_reason_summary(_reason_dataset((("complete", None),))) == "" diff --git a/tests/rate_of_closure/test_flight_playback.py b/tests/rate_of_closure/test_flight_playback.py index 03fa2c2229..eeb402442a 100644 --- a/tests/rate_of_closure/test_flight_playback.py +++ b/tests/rate_of_closure/test_flight_playback.py @@ -60,8 +60,32 @@ def test_pyqt_controls_own_one_timer_and_expose_accessible_transport(qtbot) -> N assert timer.isActive() assert controls.play_button.accessibleName() == "Play or Pause Ball Flight" assert controls.scrubber.accessibleName() == "Ball Flight Time" + assert controls.loop_check.accessibleName() == "Loop Ball Flight Playback" + controls.set_looping(True) + assert controls.loop_check.isChecked() controls.jump_to_apex() assert controls.current_time_s() == pytest.approx(1.5) controls.jump_to_landing() assert controls.current_time_s() == pytest.approx(4.0) assert not timer.isActive() + + +def test_pyqt_controls_loop_at_landing_without_stopping(qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] + pytest.importorskip("PyQt6") + pytest.importorskip("pytestqt") + from rate_of_closure.ui.pyqt6.flight_playback_controls import ( + FlightPlaybackControls, + ) + + controls = FlightPlaybackControls() + qtbot.addWidget(controls) + controls.set_timeline(2.0, 1.0) + controls.set_looping(True) + controls._set_time(1.99) # noqa: SLF001 - deterministic transport boundary test + monkeypatch.setattr(controls, "_elapsed_seconds", lambda: 0.02) + + controls.timer().start() + controls._advance() # noqa: SLF001 - exercise the timer callback directly + + assert controls.timer().isActive() + assert controls.current_time_s() == pytest.approx(0.01) diff --git a/tests/rate_of_closure/test_gui_registration.py b/tests/rate_of_closure/test_gui_registration.py index 77576a45e5..347465dc0d 100644 --- a/tests/rate_of_closure/test_gui_registration.py +++ b/tests/rate_of_closure/test_gui_registration.py @@ -3,10 +3,12 @@ from __future__ import annotations import importlib +import json import os import subprocess import sys from pathlib import Path +from typing import Any, cast import pytest @@ -14,11 +16,11 @@ @pytest.fixture -def gui_info() -> dict: +def gui_info() -> dict[str, Any]: """Load GUI registration info from the module.""" from rate_of_closure.gui_registration import get_gui_info - return get_gui_info() + return cast(dict[str, Any], get_gui_info()) class TestGuiRegistration: @@ -49,6 +51,50 @@ def test_declared_module_imports_and_exposes_class(self, gui_info: dict) -> None def test_web_port_is_declared(self, gui_info: dict) -> None: assert gui_info["web"]["port"] == 5193 + def test_documented_web_launcher_direct_path_smoke(self) -> None: + """The documented direct script must import and delegate successfully.""" + repository = Path(__file__).resolve().parents[2] + launcher = repository / "src" / "rate_of_closure" / "launch_web.py" + probe = "\n".join( + ( + "import runpy", + f"launcher_path = {str(launcher)!r}", + "import importlib.util", + f"bootstrap_path = {str(repository / '_bootstrap.py')!r}", + "factory = importlib.util.spec_from_file_location", + "spec = factory('_test_bootstrap', bootstrap_path)", + "assert spec is not None and spec.loader is not None", + "module = importlib.util.module_from_spec(spec)", + "spec.loader.exec_module(module)", + "module.bootstrap(launcher_path)", + "import shared.python.gui_launcher as gui_launcher", + "def successful_launch(*_args, **_kwargs): return 0", + "gui_launcher.launch_web_from_gui_info = successful_launch", + "runpy.run_path(launcher_path, run_name='__main__')", + ) + ) + + completed = subprocess.run( + [sys.executable, "-c", probe], + cwd=repository, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + + def test_web_package_declares_only_supported_release_surfaces(self) -> None: + """Do not advertise a desktop wrapper without its source project.""" + repository = Path(__file__).resolve().parents[2] + web_directory = repository / "src" / "rate_of_closure" / "web" + package_text = (web_directory / "package.json").read_text(encoding="utf-8") + package = json.loads(package_text) + + assert not (web_directory / "src-tauri").exists() + assert all(not name.startswith("tauri") for name in package["scripts"]) + assert "@tauri-apps/cli" not in package["devDependencies"] + def test_optional_rust_accelerator_does_not_warn_during_import(self) -> None: """A normal Python-backend launch must not resemble a crash.""" repository = Path(__file__).resolve().parents[2] diff --git a/tests/rate_of_closure/test_primary_navigation.py b/tests/rate_of_closure/test_primary_navigation.py index 0f52925610..a6bf1fb83b 100644 --- a/tests/rate_of_closure/test_primary_navigation.py +++ b/tests/rate_of_closure/test_primary_navigation.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from typing import Any import pytest @@ -123,9 +124,19 @@ def test_launch_monitor_tab_is_registered_once(qtbot, settings) -> None: # type window.close() +def test_capability_optimizer_tab_is_registered_once(qtbot, settings) -> None: # type: ignore[no-untyped-def] + window = RateOfClosureMainWindow(navigation_settings=settings) + qtbot.addWidget(window) + try: + assert window.primary_tab_ids().count("capability_optimization") == 1 + assert window._tabs.indexOf(window._capability_optimization_tab) >= 0 + finally: + window.close() + + def test_visibility_and_order_round_trip_with_deterministic_active_fallback( - qtbot, settings -) -> None: # type: ignore[no-untyped-def] + qtbot: Any, settings: QSettings +) -> None: first = RateOfClosureMainWindow(navigation_settings=settings) qtbot.addWidget(first) first.set_primary_module_active("simulation") @@ -163,8 +174,8 @@ def test_required_module_cannot_be_hidden(qtbot, settings) -> None: # type: ign def test_restore_defaults_restores_visibility_order_and_active_module( - qtbot, settings -) -> None: # type: ignore[no-untyped-def] + qtbot: Any, settings: QSettings +) -> None: window = RateOfClosureMainWindow(navigation_settings=settings) qtbot.addWidget(window) try: @@ -181,9 +192,9 @@ def test_restore_defaults_restores_visibility_order_and_active_module( window.close() -def test_saved_visibility_ignores_unknown_ids_and_preserves_required_module( - qtbot, settings -) -> None: # type: ignore[no-untyped-def] +def test_saved_visibility_ignores_unknown_ids_and_reveals_new_modules( + qtbot: Any, settings: QSettings +) -> None: settings.setValue( _NAVIGATION_STATE_KEY, json.dumps( @@ -198,10 +209,7 @@ def test_saved_visibility_ignores_unknown_ids_and_preserves_required_module( window = RateOfClosureMainWindow(navigation_settings=settings) qtbot.addWidget(window) try: - assert set(window.visible_primary_tab_ids()) == { - "simulation", - *_REQUIRED_TAB_IDS, - } + assert set(window.visible_primary_tab_ids()) == set(_DEFAULT_TAB_IDS) assert "future_module" not in window.primary_tab_ids() assert window.current_primary_module_id() in window.visible_primary_tab_ids() finally: diff --git a/tests/rate_of_closure/test_python_compatibility.py b/tests/rate_of_closure/test_python_compatibility.py index 7eac6a7e27..e0ce09ee85 100644 --- a/tests/rate_of_closure/test_python_compatibility.py +++ b/tests/rate_of_closure/test_python_compatibility.py @@ -18,6 +18,7 @@ Path("src/rate_of_closure/view_workspace.py"), Path("src/shared/python/swing_sim/conventions/registry.py"), Path("src/shared/python/swing_sim/flight/capability_contract.py"), + Path("src/shared/python/swing_sim/flight/capability_observation.py"), Path("src/shared/python/swing_sim/flight/impact_solution_contract.py"), Path("src/shared/python/swing_sim/flight/inverse_contract.py"), Path("src/shared/python/swing_sim/flight/result_contract.py"), diff --git a/tests/rate_of_closure/test_wind_strategy_panel.py b/tests/rate_of_closure/test_wind_strategy_panel.py new file mode 100644 index 0000000000..2bf81bfaeb --- /dev/null +++ b/tests/rate_of_closure/test_wind_strategy_panel.py @@ -0,0 +1,326 @@ +"""Focused UI tests for the wind-strategy workflow panel.""" + +from __future__ import annotations + +import csv +import io +import math +from dataclasses import replace + +import pytest + +from rate_of_closure.ui.pyqt6.flight_explorer_tab import FlightExplorerTab +from rate_of_closure.ui.pyqt6.main_window import RateOfClosureMainWindow +from rate_of_closure.ui.pyqt6.wind_strategy_launch import ( + WindStrategyLaunchContext, + WindStrategySettings, + build_strategy_request, + scalar_ensemble_csv, +) +from rate_of_closure.ui.pyqt6.wind_strategy_panel import WindStrategyPanel +from rate_of_closure.variation.wind_strategy_plot_adapter import ( + build_wind_strategy_plot_dataset, +) +from shared.python.swing_sim.flight import ( + LaunchConditions, + StrategyAnalysisConfig, + analyze_wind_strategies, +) +from shared.python.swing_sim.solver import ( + SpatialTarget, + SurfaceCircleTolerance, + TargetPoint, +) + + +def _context() -> WindStrategyLaunchContext: + return WindStrategyLaunchContext( + LaunchConditions.from_imperial(145.0, 12.0, 2600.0), + SpatialTarget( + label="Test green", + kind="landing_area", + point=TargetPoint(210.0, 0.0, 4.0), + tolerance=SurfaceCircleTolerance(8.0), + elevation_source="course_surface", + ground_source="test.flat", + ), + "waterloo_penner", + ) + + +def _settings() -> WindStrategySettings: + return WindStrategySettings( + trials=2, + true_speed_mps=4.0, + true_from_bearing_deg=90.0, + speed_bias_mps=-0.5, + speed_std_mps=0.6, + bearing_bias_deg=2.0, + bearing_std_deg=3.0, + correlation=0.25, + aim_gain_deg_per_mps=0.1, + seed=4199, + ) + + +def test_request_uses_current_launch_and_canonical_landing_target() -> None: + context = _context() + + request = build_strategy_request(context, _settings()) + + assert request.strategies[0].launch is context.launch + assert request.target.forward_m == 210.0 + assert request.target.right_m == 4.0 + assert request.analysis.target_radius_m == 8.0 + assert request.uncertainty.estimate_error.correlation == 0.25 + + +def test_generic_csv_preserves_all_rows_variables_and_nulls() -> None: + request = build_strategy_request(_context(), _settings()) + request = replace( + request, + analysis=StrategyAnalysisConfig( + model_name=request.analysis.model_name, + max_time_s=0.01, + failure_cost=request.analysis.failure_cost, + target_radius_m=request.analysis.target_radius_m, + ), + ) + dataset = build_wind_strategy_plot_dataset( + request, analyze_wind_strategies(request) + ) + first = dataset.rows[0] + values = {**first.values, "information_cost_delta": -1.25} + attributes = { + **(first.attributes or {}), + "equals_formula": "=SUM(1,2)", + "plus_formula": "+cmd", + "minus_formula": "-cmd", + "at_formula": "@cmd", + } + dataset = replace( + dataset, + rows=(replace(first, values=values, attributes=attributes), *dataset.rows[1:]), + ) + + rows = list(csv.DictReader(io.StringIO(scalar_ensemble_csv(dataset)))) + + assert len(rows) == len(dataset.rows) + assert set(variable.key for variable in dataset.variables).issubset(rows[0]) + assert {"row_id", "trial_index", "series_id", "cohort"}.issubset(rows[0]) + assert "attribute:actual_failure_reason" in rows[0] + assert rows[0]["actual_landing_forward_m"] == "" + assert rows[0]["attribute:actual_failure_reason"] == "ground not reached" + assert rows[0]["attribute:equals_formula"] == "'=SUM(1,2)" + assert rows[0]["attribute:plus_formula"] == "'+cmd" + assert rows[0]["attribute:minus_formula"] == "'-cmd" + assert rows[0]["attribute:at_formula"] == "'@cmd" + assert rows[0]["information_cost_delta"] == "-1.25" + + first_key, second_key = (item.key for item in dataset.variables[:2]) + variables = ( + replace(dataset.variables[0], key="=formula_key"), + replace(dataset.variables[1], key="'=formula_key"), + *dataset.variables[2:], + ) + unsafe_rows = tuple( + replace( + row, + values={ + "=formula_key": row.values[first_key], + "'=formula_key": row.values[second_key], + **{ + key: value + for key, value in row.values.items() + if key not in (first_key, second_key) + }, + }, + attributes={**(row.attributes or {}), "=attribute_key": "safe"}, + ) + for row in dataset.rows + ) + unsafe_csv = scalar_ensemble_csv( + replace(dataset, variables=variables, rows=unsafe_rows) + ) + headers = next(csv.reader(io.StringIO(unsafe_csv))) + assert "'=formula_key" in headers + assert "''=formula_key" in headers + assert len(headers) == len(set(headers)) + assert "attribute:=attribute_key" in headers + + +def test_panel_exposes_controls_status_summary_scatter_and_export(qtbot) -> None: # type: ignore[no-untyped-def] + panel = WindStrategyPanel(_context) + qtbot.addWidget(panel) + panel.apply_result( + build_strategy_request(_context(), _settings()), + analyze_wind_strategies(build_strategy_request(_context(), _settings())), + ) + + assert panel._run.accessibleName() == "Run Wind Strategy Analysis" + assert panel._input_grid.columnCount() == 4 + assert panel._input_grid.rowCount() == 5 + assert [panel._workspace.tabText(index) for index in range(2)] == [ + "Setup", + "Results", + ] + assert panel._workspace.currentIndex() == 1 + assert panel._status.text().startswith("Completed") + assert panel._summary.rowCount() == 1 + assert panel._x_axis.count() > 20 + assert panel._y_axis.count() == panel._x_axis.count() + assert panel._export.isEnabled() + basis = panel._basis.text() + assert panel._basis.accessibleName() == "Wind Strategy Calculation Basis" + for expected in ( + "model waterloo_penner", + "2 paired trials", + "seed 4199", + "target +210.000 m forward, +4.000 m right", + "hold radius 8.000 m", + "maximum time 10 s", + "time step 0.01 s", + "failure cost 100", + "CVaR alpha 0.9", + "Current Launch +0.100 deg/(m/s)", + ): + assert expected in basis + + +def test_panel_scatter_has_zoom_pan_autofit_and_legend_controls(qtbot) -> None: # type: ignore[no-untyped-def] + panel = WindStrategyPanel(_context) + qtbot.addWidget(panel) + request = build_strategy_request(_context(), _settings()) + panel.apply_result(request, analyze_wind_strategies(request)) + + assert panel._plot.toolbar().actions() + assert panel._plot.zoom_percent() == 100 + panel._plot.zoom_in() + assert panel._plot.zoom_percent() == 125 + panel._plot.auto_fit() + assert panel._plot.zoom_percent() == 100 + panel._plot.set_legend_placement("hidden") + assert panel._plot.legend_placement() == "hidden" + legends = [axis.get_legend() for axis in panel._plot.figure().axes] + assert all(legend is None or not legend.get_visible() for legend in legends) + toolbar = panel._plot.toolbar() + toolbar.push_current() + assert toolbar._nav_stack._elements + panel._y_axis.setCurrentIndex(0) + assert toolbar._nav_stack._elements == [] + + +def test_panel_seed_round_trips_full_uint32_without_coercion(qtbot) -> None: # type: ignore[no-untyped-def] + panel = WindStrategyPanel(_context) + qtbot.addWidget(panel) + + panel._seed.setText("4294967295") + assert panel.settings().seed == 4_294_967_295 + + panel._seed.setText("4294967296") + with pytest.raises(ValueError, match="uint32"): + panel.settings() + + +def test_completed_snapshot_is_invalidated_when_consumed_factors_change(qtbot) -> None: # type: ignore[no-untyped-def] + current = [_context()] + panel = WindStrategyPanel(lambda: current[0]) + qtbot.addWidget(panel) + request = build_strategy_request(current[0], _settings()) + panel.apply_result(request, analyze_wind_strategies(request)) + + panel._spins["true_speed"].setValue(6.0) + assert panel._dataset is None + assert not panel._export.isEnabled() + assert panel._summary.rowCount() == 0 + assert panel._basis.text() == "Calculation basis: no current result." + assert panel._status.text().startswith("Wind inputs changed") + invalidated_status = panel._status.text() + panel._on_progress(1, 2) + panel._on_failed("queued stale failure") + assert panel._status.text() == invalidated_status + + panel.apply_result(request, analyze_wind_strategies(request)) + current[0] = replace(current[0], model_name="penner") + qtbot.waitUntil(lambda: panel._dataset is None, timeout=1_000) + assert panel._status.text().startswith("Launch, target, or flight model changed") + + +def test_panel_run_completes_asynchronously_and_restores_controls(qtbot) -> None: # type: ignore[no-untyped-def] + panel = WindStrategyPanel(_context) + qtbot.addWidget(panel) + panel._trials.setValue(1) + + panel._run.click() + assert not panel._run.isEnabled() + qtbot.waitUntil(panel._run.isEnabled, timeout=10_000) + + assert panel._run.isEnabled() + assert panel._status.text().startswith("Completed 1 outcome") + assert panel._summary.rowCount() == 1 + assert panel._worker is None + panel.stop() + + +def test_panel_stop_cancels_and_joins_running_worker(qtbot) -> None: # type: ignore[no-untyped-def] + panel = WindStrategyPanel(_context) + qtbot.addWidget(panel) + + class StubWorker: + def __init__(self) -> None: + self.cancelled = False + self.wait_calls: list[int | None] = [] + + def cancel(self) -> None: + self.cancelled = True + + def wait(self, timeout: int | None = None) -> bool: + self.wait_calls.append(timeout) + return timeout is None + + worker = StubWorker() + panel._worker = worker + + panel.stop() + + assert worker.cancelled + assert worker.wait_calls == [10_000, None] + assert panel._worker is None + + +def test_flight_explorer_supplies_live_launch_target_and_model(qtbot) -> None: # type: ignore[no-untyped-def] + explorer = FlightExplorerTab() + qtbot.addWidget(explorer) + explorer._direct_spins["launch_angle_deg"].setValue(18.0) + explorer._spatial_target_panel.coordinate_edit("x").setText("205") + + context = explorer._wind_strategy_context() + + assert context.launch.launch_angle == pytest.approx(math.radians(18.0)) + assert context.target.point.app_coordinates_m[0] == 205.0 + assert context.model_name == explorer._model_combo.currentText() + assert explorer._wind_strategy_panel in explorer.findChildren(WindStrategyPanel) + request = build_strategy_request(context, _settings()) + explorer._wind_strategy_panel.apply_result( + request, analyze_wind_strategies(request) + ) + explorer._direct_spins["launch_angle_deg"].setValue(19.0) + qtbot.waitUntil( + lambda: explorer._wind_strategy_panel._dataset is None, + timeout=1_000, + ) + assert explorer._wind_strategy_panel._status.text().startswith("Launch, target") + explorer.stop() + + +def test_main_window_close_explicitly_stops_flight_explorer(qtbot, monkeypatch) -> None: # type: ignore[no-untyped-def] + window = RateOfClosureMainWindow() + qtbot.addWidget(window) + stopped: list[bool] = [] + monkeypatch.setattr( + window._flight_explorer_tab, "stop", lambda: stopped.append(True) + ) + + window.close() + + assert stopped == [True] diff --git a/tests/rate_of_closure/test_wind_strategy_worker.py b/tests/rate_of_closure/test_wind_strategy_worker.py new file mode 100644 index 0000000000..64d884d2a9 --- /dev/null +++ b/tests/rate_of_closure/test_wind_strategy_worker.py @@ -0,0 +1,59 @@ +"""Focused QThread contract tests for wind-strategy execution.""" + +from __future__ import annotations + +import math + +from rate_of_closure.ui.pyqt6.wind_strategy_worker import WindStrategyWorker +from shared.python.swing_sim.flight import ( + LaunchConditions, + ScalarDistribution, + StrategyAnalysisRequest, + TargetPoint, + WindStrategy, + WindUncertaintySpec, +) + + +def _request(trials: int = 2) -> StrategyAnalysisRequest: + launch = LaunchConditions.from_imperial(145.0, 12.0, 2600.0) + return StrategyAnalysisRequest( + uncertainty=WindUncertaintySpec( + trials=trials, + seed=4199, + true_speed_mps=ScalarDistribution("fixed", 4.0, minimum=0.0), + true_from_bearing_deg=ScalarDistribution("fixed", 90.0), + provenance="ui-test", + ), + strategies=( + WindStrategy("current-launch", "Current Launch", launch, math.radians(0.1)), + ), + target=TargetPoint(220.0, 0.0), + ) + + +def test_worker_reports_exact_progress_and_result(qtbot) -> None: # type: ignore[no-untyped-def] + worker = WindStrategyWorker(_request(2)) + reports: list[tuple[int, int]] = [] + worker.progressed.connect(lambda done, total: reports.append((done, total))) + + with qtbot.waitSignal(worker.succeeded, timeout=10_000) as completed: + worker.start() + worker.wait(10_000) + + assert reports == [(0, 2), (1, 2), (2, 2)] + assert len(completed.args[0].outcomes) == 2 + + +def test_worker_cancel_emits_cancelled_without_publishing_result(qtbot) -> None: # type: ignore[no-untyped-def] + worker = WindStrategyWorker(_request()) + successes: list[object] = [] + worker.succeeded.connect(successes.append) + + with qtbot.waitSignal(worker.cancelled, timeout=2_000): + worker.cancel() + worker.start() + worker.wait(2_000) + + assert worker.cancel_event.is_set() + assert successes == []