diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index cf2436beb..6336c6d2e 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -1,5 +1,52 @@ # AGENT_HANDOFF — Tools +## 2026-08-11 Capability-request workspace continuation + +Draft PR [#4348](https://github.com/D-sorganization/Tools/pull/4348) publishes +this bounded child from independently approved implementation head +`5730e74752ffb84ab3560bed6318b7d97b6e627d`, with base +`feat/4144-workspace-variation-study` unchanged. Protected current-head CI, +review, parent landing, integration, and release remain required. + +The remaining independent no-publish blocker on local head `68692bbcb` is +repaired in this child. Interactive projection now accepts only +the exact ordered `ball_speed` (`m/s`), `launch_angle` (`deg`), and +`launch_direction` (`deg`) basis with one 3-by-3 correlation matrix, one club, +and one spin default. `mph`, covariance, reordered parameters, and unsupported +shapes fail closed before projection, panel/tab apply, or whole-File mutation; +there is no implicit conversion or covariance rescaling. + +The prior repair makes both clients retain the complete validated workflow +and overlay only editable controls, preserving accepted evidence and advanced +request policy. Unsupported interactive shapes fail closed. PyQt worker +identity plus generation gates reject late success from cancelled replaced +runs. The shared hostile fixture caps numeric wire magnitude at `1e300`, and +Python overflow follows the normal validation and File/Open rollback path. + +Branch `feat/4197-workspace-capability-request` starts from exact published PR +#4343 head `4ff103d9a6ef886099c180da560e8458d5e20b49`. +Explorer-session v5 embeds the existing strict +`capability-optimization-workflow/v1` input document in PyQt6 and React. It +persists the user-authored profile/club, capability bounds and distributions, +objective, target, fixed-spin evaluator assumptions, integration settings, +budgets, and deterministic seed. It stores no computed result, observation +ensemble, worker/runtime object, or identity beyond user-authored stable IDs. + +Both clients validate the complete file before live mutation. PyQt6 applies +inside the existing rollback boundary; React lifts the full workflow document +to app workspace authority and invalidates stale results when a workspace +replaces it. Legacy v1-v4 sessions require an explicit current capability +fallback, +so migration cannot invent an optimizer request. This is bounded #4197/#4225 +input-specification parity only: it does not claim optimizer execution parity, +wind-aware optimization, saved results, UpstreamDrift qualification, protected +CI/review, integration, or issue completion. The branch must remain local until +the parent stack explicitly authorizes publication. Local qualification passes +71 focused Python workflow/workspace/File/PyQt/manifest tests and 70 focused React +contract/File/UI tests; pinned MyPy, Ruff check/format, TypeScript, +zero-warning ESLint, the 211-module production build, 11 campaign-manifest +tests, docs governance, and manifest-layout validation also pass. + ## 2026-08-11 variation-study workspace protected publication Branch `feat/4144-workspace-variation-study` is published normally as draft PR diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index c4d2f95af..f2aa64f80 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,47 @@ # Rate of Closure Ball-Flight Campaign Handoff +## 2026-08-11 Capability input specification in whole workspaces + +- Draft PR [#4348](https://github.com/D-sorganization/Tools/pull/4348) + publishes this bounded child from independently approved implementation head + `5730e74752ffb84ab3560bed6318b7d97b6e627d`, preserving base + `feat/4144-workspace-variation-study`. Protected current-head CI, review, + parent landing, integration, and release remain required. + +- The remaining no-publish finding against local head `68692bbcb` is repaired + locally. Interactive projection requires the exact ordered `ball_speed` + (`m/s`), `launch_angle` (`deg`), and `launch_direction` (`deg`) basis with a + 3-by-3 correlation matrix, one club, and one spin default. Alternate units, + covariance, reordered parameters, and unsupported shapes fail closed before + projection, UI apply, or File/Open mutation; no conversion/rescaling occurs. +- Earlier findings are also repaired: full-document authority plus editable overlays preserve accepted + evidence and advanced policy; unsupported interactive documents fail closed. + Native identity/generation gates reject stale success, and both parsers cap + numeric wire magnitude at `1e300` through the native File/Open error path. + +- Branch `feat/4197-workspace-capability-request` starts from exact draft + PR #4343 head `4ff103d9a6ef886099c180da560e8458d5e20b49`; it does not modify + or publish the parent branch. +- Explorer-session v5 embeds the established cross-runtime + `capability-optimization-workflow/v1` request. PyQt6 and React round-trip the + editable profile/club, capability ranges/distributions, objective, target, + fixed-spin evaluator assumptions, integration policy, budgets, and seed. +- Full parsing precedes application. Both clients retain the full validated + document while controls overlay only represented fields. Legacy v1-v4 files + require an explicit current capability fallback. Native apply is rollback-safe, + and both clients + invalidate computed results when restored inputs replace the live request. +- Results, observation ensembles, runtime objects, inferred identity, and + optimizer-execution claims remain excluded. Wind-aware optimizer inputs are + not yet user-editable in this workflow and therefore are not fabricated. + #4197/#4225, UpstreamDrift parity, protected CI/review, integration, and + release remain open. +- Local qualification passes 71 focused Python workflow/workspace/File/PyQt/ + manifest tests and 70 focused React contract/File/UI tests; pinned MyPy, Ruff, + TypeScript, zero-warning ESLint, the 211-module production build, 11 + campaign-manifest tests, docs governance, and manifest-layout validation + also pass. + ## 2026-08-11 variation-study workspace protected publication - Branch `feat/4144-workspace-variation-study` is published normally as draft diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json index c64035083..e533168b4 100644 --- a/docs/release/rate_of_closure_campaign.v1.json +++ b/docs/release/rate_of_closure_campaign.v1.json @@ -995,6 +995,14 @@ "kind": "repository_path", "value": "src/rate_of_closure/web/src/model/workspaceVariationSession.ts" }, + { + "kind": "repository_path", + "value": "src/rate_of_closure/application/capability_workflow.py" + }, + { + "kind": "repository_path", + "value": "src/rate_of_closure/web/src/model/capabilityWorkflow.ts" + }, { "kind": "repository_path", "value": "src/rate_of_closure/view_workspace.py" @@ -1045,7 +1053,7 @@ "Unavailable persistence actions remain disabled rather than simulating success.", "The explorer-session v2 child persists ball support/default provenance and the full canonical spatial target on Tools PyQt6 and React, while legacy v1 migration requires an explicit current-state fallback; protected integration remains open.", "The explorer-session v3 child locally persists the canonical torque-profile library, source-derived active selection, passive/prescribed run contract, and joint locks on Tools PyQt6 and React; legacy v1/v2 migration is explicit and conflict-safe, while protected integration remains open.", - "The explorer-session v4 child locally persists authored variation inputs, execution policy, deterministic run specification, and selected output focus on Tools PyQt6 and React; browser Open rechecks latest dirty/fallback state, binds parser mode, rejects stale out-of-order reads, and is superseded by confirmed New/Close operations, while native focus controls retain one valid selection and the focus derives from the complete executor output registry without filtering canonical results; results persistence, optimizer outputs, UpstreamDrift consumers, and protected integration remain open.", + "The explorer-session v5 child locally persists authored variation inputs plus the strict capability optimizer input specification on Tools PyQt6 and React. Full-document authority preserves accepted evidence and advanced policy while controls overlay represented fields; interactive projection requires the exact ordered canonical units and a 3-by-3 correlation basis, rejecting alternate units, covariance, and unsupported shapes before mutation without conversion. Stale native workers cannot publish across generations, and oversized numeric wire values fail closed. Capability results, observation ensembles, runtime objects, wind-aware optimizer inputs, UpstreamDrift consumers, and protected integration remain open.", "Camera tracking, canonical snap views, frame stepping, and Playwright coverage are published through evidence commit 2095e748ddca2d7036bbd49a731528f5634daff9 on draft PR 4298; protected integration remains open.", "Draft PR 4301 publishes issue 4300 constrained toolstrip-popover containment at immutable evidence commit ebd804ff24e7ce5ca58c7d1495c438ab1dcd83b5 as a child of epic 4218; protected integration remains open.", "The bounded issue 4224 default-legend rail slice has immutable local evidence at 83b4baa3be7424777db4dd50883b7a9e45c8ca91 on a child of exact PR 4301 head 5c8efcbe5fcd6f993ef947a85e39852d268780a6; installed React qualification passes while persistence/export qualification, native review, and protected integration remain open." diff --git a/docs/specs/CAPABILITY_OPTIMIZATION.md b/docs/specs/CAPABILITY_OPTIMIZATION.md index bfde3ba83..6c2e6093e 100644 --- a/docs/specs/CAPABILITY_OPTIMIZATION.md +++ b/docs/specs/CAPABILITY_OPTIMIZATION.md @@ -83,15 +83,18 @@ 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, +fields accept only finite JSON numbers with magnitude at most `1e300` (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 +changes invalidate captured output. Native publication also requires matching +worker identity and generation, so a cancelled pre-replacement worker cannot +publish a late success. 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, @@ -99,6 +102,35 @@ spreadsheet-safe lossless CSV, and stable JSON. Duplicate evaluator and target diagnostic labels are stage-qualified in selectors without changing their contract keys. +### Whole-Workspace Input Persistence + +Explorer-session v5 embeds the exact `capability-optimization-workflow/v1` +document as `model_session.data.capability_request`. The nested document is the +sole cross-runtime schema for this input specification; the workspace does not +duplicate individual optimizer fields. It contains only the profile, request, +target, evaluator configuration, search budgets, and deterministic seed needed +to reproduce a request. + +Whole-workspace parsing completes before either UI mutates. PyQt6 applies the +validated request inside the window's rollback boundary, while React retains +the same full document as app-owned state. Both clients overlay only fields +represented by editable controls; accepted provenance, confidence, +correlation, bias, bounds, custom problem policy, target geometry, and spin +provenance survive apply, edit, recapture, save, and reopen exactly. Documents +outside the supported single-club/three-parameter interactive projection fail +closed. Specifically, the interactive basis is exactly `ball_speed` in `m/s`, +`launch_angle` in `deg`, and `launch_direction` in `deg`, in that order, with +one matching 3-by-3 correlation matrix, one club, and one spin default. +Covariance matrices, alternate units such as `mph`, reordered parameters, and +other shapes are rejected before UI mutation; this slice performs no implicit +unit conversion or covariance rescaling. Both clients invalidate previous +computed output. Explorer-session v1-v4 migration requires an explicit current +capability fallback and never invents an optimizer request. +Ranked alternatives, observation ensembles, progress, cancellation/runtime +objects, and inferred player identity are excluded. The current interactive +workflow declares still air and has no editable wind input; workspace restore +must not fabricate wind-aware optimization or execution parity. + ## Interpretation And Limitations - Results are conditional model recommendations, not measured launch-monitor results or guarantees. diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index df4ab2746..681c174b1 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -1,5 +1,50 @@ # AGENT_HANDOFF — rate_of_closure +## 2026-08-11 Explorer-session v5 capability-request mapper + +Draft PR [#4348](https://github.com/D-sorganization/Tools/pull/4348) publishes +this bounded child from independently approved implementation head +`5730e74752ffb84ab3560bed6318b7d97b6e627d`, preserving base +`feat/4144-workspace-variation-study`. Protected current-head CI, review, +parent landing, integration, and release remain required. + +The remaining independent no-publish blocker on local head `68692bbcb` is +repaired locally. PyQt6 and React interactive projection accepts only the exact +ordered `ball_speed` (`m/s`), `launch_angle` (`deg`), and `launch_direction` +(`deg`) basis with one 3-by-3 correlation matrix, one club, and one spin +default. `mph`, covariance, reordered parameters, and unsupported shapes fail +closed before projection, UI apply, or File/Open mutation. No unit conversion +or covariance rescaling is attempted. + +The prior repair retains the full validated workflow as live authority +and overlay only editable fields, preserving accepted evidence and advanced +request policy. Unsupported interactive shapes fail closed. Native result +publication now requires matching worker identity and generation. A shared +hostile fixture caps numeric wire magnitude at `1e300`; Python overflow is a +normal validation error covered through whole-window File/Open rollback. + +The local child `feat/4197-workspace-capability-request` is based on exact +published PR #4343 head `4ff103d9a6ef886099c180da560e8458d5e20b49`. +PyQt6 and React now persist the same existing +`capability-optimization-workflow/v1` input document through explorer-session +v5: profile/club IDs, the three launch capability bounds and distributions, +objective, target, fixed total-spin/spin-axis assumptions, evaluator time +settings, candidate/trial/alternative budgets, and deterministic seed. + +The workspace owns only reproducible request inputs. It deliberately excludes +ranked results, observations, progress, workers, cancellation handles, and +inferred identity. Parsing is strict and precedes mutation. PyQt6 uses the +whole-window rollback boundary and clears result widgets; React moves the full +document to app-owned state and clears stale output after external replacement. +Legacy v1-v4 files require an explicit current capability fallback instead of a +fabricated default. This bounded slice does not claim optimizer execution +parity, wind-aware optimization, result persistence, UpstreamDrift parity, +protected release, or completion of #4197/#4225. +Local qualification passes 71 focused Python workflow/workspace/File/PyQt/ +manifest tests and 70 focused React contract/File/UI tests, plus pinned MyPy, Ruff +check/format, TypeScript, zero-warning ESLint, the 211-module production build, +11 campaign-manifest tests, docs governance, and manifest-layout validation. + ## 2026-08-11 variation-study workspace protected publication Branch `feat/4144-workspace-variation-study` is published normally as draft PR diff --git a/src/rate_of_closure/application/capability_interactive_basis.py b/src/rate_of_closure/application/capability_interactive_basis.py new file mode 100644 index 000000000..3f59a2b85 --- /dev/null +++ b/src/rate_of_closure/application/capability_interactive_basis.py @@ -0,0 +1,32 @@ +"""Exact capability basis supported by the current interactive controls.""" + +from shared.python.swing_sim.flight.capability_contract import ClubCapability + +CANONICAL_INTERACTIVE_PARAMETERS = ( + ("ball_speed", "m/s"), + ("launch_angle", "deg"), + ("launch_direction", "deg"), +) +_INTERACTIVE_DIMENSION = len(CANONICAL_INTERACTIVE_PARAMETERS) + + +def validate_capability_interactive_basis(club: ClubCapability) -> None: + """Reject any basis the controls cannot edit without transformation.""" + if club.matrix_kind != "correlation": + raise ValueError("interactive workflow requires a correlation matrix") + actual = tuple((item.parameter_id, item.unit) for item in club.parameters) + if actual != CANONICAL_INTERACTIVE_PARAMETERS: + raise ValueError( + "interactive workflow requires canonical parameter order and units: " + "ball_speed m/s, launch_angle deg, launch_direction deg" + ) + if len(club.matrix) != _INTERACTIVE_DIMENSION or any( + len(row) != _INTERACTIVE_DIMENSION for row in club.matrix + ): + raise ValueError("interactive workflow requires a 3x3 correlation matrix") + + +__all__ = [ + "CANONICAL_INTERACTIVE_PARAMETERS", + "validate_capability_interactive_basis", +] diff --git a/src/rate_of_closure/application/capability_workflow.py b/src/rate_of_closure/application/capability_workflow.py index 08d3e2ad3..0a9c926ac 100644 --- a/src/rate_of_closure/application/capability_workflow.py +++ b/src/rate_of_closure/application/capability_workflow.py @@ -4,9 +4,13 @@ import json import math +from collections.abc import Mapping from dataclasses import dataclass from typing import Any +from rate_of_closure.application.capability_interactive_basis import ( + validate_capability_interactive_basis, +) from rate_of_closure.application.capability_workflow_wire import ( validate_capability_workflow_wire, ) @@ -310,7 +314,16 @@ def capability_workflow_json(document: CapabilityWorkflowDocument) -> str: 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 capability_workflow_from_wire(json.loads(source)) + + +def capability_workflow_from_wire( + source: Mapping[str, object], +) -> CapabilityWorkflowDocument: + """Parse one exact in-memory workflow payload without JSON round-tripping.""" + if not isinstance(source, Mapping): + raise TypeError("capability workflow must be an object") + payload = validate_capability_workflow_wire(_mutable_wire_value(source)) return CapabilityWorkflowDocument( PlayerCapabilityProfile.from_dict(payload["profile"]), OptimizationRequest.from_dict(payload["request"]), @@ -319,6 +332,15 @@ def capability_workflow_from_json(source: str) -> CapabilityWorkflowDocument: ) +def _mutable_wire_value(value: object) -> Any: + """Detach recursively frozen workspace JSON for strict domain validation.""" + if isinstance(value, Mapping): + return {key: _mutable_wire_value(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_mutable_wire_value(item) for item in value] + return value + + def capability_workflow_inputs( document: CapabilityWorkflowDocument, ) -> CapabilityWorkflowInputs: @@ -331,6 +353,7 @@ def capability_workflow_inputs( "interactive workflow supports exactly one club and spin default" ) club = document.profile.clubs[0] + validate_capability_interactive_basis(club) 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") @@ -368,6 +391,7 @@ def capability_workflow_inputs( "CapabilityWorkflowInputs", "build_capability_workflow", "capability_workflow_from_json", + "capability_workflow_from_wire", "capability_workflow_inputs", "capability_workflow_json", ] diff --git a/src/rate_of_closure/application/capability_workflow_overlay.py b/src/rate_of_closure/application/capability_workflow_overlay.py new file mode 100644 index 000000000..d63b7c084 --- /dev/null +++ b/src/rate_of_closure/application/capability_workflow_overlay.py @@ -0,0 +1,115 @@ +"""Lossless editable overlay for capability workflow documents.""" + +from dataclasses import replace + +from rate_of_closure.application.capability_workflow import ( + CapabilityWorkflowDocument, + CapabilityWorkflowInputs, + capability_workflow_inputs, +) +from shared.python.swing_sim.flight.capability_contract import ( + OptimizationRequest, + PlayerCapabilityProfile, +) +from shared.python.swing_sim.flight.capability_flight_evaluator import ( + CapabilityFlightEvaluatorConfig, +) + + +def _identifier(value: str, name: str) -> str: + parsed = value.strip() + if not parsed: + raise ValueError(f"{name} must be nonempty") + return parsed + + +def _overlay_profile( + profile: PlayerCapabilityProfile, inputs: CapabilityWorkflowInputs +) -> PlayerCapabilityProfile: + values = { + "ball_speed": (inputs.ball_speed_mps, inputs.ball_speed_std_mps), + "launch_angle": (inputs.launch_angle_deg, inputs.launch_angle_std_deg), + "launch_direction": ( + inputs.launch_direction_deg, + inputs.launch_direction_std_deg, + ), + } + source_club = profile.clubs[0] + parameters = tuple( + replace( + parameter, + baseline=values[parameter.parameter_id][0], + standard_deviation=values[parameter.parameter_id][1], + ) + for parameter in source_club.parameters + ) + club_id = _identifier(inputs.club_id, "club_id") + club = replace(source_club, club_id=club_id, parameters=parameters) + return replace( + profile, + profile_id=_identifier(inputs.profile_id, "profile_id"), + clubs=(club,), + ) + + +def _overlay_request( + request: OptimizationRequest, inputs: CapabilityWorkflowInputs, club_id: str +) -> OptimizationRequest: + target = replace( + request.target, + distance_m=inputs.target_distance_m, + lateral_m=inputs.target_lateral_m, + radius_m=inputs.target_radius_m, + ) + return replace( + request, + objective=inputs.objective, + club_ids=(club_id,), + target=target, + candidate_budget=inputs.candidate_budget, + ensemble_size=inputs.ensemble_size, + alternatives_count=inputs.alternatives_count, + seed=inputs.seed, + ) + + +def _overlay_config( + config: CapabilityFlightEvaluatorConfig, + inputs: CapabilityWorkflowInputs, + club_id: str, +) -> CapabilityFlightEvaluatorConfig: + source_spin = config.spin_defaults[0] + spin = replace( + source_spin, + club_id=club_id, + total_spin_rpm=inputs.total_spin_rpm, + spin_axis_tilt_deg=inputs.spin_axis_tilt_deg, + ) + return replace( + config, + max_time_s=inputs.max_time_s, + trajectory_sample_interval_s=inputs.trajectory_sample_interval_s, + spin_defaults=(spin,), + ) + + +def overlay_capability_workflow_inputs( + document: CapabilityWorkflowDocument, + inputs: CapabilityWorkflowInputs, +) -> CapabilityWorkflowDocument: + """Overlay editable controls while retaining the complete validated basis.""" + if not isinstance(document, CapabilityWorkflowDocument): + raise TypeError("document must be a CapabilityWorkflowDocument") + if not isinstance(inputs, CapabilityWorkflowInputs): + raise TypeError("inputs must be CapabilityWorkflowInputs") + capability_workflow_inputs(document) + profile = _overlay_profile(document.profile, inputs) + club_id = profile.clubs[0].club_id + return CapabilityWorkflowDocument( + profile, + _overlay_request(document.request, inputs, club_id), + _overlay_config(document.evaluator_config, inputs, club_id), + ) + + +__all__ = ["overlay_capability_workflow_inputs"] diff --git a/src/rate_of_closure/application/capability_workflow_wire.py b/src/rate_of_closure/application/capability_workflow_wire.py index 308b36c9c..4e9593043 100644 --- a/src/rate_of_closure/application/capability_workflow_wire.py +++ b/src/rate_of_closure/application/capability_workflow_wire.py @@ -38,6 +38,7 @@ "seed", "target", } +MAX_CAPABILITY_WIRE_MAGNITUDE = 1e300 def _record(value: object, fields: set[str], name: str) -> dict[str, Any]: @@ -61,9 +62,16 @@ def _text(value: object, name: str) -> str: 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) + try: + parsed = float(value) + except OverflowError as exc: + raise ValueError(f"{name} must be finite") from exc if not math.isfinite(parsed): raise ValueError(f"{name} must be a finite number") + if abs(parsed) > MAX_CAPABILITY_WIRE_MAGNITUDE: + raise ValueError( + f"{name} magnitude must not exceed {MAX_CAPABILITY_WIRE_MAGNITUDE:g}" + ) return parsed @@ -157,4 +165,4 @@ def validate_capability_workflow_wire(payload: object) -> dict[str, Any]: return document -__all__ = ["validate_capability_workflow_wire"] +__all__ = ["MAX_CAPABILITY_WIRE_MAGNITUDE", "validate_capability_workflow_wire"] diff --git a/src/rate_of_closure/application/workspace_session.py b/src/rate_of_closure/application/workspace_session.py index b274787e9..ecf1f4cf5 100644 --- a/src/rate_of_closure/application/workspace_session.py +++ b/src/rate_of_closure/application/workspace_session.py @@ -18,6 +18,11 @@ from shared.python.swing_sim.ball_setup import BallSupportMode from ._workspace_validation import FrozenJsonValue +from .capability_workflow import ( + CapabilityWorkflowDocument, + capability_workflow_from_wire, + capability_workflow_inputs, +) from .workspace_document import ( VersionedPayload, WorkspaceDocument, @@ -51,7 +56,7 @@ EXPLORER_SESSION_SCHEMA = "rate_of_closure.explorer_session" CLUB_CONFIGURATION_SCHEMA = "rate_of_closure.club_configuration" -SESSION_SCHEMA_VERSION = 4 +SESSION_SCHEMA_VERSION = 5 CLUB_CONFIGURATION_SCHEMA_VERSION = 1 CANONICAL_MODULE_IDS = ( "explorer", @@ -85,7 +90,12 @@ _SESSION_V1_FIELDS = frozenset({"scenario", "units"}) _SESSION_V2_FIELDS = _SESSION_V1_FIELDS | {"simulation_setup"} _SESSION_V3_FIELDS = _SESSION_V2_FIELDS | {"torque_selection"} -_SESSION_FIELDS = _SESSION_V3_FIELDS | {"variation_study"} +_SESSION_V4_FIELDS = _SESSION_V3_FIELDS | {"variation_study"} +_SESSION_FIELDS = _SESSION_V4_FIELDS | {"capability_request"} + + +class LegacyCapabilityMigrationRequired(ValueError): + """Raised when a legacy workspace needs an explicit capability fallback.""" @dataclass(frozen=True) @@ -109,6 +119,7 @@ class ExplorerWorkspaceState: simulation: SimulationWorkspaceState torque: TorqueWorkspaceState variation: VariationWorkspaceState + capability: CapabilityWorkflowDocument module_order: tuple[str, ...] visible_module_ids: tuple[str, ...] active_module_id: str @@ -125,6 +136,9 @@ def __post_init__(self) -> None: raise TypeError("torque must be a TorqueWorkspaceState") if not isinstance(self.variation, VariationWorkspaceState): raise TypeError("variation must be a VariationWorkspaceState") + if not isinstance(self.capability, CapabilityWorkflowDocument): + raise TypeError("capability must be a CapabilityWorkflowDocument") + capability_workflow_inputs(self.capability) varies_tee_height = any( spec.variable_key == _TEE_HEIGHT_VARIATION_KEY for spec in self.variation.plan.noise @@ -221,6 +235,10 @@ def document_from_state( FrozenJsonValue, variation_workspace_to_payload(state.variation), ), + "capability_request": cast( + FrozenJsonValue, + state.capability.to_wire(), + ), }, ), prescribed_torque_profiles=state.torque.profiles, @@ -249,6 +267,7 @@ def state_from_document( legacy_simulation_fallback: SimulationWorkspaceState | None = None, legacy_torque_fallback: TorqueWorkspaceState | None = None, legacy_variation_fallback: VariationWorkspaceState | None = None, + legacy_capability_fallback: CapabilityWorkflowDocument | None = None, ) -> ExplorerWorkspaceState: """Validate a supported whole document before returning applicable state.""" if not isinstance(document, WorkspaceDocument): @@ -259,6 +278,7 @@ def state_from_document( 1, 2, 3, + 4, SESSION_SCHEMA_VERSION, ): raise ValueError("unsupported explorer session payload") @@ -272,6 +292,7 @@ def state_from_document( 1: _SESSION_V1_FIELDS, 2: _SESSION_V2_FIELDS, 3: _SESSION_V3_FIELDS, + 4: _SESSION_V4_FIELDS, SESSION_SCHEMA_VERSION: _SESSION_FIELDS, }[session.schema_version] session_data = _exact_mapping(session.data, session_fields, "model_session.data") @@ -315,7 +336,7 @@ def state_from_document( session_data["torque_selection"], document.prescribed_torque_profiles, ) - if session.schema_version < SESSION_SCHEMA_VERSION: + if session.schema_version < 4: if legacy_variation_fallback is None: raise LegacyVariationMigrationRequired( "legacy model_session omitted variation selection; " @@ -332,6 +353,17 @@ def state_from_document( session_data["variation_study"], document.variation_plan, ) + if session.schema_version < SESSION_SCHEMA_VERSION: + if legacy_capability_fallback is None: + raise LegacyCapabilityMigrationRequired( + "legacy model_session omitted the capability request; " + "an explicit capability migration fallback is required" + ) + capability = legacy_capability_fallback + else: + capability = capability_workflow_from_wire( + cast(Mapping[str, object], session_data["capability_request"]) + ) return ExplorerWorkspaceState( scenario=ImpactScenario(**scenario_data), club=parsed_club, @@ -339,6 +371,7 @@ def state_from_document( simulation=simulation, torque=torque, variation=variation, + capability=capability, module_order=document.layout.module_order, visible_module_ids=document.layout.visible_module_ids, active_module_id=document.layout.active_module_id, @@ -353,6 +386,7 @@ def state_from_document( "EXPLORER_SESSION_SCHEMA", "ExplorerWorkspaceState", "LegacySimulationMigrationRequired", + "LegacyCapabilityMigrationRequired", "LegacyTorqueMigrationRequired", "LegacyVariationMigrationRequired", "SimulationWorkspaceState", diff --git a/src/rate_of_closure/ui/pyqt6/capability_tab.py b/src/rate_of_closure/ui/pyqt6/capability_tab.py index 6a035ec76..0f7f22f4e 100644 --- a/src/rate_of_closure/ui/pyqt6/capability_tab.py +++ b/src/rate_of_closure/ui/pyqt6/capability_tab.py @@ -23,11 +23,15 @@ ) from rate_of_closure.application.capability_workflow import ( CapabilityWorkflowDocument, + CapabilityWorkflowInputs, build_capability_workflow, capability_workflow_from_json, capability_workflow_inputs, capability_workflow_json, ) +from rate_of_closure.application.capability_workflow_overlay import ( + overlay_capability_workflow_inputs, +) 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 @@ -58,6 +62,8 @@ class CapabilityOptimizationTab(QWidget): def __init__(self) -> None: super().__init__() self._worker: CapabilityOptimizationWorker | None = None + self._worker_generation = 0 + self._workspace_document = build_capability_workflow(CapabilityWorkflowInputs()) self._document: CapabilityWorkflowDocument | None = None self._dataset: ScalarEnsembleDataset | None = None self._result: OptimizationResult | None = None @@ -136,6 +142,7 @@ def _connect_signals(self) -> None: self.result_json_button.clicked.connect(self._export_result_json) def _invalidate(self) -> None: + self._worker_generation += 1 if self._worker is not None and self._worker.isRunning(): self._worker.cancel() self._document = None @@ -152,23 +159,95 @@ def run(self) -> None: """Validate the full basis before starting a background calculation.""" self.stop() try: - document = build_capability_workflow(self.controls.inputs()) + document = self.capability_workspace_document() except (TypeError, ValueError) as exc: self.status.setText(f"Invalid inputs: {exc}") return self._document = document + self._workspace_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) + generation = self._worker_generation + worker = self._worker + worker.progressed.connect( + lambda completed, total: self.accept_worker_progress( + worker, generation, completed, total + ) + ) + worker.succeeded.connect( + lambda result, dataset: self.accept_worker_success( + worker, generation, result, dataset + ) + ) + worker.cancelled.connect( + lambda completed, total: self.accept_worker_cancelled( + worker, generation, completed, total + ) + ) + worker.failed.connect( + lambda message: self.accept_worker_failure(worker, generation, message) + ) self._set_running(True) self._worker.start() + def capability_workspace_document(self) -> CapabilityWorkflowDocument: + """Capture only the strict, reproducible optimizer input specification.""" + return overlay_capability_workflow_inputs( + self._workspace_document, self.controls.inputs() + ) + + def apply_capability_workspace_document( + self, document: CapabilityWorkflowDocument + ) -> None: + """Replace inputs atomically and invalidate all prior computed output.""" + inputs = capability_workflow_inputs(document) + self._workspace_document = document + self.controls.set_inputs(inputs) + self._invalidate() + self.status.setText("Workspace optimizer inputs loaded — run when ready.") + + def worker_generation(self) -> int: + """Return the current result-publication generation for deterministic tests.""" + return self._worker_generation + + def _is_current_worker(self, worker: object, generation: int) -> bool: + return worker is self._worker and generation == self._worker_generation + + def accept_worker_progress( + self, worker: object, generation: int, completed: int, total: int + ) -> None: + """Accept progress only from the active worker generation.""" + if self._is_current_worker(worker, generation): + self._on_progress(completed, total) + + def accept_worker_success( + self, + worker: object, + generation: int, + result: OptimizationResult, + dataset: ScalarEnsembleDataset, + ) -> None: + """Publish output only when worker identity and generation remain current.""" + if self._is_current_worker(worker, generation): + self._on_success(result, dataset) + + def accept_worker_cancelled( + self, worker: object, generation: int, completed: int, total: int + ) -> None: + """Accept cancellation only from the active worker generation.""" + if self._is_current_worker(worker, generation): + self._on_cancelled(completed, total) + + def accept_worker_failure( + self, worker: object, generation: int, message: str + ) -> None: + """Accept failure only from the active worker generation.""" + if self._is_current_worker(worker, generation): + self._on_failed(message) + def _set_running(self, running: bool) -> None: self.run_button.setEnabled(not running) self.cancel_button.setEnabled(running) @@ -208,6 +287,7 @@ def cancel(self) -> None: def stop(self) -> None: """Cancel and join the worker so it cannot outlive the application.""" + self._worker_generation += 1 worker = self._worker if worker is None: return @@ -220,9 +300,7 @@ def stop(self) -> None: def _save_workflow(self) -> None: try: - source = capability_workflow_json( - build_capability_workflow(self.controls.inputs()) - ) + source = capability_workflow_json(self.capability_workspace_document()) except (TypeError, ValueError) as exc: self.status.setText(f"Cannot save invalid workflow: {exc}") return @@ -238,7 +316,7 @@ def _load_workflow(self) -> None: document = capability_workflow_from_json( Path(selected).read_text(encoding="utf-8") ) - self.controls.set_inputs(capability_workflow_inputs(document)) + self.apply_capability_workspace_document(document) except (OSError, TypeError, ValueError) as exc: self.status.setText(f"Workflow load failed: {exc}") else: diff --git a/src/rate_of_closure/ui/pyqt6/main_window_file_commands.py b/src/rate_of_closure/ui/pyqt6/main_window_file_commands.py index ac5ac045c..0faf0531c 100644 --- a/src/rate_of_closure/ui/pyqt6/main_window_file_commands.py +++ b/src/rate_of_closure/ui/pyqt6/main_window_file_commands.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: from rate_of_closure.ui.pyqt6.app_toolstrip import ApplicationToolstrip + from rate_of_closure.ui.pyqt6.capability_tab import CapabilityOptimizationTab from rate_of_closure.ui.pyqt6.controls_panel import ControlsPanel from rate_of_closure.ui.pyqt6.simulation_tab import SimulationTab from rate_of_closure.ui.pyqt6.variation_tab import VariationTab @@ -61,6 +62,7 @@ class MainWindowFileCommandsMixin: _controls: ControlsPanel _simulation_tab: SimulationTab _variation_tab: VariationTab + _capability_optimization_tab: CapabilityOptimizationTab _workspace_path: Path | None _workspace_metadata: WorkspaceSessionMetadata _workspace_baseline: str @@ -195,9 +197,10 @@ def _open_workspace_path(self, path: Path) -> None: legacy_simulation = session_version == 1 legacy_torque = session_version < 3 legacy_variation = session_version < 4 + legacy_capability = session_version < 5 current = ( self._capture_workspace_state() - if legacy_torque or legacy_variation + if legacy_torque or legacy_variation or legacy_capability else None ) state = state_from_document( @@ -211,6 +214,9 @@ def _open_workspace_path(self, path: Path) -> None: legacy_variation_fallback=( current.variation if legacy_variation and current else None ), + legacy_capability_fallback=( + current.capability if legacy_capability and current else None + ), ) except (OSError, TypeError, ValueError) as exc: self._show_error("Open Failed", str(exc)) @@ -239,6 +245,8 @@ def _open_workspace_path(self, path: Path) -> None: preserved.append("torque-profile library and selection") if legacy_variation: preserved.append("variation plan and analysis selection") + if legacy_capability: + preserved.append("capability optimizer input specification") suffix = ( "; legacy session preserved " + " plus ".join(preserved) if preserved @@ -281,6 +289,9 @@ def _capture_workspace_state(self) -> ExplorerWorkspaceState: simulation=self._simulation_tab.simulation_workspace_state(), torque=self._simulation_tab.torque_workspace_state(), variation=self._variation_tab.variation_workspace_state(), + capability=( + self._capability_optimization_tab.capability_workspace_document() + ), module_order=module_order, visible_module_ids=visible, active_module_id=_PYQT_TO_CANONICAL[self.current_primary_module_id()], @@ -306,6 +317,9 @@ def _apply_workspace_state_unchecked(self, state: ExplorerWorkspaceState) -> Non self._simulation_tab.apply_simulation_workspace_state(state.simulation) self._simulation_tab.apply_torque_workspace_state(state.torque) self._variation_tab.apply_variation_workspace_state(state.variation) + self._capability_optimization_tab.apply_capability_workspace_document( + state.capability + ) self._simulation_tab.compositor().import_workspace_document( workspace_to_document(state.view_workspace) ) diff --git a/src/rate_of_closure/web/src/App.tsx b/src/rate_of_closure/web/src/App.tsx index b762f74b7..b2120c2d0 100644 --- a/src/rate_of_closure/web/src/App.tsx +++ b/src/rate_of_closure/web/src/App.tsx @@ -86,6 +86,7 @@ export default function App() { }, torque: model.torqueWorkspace, variation: model.variationWorkspace, + capability: model.capabilityWorkflow, modules: workspace.viewState, viewWorkspace: workspace.viewWorkspace, }; @@ -103,6 +104,7 @@ export default function App() { model.setSpatialTarget(next.simulation.spatialTarget); model.setTorqueWorkspace(next.torque); model.setVariationWorkspace(next.variation); + model.setCapabilityWorkflow(next.capability); workspace.setViewState(next.modules); workspace.setViewWorkspace(next.viewWorkspace); }, diff --git a/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx index 1c8c9deeb..55e4f6e6e 100644 --- a/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx +++ b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx @@ -1,10 +1,49 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { runCapabilityOptimization, type CapabilityRunOutput } from "../model/capabilityRun"; import type { CapabilityRunner } from "../model/capabilityWorkerClient"; import { CapabilityOptimizationPanel } from "./CapabilityOptimizationPanel"; +import { + buildCapabilityWorkflow, + capabilityWorkflowFromJson, + capabilityWorkflowToJson, + defaultCapabilityWorkflowInputs, + type CapabilityWorkflowDocument, +} from "../model/capabilityWorkflow"; + +const customWorkflow = (): CapabilityWorkflowDocument => { + const payload = JSON.parse(capabilityWorkflowToJson( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + )); + payload.profile.provenance = "measured/session-42"; + payload.profile.confidence = 0.71; + payload.profile.clubs[0].provenance = "fit/driver-42"; + payload.profile.clubs[0].confidence = 0.63; + payload.profile.clubs[0].matrix = [ + [1, 0.2, 0], [0.2, 1, 0.1], [0, 0.1, 1], + ]; + payload.profile.clubs[0].parameters[0].bias = 0.4; + payload.request.problem_id = "custom-problem-42"; + payload.request.cvar_alpha = 0.83; + payload.request.minimum_success_fraction = 0.64; + payload.request.target.kind = "fairway"; + payload.request.target.band_half_length_m = 21; + payload.request.target.half_width_m = 8; + payload.evaluator_config.spin_defaults[0].provenance = "measured/spin-42"; + return capabilityWorkflowFromJson(JSON.stringify(payload)); +}; + +const noncanonicalWorkflow = (kind: "mph" | "covariance") => { + const payload = JSON.parse(capabilityWorkflowToJson( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + )); + if (kind === "mph") payload.profile.clubs[0].parameters[0].unit = "mph"; + else payload.profile.clubs[0].matrix_kind = "covariance"; + return capabilityWorkflowFromJson(JSON.stringify(payload)); +}; describe("CapabilityOptimizationPanel", () => { it("runs a bounded workflow and exposes alternatives plus raw diagnostics", async () => { @@ -67,4 +106,89 @@ describe("CapabilityOptimizationPanel", () => { expect(runner.mock.calls[0][0].evaluatorConfig.spinDefaults[0].spinAxisTiltDeg) .toBe(-3.5); }); + + it("uses the workspace input authority and invalidates stale output on replacement", async () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + const runner: CapabilityRunner = (document, onProgress) => ({ + promise: Promise.resolve(runCapabilityOptimization(document, onProgress)), + cancel: vi.fn(), + }); + const initial = buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), candidateBudget: 1, + ensembleSize: 1, alternativesCount: 1, + }); + const changed = buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), candidateBudget: 1, + ensembleSize: 1, alternativesCount: 1, targetDistanceM: 199, + }); + const onWorkflowChange = vi.fn(); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Run optimization" })); + expect(await screen.findByRole("table", { + name: "Ranked capability alternatives", + })).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByRole("table", { + name: "Ranked capability alternatives", + })).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Target distance"), { + target: { value: "201" }, + }); + expect(onWorkflowChange).toHaveBeenCalled(); + }); + + it("overlays edits without normalizing accepted non-editable workflow fields", () => { + function Harness() { + const [workflow, setWorkflow] = useState(customWorkflow); + return <> + + + {capabilityWorkflowToJson(workflow)} + + ; + } + render(); + + fireEvent.change(screen.getByLabelText("Target distance"), { + target: { value: "201" }, + }); + + const persisted = JSON.parse( + screen.getByLabelText("Persisted capability workflow").textContent ?? "", + ); + expect(persisted.request.target.distance_m).toBe(201); + expect(persisted.request.target).toMatchObject({ + kind: "fairway", band_half_length_m: 21, half_width_m: 8, + }); + expect(persisted.request).toMatchObject({ + problem_id: "custom-problem-42", cvar_alpha: 0.83, + minimum_success_fraction: 0.64, + }); + expect(persisted.profile).toMatchObject({ + provenance: "measured/session-42", confidence: 0.71, + }); + expect(persisted.profile.clubs[0]).toMatchObject({ + provenance: "fit/driver-42", confidence: 0.63, + matrix: [[1, 0.2, 0], [0.2, 1, 0.1], [0, 0.1, 1]], + }); + expect(persisted.profile.clubs[0].parameters[0].bias).toBe(0.4); + expect(persisted.evaluator_config.spin_defaults[0].provenance) + .toBe("measured/spin-42"); + }); + + it.each([ + ["mph", /unit/i], ["covariance", /correlation/i], + ] as const)("rejects a noncanonical %s workflow before panel apply", (kind, message) => { + expect(() => render( + , + )).toThrow(message); + }); }); diff --git a/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.tsx b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.tsx index 7e17e8868..1fe33d6ce 100644 --- a/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.tsx +++ b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.tsx @@ -1,9 +1,14 @@ -import { useEffect, useState } from "react"; +import { + useEffect, + useState, + type Dispatch, + type SetStateAction, +} from "react"; import { useCapabilityOptimization } from "../hooks/useCapabilityOptimization"; import { - buildCapabilityWorkflow, capabilityWorkflowToJson, + type CapabilityWorkflowDocument, type CapabilityWorkflowInputs, } from "../model/capabilityWorkflow"; import type { CapabilityRunner } from "../model/capabilityWorkerClient"; @@ -77,7 +82,7 @@ function WorkflowActions({ state }: { const [saveError, setSaveError] = useState(null); const save = (): void => { try { downloadText("capability-workflow.json", capabilityWorkflowToJson( - buildCapabilityWorkflow(state.inputs)), "application/json"); setSaveError(null); } + state.document()), "application/json"); setSaveError(null); } catch (reason: unknown) { setSaveError(reason instanceof Error ? reason.message : String(reason)); } }; return <>
@@ -95,10 +100,17 @@ function WorkflowActions({ state }: { className="mt-3 text-xs text-rose-400">{state.error ?? saveError}

}; } -export function CapabilityOptimizationPanel({ runner }: { +export function CapabilityOptimizationPanel({ runner, workflow, onWorkflowChange }: { readonly runner?: CapabilityRunner; + readonly workflow?: CapabilityWorkflowDocument; + readonly onWorkflowChange?: Dispatch>; }): JSX.Element { - const state = useCapabilityOptimization(runner); + if ((workflow === undefined) !== (onWorkflowChange === undefined)) { + throw new TypeError("controlled capability workflow requires a change handler"); + } + const authority = workflow === undefined || onWorkflowChange === undefined + ? undefined : { workflow, onWorkflowChange }; + const state = useCapabilityOptimization(runner, authority); 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.

diff --git a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx index af370253d..4a740166a 100644 --- a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx +++ b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx @@ -128,7 +128,10 @@ export function PrimaryWorkspacePanel(props: WorkspacePanelProps) {
} > - + ); case "plots": diff --git a/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts b/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts index 9c6dbcee3..a086ac1c9 100644 --- a/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts +++ b/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts @@ -1,4 +1,11 @@ -import { useEffect, useRef, useState } from "react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; import type { CapabilityRunOutput } from "../model/capabilityRun"; import { @@ -6,6 +13,8 @@ import { capabilityWorkflowFromJson, capabilityWorkflowInputs, defaultCapabilityWorkflowInputs, + overlayCapabilityWorkflowInputs, + type CapabilityWorkflowDocument, type CapabilityWorkflowInputs, } from "../model/capabilityWorkflow"; import { @@ -15,57 +24,112 @@ import { } from "../model/capabilityWorkerClient"; export interface CapabilityOptimizationState { - readonly inputs: CapabilityWorkflowInputs; readonly output: CapabilityRunOutput | null; - readonly status: string; readonly error: string | null; + 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; + document: () => CapabilityWorkflowDocument; update: (key: keyof CapabilityWorkflowInputs, value: string | number) => void; - run: () => void; cancel: () => void; load: (file: File) => Promise; + run: () => void; + cancel: () => void; + load: (file: File) => Promise; +} + +export interface CapabilityWorkflowAuthority { + readonly workflow: CapabilityWorkflowDocument; + readonly onWorkflowChange: Dispatch>; } +const defaultWorkflow = (): CapabilityWorkflowDocument => + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()); const message = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason); export function useCapabilityOptimization( runner: CapabilityRunner = runCapabilityInWorker, + authority?: CapabilityWorkflowAuthority, ): CapabilityOptimizationState { - const [inputs, setInputs] = useState(defaultCapabilityWorkflowInputs); + const [localWorkflow, setLocalWorkflow] = useState(defaultWorkflow); + const workflow = authority?.workflow ?? localWorkflow; + const setWorkflow = authority?.onWorkflowChange ?? setLocalWorkflow; + const [inputs, setInputs] = useState(() => capabilityWorkflowInputs(workflow)); const [output, setOutput] = useState(null); - const [status, setStatus] = useState("Ready"); const [error, setError] = 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 active = useRef(null); + const runId = useRef(0); + const priorWorkflow = useRef(workflow); const invalidate = (next: string): void => { - runId.current += 1; active.current?.cancel(); active.current = null; + runId.current += 1; + active.current?.cancel(); + active.current = null; setRunning(false); - setOutput(null); setError(null); setStatus(next); setProgress({ completed: 0, total: 0 }); + setOutput(null); + setError(null); + setStatus(next); + setProgress({ completed: 0, total: 0 }); }; useEffect(() => () => { runId.current += 1; active.current?.cancel(); }, []); + useLayoutEffect(() => { + if (priorWorkflow.current !== workflow) { + setInputs(capabilityWorkflowInputs(workflow)); + invalidate("Inputs changed — run again"); + } + priorWorkflow.current = workflow; + }, [workflow]); + const document = (): CapabilityWorkflowDocument => + overlayCapabilityWorkflowInputs(workflow, inputs); const update = (key: keyof CapabilityWorkflowInputs, value: string | number): void => { - invalidate("Inputs changed — run again"); setInputs((current) => ({ ...current, [key]: value })); + invalidate("Inputs changed — run again"); + const next = { ...inputs, [key]: value }; + setInputs(next); + try { setWorkflow(overlayCapabilityWorkflowInputs(workflow, next)); } + catch { /* Keep an invalid draft local until validation or correction. */ } }; const run = (): void => { - invalidate("Validating calculation basis"); const currentRun = ++runId.current; + 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) => { + const current = document(); + setProgress({ completed: 0, + total: current.request.candidateBudget * current.request.ensembleSize }); + setStatus("Running in background"); + const controller = runner(current, (next) => { if (currentRun === runId.current) setProgress(next); }); - active.current = controller; setRunning(true); void controller.promise.then((result) => { + active.current = controller; + setRunning(true); + void controller.promise.then((result) => { if (currentRun !== runId.current) return; - active.current = null; setRunning(false); setOutput(result); setStatus("Completed"); + 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)); + active.current = null; + setRunning(false); + setStatus("Failed"); + setError(message(reason)); }); - } catch (reason: unknown) { setStatus("Invalid inputs"); 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)); } + try { + const parsed = capabilityWorkflowFromJson(await file.text()); + capabilityWorkflowInputs(parsed); + setWorkflow(parsed); + setInputs(capabilityWorkflowInputs(parsed)); + invalidate("Workflow loaded — run when ready"); + } catch (reason: unknown) { setError(message(reason)); } }; - return { inputs, output, status, error, progress, running, + return { inputs, output, status, error, progress, running, document, update, run, cancel: () => invalidate("Cancelled"), load }; } diff --git a/src/rate_of_closure/web/src/hooks/useImpactAppModel.ts b/src/rate_of_closure/web/src/hooks/useImpactAppModel.ts index 76a1fc69c..f986432a0 100644 --- a/src/rate_of_closure/web/src/hooks/useImpactAppModel.ts +++ b/src/rate_of_closure/web/src/hooks/useImpactAppModel.ts @@ -15,6 +15,11 @@ import type { TorqueWorkspaceSnapshot } from "../model/workspaceTorqueSession"; import { loadInitialTorqueWorkspace } from "./useSimulationTorqueWorkspace"; import type { VariationWorkspaceSnapshot } from "../model/workspaceVariationSession"; import { initialVariationWorkspace } from "./useVariationWorkspace"; +import { + buildCapabilityWorkflow, + defaultCapabilityWorkflowInputs, + type CapabilityWorkflowDocument, +} from "../model/capabilityWorkflow"; export interface ImpactAppModel { readonly scenario: ImpactScenario; @@ -35,6 +40,10 @@ export interface ImpactAppModel { readonly setVariationWorkspace: Dispatch< SetStateAction >; + readonly capabilityWorkflow: CapabilityWorkflowDocument; + readonly setCapabilityWorkflow: Dispatch< + SetStateAction + >; readonly units: UnitSelections; readonly setUnits: Dispatch>; readonly generatedHead: GeneratedHead; @@ -80,6 +89,9 @@ export function useImpactAppModel(): ImpactAppModel { const [variationWorkspace, setVariationWorkspace] = useState(() => initialVariationWorkspace(), ); + const [capabilityWorkflow, setCapabilityWorkflow] = useState(() => + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + ); const [units, setUnits] = useState(DEFAULT_UNITS); const [generatedHead, setGeneratedHead] = useState(() => generatedHeadFor(defaultDriver), @@ -102,6 +114,8 @@ export function useImpactAppModel(): ImpactAppModel { setTorqueWorkspace, variationWorkspace, setVariationWorkspace, + capabilityWorkflow, + setCapabilityWorkflow, units, setUnits, generatedHead, diff --git a/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx b/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx index 04c6c6312..b945c4bed 100644 --- a/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx +++ b/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx @@ -10,6 +10,10 @@ import { starterTorqueProfile } from "../model/torqueProfileEditor"; import { DEFAULT_PRIMARY_VIEW_STATE } from "../model/viewPreferences"; import { defaultViewWorkspace } from "../model/viewWorkspace"; import { createWorkspaceDocument } from "../model/workspaceSession"; +import { + buildCapabilityWorkflow, + defaultCapabilityWorkflowInputs, +} from "../model/capabilityWorkflow"; import { boxTolerance, createSpatialTarget, @@ -49,6 +53,7 @@ const snapshot = () => { runConfig: passiveDoublePendulumRun(), }, variation: initialVariationWorkspace(), + capability: buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), modules: DEFAULT_PRIMARY_VIEW_STATE, viewWorkspace: defaultViewWorkspace, }; @@ -240,6 +245,72 @@ describe("browser workspace file controller", () => { expect(applySnapshot).not.toHaveBeenCalled(); }); + it.each([ + ["mph", /unit/i], ["covariance", /correlation/i], + ] as const)("rejects a noncanonical %s capability file before apply", async ( + kind, message, + ) => { + const applySnapshot = vi.fn(); + const encoded = JSON.parse(createWorkspaceDocument(snapshot(), { + documentId: `workspace.invalid.capability.${kind}`, + title: "Invalid", + appVersion: "1.14.34", + createdAtUtc: "2026-08-11T07:00:00Z", + modifiedAtUtc: "2026-08-11T07:00:00Z", + })); + const club = encoded.model_session.data.capability_request.profile.clubs[0]; + if (kind === "mph") club.parameters[0].unit = "mph"; + else club.matrix_kind = "covariance"; + const { result } = renderHook(() => useWorkspaceFiles({ + snapshot: snapshot(), initialSnapshot: snapshot(), applySnapshot, + applyViewWorkspace: vi.fn(), + })); + const input = document.createElement("input"); + Object.defineProperty(input, "files", { value: [ + new File([JSON.stringify(encoded)], `invalid-capability-${kind}.json`), + ] }); + act(() => { + result.current.handleCommand(APP_COMMAND_ID.fileOpenWorkspace); + result.current.onFileChange({ currentTarget: input } as never); + }); + + await waitFor(() => expect(result.current.error).toMatch(message)); + expect(applySnapshot).not.toHaveBeenCalled(); + }); + + it("rejects computed capability output before applying state", async () => { + const applySnapshot = vi.fn(); + const encoded = JSON.parse( + createWorkspaceDocument(snapshot(), { + documentId: "workspace.invalid.capability", + title: "Invalid", + appVersion: "1.14.34", + createdAtUtc: "2026-08-11T07:00:00Z", + modifiedAtUtc: "2026-08-11T07:00:00Z", + }), + ); + encoded.model_session.data.capability_request.computed_result = {}; + const { result } = renderHook(() => + useWorkspaceFiles({ + snapshot: snapshot(), + initialSnapshot: snapshot(), + applySnapshot, + applyViewWorkspace: vi.fn(), + }), + ); + const input = document.createElement("input"); + Object.defineProperty(input, "files", { + value: [new File([JSON.stringify(encoded)], "invalid-capability.json")], + }); + act(() => { + result.current.handleCommand(APP_COMMAND_ID.fileOpenWorkspace); + result.current.onFileChange({ currentTarget: input } as never); + }); + + await waitFor(() => expect(result.current.error).toMatch(/capability workflow/i)); + expect(applySnapshot).not.toHaveBeenCalled(); + }); + it("applies a valid opened workspace and marks the result clean", async () => { const opened = { ...snapshot(), @@ -340,6 +411,7 @@ describe("browser workspace file controller", () => { ); legacy.model_session.schema_version = 3; delete legacy.model_session.data.variation_study; + delete legacy.model_session.data.capability_request; legacy.variation_plan = null; const encoded = JSON.stringify(legacy); const reads = stubDeferredFileReaders(); diff --git a/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.ts b/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.ts index b252aa171..e488ff2f4 100644 --- a/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.ts +++ b/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.ts @@ -217,6 +217,7 @@ export function useWorkspaceFiles( legacySimulationFallback: current.snapshot.simulation, legacyTorqueFallback: current.snapshot.torque, legacyVariationFallback: current.snapshot.variation, + legacyCapabilityFallback: current.snapshot.capability, }); if (!confirmDiscard("open the selected workspace")) return; current.applySnapshot(parsed); 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 index 13fbe0763..5f91c3bba 100644 --- 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 @@ -1,5 +1,14 @@ { "schema_version": "capability-workflow-parser-cases/v1", + "hostile_numbers": [ + { + "id": "reject-4000-digit-json-integer", + "path": ["request", "candidate_budget"], + "digit": "9", + "digits": 4000, + "accepted": false + } + ], "cases": [ { "id": "valid-text-profile-id", diff --git a/src/rate_of_closure/web/src/model/capabilityContract.ts b/src/rate_of_closure/web/src/model/capabilityContract.ts index 04239cca7..e27def6c7 100644 --- a/src/rate_of_closure/web/src/model/capabilityContract.ts +++ b/src/rate_of_closure/web/src/model/capabilityContract.ts @@ -53,6 +53,8 @@ export type CapabilityEvaluator = ( clubId: string, parameters: Readonly>, ) => SolverEvaluation; +export const MAX_CAPABILITY_WIRE_MAGNITUDE = 1e300; + 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; @@ -66,6 +68,7 @@ const text = (value: unknown, name: string): string => { }; const finite = (value: unknown, name: string): number => { if (typeof value !== "number" || !Number.isFinite(value)) throw new RangeError(`${name} must be finite`); + if (Math.abs(value) > MAX_CAPABILITY_WIRE_MAGNITUDE) throw new RangeError(`${name} magnitude must not exceed ${MAX_CAPABILITY_WIRE_MAGNITUDE}`); return value; }; const integer = (value: unknown, name: string): number => { diff --git a/src/rate_of_closure/web/src/model/capabilityInteractiveBasis.ts b/src/rate_of_closure/web/src/model/capabilityInteractiveBasis.ts new file mode 100644 index 000000000..967b64880 --- /dev/null +++ b/src/rate_of_closure/web/src/model/capabilityInteractiveBasis.ts @@ -0,0 +1,38 @@ +/** Exact capability basis supported by the current interactive controls. */ + +import type { ClubCapability } from "./capabilityContract"; + +export const CANONICAL_INTERACTIVE_PARAMETERS = [ + ["ball_speed", "m/s"], + ["launch_angle", "deg"], + ["launch_direction", "deg"], +] as const; + +export function validateInteractiveCapabilityBasis( + club: ClubCapability, +): void { + if (club.matrixKind !== "correlation") { + throw new RangeError("interactive workflow requires a correlation matrix"); + } + const canonical = CANONICAL_INTERACTIVE_PARAMETERS.every( + ([parameterId, unit], index) => + club.parameters[index]?.parameterId === parameterId && + club.parameters[index]?.unit === unit, + ); + if ( + !canonical || + club.parameters.length !== CANONICAL_INTERACTIVE_PARAMETERS.length + ) { + throw new RangeError( + "interactive workflow requires canonical parameter order and units: " + + "ball_speed m/s, launch_angle deg, launch_direction deg", + ); + } + const dimension = CANONICAL_INTERACTIVE_PARAMETERS.length; + if ( + club.matrix.length !== dimension || + club.matrix.some((row) => row.length !== dimension) + ) { + throw new RangeError("interactive workflow requires a 3x3 correlation matrix"); + } +} diff --git a/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts b/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts index b1390f3ca..ca4690268 100644 --- a/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts +++ b/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts @@ -6,6 +6,7 @@ import { CAPABILITY_WORKFLOW_SCHEMA_VERSION, buildCapabilityWorkflow, capabilityWorkflowFromJson, + capabilityWorkflowInputs, capabilityWorkflowToJson, defaultCapabilityWorkflowInputs, } from "./capabilityWorkflow"; @@ -18,6 +19,7 @@ interface ParserCase { } const parserCases = parserCasesFixture.cases as readonly ParserCase[]; +const hostileNumbers = parserCasesFixture.hostile_numbers; const mutatedWorkflow = (testCase: ParserCase): string => { const payload: unknown = JSON.parse(capabilityWorkflowToJson( @@ -33,12 +35,37 @@ const mutatedWorkflow = (testCase: ParserCase): string => { return JSON.stringify(payload); }; +const noncanonicalInteractiveWorkflow = ( + kind: "mph" | "covariance" | "reordered", +) => { + const payload = JSON.parse(capabilityWorkflowToJson( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + )); + if (kind === "mph") payload.profile.clubs[0].parameters[0].unit = "mph"; + else if (kind === "covariance") payload.profile.clubs[0].matrix_kind = "covariance"; + else payload.profile.clubs[0].parameters.reverse(); + return capabilityWorkflowFromJson(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.each(hostileNumbers)("rejects shared hostile number $id", (testCase) => { + const source = capabilityWorkflowToJson( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + ); + const rawNumber = testCase.digit.repeat(testCase.digits); + const hostile = source.replace( + '"candidate_budget":8', + `"candidate_budget":${rawNumber}`, + ); + + expect(() => capabilityWorkflowFromJson(hostile)).toThrow(/magnitude|finite/i); + }); + it("builds a model-ready and auditable default driver workflow", () => { const document = buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()); @@ -66,6 +93,19 @@ describe("capability workflow", () => { expect(JSON.parse(encoded).schema_version).toBe(CAPABILITY_WORKFLOW_SCHEMA_VERSION); }); + it("projects the exact canonical interactive parameter basis", () => { + expect(capabilityWorkflowInputs( + buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()), + )).toEqual(defaultCapabilityWorkflowInputs()); + }); + + it.each([ + ["mph", /unit/i], ["covariance", /correlation/i], ["reordered", /order/i], + ] as const)("rejects a noncanonical %s interactive basis", (kind, message) => { + expect(() => capabilityWorkflowInputs(noncanonicalInteractiveWorkflow(kind))) + .toThrow(message); + }); + it.each([ [{ ballSpeedMps: 0 }, "ballSpeedMps"], [{ totalSpinRpm: -1 }, "totalSpinRpm"], diff --git a/src/rate_of_closure/web/src/model/capabilityWorkflow.ts b/src/rate_of_closure/web/src/model/capabilityWorkflow.ts index 071c579fb..a8d9ff16c 100644 --- a/src/rate_of_closure/web/src/model/capabilityWorkflow.ts +++ b/src/rate_of_closure/web/src/model/capabilityWorkflow.ts @@ -3,6 +3,7 @@ import { parseOptimizationRequest, parsePlayerCapabilityProfile, + MAX_CAPABILITY_WIRE_MAGNITUDE, type CapabilityObjective, type CapabilityParameter, type OptimizationRequest, @@ -12,6 +13,7 @@ import type { CapabilityFlightEvaluatorConfig, CapabilitySpinDefault, } from "./capabilityFlightEvaluator"; +import { validateInteractiveCapabilityBasis } from "./capabilityInteractiveBasis"; export const CAPABILITY_WORKFLOW_SCHEMA_VERSION = "capability-optimization-workflow/v1" as const; @@ -67,6 +69,9 @@ const finite = (value: unknown, name: string): number => { if (typeof value !== "number" || !Number.isFinite(value)) { throw new RangeError(`${name} must be finite`); } + if (Math.abs(value) > MAX_CAPABILITY_WIRE_MAGNITUDE) { + throw new RangeError(`${name} magnitude must not exceed ${MAX_CAPABILITY_WIRE_MAGNITUDE}`); + } return value; }; @@ -282,12 +287,21 @@ const parseConfig = (value: unknown): CapabilityFlightEvaluatorConfig => { export const capabilityWorkflowToJson = ( document: CapabilityWorkflowDocument, -): string => JSON.stringify({ evaluator_config: configWire(document.evaluatorConfig), +): string => JSON.stringify(capabilityWorkflowDocument(document)); + +export const capabilityWorkflowDocument = ( + document: CapabilityWorkflowDocument, +): Record => ({ 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); + return capabilityWorkflowFromDocument(JSON.parse(source)); +} + +export function capabilityWorkflowFromDocument( + value: unknown, +): CapabilityWorkflowDocument { const payload = record(value, "capability workflow"); exact(payload, ["evaluator_config", "profile", "request", "schema_version"], "capability workflow"); if (payload.schema_version !== CAPABILITY_WORKFLOW_SCHEMA_VERSION) { @@ -308,6 +322,7 @@ export function capabilityWorkflowInputs( throw new RangeError("interactive workflow supports exactly one club and spin default"); } const club = document.profile.clubs[0]; + validateInteractiveCapabilityBasis(club); const parameters = new Map(club.parameters.map((item) => [item.parameterId, item])); const ballSpeed = parameters.get("ball_speed"); const launchAngle = parameters.get("launch_angle"); @@ -331,3 +346,41 @@ export function capabilityWorkflowInputs( maxTimeS: document.evaluatorConfig.maxTimeS, trajectorySampleIntervalS: document.evaluatorConfig.trajectorySampleIntervalS }); } + +/** Overlay editable controls on a validated document without erasing evidence. */ +export function overlayCapabilityWorkflowInputs( + document: CapabilityWorkflowDocument, + input: CapabilityWorkflowInputs, +): CapabilityWorkflowDocument { + capabilityWorkflowInputs(document); + validateAuthoringInputs(input); + const values: Readonly> = { + ball_speed: [input.ballSpeedMps, input.ballSpeedStdMps], + launch_angle: [input.launchAngleDeg, input.launchAngleStdDeg], + launch_direction: [input.launchDirectionDeg, input.launchDirectionStdDeg], + }; + const sourceClub = document.profile.clubs[0]; + const club = { ...sourceClub, clubId: text(input.clubId, "clubId"), + parameters: sourceClub.parameters.map((item) => ({ ...item, + baseline: values[item.parameterId][0], + standardDeviation: values[item.parameterId][1] })) }; + const profile = { ...document.profile, profileId: text(input.profileId, "profileId"), + clubs: [club] }; + const target = { ...document.request.target, distanceM: input.targetDistanceM, + lateralM: input.targetLateralM, radiusM: input.targetRadiusM }; + const request = { ...document.request, objective: input.objective, + clubIds: [club.clubId], target, candidateBudget: input.candidateBudget, + ensembleSize: input.ensembleSize, alternativesCount: input.alternativesCount, + seed: input.seed }; + const sourceSpin = document.evaluatorConfig.spinDefaults[0]; + const evaluatorConfig = { ...document.evaluatorConfig, + maxTimeS: input.maxTimeS, + trajectorySampleIntervalS: input.trajectorySampleIntervalS, + spinDefaults: [{ ...sourceSpin, clubId: club.clubId, + totalSpinRpm: input.totalSpinRpm, + spinAxisTiltDeg: input.spinAxisTiltDeg }] }; + return capabilityWorkflowFromDocument(capabilityWorkflowDocument({ + schemaVersion: CAPABILITY_WORKFLOW_SCHEMA_VERSION, + profile, request, evaluatorConfig, + })); +} diff --git a/src/rate_of_closure/web/src/model/workspaceSession.test.ts b/src/rate_of_closure/web/src/model/workspaceSession.test.ts index 8a0f463a5..5afc03ca0 100644 --- a/src/rate_of_closure/web/src/model/workspaceSession.test.ts +++ b/src/rate_of_closure/web/src/model/workspaceSession.test.ts @@ -9,6 +9,12 @@ import { DEFAULT_PRIMARY_VIEW_STATE } from "./viewPreferences"; import { defaultViewWorkspace } from "./viewWorkspace"; import variationFixture from "./__fixtures__/workspace_variation_parity.json"; import { planFromJson } from "./variation"; +import { + buildCapabilityWorkflow, + capabilityWorkflowFromJson, + capabilityWorkflowToJson, + defaultCapabilityWorkflowInputs, +} from "./capabilityWorkflow"; import { boxTolerance, createSpatialTarget, @@ -47,11 +53,36 @@ const snapshot = (): WorkspaceSessionSnapshot => { analysisExecution: "both" as const, selectedOutputMetrics: ["carry_m", "lateral_m", "apex_m"], }, + capability: customCapabilityWorkflow(), modules: DEFAULT_PRIMARY_VIEW_STATE, viewWorkspace: defaultViewWorkspace, }; }; +const customCapabilityWorkflow = () => { + const payload = JSON.parse(capabilityWorkflowToJson(buildCapabilityWorkflow({ + ...defaultCapabilityWorkflowInputs(), profileId: "workspace-profile", + objective: "minimize_expected_miss", targetDistanceM: 241, + targetLateralM: -4, spinAxisTiltDeg: -3.5, + }))); + payload.profile.provenance = "measured/session-42"; + payload.profile.confidence = 0.71; + payload.profile.clubs[0].provenance = "fit/driver-42"; + payload.profile.clubs[0].confidence = 0.63; + payload.profile.clubs[0].matrix = [ + [1, 0.2, 0], [0.2, 1, 0.1], [0, 0.1, 1], + ]; + payload.profile.clubs[0].parameters[0].bias = 0.4; + payload.request.problem_id = "custom-problem-42"; + payload.request.cvar_alpha = 0.83; + payload.request.minimum_success_fraction = 0.64; + payload.request.target.kind = "fairway"; + payload.request.target.band_half_length_m = 21; + payload.request.target.half_width_m = 8; + payload.evaluator_config.spin_defaults[0].provenance = "measured/spin-42"; + return capabilityWorkflowFromJson(JSON.stringify(payload)); +}; + const metadata = { documentId: "workspace.web.test", title: "Web test", @@ -66,7 +97,7 @@ describe("whole workspace session contract", () => { expect(parseWorkspaceDocument(encoded)).toEqual(snapshot()); expect(JSON.parse(encoded).schema_version).toBe(2); const session = JSON.parse(encoded).model_session; - expect(session.schema_version).toBe(4); + expect(session.schema_version).toBe(5); expect(session.data.simulation_setup.data.ball_setup.provenance).toEqual({ kind: "club_default", club_name: "Driver 10.5°", @@ -88,6 +119,38 @@ describe("whole workspace session contract", () => { expect(session.data.variation_study).toEqual(variationFixture.selection); expect(JSON.parse(encoded).variation_plan).toEqual(variationFixture.plan); expect(JSON.parse(encoded).variation_plan).not.toHaveProperty("ball_setup"); + expect(session.data.capability_request).toMatchObject({ + schema_version: "capability-optimization-workflow/v1", + request: { + objective: "minimize_expected_miss", + target: { distance_m: 241, lateral_m: -4 }, + }, + }); + expect(session.data.capability_request).not.toHaveProperty("result"); + expect(capabilityWorkflowToJson( + parseWorkspaceDocument(encoded).capability, + )).toBe(capabilityWorkflowToJson(snapshot().capability)); + }); + + it("requires an explicit capability fallback to migrate a v4 session", () => { + const value = JSON.parse(createWorkspaceDocument(snapshot(), metadata)); + value.model_session.schema_version = 4; + delete value.model_session.data.capability_request; + const text = JSON.stringify(value); + + expect(() => parseWorkspaceDocument(text)).toThrow(/explicit capability/i); + expect(parseWorkspaceDocument(text, { + legacyCapabilityFallback: snapshot().capability, + }).capability).toEqual(snapshot().capability); + }); + + it("rejects computed capability output before returning workspace state", () => { + const value = JSON.parse(createWorkspaceDocument(snapshot(), metadata)); + value.model_session.data.capability_request.computed_result = {}; + + expect(() => parseWorkspaceDocument(JSON.stringify(value))).toThrow( + /capability workflow/i, + ); }); it("rejects a variation plan that duplicates the simulation ball setup", () => { @@ -126,6 +189,7 @@ describe("whole workspace session contract", () => { legacySimulationFallback: snapshot().simulation, legacyTorqueFallback: snapshot().torque, legacyVariationFallback: snapshot().variation, + legacyCapabilityFallback: snapshot().capability, }).simulation, ).toEqual(snapshot().simulation); }); @@ -149,6 +213,7 @@ describe("whole workspace session contract", () => { legacySimulationFallback: snapshot().simulation, legacyTorqueFallback: snapshot().torque, legacyVariationFallback: snapshot().variation, + legacyCapabilityFallback: snapshot().capability, }); expect(migrated.simulation.ballSetup).toEqual( snapshot().simulation.ballSetup, @@ -161,12 +226,14 @@ describe("whole workspace session contract", () => { value.model_session.schema_version = 2; delete value.model_session.data.torque_selection; delete value.model_session.data.variation_study; + delete value.model_session.data.capability_request; const text = JSON.stringify(value); expect(() => parseWorkspaceDocument(text)).toThrow(/explicit torque/i); expect( parseWorkspaceDocument(text, { legacyTorqueFallback: snapshot().torque, legacyVariationFallback: snapshot().variation, + legacyCapabilityFallback: snapshot().capability, }).torque, ).toEqual(snapshot().torque); }); @@ -196,12 +263,14 @@ describe("whole workspace session contract", () => { const value = JSON.parse(createWorkspaceDocument(snapshot(), metadata)); value.model_session.schema_version = 3; delete value.model_session.data.variation_study; + delete value.model_session.data.capability_request; const text = JSON.stringify(value); expect(() => parseWorkspaceDocument(text)).toThrow(/explicit variation/i); expect( parseWorkspaceDocument(text, { legacyVariationFallback: snapshot().variation, + legacyCapabilityFallback: snapshot().capability, }).variation, ).toEqual(snapshot().variation); @@ -212,6 +281,7 @@ describe("whole workspace session contract", () => { expect(() => parseWorkspaceDocument(text, { legacyVariationFallback: conflict, + legacyCapabilityFallback: snapshot().capability, }), ).toThrow(/conflicts/i); }); diff --git a/src/rate_of_closure/web/src/model/workspaceSession.ts b/src/rate_of_closure/web/src/model/workspaceSession.ts index 85d272cc5..9e776b634 100644 --- a/src/rate_of_closure/web/src/model/workspaceSession.ts +++ b/src/rate_of_closure/web/src/model/workspaceSession.ts @@ -36,12 +36,18 @@ import { validateWorkspaceMetadata, versionedPayload, } from "./workspaceMetadataValidation"; +import { + capabilityWorkflowDocument, + capabilityWorkflowFromDocument, + capabilityWorkflowInputs, + type CapabilityWorkflowDocument, +} from "./capabilityWorkflow"; const WORKSPACE_SCHEMA = "rate_of_closure.workspace"; const WORKSPACE_VERSION = 2; const SESSION_SCHEMA = "rate_of_closure.explorer_session"; const CLUB_SCHEMA = "rate_of_closure.club_configuration"; -const SESSION_PAYLOAD_VERSION = 4; +const SESSION_PAYLOAD_VERSION = 5; const CLUB_PAYLOAD_VERSION = 1; const CLUB_TYPES: readonly ClubType[] = [ "Driver", @@ -75,6 +81,7 @@ export interface WorkspaceSessionSnapshot { readonly simulation: SimulationWorkspaceSnapshot; readonly torque: TorqueWorkspaceSnapshot; readonly variation: VariationWorkspaceSnapshot; + readonly capability: CapabilityWorkflowDocument; readonly modules: PrimaryViewState; readonly viewWorkspace: ViewWorkspace; } @@ -319,6 +326,7 @@ export function createWorkspaceDocument( snapshot.variation, snapshot.simulation.ballSetup, ), + capability_request: capabilityWorkflowDocument(snapshot.capability), }, }, prescribed_torque_profiles: snapshot.torque.profiles.map((profile) => @@ -353,6 +361,7 @@ export interface WorkspaceParseOptions { readonly legacySimulationFallback?: SimulationWorkspaceSnapshot; readonly legacyTorqueFallback?: TorqueWorkspaceSnapshot; readonly legacyVariationFallback?: VariationWorkspaceSnapshot; + readonly legacyCapabilityFallback?: CapabilityWorkflowDocument; } /** Parse a current file or deliberately migrate v1 with an explicit fallback. */ @@ -387,7 +396,7 @@ export function parseWorkspaceDocument( const sessionEnvelope = versionedPayload( root.model_session, SESSION_SCHEMA, - [1, 2, 3, SESSION_PAYLOAD_VERSION], + [1, 2, 3, 4, SESSION_PAYLOAD_VERSION], "model_session", ); const session = exactRecord( @@ -398,12 +407,21 @@ export function parseWorkspaceDocument( ? ["scenario", "units", "simulation_setup"] : sessionEnvelope.version === 3 ? ["scenario", "units", "simulation_setup", "torque_selection"] + : sessionEnvelope.version === 4 + ? [ + "scenario", + "units", + "simulation_setup", + "torque_selection", + "variation_study", + ] : [ "scenario", "units", "simulation_setup", "torque_selection", "variation_study", + "capability_request", ], "model_session.data", ); @@ -475,7 +493,7 @@ export function parseWorkspaceDocument( simulation.ballSetup, ); let variation: VariationWorkspaceSnapshot; - if (sessionEnvelope.version < SESSION_PAYLOAD_VERSION) { + if (sessionEnvelope.version < 4) { if (options.legacyVariationFallback === undefined) { throw new RangeError( "legacy model_session requires an explicit variation migration fallback", @@ -498,6 +516,19 @@ export function parseWorkspaceDocument( simulation.ballSetup, ); } + let capability: CapabilityWorkflowDocument; + if (sessionEnvelope.version < SESSION_PAYLOAD_VERSION) { + if (options.legacyCapabilityFallback === undefined) { + throw new RangeError( + "legacy model_session requires an explicit capability migration fallback", + ); + } + capabilityWorkflowInputs(options.legacyCapabilityFallback); + capability = options.legacyCapabilityFallback; + } else { + capability = capabilityWorkflowFromDocument(session.capability_request); + capabilityWorkflowInputs(capability); + } return { scenario: scenarioFromDocument(session.scenario), club: parsedClub, @@ -505,6 +536,7 @@ export function parseWorkspaceDocument( simulation, torque, variation, + capability, modules: validatedModules(layout), viewWorkspace: viewWorkspaceFromDocument(viewEnvelope.data), }; diff --git a/tests/rate_of_closure/test_app_toolstrip.py b/tests/rate_of_closure/test_app_toolstrip.py index 4aa7c21c8..6cb3267a0 100644 --- a/tests/rate_of_closure/test_app_toolstrip.py +++ b/tests/rate_of_closure/test_app_toolstrip.py @@ -299,6 +299,101 @@ def test_invalid_variation_selection_is_rejected_before_native_ui_mutation( assert warnings and "metric" in warnings[0][1] +def test_invalid_capability_request_is_rejected_before_native_ui_mutation( + window, tmp_path, monkeypatch +) -> None: # type: ignore[no-untyped-def] + from PyQt6.QtWidgets import QFileDialog, QMessageBox + + before = window._capture_workspace_state() + raw = document_from_state(before, window._workspace_metadata).to_json_dict() + raw["model_session"]["data"]["capability_request"]["computed_result"] = {} + target = tmp_path / "invalid-capability-request.roc-workspace.json" + target.write_text(json.dumps(raw), encoding="utf-8") + warnings: list[tuple[str, str]] = [] + monkeypatch.setattr( + QFileDialog, + "getOpenFileName", + lambda *_args, **_kwargs: (str(target), ""), + ) + monkeypatch.setattr( + QMessageBox, + "warning", + lambda _parent, title, message, *_args, **_kwargs: warnings.append( + (title, message) + ), + ) + + _action(window, AppCommandId.FILE_OPEN_WORKSPACE).trigger() + + assert window._capture_workspace_state() == before + assert warnings and "capability workflow" in warnings[0][1] + + +@pytest.mark.parametrize( + ("kind", "message"), [("mph", "unit"), ("covariance", "correlation")] +) +def test_noncanonical_capability_basis_reports_open_error_without_mutation( + window, tmp_path, monkeypatch, kind: str, message: str +) -> None: # type: ignore[no-untyped-def] + from PyQt6.QtWidgets import QFileDialog, QMessageBox + + before = window._capture_workspace_state() + raw = document_from_state(before, window._workspace_metadata).to_json_dict() + club = raw["model_session"]["data"]["capability_request"]["profile"]["clubs"][0] + if kind == "mph": + club["parameters"][0]["unit"] = "mph" + else: + club["matrix_kind"] = "covariance" + target = tmp_path / f"invalid-capability-{kind}.roc-workspace.json" + target.write_text(json.dumps(raw), encoding="utf-8") + warnings: list[tuple[str, str]] = [] + monkeypatch.setattr( + QFileDialog, "getOpenFileName", lambda *_args: (str(target), "") + ) + monkeypatch.setattr( + QMessageBox, + "warning", + lambda _parent, title, text, *_args: warnings.append((title, text)), + ) + + _action(window, AppCommandId.FILE_OPEN_WORKSPACE).trigger() + + assert window._capture_workspace_state() == before + assert warnings and message in warnings[0][1] + + +def test_oversized_capability_number_reports_open_error_without_mutation( + window, tmp_path, monkeypatch +) -> None: # type: ignore[no-untyped-def] + from PyQt6.QtWidgets import QFileDialog, QMessageBox + + before = window._capture_workspace_state() + raw = json.dumps( + document_from_state(before, window._workspace_metadata).to_json_dict() + ) + raw = raw.replace('"candidate_budget": 8', '"candidate_budget": ' + "9" * 4000) + target = tmp_path / "oversized-capability-number.roc-workspace.json" + target.write_text(raw, encoding="utf-8") + warnings: list[tuple[str, str]] = [] + monkeypatch.setattr( + QFileDialog, + "getOpenFileName", + lambda *_args, **_kwargs: (str(target), ""), + ) + monkeypatch.setattr( + QMessageBox, + "warning", + lambda _parent, title, message, *_args, **_kwargs: warnings.append( + (title, message) + ), + ) + + _action(window, AppCommandId.FILE_OPEN_WORKSPACE).trigger() + + assert window._capture_workspace_state() == before + assert warnings and "finite" in warnings[0][1] + + def test_glossary_is_first_class_and_recovers_a_hidden_module(window) -> None: # type: ignore[no-untyped-def] assert window.set_primary_module_visible("glossary", False) glossary = _action(window, AppCommandId.GLOBAL_OPEN_GLOSSARY.value) diff --git a/tests/rate_of_closure/test_capability_gui.py b/tests/rate_of_closure/test_capability_gui.py index bd27ab81f..79057c63a 100644 --- a/tests/rate_of_closure/test_capability_gui.py +++ b/tests/rate_of_closure/test_capability_gui.py @@ -2,13 +2,19 @@ from __future__ import annotations +import json + import pytest pytest.importorskip("PyQt6") pytest.importorskip("pytestqt") from rate_of_closure.application.capability_workflow import ( # noqa: E402 + CapabilityWorkflowDocument, CapabilityWorkflowInputs, + build_capability_workflow, + capability_workflow_from_json, + capability_workflow_json, ) from rate_of_closure.ui.pyqt6.capability_controls import ( CapabilityControls, # noqa: E402 @@ -20,6 +26,45 @@ pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] +def _custom_workflow(): # type: ignore[no-untyped-def] + payload = json.loads( + capability_workflow_json(build_capability_workflow(CapabilityWorkflowInputs())) + ) + profile = payload["profile"] + club = profile["clubs"][0] + profile.update(provenance="measured/session-42", confidence=0.71) + club.update(provenance="fit/driver-42", confidence=0.63) + club["matrix"] = [[1.0, 0.2, 0.0], [0.2, 1.0, 0.1], [0.0, 0.1, 1.0]] + club["parameters"][0].update( + bias=0.4, + lower_bound=10.0, + upper_bound=95.0, + evidence_lower_bound=30.0, + evidence_upper_bound=85.0, + ) + request = payload["request"] + request.update( + problem_id="custom-problem-42", + cvar_alpha=0.83, + minimum_success_fraction=0.64, + ) + request["target"].update(kind="fairway", band_half_length_m=21.0, half_width_m=8.0) + payload["evaluator_config"]["spin_defaults"][0]["provenance"] = "measured/spin-42" + return capability_workflow_from_json(json.dumps(payload)) + + +def _noncanonical_workflow(kind: str) -> CapabilityWorkflowDocument: + payload = json.loads( + capability_workflow_json(build_capability_workflow(CapabilityWorkflowInputs())) + ) + club = payload["profile"]["clubs"][0] + if kind == "mph": + club["parameters"][0]["unit"] = "mph" + else: + club["matrix_kind"] = "covariance" + return capability_workflow_from_json(json.dumps(payload)) + + def test_capability_controls_round_trip_integration_settings(qtbot) -> None: # type: ignore[no-untyped-def] controls = CapabilityControls() qtbot.addWidget(controls) @@ -86,3 +131,63 @@ def test_capability_tab_rejects_oversized_interactive_workload(qtbot) -> None: assert "100000" in tab.status.text() assert tab._worker is None + + +def test_capability_workspace_apply_replaces_inputs_and_invalidates_results( + qtbot, # type: ignore[no-untyped-def] +) -> None: + tab = CapabilityOptimizationTab() + qtbot.addWidget(tab) + tab._document = build_capability_workflow(CapabilityWorkflowInputs()) + tab.results.setVisible(True) + requested = _custom_workflow() + + tab.apply_capability_workspace_document(requested) + + assert tab.capability_workspace_document() == requested + assert tab._document is None + assert not tab.results.isVisibleTo(tab) + + +@pytest.mark.parametrize( + ("kind", "message"), [("mph", "unit"), ("covariance", "correlation")] +) +def test_capability_workspace_apply_rejects_noncanonical_basis_before_mutation( + qtbot, kind: str, message: str +) -> None: # type: ignore[no-untyped-def] + tab = CapabilityOptimizationTab() + qtbot.addWidget(tab) + before = tab.capability_workspace_document() + + with pytest.raises(ValueError, match=message): + tab.apply_capability_workspace_document(_noncanonical_workflow(kind)) + + assert tab.capability_workspace_document() == before + + +def test_capability_workspace_rejects_stale_worker_success_after_replacement( + qtbot, +) -> None: # type: ignore[no-untyped-def] + tab = CapabilityOptimizationTab() + qtbot.addWidget(tab) + + class StaleWorker: + def isRunning(self) -> bool: + return False + + def cancel(self) -> None: + pass + + stale_worker = StaleWorker() + tab._worker = stale_worker # type: ignore[assignment] + stale_generation = tab.worker_generation() + + tab.apply_capability_workspace_document(_custom_workflow()) + tab.accept_worker_success(stale_worker, stale_generation, object(), object()) + + assert tab._document is None + assert tab._dataset is None + assert tab._result is None + assert not tab.results.isVisibleTo(tab) + assert not tab.csv_button.isEnabled() + assert not tab.result_json_button.isEnabled() diff --git a/tests/rate_of_closure/test_capability_workflow.py b/tests/rate_of_closure/test_capability_workflow.py index 6d93180aa..e05b570b0 100644 --- a/tests/rate_of_closure/test_capability_workflow.py +++ b/tests/rate_of_closure/test_capability_workflow.py @@ -9,9 +9,11 @@ from rate_of_closure.application.capability_workflow import ( CAPABILITY_WORKFLOW_SCHEMA_VERSION, + CapabilityWorkflowDocument, CapabilityWorkflowInputs, build_capability_workflow, capability_workflow_from_json, + capability_workflow_inputs, capability_workflow_json, ) from rate_of_closure.variation.scalar_ensemble_contract import ( @@ -37,6 +39,7 @@ ).read_text(encoding="utf-8") ) _PARSER_CASES = _PARSER_FIXTURE["cases"] +_HOSTILE_NUMBERS = _PARSER_FIXTURE["hostile_numbers"] def _mutated_workflow(case: dict[str, object]) -> str: @@ -67,6 +70,20 @@ def test_shared_parser_fixture_schema_is_supported() -> None: assert _PARSER_FIXTURE["schema_version"] == "capability-workflow-parser-cases/v1" +@pytest.mark.parametrize("case", _HOSTILE_NUMBERS, ids=lambda case: case["id"]) +def test_shared_parser_rejects_oversized_raw_json_numbers( + case: dict[str, object], +) -> None: + source = capability_workflow_json( + build_capability_workflow(CapabilityWorkflowInputs()) + ) + raw_number = str(case["digit"]) * int(case["digits"]) + source = source.replace('"candidate_budget":8', f'"candidate_budget":{raw_number}') + + with pytest.raises(ValueError, match="magnitude|finite"): + capability_workflow_from_json(source) + + def test_workflow_round_trip_preserves_strict_nested_contracts() -> None: source = build_capability_workflow( CapabilityWorkflowInputs( @@ -87,6 +104,41 @@ def test_workflow_round_trip_preserves_strict_nested_contracts() -> None: assert json.loads(encoded)["schema_version"] == CAPABILITY_WORKFLOW_SCHEMA_VERSION +def _noncanonical_interactive_workflow( + kind: str, +) -> CapabilityWorkflowDocument: + payload = json.loads( + capability_workflow_json(build_capability_workflow(CapabilityWorkflowInputs())) + ) + club = payload["profile"]["clubs"][0] + if kind == "mph": + club["parameters"][0]["unit"] = "mph" + elif kind == "covariance": + club["matrix_kind"] = "covariance" + else: + club["parameters"].reverse() + return capability_workflow_from_json(json.dumps(payload)) + + +def test_interactive_projection_accepts_exact_canonical_parameter_basis() -> None: + projected = capability_workflow_inputs( + build_capability_workflow(CapabilityWorkflowInputs()) + ) + + assert projected == CapabilityWorkflowInputs() + + +@pytest.mark.parametrize( + ("kind", "message"), + [("mph", "unit"), ("covariance", "correlation"), ("reordered", "order")], +) +def test_interactive_projection_rejects_noncanonical_basis( + kind: str, message: str +) -> None: + with pytest.raises(ValueError, match=message): + capability_workflow_inputs(_noncanonical_interactive_workflow(kind)) + + @pytest.mark.parametrize( ("changes", "message"), [ diff --git a/tests/rate_of_closure/test_workspace_session.py b/tests/rate_of_closure/test_workspace_session.py index 039ad3b0e..33826f71e 100644 --- a/tests/rate_of_closure/test_workspace_session.py +++ b/tests/rate_of_closure/test_workspace_session.py @@ -7,8 +7,13 @@ import pytest +from rate_of_closure.application.capability_workflow import ( + CapabilityWorkflowInputs, + build_capability_workflow, +) from rate_of_closure.application.workspace_session import ( ExplorerWorkspaceState, + LegacyCapabilityMigrationRequired, WorkspaceSessionMetadata, document_from_state, state_from_document, @@ -93,6 +98,15 @@ def _state() -> ExplorerWorkspaceState: analysis_execution=VariationAnalysisExecution.BOTH, selected_output_metrics=("carry_m", "lateral_m"), ), + capability=build_capability_workflow( + CapabilityWorkflowInputs( + profile_id="workspace-profile", + objective="minimize_expected_miss", + target_distance_m=241.0, + target_lateral_m=-4.0, + spin_axis_tilt_deg=-3.5, + ) + ), module_order=( "explorer", "calculation", @@ -129,7 +143,7 @@ def test_live_state_round_trips_through_strict_whole_workspace_document() -> Non assert restored == state payload = document_from_state(state, _metadata()).model_session - assert payload.schema_version == 4 + assert payload.schema_version == 5 setup = payload.to_json_dict()["data"]["simulation_setup"] assert setup["schema"] == "rate_of_closure.simulation_setup" assert setup["data"]["ball_setup"]["provenance"] == { @@ -153,6 +167,45 @@ def test_live_state_round_trips_through_strict_whole_workspace_document() -> Non assert ( document_from_state(state, _metadata()).variation_plan == state.variation.plan ) + capability = payload.to_json_dict()["data"]["capability_request"] + assert capability["schema_version"] == "capability-optimization-workflow/v1" + assert capability["request"]["objective"] == "minimize_expected_miss" + assert capability["request"]["target"]["lateral_m"] == -4.0 + assert "result" not in capability + + +def test_legacy_v4_requires_explicit_capability_request_fallback() -> None: + from rate_of_closure.application.workspace_document import VersionedPayload + + current = document_from_state(_state(), _metadata()) + data = current.model_session.to_json_dict()["data"] + legacy = replace( + current, + model_session=VersionedPayload( + current.model_session.schema, + 4, + {key: value for key, value in data.items() if key != "capability_request"}, + ), + ) + + with pytest.raises(LegacyCapabilityMigrationRequired, match="explicit"): + state_from_document(legacy) + + restored = state_from_document( + legacy, + legacy_capability_fallback=_state().capability, + ) + assert restored.capability == _state().capability + + +def test_workspace_rejects_corrupt_capability_before_returning_state() -> None: + from rate_of_closure.application.workspace_document import WorkspaceDocument + + raw = document_from_state(_state(), _metadata()).to_json_dict() + raw["model_session"]["data"]["capability_request"]["computed_result"] = {} + + with pytest.raises(ValueError, match="capability workflow"): + state_from_document(WorkspaceDocument.from_json_dict(raw)) def test_club_default_provenance_must_match_persisted_club_and_geometry() -> None: @@ -215,6 +268,7 @@ def test_legacy_v1_session_requires_and_uses_an_explicit_simulation_fallback() - legacy_simulation_fallback=_state().simulation, legacy_torque_fallback=_state().torque, legacy_variation_fallback=_state().variation, + legacy_capability_fallback=_state().capability, ) assert migrated.simulation == _state().simulation @@ -246,6 +300,7 @@ def test_legacy_cross_club_fallback_preserves_geometry_as_an_override() -> None: legacy_simulation_fallback=_state().simulation, legacy_torque_fallback=_state().torque, legacy_variation_fallback=_state().variation, + legacy_capability_fallback=_state().capability, ) assert migrated.simulation.ball_setup == _state().simulation.ball_setup assert migrated.simulation.ball_setup_user_overridden @@ -279,6 +334,7 @@ def test_legacy_v2_requires_explicit_torque_fallback_without_inventing_profiles( legacy, legacy_torque_fallback=_state().torque, legacy_variation_fallback=_state().variation, + legacy_capability_fallback=_state().capability, ) assert restored.torque == _state().torque @@ -325,6 +381,7 @@ def test_legacy_v3_variation_migration_requires_a_nonconflicting_fallback() -> N state_from_document( legacy, legacy_variation_fallback=_state().variation, + legacy_capability_fallback=_state().capability, ).variation == _state().variation ) @@ -333,7 +390,11 @@ def test_legacy_v3_variation_migration_requires_a_nonconflicting_fallback() -> N _state().variation, plan=replace(_state().variation.plan, seed=8) ) with pytest.raises(LegacyVariationMigrationRequired, match="conflicts"): - state_from_document(legacy, legacy_variation_fallback=conflicting) + state_from_document( + legacy, + legacy_variation_fallback=conflicting, + legacy_capability_fallback=_state().capability, + ) def test_state_contract_rejects_invalid_units_and_incomplete_module_registry() -> None: