@@ -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: