(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 <>
@@ -100,16 +100,16 @@ function WorkflowActions({ state }: {
className="mt-3 text-xs text-rose-400">{state.error ?? saveError}}>;
}
-export function CapabilityOptimizationPanel({ runner, inputs, onInputsChange }: {
+export function CapabilityOptimizationPanel({ runner, workflow, onWorkflowChange }: {
readonly runner?: CapabilityRunner;
- readonly inputs?: CapabilityWorkflowInputs;
- readonly onInputsChange?: Dispatch>;
+ readonly workflow?: CapabilityWorkflowDocument;
+ readonly onWorkflowChange?: Dispatch>;
}): JSX.Element {
- if ((inputs === undefined) !== (onInputsChange === undefined)) {
- throw new TypeError("controlled capability inputs require an input change handler");
+ if ((workflow === undefined) !== (onWorkflowChange === undefined)) {
+ throw new TypeError("controlled capability workflow requires a change handler");
}
- const authority = inputs === undefined || onInputsChange === undefined
- ? undefined : { inputs, onInputsChange };
+ const authority = workflow === undefined || onWorkflowChange === undefined
+ ? undefined : { workflow, onWorkflowChange };
const state = useCapabilityOptimization(runner, authority);
return
Shot Capability Optimizer
diff --git a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx
index 7a9dedc53..4a740166a 100644
--- a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx
+++ b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx
@@ -129,8 +129,8 @@ export function PrimaryWorkspacePanel(props: WorkspacePanelProps) {
}
>
);
diff --git a/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts b/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts
index daf7d830c..a086ac1c9 100644
--- a/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts
+++ b/src/rate_of_closure/web/src/hooks/useCapabilityOptimization.ts
@@ -1,5 +1,6 @@
import {
useEffect,
+ useLayoutEffect,
useRef,
useState,
type Dispatch,
@@ -12,6 +13,8 @@ import {
capabilityWorkflowFromJson,
capabilityWorkflowInputs,
defaultCapabilityWorkflowInputs,
+ overlayCapabilityWorkflowInputs,
+ type CapabilityWorkflowDocument,
type CapabilityWorkflowInputs,
} from "../model/capabilityWorkflow";
import {
@@ -21,70 +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 CapabilityInputAuthority {
- readonly inputs: CapabilityWorkflowInputs;
- readonly onInputsChange: Dispatch>;
+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?: CapabilityInputAuthority,
+ authority?: CapabilityWorkflowAuthority,
): CapabilityOptimizationState {
- const [localInputs, setLocalInputs] = useState(defaultCapabilityWorkflowInputs);
- const inputs = authority?.inputs ?? localInputs;
- const setInputs = authority?.onInputsChange ?? setLocalInputs;
+ 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 priorInputs = useRef(inputs);
+ 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(); }, []);
- useEffect(() => {
- if (priorInputs.current !== inputs) invalidate("Inputs changed — run again");
- priorInputs.current = inputs;
- }, [inputs]);
+ 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 dab764583..f986432a0 100644
--- a/src/rate_of_closure/web/src/hooks/useImpactAppModel.ts
+++ b/src/rate_of_closure/web/src/hooks/useImpactAppModel.ts
@@ -16,8 +16,9 @@ import { loadInitialTorqueWorkspace } from "./useSimulationTorqueWorkspace";
import type { VariationWorkspaceSnapshot } from "../model/workspaceVariationSession";
import { initialVariationWorkspace } from "./useVariationWorkspace";
import {
+ buildCapabilityWorkflow,
defaultCapabilityWorkflowInputs,
- type CapabilityWorkflowInputs,
+ type CapabilityWorkflowDocument,
} from "../model/capabilityWorkflow";
export interface ImpactAppModel {
@@ -39,9 +40,9 @@ export interface ImpactAppModel {
readonly setVariationWorkspace: Dispatch<
SetStateAction
>;
- readonly capabilityInputs: CapabilityWorkflowInputs;
- readonly setCapabilityInputs: Dispatch<
- SetStateAction
+ readonly capabilityWorkflow: CapabilityWorkflowDocument;
+ readonly setCapabilityWorkflow: Dispatch<
+ SetStateAction
>;
readonly units: UnitSelections;
readonly setUnits: Dispatch>;
@@ -88,8 +89,8 @@ export function useImpactAppModel(): ImpactAppModel {
const [variationWorkspace, setVariationWorkspace] = useState(() =>
initialVariationWorkspace(),
);
- const [capabilityInputs, setCapabilityInputs] = useState(
- defaultCapabilityWorkflowInputs,
+ const [capabilityWorkflow, setCapabilityWorkflow] = useState(() =>
+ buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()),
);
const [units, setUnits] = useState(DEFAULT_UNITS);
const [generatedHead, setGeneratedHead] = useState(() =>
@@ -113,8 +114,8 @@ export function useImpactAppModel(): ImpactAppModel {
setTorqueWorkspace,
variationWorkspace,
setVariationWorkspace,
- capabilityInputs,
- setCapabilityInputs,
+ 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 3bb27ab16..f424fac75 100644
--- a/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx
+++ b/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx
@@ -10,7 +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 { defaultCapabilityWorkflowInputs } from "../model/capabilityWorkflow";
+import {
+ buildCapabilityWorkflow,
+ defaultCapabilityWorkflowInputs,
+} from "../model/capabilityWorkflow";
import {
boxTolerance,
createSpatialTarget,
@@ -50,7 +53,7 @@ const snapshot = () => {
runConfig: passiveDoublePendulumRun(),
},
variation: initialVariationWorkspace(),
- capability: defaultCapabilityWorkflowInputs(),
+ capability: buildCapabilityWorkflow(defaultCapabilityWorkflowInputs()),
modules: DEFAULT_PRIMARY_VIEW_STATE,
viewWorkspace: defaultViewWorkspace,
};
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/capabilityWorkflow.test.ts b/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts
index b1390f3ca..30abf594d 100644
--- a/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts
+++ b/src/rate_of_closure/web/src/model/capabilityWorkflow.test.ts
@@ -18,6 +18,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(
@@ -39,6 +40,19 @@ describe("capability workflow", () => {
.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());
diff --git a/src/rate_of_closure/web/src/model/capabilityWorkflow.ts b/src/rate_of_closure/web/src/model/capabilityWorkflow.ts
index c4d53b39a..add3416a2 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,
@@ -67,6 +68,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;
};
@@ -340,3 +344,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 38c00f27d..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,7 +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 { defaultCapabilityWorkflowInputs } from "./capabilityWorkflow";
+import {
+ buildCapabilityWorkflow,
+ capabilityWorkflowFromJson,
+ capabilityWorkflowToJson,
+ defaultCapabilityWorkflowInputs,
+} from "./capabilityWorkflow";
import {
boxTolerance,
createSpatialTarget,
@@ -48,19 +53,36 @@ const snapshot = (): WorkspaceSessionSnapshot => {
analysisExecution: "both" as const,
selectedOutputMetrics: ["carry_m", "lateral_m", "apex_m"],
},
- capability: {
- ...defaultCapabilityWorkflowInputs(),
- profileId: "workspace-profile",
- objective: "minimize_expected_miss",
- targetDistanceM: 241,
- targetLateralM: -4,
- spinAxisTiltDeg: -3.5,
- },
+ 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",
@@ -105,6 +127,9 @@ describe("whole workspace session contract", () => {
},
});
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", () => {
diff --git a/src/rate_of_closure/web/src/model/workspaceSession.ts b/src/rate_of_closure/web/src/model/workspaceSession.ts
index 793e1a202..9e776b634 100644
--- a/src/rate_of_closure/web/src/model/workspaceSession.ts
+++ b/src/rate_of_closure/web/src/model/workspaceSession.ts
@@ -37,11 +37,10 @@ import {
versionedPayload,
} from "./workspaceMetadataValidation";
import {
- buildCapabilityWorkflow,
capabilityWorkflowDocument,
capabilityWorkflowFromDocument,
capabilityWorkflowInputs,
- type CapabilityWorkflowInputs,
+ type CapabilityWorkflowDocument,
} from "./capabilityWorkflow";
const WORKSPACE_SCHEMA = "rate_of_closure.workspace";
@@ -82,7 +81,7 @@ export interface WorkspaceSessionSnapshot {
readonly simulation: SimulationWorkspaceSnapshot;
readonly torque: TorqueWorkspaceSnapshot;
readonly variation: VariationWorkspaceSnapshot;
- readonly capability: CapabilityWorkflowInputs;
+ readonly capability: CapabilityWorkflowDocument;
readonly modules: PrimaryViewState;
readonly viewWorkspace: ViewWorkspace;
}
@@ -327,9 +326,7 @@ export function createWorkspaceDocument(
snapshot.variation,
snapshot.simulation.ballSetup,
),
- capability_request: capabilityWorkflowDocument(
- buildCapabilityWorkflow(snapshot.capability),
- ),
+ capability_request: capabilityWorkflowDocument(snapshot.capability),
},
},
prescribed_torque_profiles: snapshot.torque.profiles.map((profile) =>
@@ -364,7 +361,7 @@ export interface WorkspaceParseOptions {
readonly legacySimulationFallback?: SimulationWorkspaceSnapshot;
readonly legacyTorqueFallback?: TorqueWorkspaceSnapshot;
readonly legacyVariationFallback?: VariationWorkspaceSnapshot;
- readonly legacyCapabilityFallback?: CapabilityWorkflowInputs;
+ readonly legacyCapabilityFallback?: CapabilityWorkflowDocument;
}
/** Parse a current file or deliberately migrate v1 with an explicit fallback. */
@@ -519,20 +516,18 @@ export function parseWorkspaceDocument(
simulation.ballSetup,
);
}
- let capability: CapabilityWorkflowInputs;
+ 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",
);
}
- capability = capabilityWorkflowInputs(
- buildCapabilityWorkflow(options.legacyCapabilityFallback),
- );
+ capabilityWorkflowInputs(options.legacyCapabilityFallback);
+ capability = options.legacyCapabilityFallback;
} else {
- capability = capabilityWorkflowInputs(
- capabilityWorkflowFromDocument(session.capability_request),
- );
+ capability = capabilityWorkflowFromDocument(session.capability_request);
+ capabilityWorkflowInputs(capability);
}
return {
scenario: scenarioFromDocument(session.scenario),
diff --git a/tests/rate_of_closure/test_app_toolstrip.py b/tests/rate_of_closure/test_app_toolstrip.py
index 954862e9c..cb8ab14c9 100644
--- a/tests/rate_of_closure/test_app_toolstrip.py
+++ b/tests/rate_of_closure/test_app_toolstrip.py
@@ -329,6 +329,38 @@ def test_invalid_capability_request_is_rejected_before_native_ui_mutation(
assert warnings and "capability workflow" 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 7aaa40771..e11ba6ac7 100644
--- a/tests/rate_of_closure/test_capability_gui.py
+++ b/tests/rate_of_closure/test_capability_gui.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import json
+
import pytest
pytest.importorskip("PyQt6")
@@ -10,6 +12,8 @@
from rate_of_closure.application.capability_workflow import ( # noqa: E402
CapabilityWorkflowInputs,
build_capability_workflow,
+ capability_workflow_from_json,
+ capability_workflow_json,
)
from rate_of_closure.ui.pyqt6.capability_controls import (
CapabilityControls, # noqa: E402
@@ -21,6 +25,33 @@
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 test_capability_controls_round_trip_integration_settings(qtbot) -> None: # type: ignore[no-untyped-def]
controls = CapabilityControls()
qtbot.addWidget(controls)
@@ -96,16 +127,38 @@ def test_capability_workspace_apply_replaces_inputs_and_invalidates_results(
qtbot.addWidget(tab)
tab._document = build_capability_workflow(CapabilityWorkflowInputs())
tab.results.setVisible(True)
- requested = build_capability_workflow(
- CapabilityWorkflowInputs(
- profile_id="loaded-profile",
- target_distance_m=198.0,
- spin_axis_tilt_deg=-8.0,
- )
- )
+ 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)
+
+
+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..8937d9eb8 100644
--- a/tests/rate_of_closure/test_capability_workflow.py
+++ b/tests/rate_of_closure/test_capability_workflow.py
@@ -37,6 +37,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 +68,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(
From 5730e74752ffb84ab3560bed6318b7d97b6e627d Mon Sep 17 00:00:00 2001
From: codex-scheduled
Date: Tue, 11 Aug 2026 03:20:20 -0700
Subject: [PATCH 3/4] fix(rate): reject unsupported capability bases
---
AGENT_HANDOFF.md | 15 ++++++--
.../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 18 ++++++---
docs/release/rate_of_closure_campaign.v1.json | 2 +-
docs/specs/CAPABILITY_OPTIMIZATION.md | 15 ++++++--
src/rate_of_closure/AGENT_HANDOFF.md | 17 ++++++---
.../capability_interactive_basis.py | 32 ++++++++++++++++
.../application/capability_workflow.py | 4 ++
.../CapabilityOptimizationPanel.test.tsx | 18 +++++++++
.../web/src/hooks/useWorkspaceFiles.test.tsx | 33 ++++++++++++++++
.../src/model/capabilityInteractiveBasis.ts | 38 +++++++++++++++++++
.../web/src/model/capabilityWorkflow.test.ts | 26 +++++++++++++
.../web/src/model/capabilityWorkflow.ts | 2 +
tests/rate_of_closure/test_app_toolstrip.py | 33 ++++++++++++++++
tests/rate_of_closure/test_capability_gui.py | 29 ++++++++++++++
.../test_capability_workflow.py | 37 ++++++++++++++++++
15 files changed, 299 insertions(+), 20 deletions(-)
create mode 100644 src/rate_of_closure/application/capability_interactive_basis.py
create mode 100644 src/rate_of_closure/web/src/model/capabilityInteractiveBasis.ts
diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md
index 2573b78d9..001d52ad7 100644
--- a/AGENT_HANDOFF.md
+++ b/AGENT_HANDOFF.md
@@ -2,8 +2,15 @@
## 2026-08-11 Capability-request workspace continuation
-Independent no-publish review blockers on local head `91811c42` are repaired
-in this unpublished child. Both clients retain the complete validated workflow
+The remaining independent no-publish blocker on local head `68692bbcb` is
+repaired in this unpublished 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
@@ -29,9 +36,9 @@ 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
-63 focused Python workflow/workspace/File/PyQt/manifest tests and 62 focused React
+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 210-module production build, 11 campaign-manifest
+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
diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
index 1aeed1179..bb2ba5804 100644
--- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
+++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
@@ -2,8 +2,13 @@
## 2026-08-11 Capability input specification in whole workspaces
-- Independent no-publish findings against local head `91811c42` are repaired
- locally: full-document authority plus editable overlays preserve accepted
+- 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.
@@ -17,16 +22,17 @@
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
+ 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 63 focused Python workflow/workspace/File/PyQt/
- manifest tests and 62 focused React contract/File/UI tests; pinned MyPy, Ruff,
- TypeScript, zero-warning ESLint, the 210-module production build, 11
+- 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.
diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json
index 4d2040d3c..e533168b4 100644
--- a/docs/release/rate_of_closure_campaign.v1.json
+++ b/docs/release/rate_of_closure_campaign.v1.json
@@ -1053,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 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; 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.",
+ "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 4a6c92a20..6c2e6093e 100644
--- a/docs/specs/CAPABILITY_OPTIMIZATION.md
+++ b/docs/specs/CAPABILITY_OPTIMIZATION.md
@@ -83,8 +83,9 @@ 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 with magnitude at most `1e300` (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.
@@ -117,8 +118,14 @@ 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. Both invalidate previous computed output. Explorer-session v1-v4 migration requires an
-explicit current capability fallback and never invents an optimizer request.
+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
diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md
index bf0adcb2a..5a480e305 100644
--- a/src/rate_of_closure/AGENT_HANDOFF.md
+++ b/src/rate_of_closure/AGENT_HANDOFF.md
@@ -2,8 +2,15 @@
## 2026-08-11 Explorer-session v5 capability-request mapper
-Independent no-publish review blockers on local head `91811c42` are repaired
-locally. PyQt6 and React retain the full validated workflow as live authority
+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
@@ -27,9 +34,9 @@ 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 63 focused Python workflow/workspace/File/PyQt/
-manifest tests and 62 focused React contract/File/UI tests, plus pinned MyPy, Ruff
-check/format, TypeScript, zero-warning ESLint, the 210-module production build,
+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
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 ed1119c89..0a9c926ac 100644
--- a/src/rate_of_closure/application/capability_workflow.py
+++ b/src/rate_of_closure/application/capability_workflow.py
@@ -8,6 +8,9 @@
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,
)
@@ -350,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")
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 1c241de2c..55e4f6e6e 100644
--- a/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx
+++ b/src/rate_of_closure/web/src/components/CapabilityOptimizationPanel.test.tsx
@@ -36,6 +36,15 @@ const customWorkflow = (): CapabilityWorkflowDocument => {
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 () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
@@ -173,4 +182,13 @@ describe("CapabilityOptimizationPanel", () => {
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/hooks/useWorkspaceFiles.test.tsx b/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx
index f424fac75..b945c4bed 100644
--- a/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx
+++ b/src/rate_of_closure/web/src/hooks/useWorkspaceFiles.test.tsx
@@ -245,6 +245,39 @@ 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(
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 30abf594d..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";
@@ -34,6 +35,18 @@ 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)
@@ -80,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 add3416a2..a8d9ff16c 100644
--- a/src/rate_of_closure/web/src/model/capabilityWorkflow.ts
+++ b/src/rate_of_closure/web/src/model/capabilityWorkflow.ts
@@ -13,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;
@@ -321,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");
diff --git a/tests/rate_of_closure/test_app_toolstrip.py b/tests/rate_of_closure/test_app_toolstrip.py
index cb8ab14c9..6cb3267a0 100644
--- a/tests/rate_of_closure/test_app_toolstrip.py
+++ b/tests/rate_of_closure/test_app_toolstrip.py
@@ -329,6 +329,39 @@ def test_invalid_capability_request_is_rejected_before_native_ui_mutation(
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]
diff --git a/tests/rate_of_closure/test_capability_gui.py b/tests/rate_of_closure/test_capability_gui.py
index e11ba6ac7..79057c63a 100644
--- a/tests/rate_of_closure/test_capability_gui.py
+++ b/tests/rate_of_closure/test_capability_gui.py
@@ -10,6 +10,7 @@
pytest.importorskip("pytestqt")
from rate_of_closure.application.capability_workflow import ( # noqa: E402
+ CapabilityWorkflowDocument,
CapabilityWorkflowInputs,
build_capability_workflow,
capability_workflow_from_json,
@@ -52,6 +53,18 @@ def _custom_workflow(): # type: ignore[no-untyped-def]
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)
@@ -136,6 +149,22 @@ def test_capability_workspace_apply_replaces_inputs_and_invalidates_results(
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]
diff --git a/tests/rate_of_closure/test_capability_workflow.py b/tests/rate_of_closure/test_capability_workflow.py
index 8937d9eb8..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 (
@@ -102,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"),
[
From c35d8c63c8c57de942bb1deba86514d696d39d95 Mon Sep 17 00:00:00 2001
From: codex-scheduled
Date: Tue, 11 Aug 2026 03:31:06 -0700
Subject: [PATCH 4/4] docs(rate): link capability workspace draft PR
---
AGENT_HANDOFF.md | 12 +++++++++---
docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 8 +++++++-
src/rate_of_closure/AGENT_HANDOFF.md | 6 ++++++
3 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md
index 001d52ad7..6336c6d2e 100644
--- a/AGENT_HANDOFF.md
+++ b/AGENT_HANDOFF.md
@@ -2,8 +2,14 @@
## 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 unpublished child. Interactive projection now accepts only
+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
@@ -17,8 +23,8 @@ 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.
-Local-only branch `feat/4197-workspace-capability-request` starts from exact
-published PR #4343 head `4ff103d9a6ef886099c180da560e8458d5e20b49`.
+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,
diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
index bb2ba5804..f2aa64f80 100644
--- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
+++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
@@ -2,6 +2,12 @@
## 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
@@ -13,7 +19,7 @@
Native identity/generation gates reject stale success, and both parsers cap
numeric wire magnitude at `1e300` through the native File/Open error path.
-- Local branch `feat/4197-workspace-capability-request` starts from exact draft
+- 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
diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md
index 5a480e305..681c174b1 100644
--- a/src/rate_of_closure/AGENT_HANDOFF.md
+++ b/src/rate_of_closure/AGENT_HANDOFF.md
@@ -2,6 +2,12 @@
## 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`